diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-02-05 16:20:45 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-02-05 16:20:45 +0000 |
commit | d298fad0c0564454271cba11e6f20c19681534ac (patch) | |
tree | 0a19d07d8b3bdd2574617305c300e404f2ace581 /app | |
parent | c9f9eec79cab801a50db698f682aacffbedf07f7 (diff) | |
download | gitlab-ce-13.9.0-rc41.tar.gz |
Add latest changes from gitlab-org/gitlab@13-9-stable-eev13.9.0-rc41
Diffstat (limited to 'app')
1549 files changed, 14269 insertions, 7768 deletions
diff --git a/app/assets/images/mailers/in_product_marketing/create-0.png b/app/assets/images/mailers/in_product_marketing/create-0.png Binary files differnew file mode 100644 index 00000000000..7fc992f14f2 --- /dev/null +++ b/app/assets/images/mailers/in_product_marketing/create-0.png diff --git a/app/assets/images/mailers/in_product_marketing/create-1.png b/app/assets/images/mailers/in_product_marketing/create-1.png Binary files differnew file mode 100644 index 00000000000..0315ffefb31 --- /dev/null +++ b/app/assets/images/mailers/in_product_marketing/create-1.png diff --git a/app/assets/images/mailers/in_product_marketing/create-2.png b/app/assets/images/mailers/in_product_marketing/create-2.png Binary files differnew file mode 100644 index 00000000000..619f9fcd659 --- /dev/null +++ b/app/assets/images/mailers/in_product_marketing/create-2.png diff --git a/app/assets/images/mailers/in_product_marketing/gitlab-logo-gray-rgb.png b/app/assets/images/mailers/in_product_marketing/gitlab-logo-gray-rgb.png Binary files differnew file mode 100644 index 00000000000..31083af512e --- /dev/null +++ b/app/assets/images/mailers/in_product_marketing/gitlab-logo-gray-rgb.png diff --git a/app/assets/images/mailers/in_product_marketing/team-0.png b/app/assets/images/mailers/in_product_marketing/team-0.png Binary files differnew file mode 100644 index 00000000000..f10ae998efa --- /dev/null +++ b/app/assets/images/mailers/in_product_marketing/team-0.png diff --git a/app/assets/images/mailers/in_product_marketing/team-1.png b/app/assets/images/mailers/in_product_marketing/team-1.png Binary files differnew file mode 100644 index 00000000000..cd68464e6e8 --- /dev/null +++ b/app/assets/images/mailers/in_product_marketing/team-1.png diff --git a/app/assets/images/mailers/in_product_marketing/team-2.png b/app/assets/images/mailers/in_product_marketing/team-2.png Binary files differnew file mode 100644 index 00000000000..b199c659943 --- /dev/null +++ b/app/assets/images/mailers/in_product_marketing/team-2.png diff --git a/app/assets/images/mailers/in_product_marketing/trial-0.png b/app/assets/images/mailers/in_product_marketing/trial-0.png Binary files differnew file mode 100644 index 00000000000..3b0d7a8ecd8 --- /dev/null +++ b/app/assets/images/mailers/in_product_marketing/trial-0.png diff --git a/app/assets/images/mailers/in_product_marketing/trial-1.png b/app/assets/images/mailers/in_product_marketing/trial-1.png Binary files differnew file mode 100644 index 00000000000..3a30b2acaee --- /dev/null +++ b/app/assets/images/mailers/in_product_marketing/trial-1.png diff --git a/app/assets/images/mailers/in_product_marketing/trial-2.png b/app/assets/images/mailers/in_product_marketing/trial-2.png Binary files differnew file mode 100644 index 00000000000..95bd965b49f --- /dev/null +++ b/app/assets/images/mailers/in_product_marketing/trial-2.png diff --git a/app/assets/images/mailers/in_product_marketing/verify-0.png b/app/assets/images/mailers/in_product_marketing/verify-0.png Binary files differnew file mode 100644 index 00000000000..04b6f172b37 --- /dev/null +++ b/app/assets/images/mailers/in_product_marketing/verify-0.png diff --git a/app/assets/images/mailers/in_product_marketing/verify-1.png b/app/assets/images/mailers/in_product_marketing/verify-1.png Binary files differnew file mode 100644 index 00000000000..8997e8ba575 --- /dev/null +++ b/app/assets/images/mailers/in_product_marketing/verify-1.png diff --git a/app/assets/images/mailers/in_product_marketing/verify-2.png b/app/assets/images/mailers/in_product_marketing/verify-2.png Binary files differnew file mode 100644 index 00000000000..93c99dee246 --- /dev/null +++ b/app/assets/images/mailers/in_product_marketing/verify-2.png 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 c58ded3f1f5..a9c35d7929e 100644 --- a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue +++ b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue @@ -1,10 +1,11 @@ <script> import { mapState, mapActions } from 'vuex'; import { GlModal, GlTabs, GlTab, GlSearchBoxByType, GlSprintf } from '@gitlab/ui'; +import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import ReviewTabContainer from '~/add_context_commits_modal/components/review_tab_container.vue'; import { s__ } from '~/locale'; -import eventHub from '../event_hub'; import { deprecatedCreateFlash as createFlash } from '~/flash'; +import eventHub from '../event_hub'; import { findCommitIndex, setCommitStatus, @@ -119,7 +120,7 @@ export default { openModal() { this.searchCommits(); this.fetchContextCommits(); - this.$root.$emit('bv::show::modal', 'add-review-item'); + this.$root.$emit(BV_SHOW_MODAL, 'add-review-item'); }, handleTabChange(tabIndex) { if (tabIndex === 0) { diff --git a/app/assets/javascripts/admin/statistics_panel/components/app.vue b/app/assets/javascripts/admin/statistics_panel/components/app.vue index 29077d926cf..7e1bb3ed0ce 100644 --- a/app/assets/javascripts/admin/statistics_panel/components/app.vue +++ b/app/assets/javascripts/admin/statistics_panel/components/app.vue @@ -26,8 +26,8 @@ export default { </script> <template> - <div class="info-well"> - <div class="well-segment admin-well admin-well-statistics"> + <div class="gl-card"> + <div class="gl-card-body"> <h4>{{ __('Statistics') }}</h4> <gl-loading-icon v-if="isLoading" size="md" class="my-3" /> <template v-else> diff --git a/app/assets/javascripts/admin/cohorts/components/usage_ping_disabled.vue b/app/assets/javascripts/admin/users/components/usage_ping_disabled.vue index 5da38495010..5da38495010 100644 --- a/app/assets/javascripts/admin/cohorts/components/usage_ping_disabled.vue +++ b/app/assets/javascripts/admin/users/components/usage_ping_disabled.vue diff --git a/app/assets/javascripts/admin/users/components/user_actions.vue b/app/assets/javascripts/admin/users/components/user_actions.vue new file mode 100644 index 00000000000..6c7c434cdf4 --- /dev/null +++ b/app/assets/javascripts/admin/users/components/user_actions.vue @@ -0,0 +1,115 @@ +<script> +import { + GlButton, + GlDropdown, + GlDropdownItem, + GlDropdownSectionHeader, + GlDropdownDivider, +} from '@gitlab/ui'; +import { s__, __ } from '~/locale'; +import { convertArrayToCamelCase } from '~/lib/utils/common_utils'; +import { generateUserPaths } from '../utils'; + +export default { + components: { + GlButton, + GlDropdown, + GlDropdownItem, + GlDropdownSectionHeader, + GlDropdownDivider, + }, + props: { + user: { + type: Object, + required: true, + }, + paths: { + type: Object, + required: true, + }, + }, + computed: { + userActions() { + return convertArrayToCamelCase(this.user.actions); + }, + dropdownActions() { + return this.userActions.filter((a) => a !== 'edit'); + }, + dropdownDeleteActions() { + return this.dropdownActions.filter((a) => a.includes('delete')); + }, + dropdownSafeActions() { + return this.dropdownActions.filter((a) => !this.dropdownDeleteActions.includes(a)); + }, + hasDropdownActions() { + return this.dropdownActions.length > 0; + }, + hasDeleteActions() { + return this.dropdownDeleteActions.length > 0; + }, + hasEditAction() { + return this.userActions.includes('edit'); + }, + userPaths() { + return generateUserPaths(this.paths, this.user.username); + }, + }, + methods: { + isLdapAction(action) { + return action === 'ldapBlocked'; + }, + }, + i18n: { + edit: __('Edit'), + settings: __('Settings'), + unlock: __('Unlock'), + block: s__('AdminUsers|Block'), + unblock: s__('AdminUsers|Unblock'), + approve: s__('AdminUsers|Approve'), + reject: s__('AdminUsers|Reject'), + deactivate: s__('AdminUsers|Deactivate'), + activate: s__('AdminUsers|Activate'), + ldapBlocked: s__('AdminUsers|Cannot unblock LDAP blocked users'), + delete: s__('AdminUsers|Delete user'), + deleteWithContributions: s__('AdminUsers|Delete user and contributions'), + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-justify-content-end"> + <gl-button v-if="hasEditAction" data-testid="edit" :href="userPaths.edit">{{ + $options.i18n.edit + }}</gl-button> + + <gl-dropdown + v-if="hasDropdownActions" + data-testid="actions" + right + class="gl-ml-2" + icon="settings" + > + <gl-dropdown-section-header>{{ $options.i18n.settings }}</gl-dropdown-section-header> + + <template v-for="action in dropdownSafeActions"> + <gl-dropdown-item v-if="isLdapAction(action)" :key="action" :data-testid="action"> + {{ $options.i18n.ldap }} + </gl-dropdown-item> + <gl-dropdown-item v-else :key="action" :href="userPaths[action]" :data-testid="action"> + {{ $options.i18n[action] }} + </gl-dropdown-item> + </template> + + <gl-dropdown-divider v-if="hasDeleteActions" /> + + <gl-dropdown-item + v-for="action in dropdownDeleteActions" + :key="action" + :href="userPaths[action]" + :data-testid="`delete-${action}`" + > + <span class="gl-text-red-500">{{ $options.i18n[action] }}</span> + </gl-dropdown-item> + </gl-dropdown> + </div> +</template> diff --git a/app/assets/javascripts/admin/users/components/user_avatar.vue b/app/assets/javascripts/admin/users/components/user_avatar.vue index 4f79c4fd451..ff0e91fcb8f 100644 --- a/app/assets/javascripts/admin/users/components/user_avatar.vue +++ b/app/assets/javascripts/admin/users/components/user_avatar.vue @@ -1,12 +1,17 @@ <script> -import { GlAvatarLink, GlAvatarLabeled, GlBadge } from '@gitlab/ui'; -import { USER_AVATAR_SIZE } from '../constants'; +import { GlAvatarLink, GlAvatarLabeled, GlBadge, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { truncate } from '~/lib/utils/text_utility'; +import { USER_AVATAR_SIZE, LENGTH_OF_USER_NOTE_TOOLTIP } from '../constants'; export default { + directives: { + GlTooltip: GlTooltipDirective, + }, components: { GlAvatarLink, GlAvatarLabeled, GlBadge, + GlIcon, }, props: { user: { @@ -22,6 +27,9 @@ export default { adminUserHref() { return this.adminUserPath.replace('id', this.user.username); }, + userNoteShort() { + return truncate(this.user.note, LENGTH_OF_USER_NOTE_TOOLTIP); + }, }, USER_AVATAR_SIZE, }; @@ -42,6 +50,9 @@ export default { :sub-label="user.email" > <template #meta> + <div v-if="user.note" class="gl-text-gray-500 gl-p-1"> + <gl-icon v-gl-tooltip="userNoteShort" name="document" /> + </div> <div v-for="(badge, idx) in user.badges" :key="idx" class="gl-p-1"> <gl-badge class="gl-display-flex!" size="sm" :variant="badge.variant">{{ badge.text diff --git a/app/assets/javascripts/admin/users/components/user_date.vue b/app/assets/javascripts/admin/users/components/user_date.vue new file mode 100644 index 00000000000..38dddbf72c2 --- /dev/null +++ b/app/assets/javascripts/admin/users/components/user_date.vue @@ -0,0 +1,29 @@ +<script> +import { formatDate } from '~/lib/utils/datetime_utility'; +import { __ } from '~/locale'; +import { SHORT_DATE_FORMAT } from '../constants'; + +export default { + props: { + date: { + type: String, + required: false, + default: null, + }, + }, + computed: { + formattedDate() { + const { date } = this; + if (date === null) { + return __('Never'); + } + return formatDate(new Date(date), SHORT_DATE_FORMAT); + }, + }, +}; +</script> +<template> + <span> + {{ formattedDate }} + </span> +</template> diff --git a/app/assets/javascripts/admin/users/components/users_table.vue b/app/assets/javascripts/admin/users/components/users_table.vue index 15e31935a4c..0eefe1070ff 100644 --- a/app/assets/javascripts/admin/users/components/users_table.vue +++ b/app/assets/javascripts/admin/users/components/users_table.vue @@ -2,6 +2,8 @@ import { GlTable } from '@gitlab/ui'; import { __ } from '~/locale'; import UserAvatar from './user_avatar.vue'; +import UserActions from './user_actions.vue'; +import UserDate from './user_date.vue'; const DEFAULT_TH_CLASSES = 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!'; @@ -11,6 +13,8 @@ export default { components: { GlTable, UserAvatar, + UserActions, + UserDate, }, props: { users: { @@ -62,7 +66,19 @@ export default { stacked="md" > <template #cell(name)="{ item: user }"> - <UserAvatar :user="user" :admin-user-path="paths.adminUser" /> + <user-avatar :user="user" :admin-user-path="paths.adminUser" /> + </template> + + <template #cell(createdAt)="{ item: { createdAt } }"> + <user-date :date="createdAt" /> + </template> + + <template #cell(lastActivityOn)="{ item: { lastActivityOn } }"> + <user-date :date="lastActivityOn" show-never /> + </template> + + <template #cell(settings)="{ item: user }"> + <user-actions :user="user" :paths="paths" /> </template> </gl-table> </div> diff --git a/app/assets/javascripts/admin/users/constants.js b/app/assets/javascripts/admin/users/constants.js index 675fcf00c39..e26643cad60 100644 --- a/app/assets/javascripts/admin/users/constants.js +++ b/app/assets/javascripts/admin/users/constants.js @@ -1 +1,5 @@ export const USER_AVATAR_SIZE = 32; + +export const SHORT_DATE_FORMAT = 'd mmm, yyyy'; + +export const LENGTH_OF_USER_NOTE_TOOLTIP = 100; diff --git a/app/assets/javascripts/admin/users/index.js b/app/assets/javascripts/admin/users/index.js index f35b57c4e1a..00a84259c22 100644 --- a/app/assets/javascripts/admin/users/index.js +++ b/app/assets/javascripts/admin/users/index.js @@ -1,8 +1,9 @@ import Vue from 'vue'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import UsagePingDisabled from './components/usage_ping_disabled.vue'; import AdminUsersApp from './components/app.vue'; -export default function (el = document.querySelector('#js-admin-users-app')) { +export const initAdminUsersApp = (el = document.querySelector('#js-admin-users-app')) => { if (!el) { return false; } @@ -19,4 +20,24 @@ export default function (el = document.querySelector('#js-admin-users-app')) { }, }), }); -} +}; + +export const initCohortsEmptyState = (el = document.querySelector('#js-cohorts-empty-state')) => { + if (!el) { + return false; + } + + const { emptyStateSvgPath, enableUsagePingLink, docsLink } = el.dataset; + + return new Vue({ + el, + provide: { + svgPath: emptyStateSvgPath, + primaryButtonPath: enableUsagePingLink, + docsLink, + }, + render(h) { + return h(UsagePingDisabled); + }, + }); +}; diff --git a/app/assets/javascripts/admin/users/tabs.js b/app/assets/javascripts/admin/users/tabs.js new file mode 100644 index 00000000000..9ada77396c7 --- /dev/null +++ b/app/assets/javascripts/admin/users/tabs.js @@ -0,0 +1,23 @@ +import { historyPushState } from '~/lib/utils/common_utils'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; + +const COHORTS_PANE = 'cohorts'; + +const tabClickHandler = (e) => { + const { hash } = e.currentTarget; + const tab = hash === `#${COHORTS_PANE}` ? COHORTS_PANE : null; + const newUrl = mergeUrlParams({ tab }, window.location.href); + historyPushState(newUrl); +}; + +const initTabs = () => { + const tabLinks = document.querySelectorAll('.js-users-tab-item a'); + + if (tabLinks.length) { + tabLinks.forEach((tabLink) => { + tabLink.addEventListener('click', (e) => tabClickHandler(e)); + }); + } +}; + +export default initTabs; diff --git a/app/assets/javascripts/admin/users/utils.js b/app/assets/javascripts/admin/users/utils.js new file mode 100644 index 00000000000..f6c1091ba27 --- /dev/null +++ b/app/assets/javascripts/admin/users/utils.js @@ -0,0 +1,7 @@ +export const generateUserPaths = (paths, id) => { + return Object.fromEntries( + Object.entries(paths).map(([action, genericPath]) => { + return [action, genericPath.replace('id', id)]; + }), + ); +}; 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 2bad15faa85..dae52a530ac 100644 --- a/app/assets/javascripts/alert_management/components/alert_management_table.vue +++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue @@ -23,14 +23,10 @@ import { } from '~/vue_shared/components/paginated_table_with_search_and_tabs/constants'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { convertToSnakeCase } from '~/lib/utils/text_utility'; +import AlertStatus from '~/vue_shared/alert_details/components/alert_status.vue'; import getAlertsQuery from '~/graphql_shared/queries/get_alerts.query.graphql'; import getAlertsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql'; -import { - ALERTS_STATUS_TABS, - ALERTS_SEVERITY_LABELS, - trackAlertListViewsOptions, -} from '../constants'; -import AlertStatus from './alert_status.vue'; +import { ALERTS_STATUS_TABS, SEVERITY_LEVELS, trackAlertListViewsOptions } from '../constants'; const TH_TEST_ID = { 'data-testid': 'alert-management-severity-sort' }; @@ -96,7 +92,7 @@ export default { sortable: true, }, ], - severityLabels: ALERTS_SEVERITY_LABELS, + severityLabels: SEVERITY_LEVELS, statusTabs: ALERTS_STATUS_TABS, components: { GlAlert, diff --git a/app/assets/javascripts/alert_management/constants.js b/app/assets/javascripts/alert_management/constants.js index b79a64646eb..c98d3865621 100644 --- a/app/assets/javascripts/alert_management/constants.js +++ b/app/assets/javascripts/alert_management/constants.js @@ -1,12 +1,12 @@ import { s__ } from '~/locale'; -export const ALERTS_SEVERITY_LABELS = { - CRITICAL: s__('AlertManagement|Critical'), - HIGH: s__('AlertManagement|High'), - MEDIUM: s__('AlertManagement|Medium'), - LOW: s__('AlertManagement|Low'), - INFO: s__('AlertManagement|Info'), - UNKNOWN: s__('AlertManagement|Unknown'), +export const SEVERITY_LEVELS = { + CRITICAL: s__('severity|Critical'), + HIGH: s__('severity|High'), + MEDIUM: s__('severity|Medium'), + LOW: s__('severity|Low'), + INFO: s__('severity|Info'), + UNKNOWN: s__('severity|Unknown'), }; export const ALERTS_STATUS_TABS = [ @@ -46,20 +46,3 @@ export const trackAlertListViewsOptions = { category: 'Alert Management', action: 'view_alerts_list', }; - -/** - * Tracks snowplow event when user views alert details - */ -export const trackAlertsDetailsViewsOptions = { - category: 'Alert Management', - action: 'view_alert_details', -}; - -/** - * Tracks snowplow event when alert status is updated - */ -export const trackAlertStatusUpdateOptions = { - category: 'Alert Management', - action: 'update_alert_status', - label: 'Status', -}; diff --git a/app/assets/javascripts/alert_management/list.js b/app/assets/javascripts/alert_management/list.js index b484841ed2c..4b18dee7806 100644 --- a/app/assets/javascripts/alert_management/list.js +++ b/app/assets/javascripts/alert_management/list.js @@ -3,6 +3,7 @@ import VueApollo from 'vue-apollo'; import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; import createDefaultClient from '~/lib/graphql'; import { parseBoolean } from '~/lib/utils/common_utils'; +import { PAGE_CONFIG } from '~/vue_shared/alert_details/constants'; import AlertManagementList from './components/alert_management_list_wrapper.vue'; Vue.use(VueApollo); @@ -59,6 +60,7 @@ export default () => { populatingAlertsHelpUrl, emptyAlertSvgPath, alertManagementEnabled: parseBoolean(alertManagementEnabled), + trackAlertStatusUpdateOptions: PAGE_CONFIG.OPERATIONS.TRACK_ALERT_STATUS_UPDATE_OPTIONS, userCanEnableAlertManagement: parseBoolean(userCanEnableAlertManagement), }, apolloProvider, diff --git a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue index c52e9f5c264..66d6af6f0a4 100644 --- a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue +++ b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue @@ -8,11 +8,14 @@ import { GlSearchBoxByType, GlTooltipDirective as GlTooltip, } from '@gitlab/ui'; +import { cloneDeep } from 'lodash'; import { s__, __ } from '~/locale'; -// Mocks will be removed when integrating with BE is ready -// data format is defined and will be the same as mocked (maybe with some minor changes) -// feature rollout plan - https://gitlab.com/gitlab-org/gitlab/-/issues/262707#note_442529171 -import gitlabFieldsMock from './mocks/gitlabFields.json'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { + getMappingData, + getPayloadFields, + transformForSave, +} from '../utils/mapping_transformations'; export const i18n = { columns: { @@ -40,18 +43,25 @@ export default { directives: { GlTooltip, }, - inject: { - gitlabAlertFields: { - default: gitlabFieldsMock, - }, - }, props: { - payloadFields: { + alertFields: { + type: Array, + required: true, + validator: (fields) => { + return ( + fields.length && + fields.every(({ name, types, label }) => { + return typeof name === 'string' && Array.isArray(types) && typeof label === 'string'; + }) + ); + }, + }, + parsedPayload: { type: Array, required: false, default: () => [], }, - mapping: { + savedMapping: { type: Array, required: false, default: () => [], @@ -59,31 +69,18 @@ export default { }, data() { return { - gitlabFields: this.gitlabAlertFields, + gitlabFields: cloneDeep(this.alertFields), }; }, computed: { + payloadFields() { + return getPayloadFields(this.parsedPayload); + }, mappingData() { - return this.gitlabFields.map((gitlabField) => { - const mappingFields = this.payloadFields.filter(({ type }) => - type.some((t) => gitlabField.compatibleTypes.includes(t)), - ); - - const foundMapping = this.mapping.find( - ({ alertFieldName }) => alertFieldName === gitlabField.name, - ); - - const { fallbackAlertPaths, payloadAlertPaths } = foundMapping || {}; - - return { - mapping: payloadAlertPaths, - fallback: fallbackAlertPaths, - searchTerm: '', - fallbackSearchTerm: '', - mappingFields, - ...gitlabField, - }; - }); + return getMappingData(this.gitlabFields, this.payloadFields, this.savedMapping); + }, + hasFallbackColumn() { + return this.gitlabFields.some(({ numberOfFallbacks }) => Boolean(numberOfFallbacks)); }, }, methods: { @@ -91,6 +88,7 @@ export default { const fieldIndex = this.gitlabFields.findIndex((field) => field.name === gitlabKey); const updatedField = { ...this.gitlabFields[fieldIndex], ...{ [valueKey]: mappingKey } }; Vue.set(this.gitlabFields, fieldIndex, updatedField); + this.$emit('onMappingUpdate', transformForSave(this.mappingData)); }, setSearchTerm(search = '', searchFieldKey, gitlabKey) { const fieldIndex = this.gitlabFields.findIndex((field) => field.name === gitlabKey); @@ -99,7 +97,6 @@ export default { }, filterFields(searchTerm = '', fields) { const search = searchTerm.toLowerCase(); - return fields.filter((field) => field.label.toLowerCase().includes(search)); }, isSelected(fieldValue, mapping) { @@ -111,8 +108,10 @@ export default { this.$options.i18n.makeSelection ); }, - getFieldValue({ label, type }) { - return `${label} (${type.join(__(' or '))})`; + getFieldValue({ label, types }) { + const type = types.map((t) => capitalizeFirstCharacter(t.toLowerCase())).join(__(' or ')); + + return `${label} (${type})`; }, noResults(searchTerm, fields) { return !this.filterFields(searchTerm, fields).length; @@ -131,7 +130,11 @@ export default { <h5 id="parsedFieldsHeader" class="gl-display-table-cell gl-py-3 gl-pr-3"> {{ $options.i18n.columns.payloadKeyTitle }} </h5> - <h5 id="fallbackFieldsHeader" class="gl-display-table-cell gl-py-3 gl-pr-3"> + <h5 + v-if="hasFallbackColumn" + id="fallbackFieldsHeader" + class="gl-display-table-cell gl-py-3 gl-pr-3" + > {{ $options.i18n.columns.fallbackKeyTitle }} <gl-icon v-gl-tooltip diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue index 1ae7f826ce6..cef20321ce2 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue @@ -15,8 +15,6 @@ import { import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { s__ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import MappingBuilder from './alert_mapping_builder.vue'; -import AlertSettingsFormHelpBlock from './alert_settings_form_help_block.vue'; import getCurrentIntegrationQuery from '../graphql/queries/get_current_integration.query.graphql'; import { integrationTypes, @@ -24,6 +22,8 @@ import { targetPrometheusUrlPlaceholder, typeSet, } from '../constants'; +import MappingBuilder from './alert_mapping_builder.vue'; +import AlertSettingsFormHelpBlock from './alert_settings_form_help_block.vue'; // Mocks will be removed when integrating with BE is ready // data format is defined and will be the same as mocked (maybe with some minor changes) // feature rollout plan - https://gitlab.com/gitlab-org/gitlab/-/issues/262707#note_442529171 @@ -125,6 +125,9 @@ export default { prometheus: { default: {}, }, + multiIntegrations: { + default: false, + }, }, props: { loading: { @@ -135,6 +138,11 @@ export default { type: Boolean, required: true, }, + alertFields: { + type: Array, + required: false, + default: null, + }, }, apollo: { currentIntegration: { @@ -152,6 +160,7 @@ export default { }, resetSamplePayloadConfirmed: false, customMapping: null, + mapping: [], parsingPayload: false, currentIntegration: null, }; @@ -195,14 +204,16 @@ export default { }, showMappingBuilder() { return ( + this.multiIntegrations && this.glFeatures.multipleHttpIntegrationsCustomMapping && - this.selectedIntegration === typeSet.http + this.selectedIntegration === typeSet.http && + this.alertFields?.length ); }, - mappingBuilderFields() { + parsedSamplePayload() { return this.customMapping?.samplePayload?.payloadAlerFields?.nodes; }, - mappingBuilderMapping() { + savedMapping() { return this.customMapping?.storedMapping?.nodes; }, hasSamplePayload() { @@ -255,9 +266,20 @@ export default { }, submit() { const { name, apiUrl } = this.integrationForm; + const customMappingVariables = this.glFeatures.multipleHttpIntegrationsCustomMapping + ? { + payloadAttributeMappings: this.mapping, + payloadExample: this.integrationTestPayload.json, + } + : {}; + const variables = this.selectedIntegration === typeSet.http - ? { name, active: this.active } + ? { + name, + active: this.active, + ...customMappingVariables, + } : { apiUrl, active: this.active }; const integrationPayload = { type: this.selectedIntegration, variables }; @@ -336,6 +358,9 @@ export default { this.integrationTestPayload.json = res?.samplePayload.body; }); }, + updateMapping(mapping) { + this.mapping = mapping; + }, }, }; </script> @@ -541,8 +566,10 @@ export default { > <span>{{ $options.i18n.integrationFormSteps.step5.intro }}</span> <mapping-builder - :payload-fields="mappingBuilderFields" - :mapping="mappingBuilderMapping" + :parsed-payload="parsedSamplePayload" + :saved-mapping="savedMapping" + :alert-fields="alertFields" + @onMappingUpdate="updateMapping" /> </gl-form-group> </div> 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 d0cac066ffa..71d094dbe6e 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue @@ -12,8 +12,6 @@ import destroyHttpIntegrationMutation from '../graphql/mutations/destroy_http_in import resetHttpTokenMutation from '../graphql/mutations/reset_http_token.mutation.graphql'; import resetPrometheusTokenMutation from '../graphql/mutations/reset_prometheus_token.mutation.graphql'; import updateCurrentIntergrationMutation from '../graphql/mutations/update_current_intergration.mutation.graphql'; -import IntegrationsList from './alerts_integrations_list.vue'; -import AlertSettingsForm from './alerts_settings_form.vue'; import service from '../services'; import { typeSet } from '../constants'; import { @@ -27,6 +25,8 @@ import { UPDATE_INTEGRATION_ERROR, INTEGRATION_PAYLOAD_TEST_ERROR, } from '../utils/error_messages'; +import AlertSettingsForm from './alerts_settings_form.vue'; +import IntegrationsList from './alerts_integrations_list.vue'; export default { typeSet, @@ -57,6 +57,13 @@ export default { default: false, }, }, + props: { + alertFields: { + type: Array, + required: false, + default: null, + }, + }, apollo: { integrations: { fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, @@ -312,6 +319,7 @@ export default { <alert-settings-form :loading="isUpdating" :can-add-integration="canAddIntegration" + :alert-fields="alertFields" @create-new-integration="createNewIntegration" @update-integration="updateIntegration" @reset-token="resetToken" diff --git a/app/assets/javascripts/alerts_settings/components/mocks/gitlabFields.json b/app/assets/javascripts/alerts_settings/components/mocks/gitlabFields.json deleted file mode 100644 index ac559a30eda..00000000000 --- a/app/assets/javascripts/alerts_settings/components/mocks/gitlabFields.json +++ /dev/null @@ -1,112 +0,0 @@ -[ - { - "name": "title", - "label": "Title", - "type": [ - "String" - ], - "compatibleTypes": [ - "String", - "Number", - "DateTime" - ], - "numberOfFallbacks": 1 - }, - { - "name": "description", - "label": "Description", - "type": [ - "String" - ], - "compatibleTypes": [ - "String", - "Number", - "DateTime" - ] - }, - { - "name": "startTime", - "label": "Start time", - "type": [ - "DateTime" - ], - "compatibleTypes": [ - "Number", - "DateTime" - ] - }, - { - "name": "service", - "label": "Service", - "type": [ - "String" - ], - "compatibleTypes": [ - "String", - "Number", - "DateTime" - ] - }, - { - "name": "monitoringTool", - "label": "Monitoring tool", - "type": [ - "String" - ], - "compatibleTypes": [ - "String", - "Number", - "DateTime" - ] - }, - { - "name": "hosts", - "label": "Hosts", - "type": [ - "String", - "Array" - ], - "compatibleTypes": [ - "String", - "Array", - "Number", - "DateTime" - ] - }, - { - "name": "severity", - "label": "Severity", - "type": [ - "String" - ], - "compatibleTypes": [ - "String", - "Number", - "DateTime" - ] - }, - { - "name": "fingerprint", - "label": "Fingerprint", - "type": [ - "String" - ], - "compatibleTypes": [ - "String", - "Number", - "DateTime" - ] - }, - { - "name": "environment", - "label": "Environment", - "type": [ - "String" - ], - "compatibleTypes": [ - "String", - "Number", - "DateTime" - ] - } -] diff --git a/app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json b/app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json index 5326678155d..80fbebf2a60 100644 --- a/app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json +++ b/app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json @@ -4,95 +4,69 @@ "payloadAlerFields": { "nodes": [ { - "name": "dashboardId", + "path": ["dashboardId"], "label": "Dashboard Id", - "type": [ - "Number" - ] + "type": "string" }, { - "name": "evalMatches", + "path": ["evalMatches"], "label": "Eval Matches", - "type": [ - "Array" - ] + "type": "array" }, { - "name": "createdAt", + "path": ["createdAt"], "label": "Created At", - "type": [ - "DateTime" - ] + "type": "datetime" }, { - "name": "imageUrl", + "path": ["imageUrl"], "label": "Image Url", - "type": [ - "String" - ] + "type": "string" }, { - "name": "message", + "path": ["message"], "label": "Message", - "type": [ - "String" - ] + "type": "string" }, { - "name": "orgId", + "path": ["orgId"], "label": "Org Id", - "type": [ - "Number" - ] + "type": "string" }, { - "name": "panelId", + "path": ["panelId"], "label": "Panel Id", - "type": [ - "String" - ] + "type": "string" }, { - "name": "ruleId", + "path": ["ruleId"], "label": "Rule Id", - "type": [ - "Number" - ] + "type": "string" }, { - "name": "ruleName", + "path": ["ruleName"], "label": "Rule Name", - "type": [ - "String" - ] + "type": "string" }, { - "name": "ruleUrl", + "path": ["ruleUrl"], "label": "Rule Url", - "type": [ - "String" - ] + "type": "string" }, { - "name": "state", + "path": ["state"], "label": "State", - "type": [ - "String" - ] + "type": "string" }, { - "name": "title", + "path": ["title"], "label": "Title", - "type": [ - "String" - ] + "type": "string" }, { - "name": "tags", + "path": ["tags", "tag"], "label": "Tags", - "type": [ - "Object" - ] + "type": "string" } ] } diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql index d1dacbad40a..f3fc10b4bd4 100644 --- a/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql @@ -1,7 +1,21 @@ #import "../fragments/integration_item.fragment.graphql" -mutation createHttpIntegration($projectPath: ID!, $name: String!, $active: Boolean!) { - httpIntegrationCreate(input: { projectPath: $projectPath, name: $name, active: $active }) { +mutation createHttpIntegration( + $projectPath: ID! + $name: String! + $active: Boolean! + $payloadExample: JsonString + $payloadAttributeMappings: [AlertManagementPayloadAlertFieldInput!] +) { + httpIntegrationCreate( + input: { + projectPath: $projectPath + name: $name + active: $active + payloadExample: $payloadExample + payloadAttributeMappings: $payloadAttributeMappings + } + ) { errors integration { ...IntegrationItem diff --git a/app/assets/javascripts/alerts_settings/index.js b/app/assets/javascripts/alerts_settings/index.js index 85858956987..973f5d4ec54 100644 --- a/app/assets/javascripts/alerts_settings/index.js +++ b/app/assets/javascripts/alerts_settings/index.js @@ -31,6 +31,7 @@ export default (el) => { url, projectPath, multiIntegrations, + alertFields, } = el.dataset; return new Vue({ @@ -60,7 +61,14 @@ export default (el) => { }, apolloProvider, render(createElement) { - return createElement('alert-settings-wrapper'); + return createElement('alert-settings-wrapper', { + props: { + alertFields: + gon.features?.multipleHttpIntegrationsCustomMapping && parseBoolean(multiIntegrations) + ? JSON.parse(alertFields) + : null, + }, + }); }, }); }; diff --git a/app/assets/javascripts/alerts_settings/utils/mapping_transformations.js b/app/assets/javascripts/alerts_settings/utils/mapping_transformations.js new file mode 100644 index 00000000000..a86103540c0 --- /dev/null +++ b/app/assets/javascripts/alerts_settings/utils/mapping_transformations.js @@ -0,0 +1,61 @@ +/** + * Given data for GitLab alert fields, parsed payload fields data and previously stored mapping (if any) + * creates an object in a form convenient to build UI && interact with it + * @param {Object} gitlabFields - structure describing GitLab alert fields + * @param {Object} payloadFields - parsed from sample JSON sample alert fields + * @param {Object} savedMapping - GitLab fields to parsed fields mapping + * + * @return {Object} mapping data for UI mapping builder + */ +export const getMappingData = (gitlabFields, payloadFields, savedMapping) => { + return gitlabFields.map((gitlabField) => { + // find fields from payload that match gitlab alert field by type + const mappingFields = payloadFields.filter(({ type }) => gitlabField.types.includes(type)); + + // find the mapping that was previously stored + const foundMapping = savedMapping.find(({ fieldName }) => fieldName === gitlabField.name); + + const { fallbackAlertPaths, payloadAlertPaths } = foundMapping || {}; + + return { + mapping: payloadAlertPaths, + fallback: fallbackAlertPaths, + searchTerm: '', + fallbackSearchTerm: '', + mappingFields, + ...gitlabField, + }; + }); +}; + +/** + * Based on mapping data configured by the user creates an object in a format suitable for save on BE + * @param {Object} mappingData - structure describing mapping between GitLab fields and parsed payload fields + * + * @return {Object} mapping data to send to BE + */ +export const transformForSave = (mappingData) => { + return mappingData.reduce((acc, field) => { + const mapped = field.mappingFields.find(({ name }) => name === field.mapping); + if (mapped) { + const { path, type, label } = mapped; + acc.push({ + fieldName: field.name.toUpperCase(), + path, + type: type.toUpperCase(), + label, + }); + } + return acc; + }, []); +}; + +/** + * Adds `name` prop to each provided by BE parsed payload field + * @param {Object} payload - parsed sample payload + * + * @return {Object} same as input with an extra `name` property which basically serves as a key to make a match + */ +export const getPayloadFields = (payload) => { + return payload.map((field) => ({ ...field, name: field.path.join('_') })); +}; diff --git a/app/assets/javascripts/analytics/instance_statistics/components/app.vue b/app/assets/javascripts/analytics/instance_statistics/components/app.vue index 8df4d2e2524..cdf1b1bbe2b 100644 --- a/app/assets/javascripts/analytics/instance_statistics/components/app.vue +++ b/app/assets/javascripts/analytics/instance_statistics/components/app.vue @@ -1,10 +1,10 @@ <script> +import { TODAY, TOTAL_DAYS_TO_SHOW, START_DATE } from '../constants'; import InstanceCounts from './instance_counts.vue'; import InstanceStatisticsCountChart from './instance_statistics_count_chart.vue'; import UsersChart from './users_chart.vue'; import ProjectsAndGroupsChart from './projects_and_groups_chart.vue'; import ChartsConfig from './charts_config'; -import { TODAY, TOTAL_DAYS_TO_SHOW, START_DATE } from '../constants'; export default { name: 'InstanceStatisticsApp', diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 0a3db8ad3a6..0e42c0ae913 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -1,7 +1,7 @@ -import axios from './lib/utils/axios_utils'; -import { joinPaths } from './lib/utils/url_utility'; import { deprecatedCreateFlash as flash } from '~/flash'; import { __ } from '~/locale'; +import axios from './lib/utils/axios_utils'; +import { joinPaths } from './lib/utils/url_utility'; const DEFAULT_PER_PAGE = 20; @@ -83,6 +83,9 @@ const Api = { featureFlagUserList: '/api/:version/projects/:id/feature_flags_user_lists/:list_iid', billableGroupMembersPath: '/api/:version/groups/:id/billable_members', containerRegistryDetailsPath: '/api/:version/registry/repositories/:id/', + projectNotificationSettingsPath: '/api/:version/projects/:id/notification_settings', + groupNotificationSettingsPath: '/api/:version/groups/:id/notification_settings', + notificationSettingsPath: '/api/:version/notification_settings', group(groupId, callback = () => {}) { const url = Api.buildUrl(Api.groupPath).replace(':id', groupId); @@ -179,9 +182,9 @@ const Api = { }); }, - groupLabels(namespace) { + groupLabels(namespace, options = {}) { const url = Api.buildUrl(Api.groupLabelsPath).replace(':namespace_path', namespace); - return axios.get(url).then(({ data }) => data); + return axios.get(url, options).then(({ data }) => data); }, // Return namespaces list. Filtered by query @@ -442,10 +445,11 @@ const Api = { }); }, - applySuggestion(id, message) { + applySuggestion(id, message = '') { const url = Api.buildUrl(Api.applySuggestionPath).replace(':id', encodeURIComponent(id)); + const params = gon.features?.suggestionsCustomCommit ? { commit_message: message } : false; - return axios.put(url, { commit_message: message }); + return axios.put(url, params); }, applySuggestionBatch(ids) { @@ -905,6 +909,34 @@ const Api = { return { data, headers }; }); }, + + async updateNotificationSettings(projectId, groupId, data = {}) { + let url = Api.buildUrl(this.notificationSettingsPath); + + if (projectId) { + url = Api.buildUrl(this.projectNotificationSettingsPath).replace(':id', projectId); + } else if (groupId) { + url = Api.buildUrl(this.groupNotificationSettingsPath).replace(':id', groupId); + } + + const result = await axios.put(url, data); + + return result; + }, + + async getNotificationSettings(projectId, groupId) { + let url = Api.buildUrl(this.notificationSettingsPath); + + if (projectId) { + url = Api.buildUrl(this.projectNotificationSettingsPath).replace(':id', projectId); + } else if (groupId) { + url = Api.buildUrl(this.groupNotificationSettingsPath).replace(':id', groupId); + } + + const result = await axios.get(url); + + return result; + }, }; export default Api; diff --git a/app/assets/javascripts/api/user_api.js b/app/assets/javascripts/api/user_api.js index e5983ec3c58..5efc7063efa 100644 --- a/app/assets/javascripts/api/user_api.js +++ b/app/assets/javascripts/api/user_api.js @@ -1,8 +1,8 @@ +import { deprecatedCreateFlash as flash } from '~/flash'; +import { __ } from '~/locale'; import axios from '../lib/utils/axios_utils'; import { buildApiUrl } from './api_utils'; import { DEFAULT_PER_PAGE } from './constants'; -import { deprecatedCreateFlash as flash } from '~/flash'; -import { __ } from '~/locale'; const USER_COUNTS_PATH = '/api/:version/user_counts'; const USERS_PATH = '/api/:version/users.json'; diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 22717a3f84c..288acd1b2f1 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -4,12 +4,12 @@ import $ from 'jquery'; import { uniq } from 'lodash'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import Cookies from 'js-cookie'; +import * as Emoji from '~/emoji'; +import { dispose, fixTitle } from '~/tooltips'; import { __ } from './locale'; import { isInVueNoteablePage } from './lib/utils/dom_utils'; import { deprecatedCreateFlash as flash } from './flash'; import axios from './lib/utils/axios_utils'; -import * as Emoji from '~/emoji'; -import { dispose, fixTitle } from '~/tooltips'; const animationEndEventString = 'animationend webkitAnimationEnd MSAnimationEnd oAnimationEnd'; const transitionEndEventString = 'transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd'; diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue index 9e09f527a39..0beb6bed9ea 100644 --- a/app/assets/javascripts/badges/components/badge_form.vue +++ b/app/assets/javascripts/badges/components/badge_form.vue @@ -179,7 +179,7 @@ export default { id="badge-link-url" v-model="linkUrl" type="URL" - class="form-control" + class="form-control gl-form-input" required @input="debouncedPreview" /> @@ -194,7 +194,7 @@ export default { id="badge-image-url" v-model="imageUrl" type="URL" - class="form-control" + class="form-control gl-form-input" required @input="debouncedPreview" /> diff --git a/app/assets/javascripts/badges/components/badge_list.vue b/app/assets/javascripts/badges/components/badge_list.vue index 04c2d4a7493..811ec6d333b 100644 --- a/app/assets/javascripts/badges/components/badge_list.vue +++ b/app/assets/javascripts/badges/components/badge_list.vue @@ -1,8 +1,8 @@ <script> import { mapState } from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; -import BadgeListRow from './badge_list_row.vue'; import { GROUP_BADGE } from '../constants'; +import BadgeListRow from './badge_list_row.vue'; export default { name: 'BadgeList', diff --git a/app/assets/javascripts/badges/store/actions.js b/app/assets/javascripts/badges/store/actions.js index 3377f6c0996..17a03564432 100644 --- a/app/assets/javascripts/badges/store/actions.js +++ b/app/assets/javascripts/badges/store/actions.js @@ -1,6 +1,6 @@ import axios from '~/lib/utils/axios_utils'; -import types from './mutation_types'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import types from './mutation_types'; export const transformBackendBadge = (badge) => ({ ...convertObjectPropsToCamelCase(badge, true), diff --git a/app/assets/javascripts/badges/store/mutations.js b/app/assets/javascripts/badges/store/mutations.js index 3f4689aeb17..9e27af21ed6 100644 --- a/app/assets/javascripts/badges/store/mutations.js +++ b/app/assets/javascripts/badges/store/mutations.js @@ -1,5 +1,5 @@ -import types from './mutation_types'; import { PROJECT_BADGE } from '../constants'; +import types from './mutation_types'; const reorderBadges = (badges) => badges.sort((a, b) => { diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue index 74069b61f07..5564bca6df0 100644 --- a/app/assets/javascripts/batch_comments/components/draft_note.vue +++ b/app/assets/javascripts/batch_comments/components/draft_note.vue @@ -3,8 +3,8 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import { GlButton } from '@gitlab/ui'; import NoteableNote from '~/notes/components/noteable_note.vue'; -import PublishButton from './publish_button.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import PublishButton from './publish_button.vue'; export default { components: { @@ -79,7 +79,6 @@ export default { </script> <template> <article - role="article" class="draft-note-component note-wrapper" @mouseenter="handleMouseEnter(draft)" @mouseleave="handleMouseLeave(draft)" diff --git a/app/assets/javascripts/batch_comments/components/preview_item.vue b/app/assets/javascripts/batch_comments/components/preview_item.vue index 3e93168f0e2..589734df795 100644 --- a/app/assets/javascripts/batch_comments/components/preview_item.vue +++ b/app/assets/javascripts/batch_comments/components/preview_item.vue @@ -3,13 +3,13 @@ import { mapGetters } from 'vuex'; import { GlSprintf, GlIcon } from '@gitlab/ui'; import { IMAGE_DIFF_POSITION_TYPE } from '~/diffs/constants'; import { sprintf, __ } from '~/locale'; -import resolvedStatusMixin from '../mixins/resolved_status'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { getStartLineNumber, getEndLineNumber, getLineClasses, } from '~/notes/components/multiline_comment_utils'; +import resolvedStatusMixin from '../mixins/resolved_status'; export default { components: { 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 a29409c52ae..bd7909bfa76 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 @@ -2,8 +2,8 @@ import { deprecatedCreateFlash as flash } from '~/flash'; import { __ } from '~/locale'; import { scrollToElement } from '~/lib/utils/common_utils'; import service from '../../../services/drafts_service'; -import * as types from './mutation_types'; import { CHANGES_TAB, DISCUSSION_TAB, SHOW_TAB } from '../../../constants'; +import * as types from './mutation_types'; export const saveDraft = ({ dispatch }, draft) => dispatch('saveNote', { ...draft, isDraft: true }, { root: true }); @@ -67,13 +67,23 @@ export const publishReview = ({ commit, dispatch, getters }) => { .catch(() => commit(types.RECEIVE_PUBLISH_REVIEW_ERROR)); }; -export const updateDiscussionsAfterPublish = ({ dispatch, getters, rootGetters }) => - dispatch('fetchDiscussions', { path: getters.getNotesData.discussionsPath }, { root: true }).then( - () => - dispatch('diffs/assignDiscussionsToDiff', rootGetters.discussionsStructuredByLineCode, { - root: true, - }), - ); +export const updateDiscussionsAfterPublish = async ({ dispatch, getters, rootGetters }) => { + if (window.gon?.features?.paginatedNotes) { + await dispatch('stopPolling', null, { root: true }); + await dispatch('fetchData', null, { root: true }); + await dispatch('restartPolling', null, { root: true }); + } else { + await dispatch( + 'fetchDiscussions', + { path: getters.getNotesData.discussionsPath }, + { root: true }, + ); + } + + dispatch('diffs/assignDiscussionsToDiff', rootGetters.discussionsStructuredByLineCode, { + root: true, + }); +}; export const updateDraft = ( { commit, getters }, diff --git a/app/assets/javascripts/behaviors/autosize.js b/app/assets/javascripts/behaviors/autosize.js index a5404539c17..181d841a068 100644 --- a/app/assets/javascripts/behaviors/autosize.js +++ b/app/assets/javascripts/behaviors/autosize.js @@ -1,13 +1,11 @@ import Autosize from 'autosize'; import { waitForCSSLoaded } from '~/helpers/startup_css_helper'; -document.addEventListener('DOMContentLoaded', () => { - waitForCSSLoaded(() => { - const autosizeEls = document.querySelectorAll('.js-autosize'); +waitForCSSLoaded(() => { + const autosizeEls = document.querySelectorAll('.js-autosize'); - Autosize(autosizeEls); - Autosize.update(autosizeEls); + Autosize(autosizeEls); + Autosize.update(autosizeEls); - autosizeEls.forEach((el) => el.classList.add('js-autosize-initialized')); - }); + autosizeEls.forEach((el) => el.classList.add('js-autosize-initialized')); }); diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js index 75659bbf685..669bc90dcb9 100644 --- a/app/assets/javascripts/behaviors/index.js +++ b/app/assets/javascripts/behaviors/index.js @@ -25,19 +25,17 @@ initPageShortcuts(); initCollapseSidebarOnWindowResize(); initSelect2Dropdowns(); -document.addEventListener('DOMContentLoaded', () => { - window.requestIdleCallback( - () => { - // Check if we have to Load GFM Input - const $gfmInputs = $('.js-gfm-input:not(.js-gfm-input-initialized)'); - if ($gfmInputs.length) { - import(/* webpackChunkName: 'initGFMInput' */ './markdown/gfm_auto_complete') - .then(({ default: initGFMInput }) => { - initGFMInput($gfmInputs); - }) - .catch(() => {}); - } - }, - { timeout: 500 }, - ); -}); +window.requestIdleCallback( + () => { + // Check if we have to Load GFM Input + const $gfmInputs = $('.js-gfm-input:not(.js-gfm-input-initialized)'); + if ($gfmInputs.length) { + import(/* webpackChunkName: 'initGFMInput' */ './markdown/gfm_auto_complete') + .then(({ default: initGFMInput }) => { + initGFMInput($gfmInputs); + }) + .catch(() => {}); + } + }, + { timeout: 500 }, +); diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js b/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js index 6e3c16f0a08..2cb2bb9e7fe 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js @@ -1,7 +1,7 @@ /* eslint-disable class-methods-use-this */ -import TableRow from './table_row'; import { HIGHER_PARSE_RULE_PRIORITY } from '../constants'; +import TableRow from './table_row'; const CENTER_ALIGN = 'center'; diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js index 5e9d80e1529..e0d5f34ba06 100644 --- a/app/assets/javascripts/behaviors/markdown/render_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js @@ -1,10 +1,10 @@ import $ from 'jquery'; import syntaxHighlight from '~/syntax_highlight'; +import initUserPopovers from '../../user_popovers'; import renderMath from './render_math'; import renderMermaid from './render_mermaid'; import renderMetrics from './render_metrics'; import highlightCurrentUser from './highlight_current_user'; -import initUserPopovers from '../../user_popovers'; // Render GitLab flavoured Markdown // diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js index 479782a1f1f..0cb13815c7e 100644 --- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js +++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js @@ -140,7 +140,7 @@ function renderMermaids($els) { 'Warning: Displaying this diagram might cause performance issues on this page.', )}</div> <div class="gl-alert-actions"> - <button class="js-lazy-render-mermaid btn gl-alert-action btn-warning btn-md new-gl-button">Display</button> + <button class="js-lazy-render-mermaid btn gl-alert-action btn-warning btn-md gl-button">Display</button> </div> </div> <button type="button" class="close" data-dismiss="alert" aria-label="Close"> diff --git a/app/assets/javascripts/behaviors/quick_submit.js b/app/assets/javascripts/behaviors/quick_submit.js index 68e831252d6..12a2baed6e2 100644 --- a/app/assets/javascripts/behaviors/quick_submit.js +++ b/app/assets/javascripts/behaviors/quick_submit.js @@ -1,8 +1,8 @@ import $ from 'jquery'; import '../commons/bootstrap'; -import { isInIssuePage } from '../lib/utils/common_utils'; import { __ } from '~/locale'; import { add, show, hide } from '~/tooltips'; +import { isInIssuePage } from '../lib/utils/common_utils'; // Quick Submit behavior // diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js index 10832583783..87e78de99b8 100644 --- a/app/assets/javascripts/behaviors/shortcuts/keybindings.js +++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js @@ -29,6 +29,7 @@ export const customizations = parsedCustomizations; // All available commands export const TOGGLE_PERFORMANCE_BAR = 'globalShortcuts.togglePerformanceBar'; +export const TOGGLE_CANARY = 'globalShortcuts.toggleCanary'; /** All keybindings, grouped and ordered with descriptions */ export const keybindingGroups = [ @@ -42,6 +43,12 @@ export const keybindingGroups = [ // eslint-disable-next-line @gitlab/require-i18n-strings defaultKeys: ['p b'], }, + { + description: s__('KeyboardShortcuts|Toggle GitLab Next'), + command: TOGGLE_CANARY, + // eslint-disable-next-line @gitlab/require-i18n-strings + defaultKeys: ['g x'], + }, ], }, ] diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js index 50d2399b312..6cdf083378b 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js @@ -3,13 +3,13 @@ import Cookies from 'js-cookie'; import Mousetrap from 'mousetrap'; import Vue from 'vue'; import { flatten } from 'lodash'; -import { disableShortcuts, shouldDisableShortcuts } from './shortcuts_toggle'; -import ShortcutsToggle from './shortcuts_toggle.vue'; +import { parseBoolean, getCspNonceValue } from '~/lib/utils/common_utils'; import axios from '../../lib/utils/axios_utils'; import { refreshCurrentPage, visitUrl } from '../../lib/utils/url_utility'; import findAndFollowLink from '../../lib/utils/navigation_utility'; -import { parseBoolean, getCspNonceValue } from '~/lib/utils/common_utils'; -import { keysFor, TOGGLE_PERFORMANCE_BAR } from './keybindings'; +import { disableShortcuts, shouldDisableShortcuts } from './shortcuts_toggle'; +import ShortcutsToggle from './shortcuts_toggle.vue'; +import { keysFor, TOGGLE_PERFORMANCE_BAR, TOGGLE_CANARY } from './keybindings'; const defaultStopCallback = Mousetrap.prototype.stopCallback; Mousetrap.prototype.stopCallback = function customStopCallback(e, element, combo) { @@ -72,6 +72,7 @@ export default class Shortcuts { Mousetrap.bind('/', Shortcuts.focusSearch); Mousetrap.bind('f', this.focusFilter.bind(this)); Mousetrap.bind(keysFor(TOGGLE_PERFORMANCE_BAR), Shortcuts.onTogglePerfBar); + Mousetrap.bind(keysFor(TOGGLE_CANARY), Shortcuts.onToggleCanary); const findFileURL = document.body.dataset.findFile; @@ -124,6 +125,14 @@ export default class Shortcuts { refreshCurrentPage(); } + static onToggleCanary(e) { + e.preventDefault(); + const canaryCookieName = 'gitlab_canary'; + const currentValue = parseBoolean(Cookies.get(canaryCookieName)); + Cookies.set(canaryCookieName, (!currentValue).toString(), { expires: 365, path: '/' }); + refreshCurrentPage(); + } + static toggleMarkdownPreview(e) { // Check if short-cut was triggered while in Write Mode const $target = $(e.target); diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js index 5e8ddeb6af7..14b6ca4474b 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js @@ -1,11 +1,11 @@ import $ from 'jquery'; import Mousetrap from 'mousetrap'; -import Sidebar from '../../right_sidebar'; -import Shortcuts from './shortcuts'; -import { CopyAsGFM } from '../markdown/copy_as_gfm'; import { getSelectedFragment } from '~/lib/utils/common_utils'; import { isElementVisible } from '~/lib/utils/dom_utils'; import { clickCopyToClipboardButton } from '~/behaviors/copy_to_clipboard'; +import { CopyAsGFM } from '../markdown/copy_as_gfm'; +import Sidebar from '../../right_sidebar'; +import Shortcuts from './shortcuts'; export default class ShortcutsIssuable extends Shortcuts { constructor() { diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js index 8b7e6a56d25..c609936a02a 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js @@ -1,6 +1,6 @@ import Mousetrap from 'mousetrap'; -import ShortcutsNavigation from './shortcuts_navigation'; import findAndFollowLink from '../../lib/utils/navigation_utility'; +import ShortcutsNavigation from './shortcuts_navigation'; export default class ShortcutsWiki extends ShortcutsNavigation { constructor() { diff --git a/app/assets/javascripts/blob/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq_viewer.js index fc86f630c4e..c9152db509a 100644 --- a/app/assets/javascripts/blob/balsamiq_viewer.js +++ b/app/assets/javascripts/blob/balsamiq_viewer.js @@ -1,6 +1,6 @@ +import { __ } from '~/locale'; import { deprecatedCreateFlash as Flash } from '../flash'; import BalsamiqViewer from './balsamiq/balsamiq_viewer'; -import { __ } from '~/locale'; function onError() { const flash = new Flash(__('Balsamiq file could not be loaded.')); diff --git a/app/assets/javascripts/blob/blob_file_dropzone.js b/app/assets/javascripts/blob/blob_file_dropzone.js index 19bad64155d..f3e273bf082 100644 --- a/app/assets/javascripts/blob/blob_file_dropzone.js +++ b/app/assets/javascripts/blob/blob_file_dropzone.js @@ -2,10 +2,10 @@ import $ from 'jquery'; import Dropzone from 'dropzone'; +import { sprintf, __ } from '~/locale'; import { visitUrl } from '../lib/utils/url_utility'; import { HIDDEN_CLASS } from '../lib/utils/constants'; import csrf from '../lib/utils/csrf'; -import { sprintf, __ } from '~/locale'; Dropzone.autoDiscover = false; diff --git a/app/assets/javascripts/blob/components/blob_header.vue b/app/assets/javascripts/blob/components/blob_header.vue index a4a43b7a94e..8960d45b29c 100644 --- a/app/assets/javascripts/blob/components/blob_header.vue +++ b/app/assets/javascripts/blob/components/blob_header.vue @@ -71,7 +71,7 @@ export default { </template> </blob-filepath> - <div class="gl-display-none gl-display-sm-flex"> + <div class="gl-display-none gl-sm-display-flex"> <viewer-switcher v-if="showViewerSwitcher" v-model="viewer" /> <slot name="actions"></slot> diff --git a/app/assets/javascripts/blob/template_selectors/ci_syntax_yaml_selector.js b/app/assets/javascripts/blob/template_selectors/ci_syntax_yaml_selector.js index 9370e170571..c30ff4f1290 100644 --- a/app/assets/javascripts/blob/template_selectors/ci_syntax_yaml_selector.js +++ b/app/assets/javascripts/blob/template_selectors/ci_syntax_yaml_selector.js @@ -1,5 +1,5 @@ -import FileTemplateSelector from '../file_template_selector'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; +import FileTemplateSelector from '../file_template_selector'; export default class BlobCiSyntaxYamlSelector extends FileTemplateSelector { constructor({ mediator }) { diff --git a/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js b/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js index 3879a6c5742..0cdfd153675 100644 --- a/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js +++ b/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js @@ -1,5 +1,5 @@ -import FileTemplateSelector from '../file_template_selector'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; +import FileTemplateSelector from '../file_template_selector'; export default class BlobCiYamlSelector extends FileTemplateSelector { constructor({ mediator }) { diff --git a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js index 5d976c5acdb..42c7a4bd408 100644 --- a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js +++ b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js @@ -1,6 +1,6 @@ -import FileTemplateSelector from '../file_template_selector'; import { __ } from '~/locale'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; +import FileTemplateSelector from '../file_template_selector'; export default class DockerfileSelector extends FileTemplateSelector { constructor({ mediator }) { diff --git a/app/assets/javascripts/blob/template_selectors/gitignore_selector.js b/app/assets/javascripts/blob/template_selectors/gitignore_selector.js index 1bb1cbb74de..50a11692e98 100644 --- a/app/assets/javascripts/blob/template_selectors/gitignore_selector.js +++ b/app/assets/javascripts/blob/template_selectors/gitignore_selector.js @@ -1,5 +1,5 @@ -import FileTemplateSelector from '../file_template_selector'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; +import FileTemplateSelector from '../file_template_selector'; export default class BlobGitignoreSelector extends FileTemplateSelector { constructor({ mediator }) { diff --git a/app/assets/javascripts/blob/template_selectors/license_selector.js b/app/assets/javascripts/blob/template_selectors/license_selector.js index affa20997e9..4ae5dc70a70 100644 --- a/app/assets/javascripts/blob/template_selectors/license_selector.js +++ b/app/assets/javascripts/blob/template_selectors/license_selector.js @@ -1,5 +1,5 @@ -import FileTemplateSelector from '../file_template_selector'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; +import FileTemplateSelector from '../file_template_selector'; export default class BlobLicenseSelector extends FileTemplateSelector { constructor({ mediator }) { diff --git a/app/assets/javascripts/blob/template_selectors/metrics_dashboard_selector.js b/app/assets/javascripts/blob/template_selectors/metrics_dashboard_selector.js index 42adab05ce3..8b10b02ae1d 100644 --- a/app/assets/javascripts/blob/template_selectors/metrics_dashboard_selector.js +++ b/app/assets/javascripts/blob/template_selectors/metrics_dashboard_selector.js @@ -1,5 +1,5 @@ -import FileTemplateSelector from '../file_template_selector'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; +import FileTemplateSelector from '../file_template_selector'; export default class MetricsDashboardSelector extends FileTemplateSelector { constructor({ mediator }) { diff --git a/app/assets/javascripts/blob/template_selectors/type_selector.js b/app/assets/javascripts/blob/template_selectors/type_selector.js index f74f7535d99..65e7ff0594c 100644 --- a/app/assets/javascripts/blob/template_selectors/type_selector.js +++ b/app/assets/javascripts/blob/template_selectors/type_selector.js @@ -1,5 +1,5 @@ -import FileTemplateSelector from '../file_template_selector'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; +import FileTemplateSelector from '../file_template_selector'; export default class FileTemplateTypeSelector extends FileTemplateSelector { constructor({ mediator, dropdownData }) { diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js index 4e6ec20ec64..3a55e18d2ff 100644 --- a/app/assets/javascripts/blob/viewer/index.js +++ b/app/assets/javascripts/blob/viewer/index.js @@ -1,11 +1,11 @@ import $ from 'jquery'; import '~/behaviors/markdown/render_gfm'; +import { __ } from '~/locale'; +import { fixTitle } from '~/tooltips'; import { deprecatedCreateFlash as Flash } from '../../flash'; import { handleLocationHash } from '../../lib/utils/common_utils'; import axios from '../../lib/utils/axios_utils'; import eventHub from '../../notes/event_hub'; -import { __ } from '~/locale'; -import { fixTitle } from '~/tooltips'; const loadRichBlobViewer = (type) => { switch (type) { diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js index 7c2217a59e9..76c88efc12b 100644 --- a/app/assets/javascripts/blob_edit/blob_bundle.js +++ b/app/assets/javascripts/blob_edit/blob_bundle.js @@ -1,12 +1,12 @@ /* eslint-disable no-new */ import $ from 'jquery'; -import NewCommitForm from '../new_commit_form'; import { deprecatedCreateFlash as createFlash } from '~/flash'; -import BlobFileDropzone from '../blob/blob_file_dropzone'; import initPopover from '~/blob/suggest_gitlab_ci_yml'; import { disableButtonIfEmptyField, setCookie } from '~/lib/utils/common_utils'; import Tracking from '~/tracking'; +import BlobFileDropzone from '../blob/blob_file_dropzone'; +import NewCommitForm from '../new_commit_form'; const initPopovers = () => { const suggestEl = document.querySelector('.js-suggest-gitlab-ci-yml'); diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js index c7f66a357f3..f3b0f4ab57c 100644 --- a/app/assets/javascripts/blob_edit/edit_blob.js +++ b/app/assets/javascripts/blob_edit/edit_blob.js @@ -1,12 +1,12 @@ import $ from 'jquery'; import axios from '~/lib/utils/axios_utils'; import { deprecatedCreateFlash as createFlash } from '~/flash'; -import { BLOB_EDITOR_ERROR, BLOB_PREVIEW_ERROR } from './constants'; -import TemplateSelectorMediator from '../blob/file_template_mediator'; import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown'; import EditorLite from '~/editor/editor_lite'; import { FileTemplateExtension } from '~/editor/extensions/editor_file_template_ext'; import { insertFinalNewline } from '~/lib/utils/text_utility'; +import TemplateSelectorMediator from '../blob/file_template_mediator'; +import { BLOB_EDITOR_ERROR, BLOB_PREVIEW_ERROR } from './constants'; export default class EditBlob { // The options object has: diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index 965d3571f42..13ad820477f 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -1,6 +1,6 @@ import { sortBy } from 'lodash'; -import { ListType } from './constants'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { ListType, NOT_FILTER } from './constants'; export function getMilestone() { return null; @@ -144,6 +144,17 @@ export function isListDraggable(list) { return list.listType !== ListType.backlog && list.listType !== ListType.closed; } +export function transformNotFilters(filters) { + return Object.keys(filters) + .filter((key) => key.startsWith(NOT_FILTER)) + .reduce((obj, key) => { + return { + ...obj, + [key.substring(4, key.length - 1)]: filters[key], + }; + }, {}); +} + // EE-specific feature. Find the implementation in the `ee/`-folder export function transformBoardConfig() { return ''; @@ -157,4 +168,5 @@ export default { fullLabelId, fullIterationId, isListDraggable, + transformNotFilters, }; diff --git a/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue b/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue new file mode 100644 index 00000000000..ea68df9ce12 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue @@ -0,0 +1,21 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { mapActions } from 'vuex'; + +export default { + components: { + GlButton, + }, + methods: { + ...mapActions(['setAddColumnFormVisibility']), + }, +}; +</script> + +<template> + <span class="gl-ml-4"> + <gl-button variant="success" @click="setAddColumnFormVisibility(true)" + >{{ __('Create list') }} + </gl-button> + </span> +</template> diff --git a/app/assets/javascripts/boards/components/board_assignee_dropdown.vue b/app/assets/javascripts/boards/components/board_assignee_dropdown.vue index 5d381f9a570..c5cb61ae21c 100644 --- a/app/assets/javascripts/boards/components/board_assignee_dropdown.vue +++ b/app/assets/javascripts/boards/components/board_assignee_dropdown.vue @@ -39,50 +39,51 @@ export default { data() { return { search: '', - participants: [], + issueParticipants: [], selected: [], }; }, apollo: { - participants: { - query() { - return this.isSearchEmpty ? getIssueParticipants : searchUsers; - }, + issueParticipants: { + query: getIssueParticipants, variables() { - if (this.isSearchEmpty) { - return { - id: `gid://gitlab/Issue/${this.activeIssue.iid}`, - }; - } - return { - search: this.search, + id: `gid://gitlab/Issue/${this.activeIssue.iid}`, }; }, update(data) { - if (this.isSearchEmpty) { - return data.issue?.participants?.nodes || []; - } - - return data.users?.nodes || []; + return data.issue?.participants?.nodes || []; }, - debounce() { - const { noSearchDelay, searchDelay } = this.$options; - - return this.isSearchEmpty ? noSearchDelay : searchDelay; + }, + searchUsers: { + query: searchUsers, + variables() { + return { + search: this.search, + }; }, + update: (data) => data.users?.nodes || [], + skip() { + return this.isSearchEmpty; + }, + debounce: 250, }, }, computed: { ...mapGetters(['activeIssue']), ...mapState(['isSettingAssignees']), + participants() { + return this.isSearchEmpty ? this.issueParticipants : this.searchUsers; + }, assigneeText() { return n__('Assignee', '%d Assignees', this.selected.length); }, unSelectedFiltered() { - return this.participants.filter(({ username }) => { - return !this.selectedUserNames.includes(username); - }); + return ( + this.participants?.filter(({ username }) => { + return !this.selectedUserNames.includes(username); + }) || [] + ); }, selectedIsEmpty() { return this.selected.length === 0; @@ -96,6 +97,11 @@ export default { currentUser() { return gon?.current_username; }, + isLoading() { + return ( + this.$apollo.queries.issueParticipants?.loading || this.$apollo.queries.searchUsers?.loading + ); + }, }, created() { this.selected = cloneDeep(this.activeIssue.assignees); @@ -147,7 +153,7 @@ export default { <gl-search-box-by-type v-model.trim="search" /> </template> <template #items> - <gl-loading-icon v-if="$apollo.queries.participants.loading" size="lg" /> + <gl-loading-icon v-if="isLoading" size="lg" /> <template v-else> <gl-dropdown-item :is-checked="selectedIsEmpty" diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue index 31050eef83d..e6009343626 100644 --- a/app/assets/javascripts/boards/components/board_card.vue +++ b/app/assets/javascripts/boards/components/board_card.vue @@ -1,13 +1,14 @@ <script> -import BoardCardLayout from './board_card_layout.vue'; -import eventHub from '../eventhub'; import sidebarEventHub from '~/sidebar/event_hub'; +import eventHub from '../eventhub'; import boardsStore from '../stores/boards_store'; +import BoardCardLayout from './board_card_layout.vue'; +import BoardCardLayoutDeprecated from './board_card_layout_deprecated.vue'; export default { name: 'BoardsIssueCard', components: { - BoardCardLayout, + BoardCardLayout: gon.features?.graphqlBoardLists ? BoardCardLayout : BoardCardLayoutDeprecated, }, props: { list: { diff --git a/app/assets/javascripts/boards/components/board_card_layout.vue b/app/assets/javascripts/boards/components/board_card_layout.vue index 0a2301394c1..5e3c3702519 100644 --- a/app/assets/javascripts/boards/components/board_card_layout.vue +++ b/app/assets/javascripts/boards/components/board_card_layout.vue @@ -1,17 +1,13 @@ <script> -import { mapActions, mapGetters } from 'vuex'; -import IssueCardInner from './issue_card_inner.vue'; -import IssueCardInnerDeprecated from './issue_card_inner_deprecated.vue'; -import boardsStore from '../stores/boards_store'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { mapActions, mapGetters, mapState } from 'vuex'; import { ISSUABLE } from '~/boards/constants'; +import IssueCardInner from './issue_card_inner.vue'; export default { name: 'BoardCardLayout', components: { - IssueCardInner: gon.features?.graphqlBoardLists ? IssueCardInner : IssueCardInnerDeprecated, + IssueCardInner, }, - mixins: [glFeatureFlagMixin()], props: { list: { type: Object, @@ -42,17 +38,17 @@ export default { data() { return { showDetail: false, - multiSelect: boardsStore.multiSelect, }; }, computed: { + ...mapState(['selectedBoardItems']), ...mapGetters(['isSwimlanesOn']), multiSelectVisible() { - return this.multiSelect.list.findIndex((issue) => issue.id === this.issue.id) > -1; + return this.selectedBoardItems.findIndex((boardItem) => boardItem.id === this.issue.id) > -1; }, }, methods: { - ...mapActions(['setActiveId']), + ...mapActions(['setActiveId', 'toggleBoardItemMultiSelection']), mouseDown() { this.showDetail = true; }, @@ -63,16 +59,16 @@ export default { // Don't do anything if this happened on a no trigger element if (e.target.classList.contains('js-no-trigger')) return; - if (this.glFeatures.graphqlBoardLists || this.isSwimlanesOn) { + const isMultiSelect = e.ctrlKey || e.metaKey; + + if (!isMultiSelect) { this.setActiveId({ id: this.issue.id, sidebarType: ISSUABLE }); - return; + } else { + this.toggleBoardItemMultiSelection(this.issue); } - const isMultiSelect = e.ctrlKey || e.metaKey; - if (this.showDetail || isMultiSelect) { this.showDetail = false; - this.$emit('show', { event: e, isMultiSelect }); } }, }, diff --git a/app/assets/javascripts/boards/components/board_card_layout_deprecated.vue b/app/assets/javascripts/boards/components/board_card_layout_deprecated.vue new file mode 100644 index 00000000000..78a581690fd --- /dev/null +++ b/app/assets/javascripts/boards/components/board_card_layout_deprecated.vue @@ -0,0 +1,102 @@ +<script> +import { mapActions, mapGetters } from 'vuex'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { ISSUABLE } from '~/boards/constants'; +import boardsStore from '../stores/boards_store'; +import IssueCardInner from './issue_card_inner.vue'; +import IssueCardInnerDeprecated from './issue_card_inner_deprecated.vue'; + +export default { + name: 'BoardCardLayout', + components: { + IssueCardInner: gon.features?.graphqlBoardLists ? IssueCardInner : IssueCardInnerDeprecated, + }, + mixins: [glFeatureFlagMixin()], + props: { + list: { + type: Object, + default: () => ({}), + required: false, + }, + issue: { + type: Object, + default: () => ({}), + required: false, + }, + disabled: { + type: Boolean, + default: false, + required: false, + }, + index: { + type: Number, + default: 0, + required: false, + }, + isActive: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + showDetail: false, + multiSelect: boardsStore.multiSelect, + }; + }, + computed: { + ...mapGetters(['isSwimlanesOn']), + multiSelectVisible() { + return this.multiSelect.list.findIndex((issue) => issue.id === this.issue.id) > -1; + }, + }, + methods: { + ...mapActions(['setActiveId']), + mouseDown() { + this.showDetail = true; + }, + mouseMove() { + this.showDetail = false; + }, + showIssue(e) { + // Don't do anything if this happened on a no trigger element + if (e.target.classList.contains('js-no-trigger')) return; + + if (this.glFeatures.graphqlBoardLists || this.isSwimlanesOn) { + this.setActiveId({ id: this.issue.id, sidebarType: ISSUABLE }); + return; + } + + const isMultiSelect = e.ctrlKey || e.metaKey; + + if (this.showDetail || isMultiSelect) { + this.showDetail = false; + this.$emit('show', { event: e, isMultiSelect }); + } + }, + }, +}; +</script> + +<template> + <li + :class="{ + 'multi-select': multiSelectVisible, + 'user-can-drag': !disabled && issue.id, + 'is-disabled': disabled || !issue.id, + 'is-active': isActive, + }" + :index="index" + :data-issue-id="issue.id" + :data-issue-iid="issue.iid" + :data-issue-path="issue.referencePath" + data-testid="board_card" + class="board-card gl-p-5 gl-rounded-base" + @mousedown="mouseDown" + @mousemove="mouseMove" + @mouseup="showIssue($event)" + > + <issue-card-inner :list="list" :issue="issue" :update-filters="true" /> + </li> +</template> diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index 9f0eef844f6..2586351208c 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -1,8 +1,8 @@ <script> import { mapGetters, mapActions, mapState } from 'vuex'; import BoardListHeader from 'ee_else_ce/boards/components/board_list_header.vue'; -import BoardList from './board_list.vue'; import { isListDraggable } from '../boards_util'; +import BoardList from './board_list.vue'; export default { components: { diff --git a/app/assets/javascripts/boards/components/board_column_deprecated.vue b/app/assets/javascripts/boards/components/board_column_deprecated.vue index 35688efceb4..a8f1577d092 100644 --- a/app/assets/javascripts/boards/components/board_column_deprecated.vue +++ b/app/assets/javascripts/boards/components/board_column_deprecated.vue @@ -2,9 +2,9 @@ // This component is being replaced in favor of './board_column.vue' for GraphQL boards import Sortable from 'sortablejs'; import BoardListHeader from 'ee_else_ce/boards/components/board_list_header_deprecated.vue'; -import BoardList from './board_list_deprecated.vue'; import boardsStore from '../stores/boards_store'; import { getBoardSortableDefaultOptions, sortableEnd } from '../mixins/sortable_default_options'; +import BoardList from './board_list_deprecated.vue'; export default { components: { @@ -46,6 +46,7 @@ export default { watch: { filter: { handler() { + // eslint-disable-next-line vue/no-mutating-props this.list.page = 1; this.list.getIssues(true).catch(() => { // TODO: handle request error diff --git a/app/assets/javascripts/boards/components/board_configuration_options.vue b/app/assets/javascripts/boards/components/board_configuration_options.vue index b8ee930a8c9..4d79f2a4bc6 100644 --- a/app/assets/javascripts/boards/components/board_configuration_options.vue +++ b/app/assets/javascripts/boards/components/board_configuration_options.vue @@ -14,6 +14,10 @@ export default { type: Boolean, required: true, }, + readonly: { + type: Boolean, + required: true, + }, }, }; </script> @@ -28,12 +32,14 @@ export default { </p> <gl-form-checkbox :checked="!hideBacklogList" + :disabled="readonly" data-testid="backlog-list-checkbox" @change="$emit('update:hideBacklogList', !hideBacklogList)" >{{ __('Show the Open list') }} </gl-form-checkbox> <gl-form-checkbox :checked="!hideClosedList" + :disabled="readonly" data-testid="closed-list-checkbox" @change="$emit('update:hideClosedList', !hideClosedList)" >{{ __('Show the Closed list') }} diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index 19254343208..49c6a144b1a 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -3,11 +3,11 @@ import Draggable from 'vuedraggable'; import { mapState, mapGetters, mapActions } from 'vuex'; import { sortBy } from 'lodash'; import { GlAlert } from '@gitlab/ui'; -import BoardColumnDeprecated from './board_column_deprecated.vue'; -import BoardColumn from './board_column.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import defaultSortableConfig from '~/sortable/sortable_config'; import { sortableEnd, sortableStart } from '~/boards/mixins/sortable_default_options'; +import BoardColumn from './board_column.vue'; +import BoardColumnDeprecated from './board_column_deprecated.vue'; export default { components: { diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index c701ecd3040..879f62ee6ff 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -8,10 +8,10 @@ import { convertToGraphQLId } from '~/graphql_shared/utils'; import boardsStore from '~/boards/stores/boards_store'; import { fullLabelId, fullBoardId } from '../boards_util'; -import BoardConfigurationOptions from './board_configuration_options.vue'; import updateBoardMutation from '../graphql/board_update.mutation.graphql'; import createBoardMutation from '../graphql/board_create.mutation.graphql'; import destroyBoardMutation from '../graphql/board_destroy.mutation.graphql'; +import BoardConfigurationOptions from './board_configuration_options.vue'; const boardDefaults = { id: false, @@ -308,6 +308,7 @@ export default { <board-configuration-options :hide-backlog-list.sync="board.hide_backlog_list" :hide-closed-list.sync="board.hide_closed_list" + :readonly="readonly" /> <board-scope diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index b6e4d0980fa..d3c12d2b86d 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -4,10 +4,10 @@ import { mapActions, mapState } from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; import defaultSortableConfig from '~/sortable/sortable_config'; import { sortableStart, sortableEnd } from '~/boards/mixins/sortable_default_options'; +import { sprintf, __ } from '~/locale'; +import eventHub from '../eventhub'; import BoardNewIssue from './board_new_issue.vue'; import BoardCard from './board_card.vue'; -import eventHub from '../eventhub'; -import { sprintf, __ } from '~/locale'; export default { name: 'BoardList', diff --git a/app/assets/javascripts/boards/components/board_list_deprecated.vue b/app/assets/javascripts/boards/components/board_list_deprecated.vue index 24900346bda..72d98149153 100644 --- a/app/assets/javascripts/boards/components/board_list_deprecated.vue +++ b/app/assets/javascripts/boards/components/board_list_deprecated.vue @@ -1,17 +1,18 @@ <script> import { Sortable, MultiDrag } from 'sortablejs'; import { GlLoadingIcon } from '@gitlab/ui'; -import boardNewIssue from './board_new_issue_deprecated.vue'; -import boardCard from './board_card.vue'; -import eventHub from '../eventhub'; -import boardsStore from '../stores/boards_store'; import { sprintf, __ } from '~/locale'; import { deprecatedCreateFlash as createFlash } from '~/flash'; +import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; +import eventHub from '../eventhub'; +import boardsStore from '../stores/boards_store'; import { getBoardSortableDefaultOptions, sortableStart, sortableEnd, } from '../mixins/sortable_default_options'; +import boardCard from './board_card.vue'; +import boardNewIssue from './board_new_issue_deprecated.vue'; // This component is being replaced in favor of './board_list.vue' for GraphQL boards @@ -63,6 +64,7 @@ export default { watch: { filters: { handler() { + // eslint-disable-next-line vue/no-mutating-props this.list.loadingMore = false; this.$refs.list.scrollTop = 0; }, @@ -75,6 +77,7 @@ export default { this.list.issuesSize > this.list.issues.length && this.list.isExpanded ) { + // eslint-disable-next-line vue/no-mutating-props this.list.page += 1; this.list.getIssues(false).catch(() => { // TODO: handle request error @@ -165,7 +168,7 @@ export default { boardsStore.startMoving(list, issue); - this.$root.$emit('bv::hide::tooltip'); + this.$root.$emit(BV_HIDE_TOOLTIP); sortableStart(); }, @@ -283,6 +286,7 @@ export default { * issue indexes are far apart, this logic should ever kick in. */ setTimeout(() => { + // eslint-disable-next-line vue/no-mutating-props this.list.issues.splice(i, 1); }, 0); }); @@ -386,10 +390,12 @@ export default { loadNextPage() { const getIssues = this.list.nextPage(); const loadingDone = () => { + // eslint-disable-next-line vue/no-mutating-props this.list.loadingMore = false; }; if (getIssues) { + // eslint-disable-next-line vue/no-mutating-props this.list.loadingMore = true; getIssues.then(loadingDone).catch(loadingDone); } diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index 06f39eceb08..dc779609b86 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -10,13 +10,14 @@ import { GlTooltipDirective, } from '@gitlab/ui'; import { n__, s__, __ } from '~/locale'; -import AccessorUtilities from '../../lib/utils/accessor'; -import IssueCount from './issue_count.vue'; -import eventHub from '../eventhub'; import sidebarEventHub from '~/sidebar/event_hub'; -import { inactiveId, LIST, ListType } from '../constants'; import { isScopedLabel } from '~/lib/utils/common_utils'; import { isListDraggable } from '~/boards/boards_util'; +import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; +import { inactiveId, LIST, ListType } from '../constants'; +import eventHub from '../eventhub'; +import AccessorUtilities from '../../lib/utils/accessor'; +import IssueCount from './issue_count.vue'; export default { i18n: { @@ -85,16 +86,16 @@ export default { return !this.disabled && this.listType !== ListType.closed; }, showMilestoneListDetails() { - return ( - this.listType === ListType.milestone && - this.list.milestone && - (!this.list.collapsed || !this.isSwimlanesHeader) - ); + return this.listType === ListType.milestone && this.list.milestone && this.showListDetails; }, showAssigneeListDetails() { - return ( - this.listType === ListType.assignee && (!this.list.collapsed || !this.isSwimlanesHeader) - ); + return this.listType === ListType.assignee && this.showListDetails; + }, + showIterationListDetails() { + return this.listType === ListType.iteration && this.showListDetails; + }, + showListDetails() { + return !this.list.collapsed || !this.isSwimlanesHeader; }, issuesCount() { return this.list.issuesCount; @@ -147,6 +148,7 @@ export default { eventHub.$emit(`toggle-issue-form-${this.list.id}`); }, toggleExpanded() { + // eslint-disable-next-line vue/no-mutating-props this.list.collapsed = !this.list.collapsed; if (!this.isLoggedIn) { @@ -157,7 +159,7 @@ export default { // When expanding/collapsing, the tooltip on the caret button sometimes stays open. // Close all tooltips manually to prevent dangling tooltips. - this.$root.$emit('bv::hide::tooltip'); + this.$root.$emit(BV_HIDE_TOOLTIP); }, addToLocalStorage() { if (AccessorUtilities.isLocalStorageAccessSafe()) { @@ -216,6 +218,17 @@ export default { <gl-icon name="timer" /> </span> + <span + v-if="showIterationListDetails" + aria-hidden="true" + :class="{ + 'gl-mt-3 gl-rotate-90': list.collapsed, + 'gl-mr-2': !list.collapsed, + }" + > + <gl-icon name="iteration" /> + </span> + <a v-if="showAssigneeListDetails" :href="list.assignee.webUrl" diff --git a/app/assets/javascripts/boards/components/board_list_header_deprecated.vue b/app/assets/javascripts/boards/components/board_list_header_deprecated.vue index 21147f1616c..54cc56dcbeb 100644 --- a/app/assets/javascripts/boards/components/board_list_header_deprecated.vue +++ b/app/assets/javascripts/boards/components/board_list_header_deprecated.vue @@ -10,13 +10,14 @@ import { GlTooltipDirective, } from '@gitlab/ui'; import { n__, s__ } from '~/locale'; +import sidebarEventHub from '~/sidebar/event_hub'; +import { isScopedLabel } from '~/lib/utils/common_utils'; +import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import AccessorUtilities from '../../lib/utils/accessor'; -import IssueCount from './issue_count.vue'; import boardsStore from '../stores/boards_store'; import eventHub from '../eventhub'; -import sidebarEventHub from '~/sidebar/event_hub'; import { inactiveId, LIST, ListType } from '../constants'; -import { isScopedLabel } from '~/lib/utils/common_utils'; +import IssueCount from './issue_count.vue'; // This component is being replaced in favor of './board_list_header.vue' for GraphQL boards @@ -77,14 +78,16 @@ export default { return !this.disabled && this.listType !== ListType.closed; }, showMilestoneListDetails() { - return ( - this.list.type === 'milestone' && - this.list.milestone && - (this.list.isExpanded || !this.isSwimlanesHeader) - ); + return this.list.type === 'milestone' && this.list.milestone && this.showListDetails; }, showAssigneeListDetails() { - return this.list.type === 'assignee' && (this.list.isExpanded || !this.isSwimlanesHeader); + return this.list.type === 'assignee' && this.showListDetails; + }, + showIterationListDetails() { + return this.listType === ListType.iteration && this.showListDetails; + }, + showListDetails() { + return this.list.isExpanded || !this.isSwimlanesHeader; }, issuesCount() { return this.list.issuesSize; @@ -131,6 +134,7 @@ export default { eventHub.$emit(`toggle-issue-form-${this.list.id}`); }, toggleExpanded() { + // eslint-disable-next-line vue/no-mutating-props this.list.isExpanded = !this.list.isExpanded; if (!this.isLoggedIn) { @@ -141,7 +145,7 @@ export default { // When expanding/collapsing, the tooltip on the caret button sometimes stays open. // Close all tooltips manually to prevent dangling tooltips. - this.$root.$emit('bv::hide::tooltip'); + this.$root.$emit(BV_HIDE_TOOLTIP); }, addToLocalStorage() { if (AccessorUtilities.isLocalStorageAccessSafe()) { @@ -201,6 +205,17 @@ export default { <gl-icon name="timer" /> </span> + <span + v-if="showIterationListDetails" + aria-hidden="true" + :class="{ + 'gl-mt-3 gl-rotate-90': !list.isExpanded, + 'gl-mr-2': list.isExpanded, + }" + > + <gl-icon name="iteration" /> + </span> + <a v-if="showAssigneeListDetails" :href="list.assignee.path" diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index 14d28643046..29a88cf705e 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -2,10 +2,10 @@ import { mapActions, mapState } from 'vuex'; import { GlButton } from '@gitlab/ui'; import { getMilestone } from 'ee_else_ce/boards/boards_util'; -import eventHub from '../eventhub'; -import ProjectSelect from './project_select.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { __ } from '~/locale'; +import eventHub from '../eventhub'; +import ProjectSelect from './project_select.vue'; export default { name: 'BoardNewIssue', diff --git a/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue b/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue index 4fc58742783..eff87ff110e 100644 --- a/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue +++ b/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue @@ -2,10 +2,10 @@ import { GlButton } from '@gitlab/ui'; import { getMilestone } from 'ee_else_ce/boards/boards_util'; import ListIssue from 'ee_else_ce/boards/models/issue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import eventHub from '../eventhub'; -import ProjectSelect from './project_select_deprecated.vue'; import boardsStore from '../stores/boards_store'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import ProjectSelect from './project_select_deprecated.vue'; // This component is being replaced in favor of './board_new_issue.vue' for GraphQL boards diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue index f362fc60bd3..50831b074f4 100644 --- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue @@ -5,17 +5,13 @@ import { __ } from '~/locale'; import boardsStore from '~/boards/stores/boards_store'; import eventHub from '~/sidebar/event_hub'; import { isScopedLabel } from '~/lib/utils/common_utils'; -import { LIST } from '~/boards/constants'; +import { LIST, ListType, ListTypeTitles } from '~/boards/constants'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; // NOTE: need to revisit how we handle headerHeight, because we have so many different header and footer options. export default { headerHeight: process.env.NODE_ENV === 'development' ? '75px' : '40px', listSettingsText: __('List settings'), - assignee: 'assignee', - milestone: 'milestone', - label: 'label', - labelListText: __('Label'), components: { GlButton, GlDrawer, @@ -33,6 +29,11 @@ export default { default: false, }, }, + data() { + return { + ListType, + }; + }, computed: { ...mapGetters(['isSidebarOpen', 'shouldUseGraphQL']), ...mapState(['activeId', 'sidebarType', 'boardLists']), @@ -56,7 +57,7 @@ export default { return this.activeList.type || this.activeList.listType || null; }, listTypeTitle() { - return this.$options.labelListText; + return ListTypeTitles[ListType.label]; }, showSidebar() { return this.sidebarType === LIST; @@ -98,7 +99,7 @@ export default { > <template #header>{{ $options.listSettingsText }}</template> <template v-if="isSidebarOpen"> - <div v-if="boardListType === $options.label"> + <div v-if="boardListType === ListType.label"> <label class="js-list-label gl-display-block">{{ listTypeTitle }}</label> <gl-label :title="activeListLabel.title" diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index bf3dc5c608f..f9ab38b32c4 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -1,4 +1,7 @@ -/* eslint-disable no-new */ +// This is a true violation of @gitlab/no-runtime-template-compiler, as it +// relies on app/views/shared/boards/components/_sidebar.html.haml for its +// template. +/* eslint-disable no-new, @gitlab/no-runtime-template-compiler */ import $ from 'jquery'; import Vue from 'vue'; @@ -15,9 +18,9 @@ import Assignees from '~/sidebar/components/assignees/assignees.vue'; import Subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue'; import TimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue'; import MilestoneSelect from '~/milestone_select'; -import RemoveBtn from './sidebar/remove_issue.vue'; -import boardsStore from '../stores/boards_store'; import { isScopedLabel } from '~/lib/utils/common_utils'; +import boardsStore from '../stores/boards_store'; +import RemoveBtn from './sidebar/remove_issue.vue'; export default Vue.extend({ components: { diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index 457d0d4dcd6..8723c1bb7f3 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -5,13 +5,13 @@ import { GlLabel, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import issueCardInner from 'ee_else_ce/boards/mixins/issue_card_inner'; import { sprintf, __, n__ } from '~/locale'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import { isScopedLabel } from '~/lib/utils/common_utils'; +import { updateHistory } from '~/lib/utils/url_utility'; import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; -import IssueDueDate from './issue_due_date.vue'; -import IssueTimeEstimate from './issue_time_estimate.vue'; import eventHub from '../eventhub'; -import { isScopedLabel } from '~/lib/utils/common_utils'; import { ListType } from '../constants'; -import { updateHistory } from '~/lib/utils/url_utility'; +import IssueDueDate from './issue_due_date.vue'; +import IssueTimeEstimate from './issue_time_estimate.vue'; export default { components: { diff --git a/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue b/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue index 75cf1f0b9e1..64caac22391 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue @@ -5,11 +5,11 @@ import { GlLabel, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import issueCardInner from 'ee_else_ce/boards/mixins/issue_card_inner'; import { sprintf, __, n__ } from '~/locale'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import { isScopedLabel } from '~/lib/utils/common_utils'; import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; +import boardsStore from '../stores/boards_store'; import IssueDueDate from './issue_due_date.vue'; import IssueTimeEstimate from './issue_time_estimate_deprecated.vue'; -import boardsStore from '../stores/boards_store'; -import { isScopedLabel } from '~/lib/utils/common_utils'; export default { components: { diff --git a/app/assets/javascripts/boards/components/modal/footer.vue b/app/assets/javascripts/boards/components/modal/footer.vue index 10c29977cae..3054ebfc173 100644 --- a/app/assets/javascripts/boards/components/modal/footer.vue +++ b/app/assets/javascripts/boards/components/modal/footer.vue @@ -3,10 +3,10 @@ import { GlButton } from '@gitlab/ui'; import footerEEMixin from 'ee_else_ce/boards/mixins/modal_footer'; import { deprecatedCreateFlash as Flash } from '../../../flash'; import { __, n__ } from '../../../locale'; -import ListsDropdown from './lists_dropdown.vue'; import ModalStore from '../../stores/modal_store'; import modalMixin from '../../mixins/modal_mixins'; import boardsStore from '../../stores/boards_store'; +import ListsDropdown from './lists_dropdown.vue'; export default { components: { diff --git a/app/assets/javascripts/boards/components/modal/header.vue b/app/assets/javascripts/boards/components/modal/header.vue index 3e96ecca24c..dfc0d08c3fe 100644 --- a/app/assets/javascripts/boards/components/modal/header.vue +++ b/app/assets/javascripts/boards/components/modal/header.vue @@ -2,10 +2,10 @@ /* eslint-disable @gitlab/vue-require-i18n-strings */ import { GlButton } from '@gitlab/ui'; import { __ } from '~/locale'; -import ModalFilters from './filters'; -import ModalTabs from './tabs.vue'; import ModalStore from '../../stores/modal_store'; import modalMixin from '../../mixins/modal_mixins'; +import ModalFilters from './filters'; +import ModalTabs from './tabs.vue'; export default { components: { diff --git a/app/assets/javascripts/boards/components/modal/index.vue b/app/assets/javascripts/boards/components/modal/index.vue index 84d687a46b9..5e4af9814c7 100644 --- a/app/assets/javascripts/boards/components/modal/index.vue +++ b/app/assets/javascripts/boards/components/modal/index.vue @@ -3,11 +3,11 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { urlParamsToObject } from '~/lib/utils/common_utils'; import boardsStore from '~/boards/stores/boards_store'; +import ModalStore from '../../stores/modal_store'; import ModalHeader from './header.vue'; import ModalList from './list.vue'; import ModalFooter from './footer.vue'; import EmptyState from './empty_state.vue'; -import ModalStore from '../../stores/modal_store'; export default { components: { diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js index 2bc54155163..3c9f174dedc 100644 --- a/app/assets/javascripts/boards/components/new_list_dropdown.js +++ b/app/assets/javascripts/boards/components/new_list_dropdown.js @@ -4,12 +4,12 @@ import $ from 'jquery'; import { __ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; import { deprecatedCreateFlash as flash } from '~/flash'; -import CreateLabelDropdown from '../../create_label'; -import boardsStore from '../stores/boards_store'; -import { fullLabelId } from '../boards_util'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import store from '~/boards/stores'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; +import CreateLabelDropdown from '../../create_label'; +import boardsStore from '../stores/boards_store'; +import { fullLabelId } from '../boards_util'; function shouldCreateListGraphQL(label) { return store.getters.shouldUseGraphQL && !store.getters.getListByLabelId(fullLabelId(label)); @@ -51,16 +51,27 @@ export default function initNewListDropdown() { initDeprecatedJQueryDropdown($dropdownToggle, { data(term, callback) { - axios - .get($dropdownToggle.attr('data-list-labels-path')) - .then(({ data }) => callback(data)) - .catch(() => { - $dropdownToggle.data('bs.dropdown').hide(); - flash(__('Error fetching labels.')); - }); + const reqFailed = () => { + $dropdownToggle.data('bs.dropdown').hide(); + flash(__('Error fetching labels.')); + }; + + if (store.getters.shouldUseGraphQL) { + store + .dispatch('fetchLabels') + .then((data) => callback(data)) + .catch(reqFailed); + } else { + axios + .get($dropdownToggle.attr('data-list-labels-path')) + .then(({ data }) => callback(data)) + .catch(reqFailed); + } }, renderRow(label) { - const active = boardsStore.findListByLabelId(label.id); + const active = store.getters.shouldUseGraphQL + ? store.getters.getListByLabelId(label.id) + : boardsStore.findListByLabelId(label.id); const $li = $('<li />'); const $a = $('<a />', { class: active ? `is-active js-board-list-${getIdFromGraphQLId(active.id)}` : '', @@ -87,7 +98,7 @@ export default function initNewListDropdown() { e.preventDefault(); if (shouldCreateListGraphQL(label)) { - store.dispatch('createList', { labelId: fullLabelId(label) }); + store.dispatch('createList', { labelId: label.id }); } else if (!boardsStore.findListByLabelId(label.id)) { boardsStore.new({ title: label.title, diff --git a/app/assets/javascripts/boards/components/project_select_deprecated.vue b/app/assets/javascripts/boards/components/project_select_deprecated.vue index a043dc575ca..d4830e5afbf 100644 --- a/app/assets/javascripts/boards/components/project_select_deprecated.vue +++ b/app/assets/javascripts/boards/components/project_select_deprecated.vue @@ -6,10 +6,10 @@ import { GlSearchBoxByType, GlLoadingIcon, } from '@gitlab/ui'; -import eventHub from '../eventhub'; import { s__ } from '~/locale'; -import Api from '../../api'; import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants'; +import eventHub from '../eventhub'; +import Api from '../../api'; import { ListType } from '../constants'; export default { diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue index 4a664d5beef..373351eca22 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue @@ -88,15 +88,13 @@ export default { </gl-button> </div> </template> - <template> - <gl-datepicker - ref="datePicker" - :value="parsedDueDate" - show-clear-button - @input="setDueDate" - @clear="setDueDate(null)" - /> - </template> + <gl-datepicker + ref="datePicker" + :value="parsedDueDate" + show-clear-button + @input="setDueDate" + @clear="setDueDate(null)" + /> </board-editable-item> </template> <style> diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_issue_title.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_issue_title.vue index d0e641daf5c..d26d03323fb 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_issue_title.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_issue_title.vue @@ -136,36 +136,34 @@ export default { <template #collapsed> <span class="gl-text-gray-800">{{ issue.referencePath }}</span> </template> - <template> - <gl-alert v-if="showChangesAlert" variant="warning" class="gl-mb-5" :dismissible="false"> - {{ $options.i18n.reviewYourChanges }} - </gl-alert> - <gl-form @submit.prevent="setTitle"> - <gl-form-group :invalid-feedback="$options.i18n.invalidFeedback" :state="validationState"> - <gl-form-input - v-model="title" - v-autofocusonshow - :placeholder="$options.i18n.issueTitlePlaceholder" - :state="validationState" - /> - </gl-form-group> + <gl-alert v-if="showChangesAlert" variant="warning" class="gl-mb-5" :dismissible="false"> + {{ $options.i18n.reviewYourChanges }} + </gl-alert> + <gl-form @submit.prevent="setTitle"> + <gl-form-group :invalid-feedback="$options.i18n.invalidFeedback" :state="validationState"> + <gl-form-input + v-model="title" + v-autofocusonshow + :placeholder="$options.i18n.issueTitlePlaceholder" + :state="validationState" + /> + </gl-form-group> - <div class="gl-display-flex gl-w-full gl-justify-content-space-between gl-mt-5"> - <gl-button - variant="success" - size="small" - data-testid="submit-button" - :disabled="!title" - @click="setTitle" - > - {{ $options.i18n.submitButton }} - </gl-button> + <div class="gl-display-flex gl-w-full gl-justify-content-space-between gl-mt-5"> + <gl-button + variant="success" + size="small" + data-testid="submit-button" + :disabled="!title" + @click="setTitle" + > + {{ $options.i18n.submitButton }} + </gl-button> - <gl-button size="small" data-testid="cancel-button" @click="cancel"> - {{ $options.i18n.cancelButton }} - </gl-button> - </div> - </gl-form> - </template> + <gl-button size="small" data-testid="cancel-button" @click="cancel"> + {{ $options.i18n.cancelButton }} + </gl-button> + </div> + </gl-form> </board-editable-item> </template> diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue index 144a81f009b..a2dbd52369f 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue @@ -8,11 +8,11 @@ import { GlDropdownDivider, GlLoadingIcon, } from '@gitlab/ui'; -import { fetchPolicies } from '~/lib/graphql'; import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; -import groupMilestones from '../../graphql/group_milestones.query.graphql'; import createFlash from '~/flash'; +import { BV_DROPDOWN_HIDE } from '~/lib/utils/constants'; import { __, s__ } from '~/locale'; +import projectMilestones from '../../graphql/project_milestones.query.graphql'; export default { components: { @@ -34,22 +34,21 @@ export default { }, apollo: { milestones: { - fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, - query: groupMilestones, + query: projectMilestones, debounce: 250, skip() { return !this.edit; }, variables() { return { - fullPath: this.groupFullPath, + fullPath: this.projectPath, searchTitle: this.searchTitle, state: 'active', - includeDescendants: true, + includeAncestors: true, }; }, update(data) { - const edges = data?.group?.milestones?.edges ?? []; + const edges = data?.project?.milestones?.edges ?? []; return edges.map((item) => item.node); }, error() { @@ -75,7 +74,7 @@ export default { }, }, mounted() { - this.$root.$on('bv::dropdown::hide', () => { + this.$root.$on(BV_DROPDOWN_HIDE, () => { this.$refs.sidebarItem.collapse(); }); }, @@ -122,40 +121,38 @@ export default { <template v-if="hasMilestone" #collapsed> <strong class="gl-text-gray-900">{{ activeIssue.milestone.title }}</strong> </template> - <template> - <gl-dropdown - ref="dropdown" - :text="dropdownText" - :header-text="$options.i18n.assignMilestone" - block + <gl-dropdown + ref="dropdown" + :text="dropdownText" + :header-text="$options.i18n.assignMilestone" + block + > + <gl-search-box-by-type ref="search" v-model.trim="searchTitle" class="gl-m-3" /> + <gl-dropdown-item + data-testid="no-milestone-item" + :is-check-item="true" + :is-checked="!activeIssue.milestone" + @click="setMilestone(null)" > - <gl-search-box-by-type ref="search" v-model.trim="searchTitle" class="gl-m-3" /> + {{ $options.i18n.noMilestone }} + </gl-dropdown-item> + <gl-dropdown-divider /> + <gl-loading-icon v-if="$apollo.loading" class="gl-py-4" /> + <template v-else-if="milestones.length > 0"> <gl-dropdown-item - data-testid="no-milestone-item" + v-for="milestone in milestones" + :key="milestone.id" :is-check-item="true" - :is-checked="!activeIssue.milestone" - @click="setMilestone(null)" + :is-checked="activeIssue.milestone && milestone.id === activeIssue.milestone.id" + data-testid="milestone-item" + @click="setMilestone(milestone.id)" > - {{ $options.i18n.noMilestone }} + {{ milestone.title }} </gl-dropdown-item> - <gl-dropdown-divider /> - <gl-loading-icon v-if="$apollo.loading" class="gl-py-4" /> - <template v-else-if="milestones.length > 0"> - <gl-dropdown-item - v-for="milestone in milestones" - :key="milestone.id" - :is-check-item="true" - :is-checked="activeIssue.milestone && milestone.id === activeIssue.milestone.id" - data-testid="milestone-item" - @click="setMilestone(milestone.id)" - > - {{ milestone.title }} - </gl-dropdown-item> - </template> - <gl-dropdown-text v-else data-testid="no-milestones-found"> - {{ $options.i18n.noMilestonesFound }} - </gl-dropdown-text> - </gl-dropdown> - </template> + </template> + <gl-dropdown-text v-else data-testid="no-milestones-found"> + {{ $options.i18n.noMilestonesFound }} + </gl-dropdown-text> + </gl-dropdown> </board-editable-item> </template> diff --git a/app/assets/javascripts/boards/components/toggle_focus.vue b/app/assets/javascripts/boards/components/toggle_focus.vue new file mode 100644 index 00000000000..59ee47937c9 --- /dev/null +++ b/app/assets/javascripts/boards/components/toggle_focus.vue @@ -0,0 +1,52 @@ +<script> +import { GlIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { hide } from '~/tooltips'; + +export default { + components: { + GlIcon, + }, + props: { + issueBoardsContentSelector: { + type: String, + required: true, + }, + }, + data() { + return { + isFullscreen: false, + }; + }, + methods: { + toggleFocusMode() { + hide(this.$refs.toggleFocusModeButton); + + const issueBoardsContent = document.querySelector(this.issueBoardsContentSelector); + issueBoardsContent.classList.toggle('is-focused'); + + this.isFullscreen = !this.isFullscreen; + }, + }, + i18n: { + toggleFocusMode: __('Toggle focus mode'), + }, +}; +</script> + +<template> + <div class="board-extra-actions"> + <a + ref="toggleFocusModeButton" + href="#" + class="btn btn-default has-tooltip gl-ml-3 js-focus-mode-btn" + data-qa-selector="focus_mode_button" + role="button" + :aria-label="$options.i18n.toggleFocusMode" + :title="$options.i18n.toggleFocusMode" + @click="toggleFocusMode" + > + <gl-icon :name="isFullscreen ? 'minimize' : 'maximize'" /> + </a> + </div> +</template> diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index 9264fac5eda..723aef4875d 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -1,3 +1,5 @@ +import { __ } from '~/locale'; + export const BoardType = { project: 'project', group: 'group', @@ -6,16 +8,26 @@ export const BoardType = { export const ListType = { assignee: 'assignee', milestone: 'milestone', + iteration: 'iteration', backlog: 'backlog', closed: 'closed', label: 'label', }; +export const ListTypeTitles = { + assignee: __('Assignee'), + milestone: __('Milestone'), + iteration: __('Iteration'), + label: __('Label'), +}; + export const inactiveId = 0; export const ISSUABLE = 'issuable'; export const LIST = 'list'; +export const NOT_FILTER = 'not['; + export default { BoardType, ListType, diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js index 94b35aadaf1..83413ee216c 100644 --- a/app/assets/javascripts/boards/filtered_search_boards.js +++ b/app/assets/javascripts/boards/filtered_search_boards.js @@ -1,10 +1,10 @@ import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; import FilteredSearchManager from 'ee_else_ce/filtered_search/filtered_search_manager'; import { transformBoardConfig } from 'ee_else_ce/boards/boards_util'; +import { updateHistory } from '~/lib/utils/url_utility'; import FilteredSearchContainer from '../filtered_search/container'; import boardsStore from './stores/boards_store'; import vuexstore from './stores'; -import { updateHistory } from '~/lib/utils/url_utility'; export default class FilteredSearchBoards extends FilteredSearchManager { constructor(store, updateUrl = false, cantEdit = []) { diff --git a/app/assets/javascripts/boards/graphql/group_milestones.query.graphql b/app/assets/javascripts/boards/graphql/project_milestones.query.graphql index f2ab12ef4a7..776530ebb83 100644 --- a/app/assets/javascripts/boards/graphql/group_milestones.query.graphql +++ b/app/assets/javascripts/boards/graphql/project_milestones.query.graphql @@ -1,11 +1,11 @@ query groupMilestones( $fullPath: ID! $state: MilestoneStateEnum - $includeDescendants: Boolean + $includeAncestors: Boolean $searchTitle: String ) { - group(fullPath: $fullPath) { - milestones(state: $state, includeDescendants: $includeDescendants, searchTitle: $searchTitle) { + project(fullPath: $fullPath) { + milestones(state: $state, includeAncestors: $includeAncestors, searchTitle: $searchTitle) { edges { node { id diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index ef70a094f7c..5e8dd81438b 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -3,6 +3,7 @@ import { mapActions, mapGetters } from 'vuex'; import 'ee_else_ce/boards/models/issue'; import 'ee_else_ce/boards/models/list'; +import VueApollo from 'vue-apollo'; import BoardSidebar from 'ee_else_ce/boards/components/board_sidebar'; import initNewListDropdown from 'ee_else_ce/boards/components/new_list_dropdown'; import boardConfigToggle from 'ee_else_ce/boards/config_toggle'; @@ -15,7 +16,7 @@ import { getBoardsModalData, } from 'ee_else_ce/boards/ee_functions'; -import VueApollo from 'vue-apollo'; +import BoardAddNewColumnTrigger from '~/boards/components/board_add_new_column_trigger.vue'; import BoardContent from '~/boards/components/board_content.vue'; import BoardExtraActions from '~/boards/components/board_extra_actions.vue'; import createDefaultClient from '~/lib/graphql'; @@ -73,6 +74,7 @@ export default () => { boardsStore.setTimeTrackingLimitToHours($boardApp.dataset.timeTrackingLimitToHours); } + // eslint-disable-next-line @gitlab/no-runtime-template-compiler issueBoardsApp = new Vue({ el: $boardApp, components: { @@ -275,7 +277,7 @@ export default () => { }, }); - // eslint-disable-next-line no-new + // eslint-disable-next-line no-new, @gitlab/no-runtime-template-compiler new Vue({ el: document.getElementById('js-add-list'), data: { @@ -287,6 +289,21 @@ export default () => { }, }); + const createColumnTriggerEl = document.querySelector('.js-create-column-trigger'); + if (createColumnTriggerEl) { + // eslint-disable-next-line no-new + new Vue({ + el: createColumnTriggerEl, + components: { + BoardAddNewColumnTrigger, + }, + store, + render(createElement) { + return createElement('board-add-new-column-trigger'); + }, + }); + } + boardConfigToggle(boardsStore); const issueBoardsModal = document.getElementById('js-add-issues-btn'); diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js index 1e77326ba9c..bc23639df67 100644 --- a/app/assets/javascripts/boards/models/issue.js +++ b/app/assets/javascripts/boards/models/issue.js @@ -6,8 +6,8 @@ import axios from '~/lib/utils/axios_utils'; import './label'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import IssueProject from './project'; import boardsStore from '../stores/boards_store'; +import IssueProject from './project'; class ListIssue { constructor(obj) { diff --git a/app/assets/javascripts/boards/models/iteration.js b/app/assets/javascripts/boards/models/iteration.js new file mode 100644 index 00000000000..b7bdc204f7c --- /dev/null +++ b/app/assets/javascripts/boards/models/iteration.js @@ -0,0 +1,9 @@ +export default class ListIteration { + constructor(obj) { + this.id = obj.id; + this.title = obj.title; + this.state = obj.state; + this.webUrl = obj.web_url || obj.webUrl; + this.description = obj.description; + } +} diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index be02ac7b889..299e025529a 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -1,10 +1,11 @@ /* eslint-disable class-methods-use-this */ import { __ } from '~/locale'; -import ListLabel from './label'; -import ListAssignee from './assignee'; import { deprecatedCreateFlash as flash } from '~/flash'; import boardsStore from '../stores/boards_store'; +import ListLabel from './label'; +import ListAssignee from './assignee'; import ListMilestone from './milestone'; +import ListIteration from './iteration'; import 'ee_else_ce/boards/models/issue'; const TYPES = { @@ -57,6 +58,9 @@ class List { } else if (IS_EE && obj.milestone) { this.milestone = new ListMilestone(obj.milestone); this.title = this.milestone.title; + } else if (IS_EE && obj.iteration) { + this.iteration = new ListIteration(obj.iteration); + this.title = this.iteration.title; } // doNotFetchIssues is a temporary workaround until issues are fetched using GraphQL on issue boards diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index 1d34f21798a..8c9f86b17a4 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -5,7 +5,9 @@ import createGqClient, { fetchPolicies } from '~/lib/graphql'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { convertObjectPropsToCamelCase, urlParamsToObject } from '~/lib/utils/common_utils'; import { BoardType, ListType, inactiveId } from '~/boards/constants'; -import * as types from './mutation_types'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; +import updateAssigneesMutation from '~/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql'; import { formatBoardLists, formatListIssues, @@ -14,10 +16,8 @@ import { formatIssue, formatIssueInput, updateListPosition, + transformNotFilters, } from '../boards_util'; -import createFlash from '~/flash'; -import { __ } from '~/locale'; -import updateAssigneesMutation from '~/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql'; import listsIssuesQuery from '../graphql/lists_issues.query.graphql'; import boardLabelsQuery from '../graphql/board_labels.query.graphql'; import createBoardListMutation from '../graphql/board_list_create.mutation.graphql'; @@ -31,6 +31,7 @@ import issueSetSubscriptionMutation from '../graphql/issue_set_subscription.muta import issueSetMilestoneMutation from '../graphql/issue_set_milestone.mutation.graphql'; import issueSetTitleMutation from '../graphql/issue_set_title.mutation.graphql'; import groupProjectsQuery from '../graphql/group_projects.query.graphql'; +import * as types from './mutation_types'; const notImplemented = () => { /* eslint-disable-next-line @gitlab/require-i18n-strings */ @@ -66,6 +67,7 @@ export default { 'releaseTag', 'search', ]); + filterParams.not = transformNotFilters(filters); commit(types.SET_FILTERS, filterParams); }, @@ -153,10 +155,10 @@ export default { variables, }) .then(({ data }) => { - const labels = data[boardType]?.labels; - return labels.nodes; - }) - .catch(() => commit(types.RECEIVE_LABELS_FAILURE)); + const labels = data[boardType]?.labels.nodes; + commit(types.RECEIVE_LABELS_SUCCESS, labels); + return labels; + }); }, moveList: ( @@ -534,6 +536,21 @@ export default { commit(types.SET_SELECTED_PROJECT, project); }, + toggleBoardItemMultiSelection: ({ commit, state }, boardItem) => { + const { selectedBoardItems } = state; + const index = selectedBoardItems.indexOf(boardItem); + + if (index === -1) { + commit(types.ADD_BOARD_ITEM_TO_SELECTION, boardItem); + } else { + commit(types.REMOVE_BOARD_ITEM_FROM_SELECTION, boardItem); + } + }, + + setAddColumnFormVisibility: ({ commit }, visible) => { + commit(types.SET_ADD_COLUMN_FORM_VISIBLE, visible); + }, + fetchBacklog: () => { notImplemented(); }, diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js index d72b5c6fb8e..827c1e377cf 100644 --- a/app/assets/javascripts/boards/stores/getters.js +++ b/app/assets/javascripts/boards/stores/getters.js @@ -17,8 +17,13 @@ export default { return state.issues[state.activeId] || {}; }, + groupPathForActiveIssue: (_, getters) => { + const { referencePath = '' } = getters.activeIssue; + return referencePath.slice(0, referencePath.indexOf('/')); + }, + projectPathForActiveIssue: (_, getters) => { - const referencePath = getters.activeIssue.referencePath || ''; + const { referencePath = '' } = getters.activeIssue; return referencePath.slice(0, referencePath.indexOf('#')); }, diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js index 4697f39498a..5ec0ee158df 100644 --- a/app/assets/javascripts/boards/stores/mutation_types.js +++ b/app/assets/javascripts/boards/stores/mutation_types.js @@ -2,7 +2,7 @@ export const SET_INITIAL_BOARD_DATA = 'SET_INITIAL_BOARD_DATA'; export const SET_FILTERS = 'SET_FILTERS'; export const CREATE_LIST_SUCCESS = 'CREATE_LIST_SUCCESS'; export const CREATE_LIST_FAILURE = 'CREATE_LIST_FAILURE'; -export const RECEIVE_LABELS_FAILURE = 'RECEIVE_LABELS_FAILURE'; +export const RECEIVE_LABELS_SUCCESS = 'RECEIVE_LABELS_SUCCESS'; export const GENERATE_DEFAULT_LISTS_FAILURE = 'GENERATE_DEFAULT_LISTS_FAILURE'; export const RECEIVE_BOARD_LISTS_SUCCESS = 'RECEIVE_BOARD_LISTS_SUCCESS'; export const RECEIVE_BOARD_LISTS_FAILURE = 'RECEIVE_BOARD_LISTS_FAILURE'; @@ -40,3 +40,6 @@ export const REQUEST_GROUP_PROJECTS = 'REQUEST_GROUP_PROJECTS'; export const RECEIVE_GROUP_PROJECTS_SUCCESS = 'RECEIVE_GROUP_PROJECTS_SUCCESS'; export const RECEIVE_GROUP_PROJECTS_FAILURE = 'RECEIVE_GROUP_PROJECTS_FAILURE'; export const SET_SELECTED_PROJECT = 'SET_SELECTED_PROJECT'; +export const ADD_BOARD_ITEM_TO_SELECTION = 'ADD_BOARD_ITEM_TO_SELECTION'; +export const REMOVE_BOARD_ITEM_FROM_SELECTION = 'REMOVE_BOARD_ITEM_FROM_SELECTION'; +export const SET_ADD_COLUMN_FORM_VISIBLE = 'SET_ADD_COLUMN_FORM_VISIBLE'; diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index 6c79b22d308..acf62782fad 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -1,9 +1,9 @@ import Vue from 'vue'; import { pull, union } from 'lodash'; -import { formatIssue, moveIssueListHelper } from '../boards_util'; -import * as mutationTypes from './mutation_types'; import { s__ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { formatIssue, moveIssueListHelper } from '../boards_util'; +import * as mutationTypes from './mutation_types'; const notImplemented = () => { /* eslint-disable-next-line @gitlab/require-i18n-strings */ @@ -63,8 +63,8 @@ export default { state.error = s__('Boards|An error occurred while creating the list. Please try again.'); }, - [mutationTypes.RECEIVE_LABELS_FAILURE]: (state) => { - state.error = s__('Boards|An error occurred while fetching labels. Please reload the page.'); + [mutationTypes.RECEIVE_LABELS_SUCCESS]: (state, labels) => { + state.labels = labels; }, [mutationTypes.GENERATE_DEFAULT_LISTS_FAILURE]: (state) => { @@ -258,4 +258,20 @@ export default { [mutationTypes.SET_SELECTED_PROJECT]: (state, project) => { state.selectedProject = project; }, + + [mutationTypes.ADD_BOARD_ITEM_TO_SELECTION]: (state, boardItem) => { + state.selectedBoardItems = [...state.selectedBoardItems, boardItem]; + }, + + [mutationTypes.REMOVE_BOARD_ITEM_FROM_SELECTION]: (state, boardItem) => { + Vue.set( + state, + 'selectedBoardItems', + state.selectedBoardItems.filter((obj) => obj !== boardItem), + ); + }, + + [mutationTypes.SET_ADD_COLUMN_FORM_VISIBLE]: (state, visible) => { + state.addColumnFormVisible = visible; + }, }; diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js index aba7da373cf..badbd2d80e5 100644 --- a/app/assets/javascripts/boards/stores/state.js +++ b/app/assets/javascripts/boards/stores/state.js @@ -14,6 +14,8 @@ export default () => ({ issues: {}, filterParams: {}, boardConfig: {}, + labels: [], + selectedBoardItems: [], groupProjects: [], groupProjectsFlags: { isLoading: false, @@ -22,6 +24,7 @@ export default () => ({ }, selectedProject: {}, error: undefined, + addColumnFormVisible: false, // TODO: remove after ce/ee split of board_content.vue isShowingEpicsSwimlanes: false, }); diff --git a/app/assets/javascripts/boards/toggle_focus.js b/app/assets/javascripts/boards/toggle_focus.js index 347deb81846..0a230f72dcc 100644 --- a/app/assets/javascripts/boards/toggle_focus.js +++ b/app/assets/javascripts/boards/toggle_focus.js @@ -1,45 +1,17 @@ -import $ from 'jquery'; import Vue from 'vue'; -import { GlIcon } from '@gitlab/ui'; -import { hide } from '~/tooltips'; +import ToggleFocus from './components/toggle_focus.vue'; -export default (ModalStore, boardsStore) => { - const issueBoardsContent = document.querySelector('.content-wrapper > .js-focus-mode-board'); +export default () => { + const issueBoardsContentSelector = '.content-wrapper > .js-focus-mode-board'; return new Vue({ - el: document.getElementById('js-toggle-focus-btn'), - components: { - GlIcon, + el: '#js-toggle-focus-btn', + render(h) { + return h(ToggleFocus, { + props: { + issueBoardsContentSelector, + }, + }); }, - data: { - modal: ModalStore.store, - store: boardsStore.state, - isFullscreen: false, - }, - methods: { - toggleFocusMode() { - const $el = $(this.$refs.toggleFocusModeButton); - hide($el); - - issueBoardsContent.classList.toggle('is-focused'); - - this.isFullscreen = !this.isFullscreen; - }, - }, - template: ` - <div class="board-extra-actions"> - <a - href="#" - class="btn btn-default has-tooltip gl-ml-3 js-focus-mode-btn" - data-qa-selector="focus_mode_button" - role="button" - aria-label="Toggle focus mode" - title="Toggle focus mode" - ref="toggleFocusModeButton" - @click="toggleFocusMode"> - <gl-icon :name="isFullscreen ? 'minimize' : 'maximize'" /> - </a> - </div> - `, }); }; diff --git a/app/assets/javascripts/branches/components/divergence_graph.vue b/app/assets/javascripts/branches/components/divergence_graph.vue index deaed694b46..c4b522a43d4 100644 --- a/app/assets/javascripts/branches/components/divergence_graph.vue +++ b/app/assets/javascripts/branches/components/divergence_graph.vue @@ -1,7 +1,7 @@ <script> import { sprintf, __ } from '~/locale'; -import GraphBar from './graph_bar.vue'; import { MAX_COMMIT_COUNT } from '../constants'; +import GraphBar from './graph_bar.vue'; export default { components: { diff --git a/app/assets/javascripts/build_artifacts.js b/app/assets/javascripts/build_artifacts.js index 42e0f8b37bd..ee13e482af5 100644 --- a/app/assets/javascripts/build_artifacts.js +++ b/app/assets/javascripts/build_artifacts.js @@ -1,9 +1,9 @@ /* eslint-disable func-names */ import $ from 'jquery'; +import { hide, initTooltips, show } from '~/tooltips'; import { visitUrl } from './lib/utils/url_utility'; import { parseBoolean } from './lib/utils/common_utils'; -import { hide, initTooltips, show } from '~/tooltips'; export default class BuildArtifacts { constructor() { diff --git a/app/assets/javascripts/captcha/init_recaptcha_script.js b/app/assets/javascripts/captcha/init_recaptcha_script.js new file mode 100644 index 00000000000..b9df7604ed1 --- /dev/null +++ b/app/assets/javascripts/captcha/init_recaptcha_script.js @@ -0,0 +1,48 @@ +// NOTE: This module will be used in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/52044 +import { memoize } from 'lodash'; + +export const RECAPTCHA_API_URL_PREFIX = 'https://www.google.com/recaptcha/api.js'; +export const RECAPTCHA_ONLOAD_CALLBACK_NAME = 'recaptchaOnloadCallback'; + +/** + * Adds the Google reCAPTCHA script tag to the head of the document, and + * returns a promise of the grecaptcha object + * (https://developers.google.com/recaptcha/docs/display#js_api). + * + * It is memoized, so there will only be one instance of the script tag ever + * added to the document. + * + * See the reCAPTCHA documentation for more details: + * + * https://developers.google.com/recaptcha/docs/display#explicit_render + * + */ +export const initRecaptchaScript = memoize(() => { + // Appends the the reCAPTCHA script tag to the head of document + const appendRecaptchaScript = () => { + const script = document.createElement('script'); + script.src = `${RECAPTCHA_API_URL_PREFIX}?onload=${RECAPTCHA_ONLOAD_CALLBACK_NAME}&render=explicit`; + script.classList.add('js-recaptcha-script'); + document.head.appendChild(script); + }; + + return new Promise((resolve) => { + // This global callback resolves the Promise and is passed by name to the reCAPTCHA script. + window[RECAPTCHA_ONLOAD_CALLBACK_NAME] = (val) => { + // Let's clean up after ourselves. This is also important for testing, because `window` is NOT cleared between tests. + // https://github.com/facebook/jest/issues/1224#issuecomment-444586798. + delete window[RECAPTCHA_ONLOAD_CALLBACK_NAME]; + resolve(val); + }; + appendRecaptchaScript(); + }); +}); + +/** + * Clears the cached memoization of the default manager. + * + * This is needed for determinism in tests. + */ +export const clearMemoizeCache = () => { + initRecaptchaScript.cache.clear(); +}; diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/index.js b/app/assets/javascripts/ci_settings_pipeline_triggers/index.js index dc79bbb4d97..f2972133aad 100644 --- a/app/assets/javascripts/ci_settings_pipeline_triggers/index.js +++ b/app/assets/javascripts/ci_settings_pipeline_triggers/index.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import TriggersList from './components/triggers_list.vue'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import TriggersList from './components/triggers_list.vue'; const parseJsonArray = (triggers) => { try { diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_popover.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_popover.vue index 431819124c2..6e6527df63f 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_popover.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_popover.vue @@ -38,7 +38,9 @@ export default { <template> <div id="popover-container"> <gl-popover :target="target" triggers="hover" placement="top" container="popover-container"> - <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center"> + <div + class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-word-break-all" + > <div class="ci-popover-value gl-pr-3"> {{ displayValue }} </div> diff --git a/app/assets/javascripts/ci_variable_list/index.js b/app/assets/javascripts/ci_variable_list/index.js index a28b52d6b57..37b5f7e6df7 100644 --- a/app/assets/javascripts/ci_variable_list/index.js +++ b/app/assets/javascripts/ci_variable_list/index.js @@ -1,7 +1,7 @@ import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; import CiVariableSettings from './components/ci_variable_settings.vue'; import createStore from './store'; -import { parseBoolean } from '~/lib/utils/common_utils'; export default (containerId = 'js-ci-project-variables') => { const containerEl = document.getElementById(containerId); diff --git a/app/assets/javascripts/ci_variable_list/store/actions.js b/app/assets/javascripts/ci_variable_list/store/actions.js index ac595fa0045..350b2190aa7 100644 --- a/app/assets/javascripts/ci_variable_list/store/actions.js +++ b/app/assets/javascripts/ci_variable_list/store/actions.js @@ -1,8 +1,8 @@ -import * as types from './mutation_types'; import axios from '~/lib/utils/axios_utils'; import Api from '~/api'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { __ } from '~/locale'; +import * as types from './mutation_types'; import { prepareDataForApi, prepareDataForDisplay, prepareEnvironments } from './utils'; export const toggleValues = ({ commit }, valueState) => { diff --git a/app/assets/javascripts/ci_variable_list/store/mutations.js b/app/assets/javascripts/ci_variable_list/store/mutations.js index 961cecee298..0e7c61cecb8 100644 --- a/app/assets/javascripts/ci_variable_list/store/mutations.js +++ b/app/assets/javascripts/ci_variable_list/store/mutations.js @@ -1,5 +1,5 @@ -import * as types from './mutation_types'; import { displayText } from '../constants'; +import * as types from './mutation_types'; export default { [types.REQUEST_VARIABLES](state) { diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index eb2128b2856..11db8058318 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -2,6 +2,8 @@ import Visibility from 'visibilityjs'; import Vue from 'vue'; import { GlToast } from '@gitlab/ui'; import AccessorUtilities from '~/lib/utils/accessor'; +import initProjectSelectDropdown from '~/project_select'; +import initServerlessSurveyBanner from '~/serverless/survey_banner'; import PersistentUserCallout from '../persistent_user_callout'; import { s__, sprintf } from '../locale'; import { deprecatedCreateFlash as Flash } from '../flash'; @@ -13,8 +15,6 @@ import ClustersService from './services/clusters_service'; import ClustersStore from './stores/clusters_store'; import Applications from './components/applications.vue'; import RemoveClusterConfirmation from './components/remove_cluster_confirmation.vue'; -import initProjectSelectDropdown from '~/project_select'; -import initServerlessSurveyBanner from '~/serverless/survey_banner'; const Environments = () => import('ee_component/clusters/components/environments.vue'); diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue index 471c1a0b4a2..21870e491ba 100644 --- a/app/assets/javascripts/clusters/components/application_row.vue +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -3,12 +3,11 @@ import { GlLink, GlModalDirective, GlSprintf, GlButton, GlAlert } from '@gitlab/ import { s__, __, sprintf } from '~/locale'; import eventHub from '../event_hub'; import identicon from '../../vue_shared/components/identicon.vue'; +import { APPLICATION_STATUS, ELASTIC_STACK } from '../constants'; import UninstallApplicationButton from './uninstall_application_button.vue'; import UninstallApplicationConfirmationModal from './uninstall_application_confirmation_modal.vue'; import UpdateApplicationConfirmationModal from './update_application_confirmation_modal.vue'; -import { APPLICATION_STATUS, ELASTIC_STACK } from '../constants'; - export default { components: { GlButton, diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index e096a29ce7f..62bc1deba8d 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -10,11 +10,11 @@ import knativeLogo from 'images/cluster_app_logos/knative.png'; import prometheusLogo from 'images/cluster_app_logos/prometheus.png'; import elasticStackLogo from 'images/cluster_app_logos/elastic_stack.png'; import fluentdLogo from 'images/cluster_app_logos/fluentd.png'; -import applicationRow from './application_row.vue'; +import eventHub from '~/clusters/event_hub'; import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; -import KnativeDomainEditor from './knative_domain_editor.vue'; import { CLUSTER_TYPE, PROVIDER_TYPE, APPLICATION_STATUS, INGRESS } from '../constants'; -import eventHub from '~/clusters/event_hub'; +import applicationRow from './application_row.vue'; +import KnativeDomainEditor from './knative_domain_editor.vue'; import CrossplaneProviderStack from './crossplane_provider_stack.vue'; import IngressModsecuritySettings from './ingress_modsecurity_settings.vue'; import FluentdOutputSettings from './fluentd_output_settings.vue'; @@ -349,6 +349,7 @@ export default { {{ s__('ClusterIntegration|Issuer Email') }} </label> <div class="input-group"> + <!-- eslint-disable vue/no-mutating-props --> <input id="cert-manager-issuer-email" v-model="applications.cert_manager.email" @@ -356,6 +357,7 @@ export default { type="text" class="form-control js-email" /> + <!-- eslint-enable vue/no-mutating-props --> </div> <p class="form-text text-muted"> {{ @@ -522,6 +524,7 @@ export default { <label for="jupyter-hostname">{{ s__('ClusterIntegration|Jupyter Hostname') }}</label> <div class="input-group"> + <!-- eslint-disable vue/no-mutating-props --> <input id="jupyter-hostname" v-model="applications.jupyter.hostname" @@ -529,6 +532,7 @@ export default { type="text" class="form-control js-hostname" /> + <!-- eslint-enable vue/no-mutating-props --> <span class="input-group-append"> <clipboard-button :text="jupyterHostname" diff --git a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue index f05c8db5d56..58a5edb832f 100644 --- a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue +++ b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue @@ -11,9 +11,9 @@ import { GlIcon, } from '@gitlab/ui'; import modSecurityLogo from 'images/cluster_app_logos/gitlab.png'; -import { s__, __ } from '../../locale'; import { APPLICATION_STATUS, INGRESS, LOGGING_MODE, BLOCKING_MODE } from '~/clusters/constants'; import eventHub from '~/clusters/event_hub'; +import { s__, __ } from '../../locale'; const { UPDATING, UNINSTALLING, INSTALLING, INSTALLED, UPDATED } = APPLICATION_STATUS; @@ -130,9 +130,11 @@ export default { }, resetStatus() { if (this.initialMode !== null) { + // eslint-disable-next-line vue/no-mutating-props this.ingress.modsecurity_mode = this.initialMode; } if (this.initialValue !== null) { + // eslint-disable-next-line vue/no-mutating-props this.ingress.modsecurity_enabled = this.initialValue; } this.initialValue = null; diff --git a/app/assets/javascripts/clusters/components/knative_domain_editor.vue b/app/assets/javascripts/clusters/components/knative_domain_editor.vue index d80bd6f5b42..bf89c288b75 100644 --- a/app/assets/javascripts/clusters/components/knative_domain_editor.vue +++ b/app/assets/javascripts/clusters/components/knative_domain_editor.vue @@ -9,10 +9,10 @@ import { GlButton, GlAlert, } from '@gitlab/ui'; -import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; import { __, s__ } from '~/locale'; import { APPLICATION_STATUS } from '~/clusters/constants'; +import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; const { UPDATING, UNINSTALLING } = APPLICATION_STATUS; diff --git a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue index c157b04b4f5..c5678173be4 100644 --- a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue +++ b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue @@ -131,48 +131,46 @@ export default { :title="modalTitle" kind="danger" > - <template> - <p>{{ warningMessage }}</p> - <div v-if="confirmCleanup"> - {{ s__('ClusterIntegration|This will permanently delete the following resources:') }} - <ul> - <li> - {{ s__('ClusterIntegration|All installed applications and related resources') }} - </li> - <li> - <gl-sprintf :message="s__('ClusterIntegration|The %{gitlabNamespace} namespace')"> - <template #gitlabNamespace> - <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings --> - <code>{{ 'gitlab-managed-apps' }}</code> - </template> - </gl-sprintf> - </li> - <li>{{ s__('ClusterIntegration|Any project namespaces') }}</li> - <!-- eslint-disable @gitlab/vue-require-i18n-strings --> - <li><code>clusterroles</code></li> - <li><code>clusterrolebindings</code></li> - <!-- eslint-enable @gitlab/vue-require-i18n-strings --> - </ul> - </div> - <strong v-html="confirmationTextLabel"></strong> - <form ref="form" :action="clusterPath" method="post" class="gl-mb-5"> - <input ref="method" type="hidden" name="_method" value="delete" /> - <input :value="csrfToken" type="hidden" name="authenticity_token" /> - <input ref="cleanup" type="hidden" name="cleanup" value="true" /> - <gl-form-input - v-model="enteredClusterName" - autofocus - type="text" - name="confirm_cluster_name_input" - autocomplete="off" - /> - </form> - <span v-if="confirmCleanup">{{ - s__( - 'ClusterIntegration|If you do not wish to delete all associated GitLab resources, you can simply remove the integration.', - ) - }}</span> - </template> + <p>{{ warningMessage }}</p> + <div v-if="confirmCleanup"> + {{ s__('ClusterIntegration|This will permanently delete the following resources:') }} + <ul> + <li> + {{ s__('ClusterIntegration|All installed applications and related resources') }} + </li> + <li> + <gl-sprintf :message="s__('ClusterIntegration|The %{gitlabNamespace} namespace')"> + <template #gitlabNamespace> + <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings --> + <code>{{ 'gitlab-managed-apps' }}</code> + </template> + </gl-sprintf> + </li> + <li>{{ s__('ClusterIntegration|Any project namespaces') }}</li> + <!-- eslint-disable @gitlab/vue-require-i18n-strings --> + <li><code>clusterroles</code></li> + <li><code>clusterrolebindings</code></li> + <!-- eslint-enable @gitlab/vue-require-i18n-strings --> + </ul> + </div> + <strong v-html="confirmationTextLabel"></strong> + <form ref="form" :action="clusterPath" method="post" class="gl-mb-5"> + <input ref="method" type="hidden" name="_method" value="delete" /> + <input :value="csrfToken" type="hidden" name="authenticity_token" /> + <input ref="cleanup" type="hidden" name="cleanup" value="true" /> + <gl-form-input + v-model="enteredClusterName" + autofocus + type="text" + name="confirm_cluster_name_input" + autocomplete="off" + /> + </form> + <span v-if="confirmCleanup">{{ + s__( + 'ClusterIntegration|If you do not wish to delete all associated GitLab resources, you can simply remove the integration.', + ) + }}</span> <template #modal-footer> <gl-button variant="secondary" @click="handleCancel">{{ s__('Cancel') }}</gl-button> <template v-if="confirmCleanup"> diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue index 53eec5c8a0d..30b683cee41 100644 --- a/app/assets/javascripts/clusters_list/components/clusters.vue +++ b/app/assets/javascripts/clusters_list/components/clusters.vue @@ -10,10 +10,10 @@ import { GlTable, GlTooltipDirective, } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import { CLUSTER_TYPES, STATUSES } from '../constants'; import AncestorNotice from './ancestor_notice.vue'; import NodeErrorHelpText from './node_error_help_text.vue'; -import { CLUSTER_TYPES, STATUSES } from '../constants'; -import { __, sprintf } from '~/locale'; export default { nodeMemoryText: __('%{totalMemory} (%{freeSpacePercentage}%{percentSymbol} free)'), diff --git a/app/assets/javascripts/clusters_list/store/actions.js b/app/assets/javascripts/clusters_list/store/actions.js index 97ed0a7ab37..ee85b3e13fb 100644 --- a/app/assets/javascripts/clusters_list/store/actions.js +++ b/app/assets/javascripts/clusters_list/store/actions.js @@ -3,8 +3,8 @@ import Poll from '~/lib/utils/poll'; import axios from '~/lib/utils/axios_utils'; import { deprecatedCreateFlash as flash } from '~/flash'; import { __ } from '~/locale'; -import { MAX_REQUESTS } from '../constants'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; +import { MAX_REQUESTS } from '../constants'; import * as types from './mutation_types'; const allNodesPresent = (clusters, retryCount) => { diff --git a/app/assets/javascripts/code_navigation/components/app.vue b/app/assets/javascripts/code_navigation/components/app.vue index 85ec0a60ec5..d38b38947b6 100644 --- a/app/assets/javascripts/code_navigation/components/app.vue +++ b/app/assets/javascripts/code_navigation/components/app.vue @@ -1,7 +1,7 @@ <script> import { mapActions, mapState } from 'vuex'; -import Popover from './popover.vue'; import eventHub from '../../notes/event_hub'; +import Popover from './popover.vue'; export default { components: { diff --git a/app/assets/javascripts/code_navigation/store/actions.js b/app/assets/javascripts/code_navigation/store/actions.js index fb77a70de0b..0b6b8437db5 100644 --- a/app/assets/javascripts/code_navigation/store/actions.js +++ b/app/assets/javascripts/code_navigation/store/actions.js @@ -1,6 +1,6 @@ import axios from '~/lib/utils/axios_utils'; -import * as types from './mutation_types'; import { getCurrentHoverElement, setCurrentHoverElement, addInteractionClass } from '../utils'; +import * as types from './mutation_types'; export default { setInitialData({ commit }, data) { diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue index fe32868e6d8..fad98a66636 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue @@ -197,7 +197,7 @@ export default { <gl-button v-if="canRenderPipelineButton" block - class="gl-mt-3 gl-mb-0 gl-display-md-none" + class="gl-mt-3 gl-mb-0 gl-md-display-none" variant="success" data-testid="run_pipeline_button_mobile" :loading="state.isRunningMergeRequestPipeline" diff --git a/app/assets/javascripts/compare_autocomplete.js b/app/assets/javascripts/compare_autocomplete.js index 82384434e8f..b3958b05d62 100644 --- a/app/assets/javascripts/compare_autocomplete.js +++ b/app/assets/javascripts/compare_autocomplete.js @@ -1,12 +1,12 @@ /* eslint-disable func-names */ import $ from 'jquery'; +import { fixTitle } from '~/tooltips'; +import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import { __ } from './locale'; import axios from './lib/utils/axios_utils'; import { deprecatedCreateFlash as flash } from './flash'; import { capitalizeFirstCharacter } from './lib/utils/text_utility'; -import { fixTitle } from '~/tooltips'; -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; export default function initCompareAutocomplete(limitTo = null, clickHandler = () => {}) { $('.js-compare-dropdown').each(function () { diff --git a/app/assets/javascripts/contributors/components/contributors.vue b/app/assets/javascripts/contributors/components/contributors.vue index 86580aa170b..977c2ba907e 100644 --- a/app/assets/javascripts/contributors/components/contributors.vue +++ b/app/assets/javascripts/contributors/components/contributors.vue @@ -6,8 +6,8 @@ import { GlAreaChart } from '@gitlab/ui/dist/charts'; import { __ } from '~/locale'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; import { getDatesInRange } from '~/lib/utils/datetime_utility'; -import { xAxisLabelFormatter, dateFormatter } from '../utils'; import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue'; +import { xAxisLabelFormatter, dateFormatter } from '../utils'; export default { components: { diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue index a3f76241bf2..f104eb61e41 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue @@ -1,11 +1,11 @@ <script> /* eslint-disable vue/no-v-html */ -import { GlButton, GlFormGroup, GlFormInput, GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; +import { GlButton, GlFormGroup, GlFormInput, GlIcon, GlLink, GlSprintf, GlAlert } from '@gitlab/ui'; import { escape } from 'lodash'; import { mapState, mapActions } from 'vuex'; -import { DEFAULT_REGION } from '../constants'; import { sprintf, s__, __ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { DEFAULT_REGION } from '../constants'; export default { components: { @@ -16,6 +16,7 @@ export default { GlLink, GlSprintf, ClipboardButton, + GlAlert, }, props: { accountAndExternalIdsHelpPath: { @@ -105,9 +106,14 @@ export default { ) }} </p> - <div v-if="createRoleError" class="js-invalid-credentials bs-callout bs-callout-danger"> + <gl-alert + v-if="createRoleError" + class="js-invalid-credentials gl-mb-5" + variant="danger" + :dismissible="false" + > {{ createRoleError }} - </div> + </gl-alert> <div class="form-row"> <div class="form-group col-md-6"> <label for="gitlab-account-id">{{ __('Account ID') }}</label> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js index 55576efd3b8..6df9645b03a 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js @@ -1,9 +1,9 @@ -import * as types from './mutation_types'; -import { DEFAULT_REGION } from '../constants'; -import { setAWSConfig } from '../services/aws_services_facade'; import axios from '~/lib/utils/axios_utils'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { setAWSConfig } from '../services/aws_services_facade'; +import { DEFAULT_REGION } from '../constants'; +import * as types from './mutation_types'; const getErrorMessage = (data) => { const errorKey = Object.keys(data)[0]; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/index.js b/app/assets/javascripts/create_cluster/eks_cluster/store/index.js index 262bbb3167a..ed054989771 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/index.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/index.js @@ -1,11 +1,5 @@ import Vuex from 'vuex'; -import * as actions from './actions'; -import * as getters from './getters'; -import mutations from './mutations'; -import state from './state'; - import clusterDropdownStore from '~/create_cluster/store/cluster_dropdown'; - import { fetchRoles, fetchKeyPairs, @@ -13,6 +7,10 @@ import { fetchSubnets, fetchSecurityGroups, } from '../services/aws_services_facade'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; +import state from './state'; const createStore = ({ initialState }) => new Vuex.Store({ diff --git a/app/assets/javascripts/create_cluster/gke_cluster/store/actions.js b/app/assets/javascripts/create_cluster/gke_cluster/store/actions.js index 8977053297a..f4c35dafc22 100644 --- a/app/assets/javascripts/create_cluster/gke_cluster/store/actions.js +++ b/app/assets/javascripts/create_cluster/gke_cluster/store/actions.js @@ -1,5 +1,5 @@ -import * as types from './mutation_types'; import gapiLoader from '../gapi_loader'; +import * as types from './mutation_types'; const gapiResourceListRequest = ({ resource, params, commit, mutation, payloadKey }) => new Promise((resolve, reject) => { diff --git a/app/assets/javascripts/create_cluster/init_create_cluster.js b/app/assets/javascripts/create_cluster/init_create_cluster.js index f97da3d55db..d367d7ec333 100644 --- a/app/assets/javascripts/create_cluster/init_create_cluster.js +++ b/app/assets/javascripts/create_cluster/init_create_cluster.js @@ -1,6 +1,6 @@ +import PersistentUserCallout from '~/persistent_user_callout'; import initGkeDropdowns from './gke_cluster'; import initGkeNamespace from './gke_cluster_namespace'; -import PersistentUserCallout from '~/persistent_user_callout'; const newClusterViews = [':clusters:new', ':clusters:create_gcp', ':clusters:create_user']; diff --git a/app/assets/javascripts/custom_metrics/components/custom_metrics_form.vue b/app/assets/javascripts/custom_metrics/components/custom_metrics_form.vue index 9c28801306c..1b32225d251 100644 --- a/app/assets/javascripts/custom_metrics/components/custom_metrics_form.vue +++ b/app/assets/javascripts/custom_metrics/components/custom_metrics_form.vue @@ -2,9 +2,9 @@ import { GlButton } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import csrf from '~/lib/utils/csrf'; +import { formDataValidator } from '../constants'; import CustomMetricsFormFields from './custom_metrics_form_fields.vue'; import DeleteCustomMetricModal from './delete_custom_metric_modal.vue'; -import { formDataValidator } from '../constants'; export default { components: { diff --git a/app/assets/javascripts/cycle_analytics/components/banner.vue b/app/assets/javascripts/cycle_analytics/components/banner.vue index 4448d909c9b..cf4c35ef12b 100644 --- a/app/assets/javascripts/cycle_analytics/components/banner.vue +++ b/app/assets/javascripts/cycle_analytics/components/banner.vue @@ -1,7 +1,7 @@ <script> /* eslint-disable vue/no-v-html */ -import iconCycleAnalyticsSplash from 'icons/_icon_cycle_analytics_splash.svg'; import { GlIcon } from '@gitlab/ui'; +import iconCycleAnalyticsSplash from 'icons/_icon_cycle_analytics_splash.svg'; export default { components: { diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js index bd5a6cc40c4..56954a4e97f 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js @@ -1,9 +1,13 @@ +// This is a true violation of @gitlab/no-runtime-template-compiler, as it +// relies on app/views/projects/cycle_analytics/show.html.haml for its +// template. +/* eslint-disable @gitlab/no-runtime-template-compiler */ import $ from 'jquery'; import Vue from 'vue'; import Cookies from 'js-cookie'; import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; -import { deprecatedCreateFlash as Flash } from '../flash'; import { __ } from '~/locale'; +import { deprecatedCreateFlash as Flash } from '../flash'; import Translate from '../vue_shared/translate'; import banner from './components/banner.vue'; import stageCodeComponent from './components/stage_code_component.vue'; diff --git a/app/assets/javascripts/deploy_freeze/store/actions.js b/app/assets/javascripts/deploy_freeze/store/actions.js index 9a75c3cad2f..62045d2517d 100644 --- a/app/assets/javascripts/deploy_freeze/store/actions.js +++ b/app/assets/javascripts/deploy_freeze/store/actions.js @@ -1,7 +1,7 @@ -import * as types from './mutation_types'; import Api from '~/api'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { __ } from '~/locale'; +import * as types from './mutation_types'; export const requestAddFreezePeriod = ({ commit }) => { commit(types.REQUEST_ADD_FREEZE_PERIOD); diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue index 3ddaba7abcc..797f5a6aaf0 100644 --- a/app/assets/javascripts/deploy_keys/components/key.vue +++ b/app/assets/javascripts/deploy_keys/components/key.vue @@ -97,7 +97,7 @@ export default { methods: { projectTooltipTitle(project) { return project.can_push - ? s__('DeployKeys|Write access allowed') + ? s__('DeployKeys|Grant write permissions to this key') : s__('DeployKeys|Read access only'); }, toggleExpanded() { diff --git a/app/assets/javascripts/deploy_keys/components/keys_panel.vue b/app/assets/javascripts/deploy_keys/components/keys_panel.vue index 2693cd08cc3..d71f4f5507f 100644 --- a/app/assets/javascripts/deploy_keys/components/keys_panel.vue +++ b/app/assets/javascripts/deploy_keys/components/keys_panel.vue @@ -48,7 +48,7 @@ export default { :project-id="projectId" /> </template> - <div v-else class="settings-message text-center"> + <div v-else class="settings-message text-center gl-mt-5"> {{ s__('DeployKeys|No deploy keys found. Create one with the form above.') }} </div> </div> 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 ea4d5d7b570..ab963990ce3 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 @@ -9,12 +9,12 @@ import allVersionsMixin from '../../mixins/all_versions'; import createNoteMutation from '../../graphql/mutations/create_note.mutation.graphql'; import toggleResolveDiscussionMutation from '../../graphql/mutations/toggle_resolve_discussion.mutation.graphql'; import activeDiscussionQuery from '../../graphql/queries/active_discussion.query.graphql'; -import DesignNote from './design_note.vue'; -import DesignReplyForm from './design_reply_form.vue'; import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants'; -import ToggleRepliesWidget from './toggle_replies_widget.vue'; import { hasErrors } from '../../utils/cache_update'; import { ADD_DISCUSSION_COMMENT_ERROR } from '../../utils/error_messages'; +import DesignNote from './design_note.vue'; +import DesignReplyForm from './design_reply_form.vue'; +import ToggleRepliesWidget from './toggle_replies_widget.vue'; export default { components: { diff --git a/app/assets/javascripts/design_management/components/design_notes/design_note.vue b/app/assets/javascripts/design_management/components/design_notes/design_note.vue index 421a4dc274a..371f2d06486 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_note.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_note.vue @@ -1,13 +1,13 @@ <script> import { ApolloMutation } from 'vue-apollo'; import { GlTooltipDirective, GlIcon, GlLink, GlSafeHtmlDirective } from '@gitlab/ui'; -import updateNoteMutation from '../../graphql/mutations/update_note.mutation.graphql'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import DesignReplyForm from './design_reply_form.vue'; +import updateNoteMutation from '../../graphql/mutations/update_note.mutation.graphql'; import { findNoteId, extractDesignNoteId } from '../../utils/design_management_utils'; import { hasErrors } from '../../utils/cache_update'; +import DesignReplyForm from './design_reply_form.vue'; export default { components: { diff --git a/app/assets/javascripts/design_management/components/design_overlay.vue b/app/assets/javascripts/design_management/components/design_overlay.vue index 3c2ce693bc0..d19ac198fb2 100644 --- a/app/assets/javascripts/design_management/components/design_overlay.vue +++ b/app/assets/javascripts/design_management/components/design_overlay.vue @@ -2,8 +2,8 @@ import { __ } from '~/locale'; import activeDiscussionQuery from '../graphql/queries/active_discussion.query.graphql'; import updateActiveDiscussionMutation from '../graphql/mutations/update_active_discussion.mutation.graphql'; -import DesignNotePin from './design_note_pin.vue'; import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../constants'; +import DesignNotePin from './design_note_pin.vue'; export default { name: 'DesignOverlay', diff --git a/app/assets/javascripts/design_management/components/design_sidebar.vue b/app/assets/javascripts/design_management/components/design_sidebar.vue index 50b12fd739b..d6063e2e79e 100644 --- a/app/assets/javascripts/design_management/components/design_sidebar.vue +++ b/app/assets/javascripts/design_management/components/design_sidebar.vue @@ -3,13 +3,13 @@ import Cookies from 'js-cookie'; import { GlCollapse, GlButton, GlPopover } from '@gitlab/ui'; import { s__ } from '~/locale'; import { parseBoolean } from '~/lib/utils/common_utils'; +import Participants from '~/sidebar/components/participants/participants.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import updateActiveDiscussionMutation from '../graphql/mutations/update_active_discussion.mutation.graphql'; import { extractDiscussions, extractParticipants } from '../utils/design_management_utils'; import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../constants'; import DesignDiscussion from './design_notes/design_discussion.vue'; -import Participants from '~/sidebar/components/participants/participants.vue'; import DesignTodoButton from './design_todo_button.vue'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { diff --git a/app/assets/javascripts/design_management/components/design_todo_button.vue b/app/assets/javascripts/design_management/components/design_todo_button.vue index db14db79989..e80300ad57b 100644 --- a/app/assets/javascripts/design_management/components/design_todo_button.vue +++ b/app/assets/javascripts/design_management/components/design_todo_button.vue @@ -1,8 +1,8 @@ <script> import todoMarkDoneMutation from '~/graphql_shared/mutations/todo_mark_done.mutation.graphql'; +import TodoButton from '~/vue_shared/components/todo_button.vue'; import getDesignQuery from '../graphql/queries/get_design.query.graphql'; import createDesignTodoMutation from '../graphql/mutations/create_design_todo.mutation.graphql'; -import TodoButton from '~/vue_shared/components/todo_button.vue'; import allVersionsMixin from '../mixins/all_versions'; import { updateStoreAfterDeleteDesignTodo } from '../utils/cache_update'; import { findIssueId, findDesignId } from '../utils/design_management_utils'; diff --git a/app/assets/javascripts/design_management/components/list/item.vue b/app/assets/javascripts/design_management/components/list/item.vue index fa09c7c15cc..67110265b5f 100644 --- a/app/assets/javascripts/design_management/components/list/item.vue +++ b/app/assets/javascripts/design_management/components/list/item.vue @@ -1,5 +1,5 @@ <script> -import { GlLoadingIcon, GlIcon, GlIntersectionObserver } from '@gitlab/ui'; +import { GlLoadingIcon, GlIcon, GlIntersectionObserver, GlTooltipDirective } from '@gitlab/ui'; import Timeago from '~/vue_shared/components/time_ago_tooltip.vue'; import { n__, __ } from '~/locale'; import { DESIGN_ROUTE_NAME } from '../../router/constants'; @@ -11,6 +11,9 @@ export default { GlIcon, Timeago, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { id: { type: [Number, String], @@ -130,7 +133,7 @@ export default { <div class="card-body gl-p-0 gl-display-flex gl-align-items-center gl-justify-content-center gl-overflow-hidden gl-relative" > - <div v-if="icon.name" data-testid="designEvent" class="design-event gl-absolute"> + <div v-if="icon.name" data-testid="design-event" class="gl-top-5 gl-right-5 gl-absolute"> <span :title="icon.tooltip" :aria-label="icon.tooltip"> <gl-icon :name="icon.name" @@ -153,9 +156,10 @@ export default { v-show="showImage" :src="imageLink" :alt="filename" - class="gl-display-block gl-mx-auto gl-max-w-full mh-100 design-img" + class="gl-display-block gl-mx-auto gl-max-w-full gl-max-h-full design-img" data-qa-selector="design_image" :data-qa-filename="filename" + :data-testid="`design-img-${id}`" @load="onImageLoad" @error="onImageError" /> @@ -163,9 +167,14 @@ export default { </div> <div class="card-footer gl-display-flex gl-w-full"> <div class="gl-display-flex gl-flex-direction-column str-truncated-100"> - <span class="gl-font-weight-bold str-truncated-100" data-qa-selector="design_file_name">{{ - filename - }}</span> + <span + v-gl-tooltip + class="gl-font-weight-bold str-truncated-100" + data-qa-selector="design_file_name" + :data-testid="`design-img-filename-${id}`" + :title="filename" + >{{ filename }}</span + > <span v-if="updatedAt" class="str-truncated-100"> {{ __('Updated') }} <timeago :time="updatedAt" tooltip-placement="bottom" /> </span> diff --git a/app/assets/javascripts/design_management/components/toolbar/index.vue b/app/assets/javascripts/design_management/components/toolbar/index.vue index 3509a701984..295327b0f05 100644 --- a/app/assets/javascripts/design_management/components/toolbar/index.vue +++ b/app/assets/javascripts/design_management/components/toolbar/index.vue @@ -3,9 +3,9 @@ import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import permissionsQuery from 'shared_queries/design_management/design_permissions.query.graphql'; import { __, sprintf } from '~/locale'; import timeagoMixin from '~/vue_shared/mixins/timeago'; -import DesignNavigation from './design_navigation.vue'; import DeleteButton from '../delete_button.vue'; import { DESIGNS_ROUTE_NAME } from '../../router/constants'; +import DesignNavigation from './design_navigation.vue'; export default { components: { diff --git a/app/assets/javascripts/design_management/mixins/all_designs.js b/app/assets/javascripts/design_management/mixins/all_designs.js index 4783382d563..e92f8006a0d 100644 --- a/app/assets/javascripts/design_management/mixins/all_designs.js +++ b/app/assets/javascripts/design_management/mixins/all_designs.js @@ -2,8 +2,8 @@ import { propertyOf } from 'lodash'; import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql'; import createFlash, { FLASH_TYPES } from '~/flash'; import { s__ } from '~/locale'; -import allVersionsMixin from './all_versions'; import { DESIGNS_ROUTE_NAME } from '../router/constants'; +import allVersionsMixin from './all_versions'; export default { mixins: [allVersionsMixin], diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue index 5c82a7331b6..59f567fc372 100644 --- a/app/assets/javascripts/design_management/pages/index.vue +++ b/app/assets/javascripts/design_management/pages/index.vue @@ -6,12 +6,12 @@ import permissionsQuery from 'shared_queries/design_management/design_permission import createFlash, { FLASH_TYPES } from '~/flash'; import { __, s__, sprintf } from '~/locale'; import { getFilename } from '~/lib/utils/file_upload'; +import DesignDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue'; import UploadButton from '../components/upload/button.vue'; import DeleteButton from '../components/delete_button.vue'; import Design from '../components/list/item.vue'; import DesignDestroyer from '../components/design_destroyer.vue'; import DesignVersionDropdown from '../components/upload/design_version_dropdown.vue'; -import DesignDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue'; import uploadDesignMutation from '../graphql/mutations/upload_design.mutation.graphql'; import moveDesignMutation from '../graphql/mutations/move_design.mutation.graphql'; import allDesignsMixin from '../mixins/all_designs'; diff --git a/app/assets/javascripts/design_management/utils/error_messages.js b/app/assets/javascripts/design_management/utils/error_messages.js index cb4bb6e26a8..e7b2c814bb3 100644 --- a/app/assets/javascripts/design_management/utils/error_messages.js +++ b/app/assets/javascripts/design_management/utils/error_messages.js @@ -44,13 +44,13 @@ export const MOVE_DESIGN_ERROR = __( 'Something went wrong when reordering designs. Please try again', ); -export const CREATE_DESIGN_TODO_ERROR = __('Failed to create To-Do for the design.'); +export const CREATE_DESIGN_TODO_ERROR = __('Failed to create a to-do item for the design.'); -export const CREATE_DESIGN_TODO_EXISTS_ERROR = __('There is already a To-Do for this design.'); +export const CREATE_DESIGN_TODO_EXISTS_ERROR = __('There is already a to-do item for this design.'); -export const DELETE_DESIGN_TODO_ERROR = __('Failed to remove To-Do for the design.'); +export const DELETE_DESIGN_TODO_ERROR = __('Failed to remove a to-do item for the design.'); -export const TOGGLE_TODO_ERROR = __('Failed to toggle To-Do for the design.'); +export const TOGGLE_TODO_ERROR = __('Failed to toggle the to-do status for the design.'); const MAX_SKIPPED_FILES_LISTINGS = 5; diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 32822fe1fe8..0d74d654cd6 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -14,19 +14,9 @@ import { updateHistory } from '~/lib/utils/url_utility'; import notesEventHub from '../../notes/event_hub'; import eventHub from '../event_hub'; -import CompareVersions from './compare_versions.vue'; -import DiffFile from './diff_file.vue'; -import NoChanges from './no_changes.vue'; -import CommitWidget from './commit_widget.vue'; -import TreeList from './tree_list.vue'; - -import HiddenFilesWarning from './hidden_files_warning.vue'; -import MergeConflictWarning from './merge_conflict_warning.vue'; -import CollapsedFilesWarning from './collapsed_files_warning.vue'; - import { diffsApp } from '../utils/performance'; import { fileByFile } from '../utils/preferences'; - +import { reviewStatuses } from '../utils/file_reviews'; import { TREE_LIST_WIDTH_STORAGE_KEY, INITIAL_TREE_WIDTH, @@ -40,6 +30,15 @@ import { ALERT_COLLAPSED_FILES, EVT_VIEW_FILE_BY_FILE, } from '../constants'; +import CompareVersions from './compare_versions.vue'; +import DiffFile from './diff_file.vue'; +import NoChanges from './no_changes.vue'; +import CommitWidget from './commit_widget.vue'; +import TreeList from './tree_list.vue'; + +import HiddenFilesWarning from './hidden_files_warning.vue'; +import MergeConflictWarning from './merge_conflict_warning.vue'; +import CollapsedFilesWarning from './collapsed_files_warning.vue'; export default { name: 'DiffsApp', @@ -169,12 +168,7 @@ export default { 'hasConflicts', 'viewDiffsFileByFile', ]), - ...mapGetters('diffs', [ - 'whichCollapsedTypes', - 'isParallelView', - 'currentDiffIndex', - 'fileReviews', - ]), + ...mapGetters('diffs', ['whichCollapsedTypes', 'isParallelView', 'currentDiffIndex']), ...mapGetters(['isNotesFetched', 'getNoteableData']), diffs() { if (!this.viewDiffsFileByFile) { @@ -232,6 +226,9 @@ export default { return visible; }, + fileReviews() { + return reviewStatuses(this.diffFiles, this.mrReviews); + }, }, watch: { commit(newCommit, oldCommit) { diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue index 489278fd6ef..8db0a542eb5 100644 --- a/app/assets/javascripts/diffs/components/compare_versions.vue +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -3,11 +3,11 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import { GlTooltipDirective, GlLink, GlButton, GlSprintf } from '@gitlab/ui'; import { __ } from '~/locale'; import { polyfillSticky } from '~/lib/utils/sticky'; +import { CENTERED_LIMITED_CONTAINER_CLASSES, EVT_EXPAND_ALL_FILES } from '../constants'; +import eventHub from '../event_hub'; import CompareDropdownLayout from './compare_dropdown_layout.vue'; import SettingsDropdown from './settings_dropdown.vue'; import DiffStats from './diff_stats.vue'; -import { CENTERED_LIMITED_CONTAINER_CLASSES, EVT_EXPAND_ALL_FILES } from '../constants'; -import eventHub from '../event_hub'; export default { components: { diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue index f4e2571dd09..137fcfdca88 100644 --- a/app/assets/javascripts/diffs/components/diff_content.vue +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -8,17 +8,17 @@ import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; import NotDiffableViewer from '~/vue_shared/components/diff_viewer/viewers/not_diffable.vue'; import NoPreviewViewer from '~/vue_shared/components/diff_viewer/viewers/no_preview.vue'; import DiffFileDrafts from '~/batch_comments/components/diff_file_drafts.vue'; -import InlineDiffView from './inline_diff_view.vue'; -import ParallelDiffView from './parallel_diff_view.vue'; -import DiffView from './diff_view.vue'; +import { diffViewerModes } from '~/ide/constants'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import NoteForm from '../../notes/components/note_form.vue'; -import ImageDiffOverlay from './image_diff_overlay.vue'; -import DiffDiscussions from './diff_discussions.vue'; import eventHub from '../../notes/event_hub'; import { IMAGE_DIFF_POSITION_TYPE } from '../constants'; import { getDiffMode } from '../store/utils'; -import { diffViewerModes } from '~/ide/constants'; +import InlineDiffView from './inline_diff_view.vue'; +import ParallelDiffView from './parallel_diff_view.vue'; +import DiffView from './diff_view.vue'; +import ImageDiffOverlay from './image_diff_overlay.vue'; +import DiffDiscussions from './diff_discussions.vue'; import { mapInline, mapParallel } from './diff_row_utils'; export default { diff --git a/app/assets/javascripts/diffs/components/diff_discussion_reply.vue b/app/assets/javascripts/diffs/components/diff_discussion_reply.vue index 531ebaddacd..a7a6b38235b 100644 --- a/app/assets/javascripts/diffs/components/diff_discussion_reply.vue +++ b/app/assets/javascripts/diffs/components/diff_discussion_reply.vue @@ -2,14 +2,12 @@ import { mapGetters } from 'vuex'; import NoteSignedOutWidget from '~/notes/components/note_signed_out_widget.vue'; import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; -import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; export default { name: 'DiffDiscussionReply', components: { NoteSignedOutWidget, ReplyPlaceholder, - UserAvatarLink, }, props: { hasForm: { @@ -36,13 +34,6 @@ export default { <template v-if="userCanReply"> <slot v-if="hasForm" name="form"></slot> <template v-else-if="renderReplyPlaceholder"> - <user-avatar-link - :link-href="currentUser.path" - :img-src="currentUser.avatar_url" - :img-alt="currentUser.name" - :img-size="40" - class="d-none d-sm-block" - /> <reply-placeholder :button-text="__('Start a new discussion...')" @onClick="$emit('showNewDiscussionForm')" diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index e613b684345..5659f1a098e 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -1,16 +1,16 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; import { escape } from 'lodash'; -import { GlButton, GlLoadingIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlButton, GlLoadingIcon, GlSafeHtmlDirective as SafeHtml, GlSprintf } from '@gitlab/ui'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { sprintf } from '~/locale'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { hasDiff } from '~/helpers/diffs_helper'; -import notesEventHub from '../../notes/event_hub'; -import DiffFileHeader from './diff_file_header.vue'; -import DiffContent from './diff_content.vue'; import { diffViewerErrors } from '~/ide/constants'; -import { collapsedType, isCollapsed } from '../utils/diff_file'; +import notesEventHub from '../../notes/event_hub'; + +import { collapsedType, isCollapsed, getShortShaFromFile } from '../utils/diff_file'; + import { DIFF_FILE_AUTOMATIC_COLLAPSE, DIFF_FILE_MANUAL_COLLAPSE, @@ -20,6 +20,8 @@ import { } from '../constants'; import { DIFF_FILE, GENERIC_ERROR } from '../i18n'; import eventHub from '../event_hub'; +import DiffContent from './diff_content.vue'; +import DiffFileHeader from './diff_file_header.vue'; export default { components: { @@ -27,6 +29,7 @@ export default { DiffContent, GlButton, GlLoadingIcon, + GlSprintf, }, directives: { SafeHtml, @@ -81,15 +84,11 @@ export default { ...mapState('diffs', ['currentDiffFileId']), ...mapGetters(['isNotesFetched']), ...mapGetters('diffs', ['getDiffFileDiscussions']), - viewBlobLink() { - return sprintf( - this.$options.i18n.blobView, - { - linkStart: `<a href="${escape(this.file.view_path)}">`, - linkEnd: '</a>', - }, - false, - ); + viewBlobHref() { + return escape(this.file.view_path); + }, + shortSha() { + return getShortShaFromFile(this.file); }, showLoadingIcon() { return this.isLoadingCollapsedDiff || (!this.file.renderIt && !this.isCollapsed); @@ -98,7 +97,7 @@ export default { return hasDiff(this.file); }, isFileTooLarge() { - return this.file.viewer.error === diffViewerErrors.too_large; + return !this.manuallyCollapsed && this.file.viewer.error === diffViewerErrors.too_large; }, errorMessage() { return !this.manuallyCollapsed ? this.file.viewer.error_message : ''; @@ -144,6 +143,12 @@ export default { showContent() { return !this.isCollapsed && !this.isFileTooLarge; }, + showLocalFileReviews() { + const loggedIn = Boolean(gon.current_user_id); + const featureOn = this.glFeatures.localFileReviews; + + return loggedIn && featureOn; + }, }, watch: { 'file.file_hash': { @@ -181,6 +186,10 @@ export default { if (this.hasDiff) { this.postRender(); } + + if (this.reviewed && !this.isCollapsed && this.showLocalFileReviews) { + this.handleToggle(); + } }, beforeDestroy() { eventHub.$off(EVT_EXPAND_ALL_FILES, this.expandAllListener); @@ -273,9 +282,11 @@ export default { :can-current-user-fork="canCurrentUserFork" :diff-file="file" :collapsible="true" + :reviewed="reviewed" :expanded="!isCollapsed" :add-merge-request-buttons="true" :view-diffs-file-by-file="viewDiffsFileByFile" + :show-local-file-reviews="showLocalFileReviews" class="js-file-title file-title gl-border-1 gl-border-solid gl-border-gray-100" :class="hasBodyClasses.header" @toggleFile="handleToggle" @@ -309,14 +320,27 @@ export default { data-testid="loader-icon" /> <div v-else-if="errorMessage" class="diff-viewer"> - <div v-safe-html="errorMessage" class="nothing-here-block"></div> + <div + v-if="isFileTooLarge" + class="collapsed-file-warning gl-p-7 gl-bg-orange-50 gl-text-center gl-rounded-bottom-left-base gl-rounded-bottom-right-base" + > + <p class="gl-mb-5"> + {{ $options.i18n.tooLarge }} + </p> + <gl-button data-testid="blob-button" category="secondary" :href="viewBlobHref"> + <gl-sprintf :message="$options.i18n.blobView"> + <template #commitSha>{{ shortSha }}</template> + </gl-sprintf> + </gl-button> + </div> + <div v-else v-safe-html="errorMessage" class="nothing-here-block"></div> </div> <template v-else> <div v-show="showWarning" class="collapsed-file-warning gl-p-7 gl-bg-orange-50 gl-text-center gl-rounded-bottom-left-base gl-rounded-bottom-right-base" > - <p class="gl-mb-8"> + <p class="gl-mb-5"> {{ $options.i18n.autoCollapsed }} </p> <gl-button diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index 53d1383b82e..eb842a09d64 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -1,6 +1,6 @@ <script> import { escape } from 'lodash'; -import { mapActions, mapGetters } from 'vuex'; +import { mapActions, mapGetters, mapState } from 'vuex'; import { GlTooltipDirective, GlSafeHtmlDirective, @@ -10,17 +10,24 @@ import { GlDropdown, GlDropdownItem, GlDropdownDivider, + GlFormCheckbox, GlLoadingIcon, } from '@gitlab/ui'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import { truncateSha } from '~/lib/utils/text_utility'; import { __, s__, sprintf } from '~/locale'; -import { diffViewerModes } from '~/ide/constants'; -import DiffStats from './diff_stats.vue'; import { scrollToElement } from '~/lib/utils/common_utils'; -import { isCollapsed } from '../utils/diff_file'; + +import { diffViewerModes } from '~/ide/constants'; +import { collapsedType, isCollapsed } from '../utils/diff_file'; +import { reviewable } from '../utils/file_reviews'; + +import { DIFF_FILE_AUTOMATIC_COLLAPSE } from '../constants'; + import { DIFF_FILE_HEADER } from '../i18n'; +import DiffStats from './diff_stats.vue'; export default { components: { @@ -33,12 +40,14 @@ export default { GlDropdown, GlDropdownItem, GlDropdownDivider, + GlFormCheckbox, GlLoadingIcon, }, directives: { GlTooltip: GlTooltipDirective, SafeHtml: GlSafeHtmlDirective, }, + mixins: [glFeatureFlagsMixin()], i18n: { ...DIFF_FILE_HEADER, }, @@ -76,6 +85,16 @@ export default { required: false, default: false, }, + showLocalFileReviews: { + type: Boolean, + required: false, + default: false, + }, + reviewed: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -83,6 +102,7 @@ export default { }; }, computed: { + ...mapState('diffs', ['latestDiff']), ...mapGetters('diffs', ['diffHasExpandedDiscussions', 'diffHasDiscussions']), diffContentIDSelector() { return `#diff-content-${this.diffFile.file_hash}`; @@ -170,6 +190,9 @@ export default { (this.diffFile.edit_path || this.diffFile.ide_edit_path) ); }, + isReviewable() { + return reviewable(this.diffFile); + }, }, methods: { ...mapActions('diffs', [ @@ -177,6 +200,8 @@ export default { 'toggleFileDiscussionWrappers', 'toggleFullDiff', 'toggleActiveFileByHash', + 'reviewFile', + 'setFileCollapsedByUser', ]), handleToggleFile() { this.$emit('toggleFile'); @@ -204,6 +229,26 @@ export default { setMoreActionsShown(val) { this.moreActionsShown = val; }, + toggleReview(newReviewedStatus) { + const autoCollapsed = + this.isCollapsed && collapsedType(this.diffFile) === DIFF_FILE_AUTOMATIC_COLLAPSE; + const open = this.expanded; + const closed = !open; + const reviewed = newReviewedStatus; + + this.reviewFile({ file: this.diffFile, reviewed }); + + if (reviewed && autoCollapsed) { + this.setFileCollapsedByUser({ + filePath: this.diffFile.file_path, + collapsed: true, + }); + } + + if ((open && reviewed) || (closed && !reviewed)) { + this.$emit('toggleFile'); + } + }, }, }; </script> @@ -213,6 +258,8 @@ export default { ref="header" :class="{ 'gl-z-dropdown-menu!': moreActionsShown }" class="js-file-title file-title file-title-flex-parent" + data-qa-selector="file_title_container" + :data-qa-file-name="filePath" @click.self="handleToggleFile" > <div class="file-header-content"> @@ -289,6 +336,19 @@ export default { class="file-actions d-flex align-items-center gl-ml-auto gl-align-self-start" > <diff-stats :added-lines="diffFile.added_lines" :removed-lines="diffFile.removed_lines" /> + <gl-form-checkbox + v-if="isReviewable && showLocalFileReviews" + v-gl-tooltip.hover + data-testid="fileReviewCheckbox" + class="gl-mb-0" + :title="$options.i18n.fileReviewTooltip" + :checked="reviewed" + @change="toggleReview" + > + <span class="gl-line-height-20"> + {{ $options.i18n.fileReviewLabel }} + </span> + </gl-form-checkbox> <gl-button-group class="gl-pt-0!"> <gl-button v-if="diffFile.external_url" @@ -307,6 +367,7 @@ export default { right toggle-class="btn-icon js-diff-more-actions" class="gl-pt-0!" + data-qa-selector="dropdown_button" @show="setMoreActionsShown(true)" @hidden="setMoreActionsShown(false)" > @@ -340,6 +401,7 @@ export default { ref="ideEditButton" :href="diffFile.ide_edit_path" class="js-ide-edit-blob" + data-qa-selector="edit_in_ide_button" > {{ __('Edit in Web IDE') }} </gl-dropdown-item> diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue index 463b7f5cff4..e4c64bb0e07 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -6,7 +6,6 @@ import { s__ } from '~/locale'; import noteForm from '../../notes/components/note_form.vue'; import MultilineCommentForm from '../../notes/components/multiline_comment_form.vue'; import autosave from '../../notes/mixins/autosave'; -import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import { DIFF_NOTE_TYPE, INLINE_DIFF_LINES_KEY, PARALLEL_DIFF_VIEW_TYPE } from '../constants'; import { commentLineOptions, @@ -16,7 +15,6 @@ import { export default { components: { noteForm, - userAvatarLink, MultilineCommentForm, }, mixins: [autosave, diffLineNoteFormMixin, glFeatureFlagsMixin()], @@ -174,14 +172,6 @@ export default { :comment-line-options="commentLineOptions" /> </div> - <user-avatar-link - v-if="author" - :link-href="author.path" - :img-src="author.avatar_url" - :img-alt="author.name" - :img-size="40" - class="d-none d-sm-block" - /> <note-form ref="noteForm" :is-editing="true" diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue index db03da966c3..a8aca6deee6 100644 --- a/app/assets/javascripts/diffs/components/diff_row.vue +++ b/app/assets/javascripts/diffs/components/diff_row.vue @@ -1,6 +1,8 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; import { GlTooltipDirective, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { CONTEXT_LINE_CLASS_NAME, PARALLEL_DIFF_VIEW_TYPE, @@ -10,7 +12,6 @@ import { CONFLICT_THEIR, CONFLICT_MARKER, } from '../constants'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import DiffGutterAvatars from './diff_gutter_avatars.vue'; import * as utils from './diff_row_utils'; @@ -99,10 +100,10 @@ export default { }); }, addCommentTooltipLeft() { - return utils.addCommentTooltip(this.line.left); + return utils.addCommentTooltip(this.line.left, this.glFeatures.dragCommentSelection); }, addCommentTooltipRight() { - return utils.addCommentTooltip(this.line.right); + return utils.addCommentTooltip(this.line.right, this.glFeatures.dragCommentSelection); }, emptyCellRightClassMap() { return { conflict_their: this.line.left?.type === CONFLICT_OUR }; @@ -111,13 +112,7 @@ export default { return { conflict_our: this.line.right?.type === CONFLICT_THEIR }; }, shouldRenderCommentButton() { - return ( - this.isLoggedIn && - !this.line.isContextLineLeft && - !this.line.isMetaLineLeft && - !this.line.hasDiscussionsLeft && - !this.line.hasDiscussionsRight - ); + return this.isLoggedIn && !this.line.isContextLineLeft && !this.line.isMetaLineLeft; }, isLeftConflictMarker() { return [CONFLICT_MARKER_OUR, CONFLICT_MARKER_THEIR].includes(this.line.left?.type); @@ -168,7 +163,7 @@ export default { this.$emit('enterdragging', { ...line, index }); }, onDragStart(line) { - this.$root.$emit('bv::hide::tooltip'); + this.$root.$emit(BV_HIDE_TOOLTIP); this.dragging = true; this.$emit('startdragging', line); }, @@ -199,7 +194,7 @@ export default { > <template v-if="!isLeftConflictMarker"> <span - v-if="shouldRenderCommentButton" + v-if="shouldRenderCommentButton && !line.hasDiscussionsLeft" v-gl-tooltip data-testid="leftCommentButton" class="add-diff-note tooltip-wrapper" @@ -301,7 +296,7 @@ export default { <div :class="classNameMapCellRight" class="diff-td diff-line-num new_line"> <template v-if="line.right.type !== $options.CONFLICT_MARKER_THEIR"> <span - v-if="shouldRenderCommentButton" + v-if="shouldRenderCommentButton && !line.hasDiscussionsRight" v-gl-tooltip data-testid="rightCommentButton" class="add-diff-note tooltip-wrapper" diff --git a/app/assets/javascripts/diffs/components/diff_row_utils.js b/app/assets/javascripts/diffs/components/diff_row_utils.js index 7606c39ad37..cd45474afcd 100644 --- a/app/assets/javascripts/diffs/components/diff_row_utils.js +++ b/app/assets/javascripts/diffs/components/diff_row_utils.js @@ -50,11 +50,11 @@ export const classNameMapCell = ({ line, hll, isLoggedIn, isHover }) => { ]; }; -export const addCommentTooltip = (line) => { +export const addCommentTooltip = (line, dragCommentSelectionEnabled = false) => { let tooltip; if (!line) return tooltip; - tooltip = gon.drag_comment_selection + tooltip = dragCommentSelectionEnabled ? __('Add a comment to this line or drag for multiple lines') : __('Add a comment to this line'); const brokenSymlinks = line.commentsDisabled; diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue index 79800f835f4..b710e588360 100644 --- a/app/assets/javascripts/diffs/components/diff_view.vue +++ b/app/assets/javascripts/diffs/components/diff_view.vue @@ -2,10 +2,10 @@ import { mapGetters, mapState, mapActions } from 'vuex'; import draftCommentsMixin from '~/diffs/mixins/draft_comments'; import DraftNote from '~/batch_comments/components/draft_note.vue'; +import { getCommentedLines } from '~/notes/components/multiline_comment_utils'; import DiffRow from './diff_row.vue'; import DiffCommentCell from './diff_comment_cell.vue'; import DiffExpansionCell from './diff_expansion_cell.vue'; -import { getCommentedLines } from '~/notes/components/multiline_comment_utils'; export default { components: { @@ -61,10 +61,10 @@ export default { ...mapActions(['setSelectedCommentPosition']), ...mapActions('diffs', ['showCommentForm']), showCommentLeft(line) { - return !this.inline || line.left; + return line.left && !line.right; }, showCommentRight(line) { - return !this.inline || (line.right && !line.left); + return line.right && !line.left; }, onStartDragging(line) { this.dragStart = line; @@ -138,24 +138,30 @@ export default { :class="line.commentRowClasses" class="diff-grid-comments diff-tr notes_holder" > - <div v-if="showCommentLeft(line)" class="diff-td notes-content parallel old"> + <div + v-if="line.left || !inline" + :class="{ parallel: !inline }" + class="diff-td notes-content old" + > <diff-comment-cell - v-if="line.left" + v-if="line.left && (line.left.renderDiscussion || line.left.hasCommentForm)" :line="line.left" :diff-file-hash="diffFile.file_hash" :help-page-path="helpPagePath" - :has-draft="line.left.hasDraft" line-position="left" /> </div> - <div v-if="showCommentRight(line)" class="diff-td notes-content parallel new"> + <div + v-if="line.right || !inline" + :class="{ parallel: !inline }" + class="diff-td notes-content new" + > <diff-comment-cell - v-if="line.right" + v-if="line.right && (line.right.renderDiscussion || line.right.hasCommentForm)" :line="line.right" :diff-file-hash="diffFile.file_hash" :line-index="index" :help-page-path="helpPagePath" - :has-draft="line.right.hasDraft" line-position="right" /> </div> diff --git a/app/assets/javascripts/diffs/components/image_diff_overlay.vue b/app/assets/javascripts/diffs/components/image_diff_overlay.vue index 6a1e0d8cbd6..54b491faa2a 100644 --- a/app/assets/javascripts/diffs/components/image_diff_overlay.vue +++ b/app/assets/javascripts/diffs/components/image_diff_overlay.vue @@ -1,8 +1,8 @@ <script> import { mapActions, mapGetters } from 'vuex'; import { isArray } from 'lodash'; -import imageDiffMixin from 'ee_else_ce/diffs/mixins/image_diff'; import { GlIcon } from '@gitlab/ui'; +import imageDiffMixin from 'ee_else_ce/diffs/mixins/image_diff'; function calcPercent(pos, size, renderedSize) { return (((pos / size) * 100) / ((renderedSize / size) * 100)) * 100; diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue index 28485a2fdac..fd4e2004857 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_view.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue @@ -3,10 +3,10 @@ import { mapGetters, mapState } from 'vuex'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import draftCommentsMixin from '~/diffs/mixins/draft_comments'; import DraftNote from '~/batch_comments/components/draft_note.vue'; +import { getCommentedLines } from '~/notes/components/multiline_comment_utils'; import inlineDiffTableRow from './inline_diff_table_row.vue'; import DiffCommentCell from './diff_comment_cell.vue'; import DiffExpansionCell from './diff_expansion_cell.vue'; -import { getCommentedLines } from '~/notes/components/multiline_comment_utils'; export default { components: { diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue index 21e0bf18dbf..21a5eece125 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_view.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue @@ -2,10 +2,10 @@ import { mapGetters, mapState } from 'vuex'; import draftCommentsMixin from '~/diffs/mixins/draft_comments'; import DraftNote from '~/batch_comments/components/draft_note.vue'; +import { getCommentedLines } from '~/notes/components/multiline_comment_utils'; import parallelDiffTableRow from './parallel_diff_table_row.vue'; import DiffCommentCell from './diff_comment_cell.vue'; import DiffExpansionCell from './diff_expansion_cell.vue'; -import { getCommentedLines } from '~/notes/components/multiline_comment_utils'; export default { components: { diff --git a/app/assets/javascripts/diffs/i18n.js b/app/assets/javascripts/diffs/i18n.js index c4ac99ead91..2a061876937 100644 --- a/app/assets/javascripts/diffs/i18n.js +++ b/app/assets/javascripts/diffs/i18n.js @@ -1,13 +1,16 @@ -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; export const GENERIC_ERROR = __('Something went wrong on our end. Please try again!'); export const DIFF_FILE_HEADER = { optionsDropdownTitle: __('Options'), + fileReviewLabel: __('Viewed'), + fileReviewTooltip: __('Collapses this file (only for you) until it’s changed again.'), }; export const DIFF_FILE = { - blobView: __('You can %{linkStart}view the blob%{linkEnd} instead.'), + tooLarge: s__('MRDiffFile|Changes are too large to be shown.'), + blobView: s__('MRDiffFile|View file @ %{commitSha}'), editInFork: __( "You're not allowed to %{tag_start}edit%{tag_end} files in this project directly. Please fork this project, make your changes there, and submit a merge request.", ), diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index e95e9ac3ee4..32b4b5c57b4 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -7,20 +7,11 @@ import { deprecatedCreateFlash as createFlash } from '~/flash'; import { __, s__ } from '~/locale'; import { handleLocationHash, historyPushState, scrollToElement } from '~/lib/utils/common_utils'; import { mergeUrlParams, getLocationHash } from '~/lib/utils/url_utility'; +import { diffViewerModes } from '~/ide/constants'; import TreeWorker from '../workers/tree_worker'; import notesEventHub from '../../notes/event_hub'; import eventHub from '../event_hub'; import { - getDiffPositionByLineCode, - getNoteFormData, - convertExpandLines, - idleCallback, - allDiscussionWrappersExpanded, - prepareDiffData, - prepareLineForRenamedFile, -} from './utils'; -import * as types from './mutation_types'; -import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME, @@ -48,10 +39,19 @@ import { DIFF_VIEW_ALL_FILES, DIFF_FILE_BY_FILE_COOKIE_NAME, } from '../constants'; -import { diffViewerModes } from '~/ide/constants'; import { isCollapsed } from '../utils/diff_file'; import { getDerivedMergeRequestInformation } from '../utils/merge_request'; import { markFileReview, setReviewsForMergeRequest } from '../utils/file_reviews'; +import * as types from './mutation_types'; +import { + getDiffPositionByLineCode, + getNoteFormData, + convertExpandLines, + idleCallback, + allDiscussionWrappersExpanded, + prepareDiffData, + prepareLineForRenamedFile, +} from './utils'; export const setBaseConfig = ({ commit }, options) => { const { @@ -749,12 +749,10 @@ export const setFileByFile = ({ commit }, { fileByFile }) => { ); }; -export function reviewFile({ commit, state, getters }, { file, reviewed = true }) { +export function reviewFile({ commit, state }, { file, reviewed = true }) { const { mrPath } = getDerivedMergeRequestInformation({ endpoint: file.load_collapsed_diff_url }); - const reviews = setReviewsForMergeRequest( - mrPath, - markFileReview(getters.fileReviews(state), file, reviewed), - ); + const reviews = markFileReview(state.mrReviews, file, reviewed); + setReviewsForMergeRequest(mrPath, reviews); commit(types.SET_MR_FILE_REVIEWS, reviews); } diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js index a167b6d4694..149afc01056 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -1,11 +1,10 @@ import { __, n__ } from '~/locale'; -import { parallelizeDiffLines } from './utils'; -import { isFileReviewed } from '../utils/file_reviews'; import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE, INLINE_DIFF_LINES_KEY, } from '../constants'; +import { parallelizeDiffLines } from './utils'; export * from './getters_versions_dropdowns'; @@ -155,7 +154,3 @@ export const diffLines = (state) => (file, unifiedDiffComponents) => { state.diffViewType === INLINE_DIFF_VIEW_TYPE, ); }; - -export function fileReviews(state) { - return state.diffFiles.map((file) => isFileReviewed(state.mrReviews, file)); -} diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js index aa89c74cef0..f93435363ec 100644 --- a/app/assets/javascripts/diffs/store/modules/diff_state.js +++ b/app/assets/javascripts/diffs/store/modules/diff_state.js @@ -47,4 +47,5 @@ export default () => ({ showSuggestPopover: true, defaultSuggestionCommitMessage: '', mrReviews: {}, + latestDiff: true, }); diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 06f0f2c3dfb..9db29e8491e 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -1,6 +1,11 @@ import Vue from 'vue'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { + DIFF_FILE_MANUAL_COLLAPSE, + DIFF_FILE_AUTOMATIC_COLLAPSE, + INLINE_DIFF_LINES_KEY, +} from '../constants'; +import { findDiffFile, addLineReferences, removeMatchLine, @@ -9,11 +14,6 @@ import { isDiscussionApplicableToLine, updateLineInFile, } from './utils'; -import { - DIFF_FILE_MANUAL_COLLAPSE, - DIFF_FILE_AUTOMATIC_COLLAPSE, - INLINE_DIFF_LINES_KEY, -} from '../constants'; import * as types from './mutation_types'; function updateDiffFilesInState(state, files) { @@ -159,7 +159,12 @@ export default { [types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { discussion, diffPositionByLineCode, hash }) { const { latestDiff } = state; - const discussionLineCodes = [discussion.line_code, ...(discussion.line_codes || [])]; + const originalStartLineCode = discussion.original_position?.line_range?.start?.line_code; + const discussionLineCodes = [ + discussion.line_code, + originalStartLineCode, + ...(discussion.line_codes || []), + ]; const fileHash = discussion.diff_file.file_hash; const lineCheck = (line) => discussionLineCodes.some( diff --git a/app/assets/javascripts/diffs/utils/diff_file.js b/app/assets/javascripts/diffs/utils/diff_file.js index ce0398e75fc..7e6fde320d2 100644 --- a/app/assets/javascripts/diffs/utils/diff_file.js +++ b/app/assets/javascripts/diffs/utils/diff_file.js @@ -1,3 +1,5 @@ +import { truncateSha } from '~/lib/utils/text_utility'; + import { DIFF_FILE_SYMLINK_MODE, DIFF_FILE_DELETED_MODE, @@ -78,3 +80,7 @@ export function isCollapsed(file) { return collapsedStates[type]; } + +export function getShortShaFromFile(file) { + return file.content_sha ? truncateSha(String(file.content_sha)) : null; +} diff --git a/app/assets/javascripts/diffs/utils/file_reviews.js b/app/assets/javascripts/diffs/utils/file_reviews.js index 0047955643a..5fafc1714ae 100644 --- a/app/assets/javascripts/diffs/utils/file_reviews.js +++ b/app/assets/javascripts/diffs/utils/file_reviews.js @@ -2,6 +2,16 @@ function getFileReviewsKey(mrPath) { return `${mrPath}-file-reviews`; } +export function isFileReviewed(reviews, file) { + const fileReviews = reviews[file.file_identifier_hash]; + + return file?.id && fileReviews?.length ? new Set(fileReviews).has(file.id) : false; +} + +export function reviewStatuses(files, reviews) { + return files.map((file) => isFileReviewed(reviews, file)); +} + export function getReviewsForMergeRequest(mrPath) { const reviewsForMr = localStorage.getItem(getFileReviewsKey(mrPath)); let reviews = {}; @@ -23,23 +33,17 @@ export function setReviewsForMergeRequest(mrPath, reviews) { return reviews; } -export function isFileReviewed(reviews, file) { - const fileReviews = reviews[file.file_identifier_hash]; - - return file?.id && fileReviews?.length ? new Set(fileReviews).has(file.id) : false; -} - export function reviewable(file) { return Boolean(file.id) && Boolean(file.file_identifier_hash); } export function markFileReview(reviews, file, reviewed = true) { const usableReviews = { ...(reviews || {}) }; - let updatedReviews = usableReviews; + const updatedReviews = usableReviews; let fileReviews; if (reviewable(file)) { - fileReviews = new Set([...(usableReviews[file.file_identifier_hash] || [])]); + fileReviews = new Set(usableReviews[file.file_identifier_hash] || []); if (reviewed) { fileReviews.add(file.id); @@ -47,10 +51,7 @@ export function markFileReview(reviews, file, reviewed = true) { fileReviews.delete(file.id); } - updatedReviews = { - ...usableReviews, - [file.file_identifier_hash]: Array.from(fileReviews), - }; + updatedReviews[file.file_identifier_hash] = Array.from(fileReviews); if (updatedReviews[file.file_identifier_hash].length === 0) { delete updatedReviews[file.file_identifier_hash]; diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index d7aacfbce60..13db9400777 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -2,12 +2,12 @@ import $ from 'jquery'; import Dropzone from 'dropzone'; import { escape } from 'lodash'; import './behaviors/preview_markdown'; -import PasteMarkdownTable from './behaviors/markdown/paste_markdown_table'; -import csrf from './lib/utils/csrf'; -import axios from './lib/utils/axios_utils'; import { n__, __ } from '~/locale'; import { getFilename } from '~/lib/utils/file_upload'; import { spriteIcon } from '~/lib/utils/common_utils'; +import PasteMarkdownTable from './behaviors/markdown/paste_markdown_table'; +import csrf from './lib/utils/csrf'; +import axios from './lib/utils/axios_utils'; Dropzone.autoDiscover = false; diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js index c311e1b561c..72ffbfd62ce 100644 --- a/app/assets/javascripts/due_date_select.js +++ b/app/assets/javascripts/due_date_select.js @@ -3,10 +3,10 @@ import $ from 'jquery'; import Pikaday from 'pikaday'; import dateFormat from 'dateformat'; import { __ } from '~/locale'; +import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import axios from './lib/utils/axios_utils'; import { timeFor, parsePikadayDate, pikadayToString } from './lib/utils/datetime_utility'; import boardsStore from './boards/stores/boards_store'; -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; class DueDateSelect { constructor({ $dropdown, $loading } = {}) { diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js index 8d1a3d17c6e..d9e6a6c13e2 100644 --- a/app/assets/javascripts/editor/constants.js +++ b/app/assets/javascripts/editor/constants.js @@ -11,6 +11,11 @@ export const ERROR_INSTANCE_REQUIRED_FOR_EXTENSION = __( 'Editor Lite instance is required to set up an extension.', ); +export const EDITOR_READY_EVENT = 'editor-ready'; + +export const EDITOR_TYPE_CODE = 'vs.editor.ICodeEditor'; +export const EDITOR_TYPE_DIFF = 'vs.editor.IDiffEditor'; + // // EXTENSIONS' CONSTANTS // diff --git a/app/assets/javascripts/editor/editor_lite.js b/app/assets/javascripts/editor/editor_lite.js index 1808f968b8c..9403783c991 100644 --- a/app/assets/javascripts/editor/editor_lite.js +++ b/app/assets/javascripts/editor/editor_lite.js @@ -4,9 +4,14 @@ import languages from '~/ide/lib/languages'; import { defaultEditorOptions } from '~/ide/lib/editor_options'; import { registerLanguages } from '~/ide/utils'; import { joinPaths } from '~/lib/utils/url_utility'; -import { clearDomElement } from './utils'; -import { EDITOR_LITE_INSTANCE_ERROR_NO_EL, URI_PREFIX } from './constants'; import { uuids } from '~/diffs/utils/uuids'; +import { clearDomElement } from './utils'; +import { + EDITOR_LITE_INSTANCE_ERROR_NO_EL, + URI_PREFIX, + EDITOR_READY_EVENT, + EDITOR_TYPE_DIFF, +} from './constants'; export default class EditorLite { constructor(options = {}) { @@ -29,15 +34,12 @@ export default class EditorLite { monacoEditor.setTheme(theme ? themeName : DEFAULT_THEME); } - static updateModelLanguage(path, instance) { - if (!instance) return; - const model = instance.getModel(); + static getModelLanguage(path) { const ext = `.${path.split('.').pop()}`; const language = monacoLanguages .getLanguages() .find((lang) => lang.extensions.indexOf(ext) !== -1); - const id = language ? language.id : 'plaintext'; - monacoEditor.setModelLanguage(model, id); + return language ? language.id : 'plaintext'; } static pushToImportsArray(arr, toImport) { @@ -73,50 +75,19 @@ export default class EditorLite { }); } - /** - * Creates a monaco instance with the given options. - * - * @param {Object} options Options used to initialize monaco. - * @param {Element} options.el The element which will be used to create the monacoEditor. - * @param {string} options.blobPath The path used as the URI of the model. Monaco uses the extension of this path to determine the language. - * @param {string} options.blobContent The content to initialize the monacoEditor. - * @param {string} options.blobGlobalId This is used to help globally identify monaco instances that are created with the same blobPath. - */ - createInstance({ - el = undefined, - blobPath = '', - blobContent = '', - blobGlobalId = uuids()[0], - extensions = [], - ...instanceOptions - } = {}) { + static prepareInstance(el) { if (!el) { throw new Error(EDITOR_LITE_INSTANCE_ERROR_NO_EL); } clearDomElement(el); - const uriFilePath = joinPaths(URI_PREFIX, blobGlobalId, blobPath); - - const model = monacoEditor.createModel(blobContent, undefined, Uri.file(uriFilePath)); - monacoEditor.onDidCreateEditor(() => { delete el.dataset.editorLoading; }); + } - const instance = monacoEditor.create(el, { - ...this.options, - ...instanceOptions, - }); - instance.setModel(model); - instance.onDidDispose(() => { - const index = this.instances.findIndex((inst) => inst === instance); - this.instances.splice(index, 1); - model.dispose(); - }); - instance.updateModelLanguage = (path) => EditorLite.updateModelLanguage(path, instance); - instance.use = (args) => this.use(args, instance); - + static manageDefaultExtensions(instance, el, extensions) { EditorLite.loadExtensions(extensions, instance) .then((modules) => { if (modules) { @@ -126,33 +97,167 @@ export default class EditorLite { } }) .then(() => { - el.dispatchEvent(new Event('editor-ready')); + el.dispatchEvent(new Event(EDITOR_READY_EVENT)); }) .catch((e) => { throw e; }); + } + + static createEditorModel({ + blobPath, + blobContent, + blobOriginalContent, + blobGlobalId, + instance, + isDiff, + } = {}) { + if (!instance) { + return null; + } + const uriFilePath = joinPaths(URI_PREFIX, blobGlobalId, blobPath); + const uri = Uri.file(uriFilePath); + const existingModel = monacoEditor.getModel(uri); + const model = existingModel || monacoEditor.createModel(blobContent, undefined, uri); + if (!isDiff) { + instance.setModel(model); + return model; + } + const diffModel = { + original: monacoEditor.createModel( + blobOriginalContent, + EditorLite.getModelLanguage(model.uri.path), + ), + modified: model, + }; + instance.setModel(diffModel); + return diffModel; + } + + static convertMonacoToELInstance = (inst) => { + const editorLiteInstanceAPI = { + updateModelLanguage: (path) => { + return EditorLite.instanceUpdateLanguage(inst, path); + }, + use: (exts = []) => { + return EditorLite.instanceApplyExtension(inst, exts); + }, + }; + const handler = { + get(target, prop, receiver) { + if (Reflect.has(editorLiteInstanceAPI, prop)) { + return editorLiteInstanceAPI[prop]; + } + return Reflect.get(target, prop, receiver); + }, + }; + return new Proxy(inst, handler); + }; + + static instanceUpdateLanguage(inst, path) { + const lang = EditorLite.getModelLanguage(path); + const model = inst.getModel(); + return monacoEditor.setModelLanguage(model, lang); + } + + static instanceApplyExtension(inst, exts = []) { + const extensions = [].concat(exts); + extensions.forEach((extension) => { + EditorLite.mixIntoInstance(extension, inst); + }); + return inst; + } + + static instanceRemoveFromRegistry(editor, instance) { + const index = editor.instances.findIndex((inst) => inst === instance); + editor.instances.splice(index, 1); + } + + static instanceDisposeModels(editor, instance, model) { + const instanceModel = instance.getModel() || model; + if (!instanceModel) { + return; + } + if (instance.getEditorType() === EDITOR_TYPE_DIFF) { + const { original, modified } = instanceModel; + if (original) { + original.dispose(); + } + if (modified) { + modified.dispose(); + } + } else { + instanceModel.dispose(); + } + } + + /** + * Creates a monaco instance with the given options. + * + * @param {Object} options Options used to initialize monaco. + * @param {Element} options.el The element which will be used to create the monacoEditor. + * @param {string} options.blobPath The path used as the URI of the model. Monaco uses the extension of this path to determine the language. + * @param {string} options.blobContent The content to initialize the monacoEditor. + * @param {string} options.blobGlobalId This is used to help globally identify monaco instances that are created with the same blobPath. + */ + createInstance({ + el = undefined, + blobPath = '', + blobContent = '', + blobOriginalContent = '', + blobGlobalId = uuids()[0], + extensions = [], + isDiff = false, + ...instanceOptions + } = {}) { + EditorLite.prepareInstance(el); + + const createEditorFn = isDiff ? 'createDiffEditor' : 'create'; + const instance = EditorLite.convertMonacoToELInstance( + monacoEditor[createEditorFn].call(this, el, { + ...this.options, + ...instanceOptions, + }), + ); + + let model; + if (instanceOptions.model !== null) { + model = EditorLite.createEditorModel({ + blobGlobalId, + blobOriginalContent, + blobPath, + blobContent, + instance, + isDiff, + }); + } + + instance.onDidDispose(() => { + EditorLite.instanceRemoveFromRegistry(this, instance); + EditorLite.instanceDisposeModels(this, instance, model); + }); + + EditorLite.manageDefaultExtensions(instance, el, extensions); this.instances.push(instance); return instance; } + createDiffInstance(args) { + return this.createInstance({ + ...args, + isDiff: true, + }); + } + dispose() { this.instances.forEach((instance) => instance.dispose()); } - use(exts = [], instance = null) { - const extensions = Array.isArray(exts) ? exts : [exts]; - const initExtensions = (inst) => { - extensions.forEach((extension) => { - EditorLite.mixIntoInstance(extension, inst); - }); - }; - if (instance) { - initExtensions(instance); - } else { - this.instances.forEach((inst) => { - initExtensions(inst); - }); - } + use(exts) { + this.instances.forEach((inst) => { + inst.use(exts); + }); + return this; } } diff --git a/app/assets/javascripts/editor/extensions/editor_ci_schema_ext.js b/app/assets/javascripts/editor/extensions/editor_ci_schema_ext.js index eb47c20912e..ae6c89d3942 100644 --- a/app/assets/javascripts/editor/extensions/editor_ci_schema_ext.js +++ b/app/assets/javascripts/editor/extensions/editor_ci_schema_ext.js @@ -1,7 +1,7 @@ import Api from '~/api'; import { registerSchema } from '~/ide/utils'; -import { EditorLiteExtension } from './editor_lite_extension_base'; import { EXTENSION_CI_SCHEMA_FILE_NAME_MATCH } from '../constants'; +import { EditorLiteExtension } from './editor_lite_extension_base'; export class CiSchemaExtension extends EditorLiteExtension { /** diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue index c6b34fecbb7..9e058af56c4 100644 --- a/app/assets/javascripts/environments/components/container.vue +++ b/app/assets/javascripts/environments/components/container.vue @@ -26,26 +26,6 @@ export default { type: Boolean, required: true, }, - deployBoardsHelpPath: { - type: String, - required: false, - default: '', - }, - helpCanaryDeploymentsPath: { - type: String, - required: false, - default: '', - }, - lockPromotionSvgPath: { - type: String, - required: false, - default: '', - }, - userCalloutsPath: { - type: String, - required: false, - default: '', - }, }, methods: { onChangePage(page) { @@ -62,14 +42,7 @@ export default { <slot name="empty-state"></slot> <div v-if="!isLoading && environments.length > 0" class="table-holder"> - <environment-table - :environments="environments" - :can-read-environment="canReadEnvironment" - :user-callouts-path="userCalloutsPath" - :lock-promotion-svg-path="lockPromotionSvgPath" - :help-canary-deployments-path="helpCanaryDeploymentsPath" - :deploy-boards-help-path="deployBoardsHelpPath" - /> + <environment-table :environments="environments" :can-read-environment="canReadEnvironment" /> <table-pagination v-if="pagination && pagination.totalPages > 1" diff --git a/app/assets/javascripts/environments/components/deploy_board.vue b/app/assets/javascripts/environments/components/deploy_board.vue index 07cb968d8d3..9249ea53a84 100644 --- a/app/assets/javascripts/environments/components/deploy_board.vue +++ b/app/assets/javascripts/environments/components/deploy_board.vue @@ -44,11 +44,6 @@ export default { type: Object, required: true, }, - deployBoardsHelpPath: { - type: String, - required: false, - default: '', - }, isLoading: { type: Boolean, required: true, diff --git a/app/assets/javascripts/environments/components/environment_delete.vue b/app/assets/javascripts/environments/components/environment_delete.vue index 75d92d3295d..0dea04f7c7d 100644 --- a/app/assets/javascripts/environments/components/environment_delete.vue +++ b/app/assets/javascripts/environments/components/environment_delete.vue @@ -6,6 +6,7 @@ import { GlTooltipDirective, GlButton, GlModalDirective } from '@gitlab/ui'; import { s__ } from '~/locale'; +import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import eventHub from '../event_hub'; export default { @@ -40,7 +41,7 @@ export default { }, methods: { onClick() { - this.$root.$emit('bv::hide::tooltip', this.$options.deleteEnvironmentTooltipId); + this.$root.$emit(BV_HIDE_TOOLTIP, this.$options.deleteEnvironmentTooltipId); eventHub.$emit('requestDeleteEnvironment', this.environment); }, onDeleteEnvironment(environment) { @@ -59,7 +60,7 @@ export default { :loading="isLoading" :title="title" :aria-label="title" - class="gl-display-none gl-display-md-block" + class="gl-display-none gl-md-display-block" variant="danger" category="primary" icon="remove" diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index 1724cc692bd..cc55c05b08b 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -549,7 +549,7 @@ export default { upcomingDeploymentCellClasses() { return [ this.tableData.upcoming.spacing, - { 'gl-display-none gl-display-md-block': !this.upcomingDeployment }, + { 'gl-display-none gl-md-display-block': !this.upcomingDeployment }, ]; }, }, diff --git a/app/assets/javascripts/environments/components/environment_monitoring.vue b/app/assets/javascripts/environments/components/environment_monitoring.vue index 4dc2c0ec1bd..7f70433776d 100644 --- a/app/assets/javascripts/environments/components/environment_monitoring.vue +++ b/app/assets/javascripts/environments/components/environment_monitoring.vue @@ -30,7 +30,7 @@ export default { :href="monitoringUrl" :title="title" :aria-label="title" - class="monitoring-url gl-display-none gl-display-sm-none gl-display-md-block" + class="monitoring-url gl-display-none gl-sm-display-none gl-md-display-block" icon="chart" rel="noopener noreferrer nofollow" variant="default" diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue index 48edde82ce7..397616c654f 100644 --- a/app/assets/javascripts/environments/components/environment_rollback.vue +++ b/app/assets/javascripts/environments/components/environment_rollback.vue @@ -68,7 +68,7 @@ export default { <gl-button v-gl-tooltip v-gl-modal.confirm-rollback-modal - class="gl-display-none gl-display-md-block text-secondary" + class="gl-display-none gl-md-display-block text-secondary" :loading="isLoading" :title="title" :icon="isLastDeployment ? 'repeat' : 'redo'" diff --git a/app/assets/javascripts/environments/components/environment_stop.vue b/app/assets/javascripts/environments/components/environment_stop.vue index 8e100623199..adb0b05d002 100644 --- a/app/assets/javascripts/environments/components/environment_stop.vue +++ b/app/assets/javascripts/environments/components/environment_stop.vue @@ -6,6 +6,7 @@ import { GlTooltipDirective, GlButton, GlModalDirective } from '@gitlab/ui'; import { s__ } from '~/locale'; +import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import eventHub from '../event_hub'; export default { @@ -40,7 +41,7 @@ export default { }, methods: { onClick() { - this.$root.$emit('bv::hide::tooltip', this.$options.stopEnvironmentTooltipId); + this.$root.$emit(BV_HIDE_TOOLTIP, this.$options.stopEnvironmentTooltipId); eventHub.$emit('requestStopEnvironment', this.environment); }, onStopEnvironment(environment) { diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index 6f68c6e864a..1a8a56e892a 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -2,10 +2,10 @@ import { GlBadge, GlButton, GlModalDirective, GlTab, GlTabs } from '@gitlab/ui'; import { deprecatedCreateFlash as Flash } from '~/flash'; import { s__ } from '~/locale'; -import emptyState from './empty_state.vue'; +import CIPaginationMixin from '~/vue_shared/mixins/ci_pagination_api_mixin'; import eventHub from '../event_hub'; import environmentsMixin from '../mixins/environments_mixin'; -import CIPaginationMixin from '~/vue_shared/mixins/ci_pagination_api_mixin'; +import emptyState from './empty_state.vue'; import EnableReviewAppModal from './enable_review_app_modal.vue'; import StopEnvironmentModal from './stop_environment_modal.vue'; import DeleteEnvironmentModal from './delete_environment_modal.vue'; @@ -51,30 +51,10 @@ export default { type: String, required: true, }, - helpCanaryDeploymentsPath: { - type: String, - required: false, - default: '', - }, helpPagePath: { type: String, required: true, }, - deployBoardsHelpPath: { - type: String, - required: false, - default: '', - }, - lockPromotionSvgPath: { - type: String, - required: false, - default: '', - }, - userCalloutsPath: { - type: String, - required: false, - default: '', - }, }, created() { @@ -133,7 +113,7 @@ export default { <confirm-rollback-modal :environment="environmentInRollbackModal" /> <div class="gl-w-full"> - <div class="gl-display-flex gl-flex-direction-column gl-mt-3 gl-display-md-none!"> + <div class="gl-display-flex gl-flex-direction-column gl-mt-3 gl-md-display-none!"> <gl-button v-if="state.reviewAppDetails.can_setup_review_app" v-gl-modal="$options.modal.id" @@ -167,7 +147,7 @@ export default { </gl-tab> <template #tabs-end> <div - class="gl-display-none gl-display-md-flex gl-lg-align-items-center gl-lg-flex-direction-row gl-lg-flex-fill-1 gl-lg-justify-content-end gl-lg-mt-0" + class="gl-display-none gl-md-display-flex gl-lg-align-items-center gl-lg-flex-direction-row gl-lg-flex-fill-1 gl-lg-justify-content-end gl-lg-mt-0" > <gl-button v-if="state.reviewAppDetails.can_setup_review_app" @@ -195,10 +175,6 @@ export default { :environments="state.environments" :pagination="state.paginationInformation" :can-read-environment="canReadEnvironment" - :user-callouts-path="userCalloutsPath" - :lock-promotion-svg-path="lockPromotionSvgPath" - :help-canary-deployments-path="helpCanaryDeploymentsPath" - :deploy-boards-help-path="deployBoardsHelpPath" @onChangePage="onChangePage" > <template v-if="!isLoading && state.environments.length === 0" #empty-state> diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index bbb56ca6f26..c6a7fe1f57d 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -23,31 +23,11 @@ export default { required: true, default: () => [], }, - deployBoardsHelpPath: { - type: String, - required: false, - default: '', - }, canReadEnvironment: { type: Boolean, required: false, default: false, }, - helpCanaryDeploymentsPath: { - type: String, - required: false, - default: '', - }, - lockPromotionSvgPath: { - type: String, - required: false, - default: '', - }, - userCalloutsPath: { - type: String, - required: false, - default: '', - }, }, data() { return { @@ -189,7 +169,6 @@ export default { <div class="deploy-board-container"> <deploy-board :deploy-board-data="model.deployBoardData" - :deploy-boards-help-path="deployBoardsHelpPath" :is-loading="model.isLoadingDeployBoard" :is-empty="model.isEmptyDeployBoard" :logs-path="model.logs_path" diff --git a/app/assets/javascripts/environments/components/stop_environment_modal.vue b/app/assets/javascripts/environments/components/stop_environment_modal.vue index 0832822520d..828a7098b36 100644 --- a/app/assets/javascripts/environments/components/stop_environment_modal.vue +++ b/app/assets/javascripts/environments/components/stop_environment_modal.vue @@ -1,7 +1,7 @@ <script> import { GlSprintf, GlTooltipDirective, GlModal } from '@gitlab/ui'; -import eventHub from '../event_hub'; import { __, s__ } from '~/locale'; +import eventHub from '../event_hub'; export default { id: 'stop-environment-modal', diff --git a/app/assets/javascripts/environments/folder/environments_folder_bundle.js b/app/assets/javascripts/environments/folder/environments_folder_bundle.js index e4726412f99..1be9a4608cb 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_bundle.js +++ b/app/assets/javascripts/environments/folder/environments_folder_bundle.js @@ -1,9 +1,9 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import environmentsFolderApp from './environments_folder_view.vue'; +import createDefaultClient from '~/lib/graphql'; import { parseBoolean } from '../../lib/utils/common_utils'; import Translate from '../../vue_shared/translate'; -import createDefaultClient from '~/lib/graphql'; +import environmentsFolderApp from './environments_folder_view.vue'; Vue.use(Translate); Vue.use(VueApollo); diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue index dbb60fa4622..d6244cbe4d7 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_view.vue +++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue @@ -34,21 +34,6 @@ export default { type: Boolean, required: true, }, - userCalloutsPath: { - type: String, - required: false, - default: '', - }, - lockPromotionSvgPath: { - type: String, - required: false, - default: '', - }, - helpCanaryDeploymentsPath: { - type: String, - required: false, - default: '', - }, }, methods: { successCallback(resp) { @@ -88,9 +73,6 @@ export default { :environments="state.environments" :pagination="state.paginationInformation" :can-read-environment="canReadEnvironment" - :user-callouts-path="userCalloutsPath" - :lock-promotion-svg-path="lockPromotionSvgPath" - :help-canary-deployments-path="helpCanaryDeploymentsPath" @onChangePage="onChangePage" /> </div> diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js index 4d734a457ab..68348648e61 100644 --- a/app/assets/javascripts/environments/index.js +++ b/app/assets/javascripts/environments/index.js @@ -1,9 +1,9 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import environmentsComponent from './components/environments_app.vue'; +import createDefaultClient from '~/lib/graphql'; import { parseBoolean } from '../lib/utils/common_utils'; import Translate from '../vue_shared/translate'; -import createDefaultClient from '~/lib/graphql'; +import environmentsComponent from './components/environments_app.vue'; Vue.use(Translate); Vue.use(VueApollo); @@ -30,7 +30,6 @@ export default () => { endpoint: environmentsData.environmentsDataEndpoint, newEnvironmentPath: environmentsData.newEnvironmentPath, helpPagePath: environmentsData.helpPagePath, - deployBoardsHelpPath: environmentsData.deployBoardsHelpPath, canCreateEnvironment: parseBoolean(environmentsData.canCreateEnvironment), canReadEnvironment: parseBoolean(environmentsData.canReadEnvironment), }; @@ -41,7 +40,6 @@ export default () => { endpoint: this.endpoint, newEnvironmentPath: this.newEnvironmentPath, helpPagePath: this.helpPagePath, - deployBoardsHelpPath: this.deployBoardsHelpPath, canCreateEnvironment: this.canCreateEnvironment, canReadEnvironment: this.canReadEnvironment, }, diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js index 8911885e920..f7fdbb03f04 100644 --- a/app/assets/javascripts/environments/stores/environments_store.js +++ b/app/assets/javascripts/environments/stores/environments_store.js @@ -1,5 +1,5 @@ -import { setDeployBoard } from './helpers'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; +import { setDeployBoard } from './helpers'; /** * Environments Store. diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue index e21c6b62b91..fca2d5b06e5 100644 --- a/app/assets/javascripts/error_tracking/components/error_details.vue +++ b/app/assets/javascripts/error_tracking/components/error_details.vue @@ -16,10 +16,8 @@ import { import { deprecatedCreateFlash as createFlash } from '~/flash'; import { __, sprintf, n__ } from '~/locale'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; -import Stacktrace from './stacktrace.vue'; import TrackEventDirective from '~/vue_shared/directives/track_event'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import { severityLevel, severityLevelVariant, errorStatus } from './constants'; import Tracking from '~/tracking'; import { trackClickErrorLinkToSentryOptions, @@ -28,6 +26,8 @@ import { } from '../utils'; import query from '../queries/details.query.graphql'; +import { severityLevel, severityLevelVariant, errorStatus } from './constants'; +import Stacktrace from './stacktrace.vue'; const SENTRY_TIMEOUT = 10000; diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue index 7ccb6253508..b712c8d6e85 100644 --- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue +++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue @@ -18,9 +18,9 @@ import { isEmpty } from 'lodash'; import AccessorUtils from '~/lib/utils/accessor'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { __ } from '~/locale'; -import ErrorTrackingActions from './error_tracking_actions.vue'; import Tracking from '~/tracking'; import { trackErrorListViewsOptions, trackErrorStatusUpdateOptions } from '../utils'; +import ErrorTrackingActions from './error_tracking_actions.vue'; export const tableDataClass = 'table-col d-flex d-md-table-cell align-items-center'; diff --git a/app/assets/javascripts/error_tracking/details.js b/app/assets/javascripts/error_tracking/details.js index 55ab362f805..65cda53626a 100644 --- a/app/assets/javascripts/error_tracking/details.js +++ b/app/assets/javascripts/error_tracking/details.js @@ -1,9 +1,9 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; +import csrf from '~/lib/utils/csrf'; import store from './store'; import ErrorDetails from './components/error_details.vue'; -import csrf from '~/lib/utils/csrf'; Vue.use(VueApollo); diff --git a/app/assets/javascripts/error_tracking/store/actions.js b/app/assets/javascripts/error_tracking/store/actions.js index 8f1e7e0b959..a27ebd16956 100644 --- a/app/assets/javascripts/error_tracking/store/actions.js +++ b/app/assets/javascripts/error_tracking/store/actions.js @@ -1,8 +1,8 @@ -import service from '../services'; -import * as types from './mutation_types'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { visitUrl } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; +import service from '../services'; +import * as types from './mutation_types'; export const setStatus = ({ commit }, status) => { commit(types.SET_ERROR_STATUS, status.toLowerCase()); diff --git a/app/assets/javascripts/error_tracking/store/details/actions.js b/app/assets/javascripts/error_tracking/store/details/actions.js index 394dec938cf..7319d45bbd2 100644 --- a/app/assets/javascripts/error_tracking/store/details/actions.js +++ b/app/assets/javascripts/error_tracking/store/details/actions.js @@ -1,8 +1,8 @@ -import service from '../../services'; -import * as types from './mutation_types'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import Poll from '~/lib/utils/poll'; import { __ } from '~/locale'; +import service from '../../services'; +import * as types from './mutation_types'; let stackTracePoll; diff --git a/app/assets/javascripts/error_tracking/store/list/actions.js b/app/assets/javascripts/error_tracking/store/list/actions.js index a242c0e4236..f07e546241a 100644 --- a/app/assets/javascripts/error_tracking/store/list/actions.js +++ b/app/assets/javascripts/error_tracking/store/list/actions.js @@ -1,8 +1,8 @@ -import Service from '../../services'; -import * as types from './mutation_types'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import Poll from '~/lib/utils/poll'; import { __ } from '~/locale'; +import Service from '../../services'; +import * as types from './mutation_types'; let eTagPoll; diff --git a/app/assets/javascripts/error_tracking/store/list/mutations.js b/app/assets/javascripts/error_tracking/store/list/mutations.js index 84a62fa9024..82d747e83f8 100644 --- a/app/assets/javascripts/error_tracking/store/list/mutations.js +++ b/app/assets/javascripts/error_tracking/store/list/mutations.js @@ -1,6 +1,6 @@ -import * as types from './mutation_types'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import AccessorUtils from '~/lib/utils/accessor'; +import * as types from './mutation_types'; export default { [types.SET_ERRORS](state, data) { diff --git a/app/assets/javascripts/error_tracking_settings/store/mutations.js b/app/assets/javascripts/error_tracking_settings/store/mutations.js index 1fc028093c1..2242169aa1e 100644 --- a/app/assets/javascripts/error_tracking_settings/store/mutations.js +++ b/app/assets/javascripts/error_tracking_settings/store/mutations.js @@ -1,7 +1,7 @@ import { pick } from 'lodash'; import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; -import * as types from './mutation_types'; import { projectKeys } from '../utils'; +import * as types from './mutation_types'; export default { [types.CLEAR_PROJECTS](state) { diff --git a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue index 210212fa900..b1e60066e11 100644 --- a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue +++ b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue @@ -91,9 +91,9 @@ export default { <h3 class="page-title gl-m-0">{{ title }}</h3> </div> - <div v-if="error.length" class="alert alert-danger"> + <gl-alert v-if="error.length" variant="warning" class="gl-mb-5" :dismissible="false"> <p v-for="(message, index) in error" :key="index" class="gl-mb-0">{{ message }}</p> - </div> + </gl-alert> <feature-flag-form :name="name" diff --git a/app/assets/javascripts/feature_flags/components/feature_flags.vue b/app/assets/javascripts/feature_flags/components/feature_flags.vue index ddeefd7b827..8a0b388b0aa 100644 --- a/app/assets/javascripts/feature_flags/components/feature_flags.vue +++ b/app/assets/javascripts/feature_flags/components/feature_flags.vue @@ -3,16 +3,16 @@ import { mapState, mapActions } from 'vuex'; import { isEmpty } from 'lodash'; import { GlAlert, GlButton, GlModalDirective, GlSprintf, GlTabs } from '@gitlab/ui'; -import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '../constants'; -import FeatureFlagsTab from './feature_flags_tab.vue'; -import FeatureFlagsTable from './feature_flags_table.vue'; -import UserListsTable from './user_lists_table.vue'; import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; import { buildUrlWithCurrentLocation, getParameterByName, historyPushState, } from '~/lib/utils/common_utils'; +import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '../constants'; +import FeatureFlagsTab from './feature_flags_tab.vue'; +import FeatureFlagsTable from './feature_flags_table.vue'; +import UserListsTable from './user_lists_table.vue'; import ConfigureFeatureFlagsModal from './configure_feature_flags_modal.vue'; @@ -198,7 +198,7 @@ export default { @token="rotateInstanceId()" /> <div :class="topAreaBaseClasses"> - <div class="gl-display-flex gl-flex-direction-column gl-display-md-none!"> + <div class="gl-display-flex gl-flex-direction-column gl-md-display-none!"> <gl-button v-if="canUserConfigure" v-gl-modal="'configure-feature-flags'" @@ -285,7 +285,7 @@ export default { </feature-flags-tab> <template #tabs-end> <li - class="gl-display-none gl-display-md-flex gl-align-items-center gl-flex-fill-1 gl-justify-content-end" + class="gl-display-none gl-md-display-flex gl-align-items-center gl-flex-fill-1 gl-justify-content-end" > <gl-button v-if="canUserConfigure" diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_tab.vue b/app/assets/javascripts/feature_flags/components/feature_flags_tab.vue index 24b0b54d1be..d0df00e446b 100644 --- a/app/assets/javascripts/feature_flags/components/feature_flags_tab.vue +++ b/app/assets/javascripts/feature_flags/components/feature_flags_tab.vue @@ -68,41 +68,39 @@ export default { <span data-testid="feature-flags-tab-title">{{ title }}</span> <gl-badge size="sm" class="gl-tab-counter-badge">{{ itemCount }}</gl-badge> </template> - <template> - <gl-alert - v-for="(message, index) in alerts" - :key="index" - data-testid="serverErrors" - variant="danger" - @dismiss="clearAlert(index)" - > - {{ message }} - </gl-alert> + <gl-alert + v-for="(message, index) in alerts" + :key="index" + data-testid="serverErrors" + variant="danger" + @dismiss="clearAlert(index)" + > + {{ message }} + </gl-alert> - <gl-loading-icon v-if="isLoading" :label="loadingLabel" size="md" class="gl-mt-4" /> + <gl-loading-icon v-if="isLoading" :label="loadingLabel" size="md" class="gl-mt-4" /> - <gl-empty-state - v-else-if="errorState" - :title="errorTitle" - :description="s__(`FeatureFlags|Try again in a few moments or contact your support team.`)" - :svg-path="errorStateSvgPath" - data-testid="error-state" - /> + <gl-empty-state + v-else-if="errorState" + :title="errorTitle" + :description="s__(`FeatureFlags|Try again in a few moments or contact your support team.`)" + :svg-path="errorStateSvgPath" + data-testid="error-state" + /> - <gl-empty-state - v-else-if="emptyState" - :title="emptyTitle" - :svg-path="errorStateSvgPath" - data-testid="empty-state" - > - <template #description> - {{ emptyDescription }} - <gl-link :href="featureFlagsHelpPagePath" target="_blank"> - {{ s__('FeatureFlags|More information') }} - </gl-link> - </template> - </gl-empty-state> - <slot> </slot> - </template> + <gl-empty-state + v-else-if="emptyState" + :title="emptyTitle" + :svg-path="errorStateSvgPath" + data-testid="empty-state" + > + <template #description> + {{ emptyDescription }} + <gl-link :href="featureFlagsHelpPagePath" target="_blank"> + {{ s__('FeatureFlags|More information') }} + </gl-link> + </template> + </gl-empty-state> + <slot> </slot> </gl-tab> </template> diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue index f3b199b5aca..04a5e5bc3c5 100644 --- a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue +++ b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue @@ -199,7 +199,7 @@ export default { :key="strategy.id" data-testid="strategy-badge" variant="info" - class="gl-mr-3 gl-mt-2" + class="gl-mr-3 gl-mt-2 gl-white-space-normal gl-text-left gl-px-5" >{{ strategyBadgeText(strategy) }}</gl-badge > </template> diff --git a/app/assets/javascripts/feature_flags/components/form.vue b/app/assets/javascripts/feature_flags/components/form.vue index 253661ece1f..1e5d8e219d2 100644 --- a/app/assets/javascripts/feature_flags/components/form.vue +++ b/app/assets/javascripts/feature_flags/components/form.vue @@ -10,13 +10,11 @@ import { GlFormCheckbox, GlSprintf, GlIcon, + GlToggle, } from '@gitlab/ui'; import RelatedIssuesRoot from '~/related_issues/components/related_issues_root.vue'; import { s__ } from '~/locale'; import featureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import ToggleButton from '~/vue_shared/components/toggle_button.vue'; -import EnvironmentsDropdown from './environments_dropdown.vue'; -import Strategy from './strategy.vue'; import { ROLLOUT_STRATEGY_ALL_USERS, ROLLOUT_STRATEGY_PERCENT_ROLLOUT, @@ -27,6 +25,8 @@ import { LEGACY_FLAG, } from '../constants'; import { createNewEnvironmentScope } from '../store/helpers'; +import EnvironmentsDropdown from './environments_dropdown.vue'; +import Strategy from './strategy.vue'; export default { components: { @@ -37,7 +37,7 @@ export default { GlTooltip, GlSprintf, GlIcon, - ToggleButton, + GlToggle, EnvironmentsDropdown, Strategy, RelatedIssuesRoot, @@ -372,7 +372,7 @@ export default { {{ s__('FeatureFlags|Environment Spec') }} </div> <div - class="table-mobile-content js-feature-flag-status d-flex align-items-center justify-content-start" + class="table-mobile-content gl-display-flex gl-align-items-center gl-justify-content-start" > <p v-if="isAllEnvironment(scope.environmentScope)" class="js-scope-all pl-3"> {{ $options.translations.allEnvironmentsText }} @@ -398,10 +398,10 @@ export default { <div class="table-mobile-header" role="rowheader"> {{ s__('FeatureFlags|Status') }} </div> - <div class="table-mobile-content js-feature-flag-status"> - <toggle-button + <div class="table-mobile-content gl-display-flex gl-justify-content-center"> + <gl-toggle :value="scope.active" - :disabled-input="!active || !canUpdateScope(scope)" + :disabled="!active || !canUpdateScope(scope)" @change="(status) => (scope.active = status)" /> </div> @@ -498,25 +498,26 @@ export default { <div class="table-mobile-header" role="rowheader"> {{ s__('FeatureFlags|Remove') }} </div> - <div class="table-mobile-content js-feature-flag-delete"> + <div class="table-mobile-content"> <gl-button v-if="!isAllEnvironment(scope.environmentScope) && canUpdateScope(scope)" v-gl-tooltip :title="s__('FeatureFlags|Remove')" class="js-delete-scope btn-transparent pr-3 pl-3" icon="clear" + data-testid="feature-flag-delete" @click="removeScope(scope)" /> </div> </div> </div> - <div class="js-add-new-scope gl-responsive-table-row" role="row"> + <div class="gl-responsive-table-row" role="row" data-testid="add-new-scope"> <div class="table-section section-30" role="gridcell"> <div class="table-mobile-header" role="rowheader"> {{ s__('FeatureFlags|Environment Spec') }} </div> - <div class="table-mobile-content js-feature-flag-status"> + <div class="table-mobile-content"> <environments-dropdown class="js-new-scope-name col-12" :value="newScope" @@ -530,9 +531,9 @@ export default { <div class="table-mobile-header" role="rowheader"> {{ s__('FeatureFlags|Status') }} </div> - <div class="table-mobile-content js-feature-flag-status"> - <toggle-button - :disabled-input="!active" + <div class="table-mobile-content gl-display-flex gl-justify-content-center"> + <gl-toggle + :disabled="!active" :value="false" @change="createNewScope({ active: true })" /> diff --git a/app/assets/javascripts/feature_flags/components/new_feature_flag.vue b/app/assets/javascripts/feature_flags/components/new_feature_flag.vue index 529fefd7e45..19be57f9d27 100644 --- a/app/assets/javascripts/feature_flags/components/new_feature_flag.vue +++ b/app/assets/javascripts/feature_flags/components/new_feature_flag.vue @@ -1,15 +1,16 @@ <script> +import { GlAlert } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; import axios from '~/lib/utils/axios_utils'; -import FeatureFlagForm from './form.vue'; +import featureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { NEW_VERSION_FLAG, ROLLOUT_STRATEGY_ALL_USERS } from '../constants'; import { createNewEnvironmentScope } from '../store/helpers'; - -import featureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import FeatureFlagForm from './form.vue'; export default { components: { FeatureFlagForm, + GlAlert, }, mixins: [featureFlagsMixin()], inject: { @@ -61,9 +62,9 @@ export default { <div> <h3 class="page-title">{{ s__('FeatureFlags|New feature flag') }}</h3> - <div v-if="error.length" class="alert alert-danger"> - <p v-for="(message, index) in error" :key="index" class="mb-0">{{ message }}</p> - </div> + <gl-alert v-if="error.length" variant="warning" class="gl-mb-5" :dismissible="false"> + <p v-for="(message, index) in error" :key="index" class="gl-mb-0">{{ message }}</p> + </gl-alert> <feature-flag-form :cancel-path="path" diff --git a/app/assets/javascripts/feature_flags/store/edit/actions.js b/app/assets/javascripts/feature_flags/store/edit/actions.js index c4515e07a00..3ff7dac96e8 100644 --- a/app/assets/javascripts/feature_flags/store/edit/actions.js +++ b/app/assets/javascripts/feature_flags/store/edit/actions.js @@ -1,10 +1,10 @@ -import * as types from './mutation_types'; import axios from '~/lib/utils/axios_utils'; import { visitUrl } from '~/lib/utils/url_utility'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { __ } from '~/locale'; import { NEW_VERSION_FLAG } from '../../constants'; import { mapFromScopesViewModel, mapStrategiesToRails } from '../helpers'; +import * as types from './mutation_types'; /** * Handles the edition of a feature flag. diff --git a/app/assets/javascripts/feature_flags/store/edit/mutations.js b/app/assets/javascripts/feature_flags/store/edit/mutations.js index e60dbaf4a34..777013a2b7d 100644 --- a/app/assets/javascripts/feature_flags/store/edit/mutations.js +++ b/app/assets/javascripts/feature_flags/store/edit/mutations.js @@ -1,6 +1,6 @@ -import * as types from './mutation_types'; import { mapToScopesViewModel, mapStrategiesToViewModel } from '../helpers'; import { LEGACY_FLAG } from '../../constants'; +import * as types from './mutation_types'; export default { [types.REQUEST_FEATURE_FLAG](state) { diff --git a/app/assets/javascripts/feature_flags/store/index/actions.js b/app/assets/javascripts/feature_flags/store/index/actions.js index 6b6b3d55e16..4372c280f39 100644 --- a/app/assets/javascripts/feature_flags/store/index/actions.js +++ b/app/assets/javascripts/feature_flags/store/index/actions.js @@ -1,6 +1,6 @@ import Api from '~/api'; -import * as types from './mutation_types'; import axios from '~/lib/utils/axios_utils'; +import * as types from './mutation_types'; export const setFeatureFlagsOptions = ({ commit }, options) => commit(types.SET_FEATURE_FLAGS_OPTIONS, options); diff --git a/app/assets/javascripts/feature_flags/store/index/mutations.js b/app/assets/javascripts/feature_flags/store/index/mutations.js index 910b2ec42d4..25eb7da1c72 100644 --- a/app/assets/javascripts/feature_flags/store/index/mutations.js +++ b/app/assets/javascripts/feature_flags/store/index/mutations.js @@ -1,8 +1,8 @@ import Vue from 'vue'; -import * as types from './mutation_types'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '../../constants'; import { mapToScopesViewModel } from '../helpers'; +import * as types from './mutation_types'; const mapFlag = (flag) => ({ ...flag, scopes: mapToScopesViewModel(flag.scopes || []) }); diff --git a/app/assets/javascripts/feature_flags/store/new/actions.js b/app/assets/javascripts/feature_flags/store/new/actions.js index 6d595603819..d0a1c77a69e 100644 --- a/app/assets/javascripts/feature_flags/store/new/actions.js +++ b/app/assets/javascripts/feature_flags/store/new/actions.js @@ -1,8 +1,8 @@ -import * as types from './mutation_types'; import axios from '~/lib/utils/axios_utils'; import { visitUrl } from '~/lib/utils/url_utility'; import { NEW_VERSION_FLAG } from '../../constants'; import { mapFromScopesViewModel, mapStrategiesToRails } from '../helpers'; +import * as types from './mutation_types'; /** * Handles the creation of a new feature flag. diff --git a/app/assets/javascripts/feature_highlight/feature_highlight.js b/app/assets/javascripts/feature_highlight/feature_highlight.js index 2da9aadd2b1..124be35f6ca 100644 --- a/app/assets/javascripts/feature_highlight/feature_highlight.js +++ b/app/assets/javascripts/feature_highlight/feature_highlight.js @@ -1,6 +1,6 @@ import $ from 'jquery'; -import { getSelector, inserted } from './feature_highlight_helper'; import { togglePopover, mouseenter, debouncedMouseleave } from '../shared/popover'; +import { getSelector, inserted } from './feature_highlight_helper'; export function setupFeatureHighlightPopover(id, debounceTimeout = 300) { const $selector = $(getSelector(id)); diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js index 588bd534224..43365ba8613 100644 --- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js +++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js @@ -1,3 +1,4 @@ +import { mergeUrlParams } from '../lib/utils/url_utility'; import DropdownHint from './dropdown_hint'; import DropdownUser from './dropdown_user'; import DropdownNonUser from './dropdown_non_user'; @@ -6,7 +7,6 @@ import NullDropdown from './null_dropdown'; import DropdownAjaxFilter from './dropdown_ajax_filter'; import DropdownOperator from './dropdown_operator'; import DropdownUtils from './dropdown_utils'; -import { mergeUrlParams } from '../lib/utils/url_utility'; export default class AvailableDropdownMappings { constructor({ diff --git a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js index 2c0c3024d38..99e510ba0c9 100644 --- a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js +++ b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js @@ -1,9 +1,9 @@ +import { __ } from '~/locale'; import { deprecatedCreateFlash as createFlash } from '../flash'; import AjaxFilter from '../droplab/plugins/ajax_filter'; import FilteredSearchDropdown from './filtered_search_dropdown'; import DropdownUtils from './dropdown_utils'; import FilteredSearchTokenizer from './filtered_search_tokenizer'; -import { __ } from '~/locale'; export default class DropdownAjaxFilter extends FilteredSearchDropdown { constructor(options = {}) { diff --git a/app/assets/javascripts/filtered_search/dropdown_emoji.js b/app/assets/javascripts/filtered_search/dropdown_emoji.js index 001030b5f5f..8fb483162f1 100644 --- a/app/assets/javascripts/filtered_search/dropdown_emoji.js +++ b/app/assets/javascripts/filtered_search/dropdown_emoji.js @@ -1,9 +1,9 @@ +import { __ } from '~/locale'; import { deprecatedCreateFlash as Flash } from '../flash'; import Ajax from '../droplab/plugins/ajax'; import Filter from '../droplab/plugins/filter'; import FilteredSearchDropdown from './filtered_search_dropdown'; import DropdownUtils from './dropdown_utils'; -import { __ } from '~/locale'; export default class DropdownEmoji extends FilteredSearchDropdown { constructor(options = {}) { diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js index 1180f8683a1..6ab4015eb80 100644 --- a/app/assets/javascripts/filtered_search/dropdown_hint.js +++ b/app/assets/javascripts/filtered_search/dropdown_hint.js @@ -1,9 +1,9 @@ import Filter from '~/droplab/plugins/filter'; +import { __ } from '~/locale'; import FilteredSearchDropdown from './filtered_search_dropdown'; import DropdownUtils from './dropdown_utils'; import FilteredSearchDropdownManager from './filtered_search_dropdown_manager'; import FilteredSearchVisualTokens from './filtered_search_visual_tokens'; -import { __ } from '~/locale'; export default class DropdownHint extends FilteredSearchDropdown { constructor(options = {}) { diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js b/app/assets/javascripts/filtered_search/dropdown_non_user.js index 11261debeda..36c756d6a49 100644 --- a/app/assets/javascripts/filtered_search/dropdown_non_user.js +++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js @@ -1,9 +1,9 @@ +import { __ } from '~/locale'; import { deprecatedCreateFlash as Flash } from '../flash'; import Ajax from '../droplab/plugins/ajax'; import Filter from '../droplab/plugins/filter'; import FilteredSearchDropdown from './filtered_search_dropdown'; import DropdownUtils from './dropdown_utils'; -import { __ } from '~/locale'; export default class DropdownNonUser extends FilteredSearchDropdown { constructor(options = {}) { diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 11b2eb839ce..755e60dd7d2 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -2,26 +2,26 @@ import { last } from 'lodash'; import recentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys'; import { getParameterByName, getUrlParamsArray } from '~/lib/utils/common_utils'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; +import { + ENTER_KEY_CODE, + BACKSPACE_KEY_CODE, + DELETE_KEY_CODE, + UP_KEY_CODE, + DOWN_KEY_CODE, +} from '~/lib/utils/keycodes'; +import { __ } from '~/locale'; import { visitUrl } from '../lib/utils/url_utility'; import { deprecatedCreateFlash as Flash } from '../flash'; +import { addClassIfElementExists } from '../lib/utils/dom_utils'; import FilteredSearchContainer from './container'; import RecentSearchesRoot from './recent_searches_root'; import RecentSearchesStore from './stores/recent_searches_store'; import RecentSearchesService from './services/recent_searches_service'; import eventHub from './event_hub'; -import { addClassIfElementExists } from '../lib/utils/dom_utils'; import FilteredSearchTokenizer from './filtered_search_tokenizer'; import FilteredSearchDropdownManager from './filtered_search_dropdown_manager'; import FilteredSearchVisualTokens from './filtered_search_visual_tokens'; import DropdownUtils from './dropdown_utils'; -import { - ENTER_KEY_CODE, - BACKSPACE_KEY_CODE, - DELETE_KEY_CODE, - UP_KEY_CODE, - DOWN_KEY_CODE, -} from '~/lib/utils/keycodes'; -import { __ } from '~/locale'; export default class FilteredSearchManager { constructor({ diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js index 4e594dfa910..92899de7e8e 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js +++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js @@ -1,5 +1,5 @@ -import VisualTokenValue from './visual_token_value'; import { objectToQueryString, spriteIcon } from '~/lib/utils/common_utils'; +import VisualTokenValue from './visual_token_value'; import FilteredSearchContainer from './container'; export default class FilteredSearchVisualTokens { diff --git a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js index 46867b184c8..2c58506985a 100644 --- a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js @@ -1,6 +1,6 @@ import { flattenDeep } from 'lodash'; -import FilteredSearchTokenKeys from './filtered_search_token_keys'; import { __ } from '~/locale'; +import FilteredSearchTokenKeys from './filtered_search_token_keys'; export const tokenKeys = [ { diff --git a/app/assets/javascripts/filtered_search/recent_searches_root.js b/app/assets/javascripts/filtered_search/recent_searches_root.js index 6c8e77a7fe5..1182cb34210 100644 --- a/app/assets/javascripts/filtered_search/recent_searches_root.js +++ b/app/assets/javascripts/filtered_search/recent_searches_root.js @@ -28,19 +28,18 @@ class RecentSearchesRoot { const { state } = this.store; this.vm = new Vue({ el: this.wrapperElement, - components: { - RecentSearchesDropdownContent, - }, data() { return state; }, - template: ` - <recent-searches-dropdown-content - :items="recentSearches" - :is-local-storage-available="isLocalStorageAvailable" - :allowed-keys="allowedKeys" - /> - `, + render(h) { + return h(RecentSearchesDropdownContent, { + props: { + items: this.recentSearches, + isLocalStorageAvailable: this.isLocalStorageAvailable, + allowedKeys: this.allowedKeys, + }, + }); + }, }); } diff --git a/app/assets/javascripts/filtered_search/recent_searches_storage_keys.js b/app/assets/javascripts/filtered_search/recent_searches_storage_keys.js index 54d49821d92..446a0e5eb24 100644 --- a/app/assets/javascripts/filtered_search/recent_searches_storage_keys.js +++ b/app/assets/javascripts/filtered_search/recent_searches_storage_keys.js @@ -3,4 +3,6 @@ export default { merge_requests: 'merge-request-recent-searches', group_members: 'group-members-recent-searches', group_invited_members: 'group-invited-members-recent-searches', + project_members: 'project-members-recent-searches', + project_group_links: 'project-group-links-recent-searches', }; diff --git a/app/assets/javascripts/filtered_search/services/recent_searches_service.js b/app/assets/javascripts/filtered_search/services/recent_searches_service.js index a056dea928d..56824977a43 100644 --- a/app/assets/javascripts/filtered_search/services/recent_searches_service.js +++ b/app/assets/javascripts/filtered_search/services/recent_searches_service.js @@ -1,5 +1,5 @@ -import RecentSearchesServiceError from './recent_searches_service_error'; import AccessorUtilities from '../../lib/utils/accessor'; +import RecentSearchesServiceError from './recent_searches_service_error'; class RecentSearchesService { constructor(localStorageKey = 'issuable-recent-searches') { diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list.vue index 9d898d1a1a1..6feeb5f03ad 100644 --- a/app/assets/javascripts/frequent_items/components/frequent_items_list.vue +++ b/app/assets/javascripts/frequent_items/components/frequent_items_list.vue @@ -1,7 +1,7 @@ <script> +import { sanitizeItem } from '../utils'; import FrequentItemsListItem from './frequent_items_list_item.vue'; import frequentItemsMixin from './frequent_items_mixin'; -import { sanitizeItem } from '../utils'; export default { components: { diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue b/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue index 8042e8c7bc9..b4a57a0a619 100644 --- a/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue +++ b/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue @@ -2,9 +2,8 @@ import { debounce } from 'lodash'; import { mapActions, mapState } from 'vuex'; import { GlIcon } from '@gitlab/ui'; -import eventHub from '../event_hub'; -import frequentItemsMixin from './frequent_items_mixin'; import Tracking from '~/tracking'; +import frequentItemsMixin from './frequent_items_mixin'; const trackingMixin = Tracking.mixin(); @@ -32,12 +31,6 @@ export default { this.setSearchQuery(this.searchQuery); }, 500), }, - mounted() { - eventHub.$on(`${this.namespace}-dropdownOpen`, this.setFocus); - }, - beforeDestroy() { - eventHub.$off(`${this.namespace}-dropdownOpen`, this.setFocus); - }, methods: { ...mapActions(['setSearchQuery']), setFocus() { diff --git a/app/assets/javascripts/frequent_items/index.js b/app/assets/javascripts/frequent_items/index.js index cef8be37a40..ab36189c239 100644 --- a/app/assets/javascripts/frequent_items/index.js +++ b/app/assets/javascripts/frequent_items/index.js @@ -1,8 +1,8 @@ import $ from 'jquery'; import Vue from 'vue'; import Translate from '~/vue_shared/translate'; -import eventHub from './event_hub'; import { createStore } from '~/frequent_items/store'; +import eventHub from './event_hub'; Vue.use(Translate); diff --git a/app/assets/javascripts/frequent_items/store/actions.js b/app/assets/javascripts/frequent_items/store/actions.js index f4156487625..90b454d1b42 100644 --- a/app/assets/javascripts/frequent_items/store/actions.js +++ b/app/assets/javascripts/frequent_items/store/actions.js @@ -1,7 +1,7 @@ import AccessorUtilities from '~/lib/utils/accessor'; -import * as types from './mutation_types'; -import { getTopFrequentItems } from '../utils'; import { getGroups, getProjects } from '~/rest_api'; +import { getTopFrequentItems } from '../utils'; +import * as types from './mutation_types'; export const setNamespace = ({ commit }, namespace) => { commit(types.SET_NAMESPACE, namespace); diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index cf9ff87f25e..febb108ec71 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -4,16 +4,20 @@ import { escape, template } from 'lodash'; import { s__ } from '~/locale'; import SidebarMediator from '~/sidebar/sidebar_mediator'; import { isUserBusy } from '~/set_status_modal/utils'; +import axios from '~/lib/utils/axios_utils'; +import * as Emoji from '~/emoji'; import glRegexp from './lib/utils/regexp'; import AjaxCache from './lib/utils/ajax_cache'; -import axios from '~/lib/utils/axios_utils'; import { spriteIcon } from './lib/utils/common_utils'; -import * as Emoji from '~/emoji'; function sanitize(str) { return str.replace(/<(?:.|\n)*?>/gm, ''); } +function createMemberSearchString(member) { + return `${member.name.replace(/ /g, '')} ${member.username}`; +} + export function membersBeforeSave(members) { return members.map((member) => { const GROUP_TYPE = 'Group'; @@ -40,7 +44,7 @@ export function membersBeforeSave(members) { username: member.username, avatarTag: autoCompleteAvatar.length === 1 ? txtAvatar : imgAvatar, title: sanitize(title), - search: sanitize(`${member.username} ${member.name}`), + search: sanitize(createMemberSearchString(member)), icon: avatarIcon, availability: member?.availability, }; @@ -298,9 +302,7 @@ class GfmAutoComplete { // Cache assignees list for easier filtering later assignees = - SidebarMediator.singleton?.store?.assignees?.map( - (assignee) => `${assignee.username} ${assignee.name}`, - ) || []; + SidebarMediator.singleton?.store?.assignees?.map(createMemberSearchString) || []; const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers); return match && match.length ? match[1] : null; diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index 3e777c2dc09..bb5dea877b8 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -1,9 +1,9 @@ import $ from 'jquery'; import autosize from 'autosize'; import GfmAutoComplete, { defaultAutocompleteConfig } from 'ee_else_ce/gfm_auto_complete'; +import { disableButtonIfEmptyField } from '~/lib/utils/common_utils'; import dropzoneInput from './dropzone_input'; import { addMarkdownListeners, removeMarkdownListeners } from './lib/utils/text_markdown'; -import { disableButtonIfEmptyField } from '~/lib/utils/common_utils'; export default class GLForm { /** diff --git a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue index 7a991ac2455..7b029c6cf54 100644 --- a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue +++ b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue @@ -54,9 +54,9 @@ export default { <template> <section id="grafana" class="settings no-animate js-grafana-integration"> <div class="settings-header"> - <h3 class="js-section-header h4"> + <h4 class="js-section-header"> {{ s__('GrafanaIntegration|Grafana authentication') }} - </h3> + </h4> <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button> <p class="js-section-sub-header"> {{ s__('GrafanaIntegration|Embed Grafana charts in GitLab issues.') }} diff --git a/app/assets/javascripts/graphql_shared/fragments/alert_note.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/alert_note.fragment.graphql index 74b425717a0..801311301ac 100644 --- a/app/assets/javascripts/graphql_shared/fragments/alert_note.fragment.graphql +++ b/app/assets/javascripts/graphql_shared/fragments/alert_note.fragment.graphql @@ -1,4 +1,4 @@ -#import "~/graphql_shared/fragments/author.fragment.graphql" +#import "./author.fragment.graphql" fragment AlertNote on Note { id diff --git a/app/assets/javascripts/graphql_shared/mutations/update_alert_status.mutation.graphql b/app/assets/javascripts/graphql_shared/mutations/alert_status_update.mutation.graphql index 42dc388c9d1..ba1e607bc10 100644 --- a/app/assets/javascripts/graphql_shared/mutations/update_alert_status.mutation.graphql +++ b/app/assets/javascripts/graphql_shared/mutations/alert_status_update.mutation.graphql @@ -1,4 +1,4 @@ -#import "~/graphql_shared/fragments/alert_note.fragment.graphql" +#import "../fragments/alert_note.fragment.graphql" mutation updateAlertStatus($projectPath: ID!, $status: AlertManagementStatus!, $iid: String!) { updateAlertStatus(input: { iid: $iid, status: $status, projectPath: $projectPath }) { diff --git a/app/assets/javascripts/graphql_shared/queries/get_alerts.query.graphql b/app/assets/javascripts/graphql_shared/queries/get_alerts.query.graphql index e94758ef60e..7a676e67f1b 100644 --- a/app/assets/javascripts/graphql_shared/queries/get_alerts.query.graphql +++ b/app/assets/javascripts/graphql_shared/queries/get_alerts.query.graphql @@ -10,6 +10,7 @@ query getAlerts( $nextPageCursor: String = "" $searchTerm: String = "" $assigneeUsername: String = "" + $domain: AlertManagementDomainFilter = operations ) { project(fullPath: $projectPath) { alertManagementAlerts( @@ -21,6 +22,7 @@ query getAlerts( last: $lastPageSize after: $nextPageCursor before: $prevPageCursor + domain: $domain ) { nodes { ...AlertListItem diff --git a/app/assets/javascripts/group.js b/app/assets/javascripts/group.js index 6878635b288..29ddd0c5fef 100644 --- a/app/assets/javascripts/group.js +++ b/app/assets/javascripts/group.js @@ -1,7 +1,7 @@ -import { slugify } from './lib/utils/text_utility'; import fetchGroupPathAvailability from '~/pages/groups/new/fetch_group_path_availability'; import { deprecatedCreateFlash as flash } from '~/flash'; import { __ } from '~/locale'; +import { slugify } from './lib/utils/text_utility'; export default class Group { constructor() { diff --git a/app/assets/javascripts/group_label_subscription.js b/app/assets/javascripts/group_label_subscription.js index bfaa54080bd..9e846b88a11 100644 --- a/app/assets/javascripts/group_label_subscription.js +++ b/app/assets/javascripts/group_label_subscription.js @@ -1,8 +1,8 @@ import $ from 'jquery'; import { __ } from '~/locale'; +import { fixTitle, hide } from '~/tooltips'; import axios from './lib/utils/axios_utils'; import { deprecatedCreateFlash as flash } from './flash'; -import { fixTitle, hide } from '~/tooltips'; const tooltipTitles = { group: __('Unsubscribe at group level'), diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index d65ad974c73..0997fb3a98c 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -12,8 +12,6 @@ import itemStats from './item_stats.vue'; import itemStatsValue from './item_stats_value.vue'; import itemActions from './item_actions.vue'; -import { showLearnGitLabGroupItemPopover } from '~/onboarding_issues'; - export default { directives: { GlTooltip: GlTooltipDirective, @@ -78,11 +76,6 @@ export default { return this.group.microdata || {}; }, }, - mounted() { - if (this.group.name === 'Learn GitLab') { - showLearnGitLabGroupItemPopover(this.group.id); - } - }, methods: { onClickRowGroup(e) { const NO_EXPAND_CLS = 'no-expand'; @@ -179,7 +172,12 @@ export default { <div class="metadata align-items-md-center d-flex flex-grow-1 flex-shrink-0 flex-wrap justify-content-md-between" > - <item-actions v-if="isGroup" :group="group" :parent-group="parentGroup" /> + <item-actions + v-if="isGroup" + :group="group" + :parent-group="parentGroup" + :action="action" + /> <item-stats :item="group" class="group-stats gl-mt-2 d-none d-md-flex" /> </div> </div> diff --git a/app/assets/javascripts/groups/groups_filterable_list.js b/app/assets/javascripts/groups/groups_filterable_list.js index c33ad8b6ecb..cedf16cd7f1 100644 --- a/app/assets/javascripts/groups/groups_filterable_list.js +++ b/app/assets/javascripts/groups/groups_filterable_list.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import FilterableList from '~/filterable_list'; -import eventHub from './event_hub'; import { normalizeHeaders, getParameterByName } from '../lib/utils/common_utils'; +import eventHub from './event_hub'; export default class GroupFilterableList extends FilterableList { constructor({ diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js index e11c3aaf984..4fd2c12c9fe 100644 --- a/app/assets/javascripts/groups/index.js +++ b/app/assets/javascripts/groups/index.js @@ -33,8 +33,8 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => { dataEl = containerEl.querySelector(CONTENT_LIST_CLASS); } - Vue.component('group-folder', groupFolderComponent); - Vue.component('group-item', groupItemComponent); + Vue.component('GroupFolder', groupFolderComponent); + Vue.component('GroupItem', groupItemComponent); Vue.use(GlToast); diff --git a/app/assets/javascripts/groups/members/constants.js b/app/assets/javascripts/groups/members/constants.js index 6d71b666d7a..3315712891d 100644 --- a/app/assets/javascripts/groups/members/constants.js +++ b/app/assets/javascripts/groups/members/constants.js @@ -1,5 +1 @@ export const GROUP_MEMBER_BASE_PROPERTY_NAME = 'group_member'; -export const GROUP_MEMBER_ACCESS_LEVEL_PROPERTY_NAME = 'access_level'; - -export const GROUP_LINK_BASE_PROPERTY_NAME = 'group_link'; -export const GROUP_LINK_ACCESS_LEVEL_PROPERTY_NAME = 'group_access'; diff --git a/app/assets/javascripts/groups/members/utils.js b/app/assets/javascripts/groups/members/utils.js index 4fcf348b69f..71918bfe9f0 100644 --- a/app/assets/javascripts/groups/members/utils.js +++ b/app/assets/javascripts/groups/members/utils.js @@ -1,45 +1,8 @@ -import { isUndefined } from 'lodash'; -import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; -import { - GROUP_MEMBER_BASE_PROPERTY_NAME, - GROUP_MEMBER_ACCESS_LEVEL_PROPERTY_NAME, - GROUP_LINK_BASE_PROPERTY_NAME, - GROUP_LINK_ACCESS_LEVEL_PROPERTY_NAME, -} from './constants'; - -export const parseDataAttributes = (el) => { - const { members, groupId, memberPath, canManageMembers } = el.dataset; - - return { - members: convertObjectPropsToCamelCase(JSON.parse(members), { deep: true }), - sourceId: parseInt(groupId, 10), - memberPath, - canManageMembers: parseBoolean(canManageMembers), - }; -}; - -const baseRequestFormatter = (basePropertyName, accessLevelPropertyName) => ({ - accessLevel, - ...otherProperties -}) => { - const accessLevelProperty = !isUndefined(accessLevel) - ? { [accessLevelPropertyName]: accessLevel } - : {}; +import { baseRequestFormatter } from '~/members/utils'; +import { MEMBER_ACCESS_LEVEL_PROPERTY_NAME } from '~/members/constants'; +import { GROUP_MEMBER_BASE_PROPERTY_NAME } from './constants'; - return { - [basePropertyName]: { - ...accessLevelProperty, - ...otherProperties, - }, - }; -}; - -export const memberRequestFormatter = baseRequestFormatter( +export const groupMemberRequestFormatter = baseRequestFormatter( GROUP_MEMBER_BASE_PROPERTY_NAME, - GROUP_MEMBER_ACCESS_LEVEL_PROPERTY_NAME, -); - -export const groupLinkRequestFormatter = baseRequestFormatter( - GROUP_LINK_BASE_PROPERTY_NAME, - GROUP_LINK_ACCESS_LEVEL_PROPERTY_NAME, + MEMBER_ACCESS_LEVEL_PROPERTY_NAME, ); diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js index c65fff432d0..840b3030177 100644 --- a/app/assets/javascripts/groups_select.js +++ b/app/assets/javascripts/groups_select.js @@ -1,9 +1,9 @@ import $ from 'jquery'; import { escape } from 'lodash'; +import { __ } from '~/locale'; import axios from './lib/utils/axios_utils'; import Api from './api'; import { normalizeHeaders } from './lib/utils/common_utils'; -import { __ } from '~/locale'; import { loadCSSFile } from './lib/utils/css_utils'; const fetchGroups = (params) => { diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js index 9f9708bf879..bcafb09bc66 100644 --- a/app/assets/javascripts/header.js +++ b/app/assets/javascripts/header.js @@ -106,7 +106,5 @@ export function initNavUserDropdownTracking() { } } -document.addEventListener('DOMContentLoaded', () => { - requestIdleCallback(initStatusTriggers); - requestIdleCallback(initNavUserDropdownTracking); -}); +requestIdleCallback(initStatusTriggers); +requestIdleCallback(initNavUserDropdownTracking); diff --git a/app/assets/javascripts/ide/components/activity_bar.vue b/app/assets/javascripts/ide/components/activity_bar.vue index 644808cb83a..edb7e373f6f 100644 --- a/app/assets/javascripts/ide/components/activity_bar.vue +++ b/app/assets/javascripts/ide/components/activity_bar.vue @@ -1,6 +1,7 @@ <script> import { mapActions, mapState } from 'vuex'; import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { leftSidebarViews } from '../constants'; export default { @@ -11,7 +12,7 @@ export default { GlTooltip: GlTooltipDirective, }, computed: { - ...mapState(['currentActivityView']), + ...mapState(['currentActivityView', 'stagedFiles']), }, methods: { ...mapActions(['updateActivityBarView']), @@ -20,7 +21,7 @@ export default { this.updateActivityBarView(view); - this.$root.$emit('bv::hide::tooltip'); + this.$root.$emit(BV_HIDE_TOOLTIP); }, }, leftSidebarViews, @@ -81,6 +82,9 @@ export default { @click.prevent="changedActivityView($event, $options.leftSidebarViews.commit.name)" > <gl-icon name="commit" /> + <div v-if="stagedFiles.length > 0" class="ide-commit-badge badge badge-pill"> + {{ stagedFiles.length }} + </div> </button> </li> </ul> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue index b89329c92ec..c7f79b3ace4 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue @@ -3,7 +3,10 @@ import { escape } from 'lodash'; import { mapState, mapGetters, createNamespacedHelpers } from 'vuex'; import { GlSprintf } from '@gitlab/ui'; import { s__ } from '~/locale'; -import consts from '../../stores/modules/commit/constants'; +import { + COMMIT_TO_CURRENT_BRANCH, + COMMIT_TO_NEW_BRANCH, +} from '../../stores/modules/commit/constants'; import RadioGroup from './radio_group.vue'; import NewMergeRequestOption from './new_merge_request_option.vue'; @@ -53,14 +56,14 @@ export default { } if (this.shouldDefaultToCurrentBranch) { - this.updateCommitAction(consts.COMMIT_TO_CURRENT_BRANCH); + this.updateCommitAction(COMMIT_TO_CURRENT_BRANCH); } else { - this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH); + this.updateCommitAction(COMMIT_TO_NEW_BRANCH); } }, }, - commitToCurrentBranch: consts.COMMIT_TO_CURRENT_BRANCH, - commitToNewBranch: consts.COMMIT_TO_NEW_BRANCH, + commitToCurrentBranch: COMMIT_TO_CURRENT_BRANCH, + commitToNewBranch: COMMIT_TO_NEW_BRANCH, currentBranchPermissionsTooltip: s__( "IDE|This option is disabled because you don't have write permissions for the current branch.", ), diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue index 7c3e522a488..dd649c7d46a 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue @@ -1,12 +1,16 @@ <script> import { mapState, mapActions, mapGetters } from 'vuex'; -import { GlModal, GlSafeHtmlDirective, GlButton } from '@gitlab/ui'; -import { n__, __ } from '~/locale'; +import { GlModal, GlSafeHtmlDirective, GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { n__, s__ } from '~/locale'; +import { leftSidebarViews, MAX_WINDOW_HEIGHT_COMPACT } from '../../constants'; +import { createUnexpectedCommitError } from '../../lib/errors'; import CommitMessageField from './message_field.vue'; import Actions from './actions.vue'; import SuccessMessage from './success_message.vue'; -import { leftSidebarViews, MAX_WINDOW_HEIGHT_COMPACT } from '../../constants'; -import { createUnexpectedCommitError } from '../../lib/errors'; + +const MSG_CANNOT_PUSH_CODE = s__( + 'WebIDE|You need permission to edit files directly in this project.', +); export default { components: { @@ -18,6 +22,7 @@ export default { }, directives: { SafeHtml: GlSafeHtmlDirective, + GlTooltip: GlTooltipDirective, }, data() { return { @@ -30,15 +35,21 @@ export default { computed: { ...mapState(['changedFiles', 'stagedFiles', 'currentActivityView', 'lastCommitMsg']), ...mapState('commit', ['commitMessage', 'submitCommitLoading', 'commitError']), - ...mapGetters(['someUncommittedChanges']), + ...mapGetters(['someUncommittedChanges', 'canPushCode']), ...mapGetters('commit', ['discardDraftButtonDisabled', 'preBuiltCommitMessage']), + commitButtonDisabled() { + return !this.canPushCode || !this.someUncommittedChanges; + }, + commitButtonTooltip() { + if (!this.canPushCode) { + return MSG_CANNOT_PUSH_CODE; + } + + return ''; + }, overviewText() { return n__('%d changed file', '%d changed files', this.stagedFiles.length); }, - commitButtonText() { - return this.stagedFiles.length ? __('Commit') : __('Stage & Commit'); - }, - currentViewIsCommitView() { return this.currentActivityView === leftSidebarViews.commit.name; }, @@ -73,6 +84,12 @@ export default { 'updateCommitAction', ]), commit() { + // Even though the submit button will be disabled, we need to disable the submission + // since hitting enter on the branch name text input also submits the form. + if (!this.canPushCode) { + return false; + } + return this.commitChanges(); }, handleCompactState() { @@ -113,6 +130,8 @@ export default { this.componentHeight = null; }, }, + // Expose for tests + MSG_CANNOT_PUSH_CODE, }; </script> @@ -134,17 +153,22 @@ export default { @after-enter="afterEndTransition" > <div v-if="isCompact" ref="compactEl" class="commit-form-compact"> - <gl-button - :disabled="!someUncommittedChanges" - category="primary" - variant="info" - block - class="qa-begin-commit-button" - data-testid="begin-commit-button" - @click="beginCommit" + <div + v-gl-tooltip="{ title: commitButtonTooltip }" + data-testid="begin-commit-button-tooltip" > - {{ __('Commit…') }} - </gl-button> + <gl-button + :disabled="commitButtonDisabled" + category="primary" + variant="info" + block + class="qa-begin-commit-button" + data-testid="begin-commit-button" + @click="beginCommit" + > + {{ __('Commit…') }} + </gl-button> + </div> <p class="text-center bold">{{ overviewText }}</p> </div> <form v-else ref="formEl" @submit.prevent.stop="commit"> @@ -157,16 +181,29 @@ export default { /> <div class="clearfix gl-mt-5"> <actions /> + <div + v-gl-tooltip="{ title: commitButtonTooltip }" + class="float-left" + data-testid="commit-button-tooltip" + > + <gl-button + :disabled="commitButtonDisabled" + :loading="submitCommitLoading" + data-testid="commit-button" + class="qa-commit-button" + category="primary" + variant="success" + @click="commit" + > + {{ __('Commit') }} + </gl-button> + </div> <gl-button - :loading="submitCommitLoading" - class="float-left qa-commit-button" - category="primary" - variant="success" - @click="commit" + v-if="!discardDraftButtonDisabled" + class="float-right" + data-testid="discard-draft" + @click="discardDraft" > - {{ __('Commit') }} - </gl-button> - <gl-button v-if="!discardDraftButtonDisabled" class="float-right" @click="discardDraft"> {{ __('Discard draft') }} </gl-button> <gl-button diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue index 4192a002486..ab90ab0791a 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue @@ -85,7 +85,11 @@ export default { role="button" @click="openFileInEditor" > - <span class="multi-file-commit-list-file-path d-flex align-items-center"> + <span + class="multi-file-commit-list-file-path d-flex align-items-center" + data-qa-selector="file_to_commit_content" + :data-qa-file-name="file.name" + > <file-icon :file-name="file.name" class="gl-mr-3" /> <template v-if="file.prevName && file.prevName !== file.name"> {{ file.prevName }} → diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index aac899fde0d..131a039ef1a 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -1,7 +1,7 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; -import { GlButton, GlLoadingIcon } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { GlAlert, GlButton, GlLoadingIcon } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; import { WEBIDE_MARK_APP_START, WEBIDE_MARK_FILE_FINISH, @@ -10,13 +10,12 @@ import { WEBIDE_MEASURE_BEFORE_VUE, } from '~/performance/constants'; import { performanceMarkAndMeasure } from '~/performance/utils'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { modalTypes } from '../constants'; import eventHub from '../eventhub'; +import { measurePerformance } from '../utils'; import IdeSidebar from './ide_side_bar.vue'; import RepoEditor from './repo_editor.vue'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; - -import { measurePerformance } from '../utils'; eventHub.$on(WEBIDE_MEASURE_FILE_AFTER_INTERACTION, () => measurePerformance( @@ -26,10 +25,15 @@ eventHub.$on(WEBIDE_MEASURE_FILE_AFTER_INTERACTION, () => ), ); +const MSG_CANNOT_PUSH_CODE = s__( + 'WebIDE|You need permission to edit files directly in this project. Fork this project to make your changes and submit a merge request.', +); + export default { components: { IdeSidebar, RepoEditor, + GlAlert, GlButton, GlLoadingIcon, ErrorMessage: () => import(/* webpackChunkName: 'ide_runtime' */ './error_message.vue'), @@ -59,12 +63,14 @@ export default { 'loading', ]), ...mapGetters([ + 'canPushCode', 'activeFile', 'someUncommittedChanges', 'isCommitModeActive', 'allBlobs', 'emptyRepo', 'currentTree', + 'hasCurrentProject', 'editorTheme', 'getUrlForPath', ]), @@ -110,6 +116,7 @@ export default { this.loadDeferred = true; }, }, + MSG_CANNOT_PUSH_CODE, }; </script> @@ -118,6 +125,9 @@ export default { class="ide position-relative d-flex flex-column align-items-stretch" :class="{ [`theme-${themeName}`]: themeName }" > + <gl-alert v-if="!canPushCode" :dismissible="false">{{ + $options.MSG_CANNOT_PUSH_CODE + }}</gl-alert> <error-message v-if="errorMessage" :message="errorMessage" /> <div class="ide-view flex-grow d-flex"> <template v-if="loadDeferred"> diff --git a/app/assets/javascripts/ide/components/ide_review.vue b/app/assets/javascripts/ide/components/ide_review.vue index 7d2f0acb08c..d75629e9995 100644 --- a/app/assets/javascripts/ide/components/ide_review.vue +++ b/app/assets/javascripts/ide/components/ide_review.vue @@ -1,8 +1,8 @@ <script> import { mapGetters, mapState, mapActions } from 'vuex'; +import { viewerTypes } from '../constants'; import IdeTreeList from './ide_tree_list.vue'; import EditorModeDropdown from './editor_mode_dropdown.vue'; -import { viewerTypes } from '../constants'; export default { components: { diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue index 135b28685ed..a447dae3f80 100644 --- a/app/assets/javascripts/ide/components/ide_side_bar.vue +++ b/app/assets/javascripts/ide/components/ide_side_bar.vue @@ -1,12 +1,12 @@ <script> import { mapState, mapGetters } from 'vuex'; import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; +import { SIDEBAR_INIT_WIDTH, leftSidebarViews } from '../constants'; import IdeTree from './ide_tree.vue'; import ResizablePanel from './resizable_panel.vue'; import ActivityBar from './activity_bar.vue'; import CommitForm from './commit_sidebar/form.vue'; import IdeProjectHeader from './ide_project_header.vue'; -import { SIDEBAR_INIT_WIDTH, leftSidebarViews } from '../constants'; export default { components: { diff --git a/app/assets/javascripts/ide/components/ide_sidebar_nav.vue b/app/assets/javascripts/ide/components/ide_sidebar_nav.vue index 9dbed0ace40..6bc84eb90c6 100644 --- a/app/assets/javascripts/ide/components/ide_sidebar_nav.vue +++ b/app/assets/javascripts/ide/components/ide_sidebar_nav.vue @@ -1,5 +1,6 @@ <script> import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { otherSide } from '../utils'; import { SIDE_RIGHT } from '../constants'; @@ -50,7 +51,7 @@ export default { }, clickTab(e, tab) { e.currentTarget.blur(); - this.$root.$emit('bv::hide::tooltip'); + this.$root.$emit(BV_HIDE_TOOLTIP); if (this.isActiveTab(tab)) { this.$emit('close'); diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue index ee292190e06..8a47835a252 100644 --- a/app/assets/javascripts/ide/components/ide_status_bar.vue +++ b/app/assets/javascripts/ide/components/ide_status_bar.vue @@ -2,12 +2,12 @@ /* eslint-disable @gitlab/vue-require-i18n-strings */ import { mapActions, mapState, mapGetters } from 'vuex'; import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import IdeStatusList from './ide_status_list.vue'; -import IdeStatusMr from './ide_status_mr.vue'; import timeAgoMixin from '~/vue_shared/mixins/timeago'; import CiIcon from '../../vue_shared/components/ci_icon.vue'; import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue'; import { rightSidebarViews } from '../constants'; +import IdeStatusMr from './ide_status_mr.vue'; +import IdeStatusList from './ide_status_list.vue'; export default { components: { diff --git a/app/assets/javascripts/ide/components/ide_status_list.vue b/app/assets/javascripts/ide/components/ide_status_list.vue index aa61c0d9b5e..9966d109b55 100644 --- a/app/assets/javascripts/ide/components/ide_status_list.vue +++ b/app/assets/javascripts/ide/components/ide_status_list.vue @@ -1,8 +1,8 @@ <script> import { mapGetters } from 'vuex'; import { GlLink, GlTooltipDirective } from '@gitlab/ui'; -import TerminalSyncStatusSafe from './terminal_sync/terminal_sync_status_safe.vue'; import { isTextFile, getFileEOL } from '~/ide/utils'; +import TerminalSyncStatusSafe from './terminal_sync/terminal_sync_status_safe.vue'; export default { components: { diff --git a/app/assets/javascripts/ide/components/ide_tree.vue b/app/assets/javascripts/ide/components/ide_tree.vue index e563de6659a..d10714a687d 100644 --- a/app/assets/javascripts/ide/components/ide_tree.vue +++ b/app/assets/javascripts/ide/components/ide_tree.vue @@ -58,8 +58,9 @@ export default { <new-entry-button :label="__('New file')" :show-label="false" - class="d-flex border-0 p-0 mr-3 qa-new-file" + class="d-flex border-0 p-0 mr-3" icon="doc-new" + data-qa-selector="new_file_button" @click="createNewFile()" /> <upload @@ -73,6 +74,7 @@ export default { :show-label="false" class="d-flex border-0 p-0" icon="folder-new" + data-qa-selector="new_directory_button" @click="createNewFolder()" /> </div> diff --git a/app/assets/javascripts/ide/components/merge_requests/list.vue b/app/assets/javascripts/ide/components/merge_requests/list.vue index 4b3c6e61e11..c6711d1ac10 100644 --- a/app/assets/javascripts/ide/components/merge_requests/list.vue +++ b/app/assets/javascripts/ide/components/merge_requests/list.vue @@ -3,8 +3,8 @@ import { mapActions, mapState } from 'vuex'; import { debounce } from 'lodash'; import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; import { __ } from '~/locale'; -import Item from './item.vue'; import TokenedInput from '../shared/tokened_input.vue'; +import Item from './item.vue'; const SEARCH_TYPES = [ { type: 'created', label: __('Created by me') }, diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue index 692878de5e1..46d21bc14aa 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/index.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue @@ -1,9 +1,9 @@ <script> import { mapActions } from 'vuex'; import { GlIcon } from '@gitlab/ui'; +import { modalTypes } from '../../constants'; import upload from './upload.vue'; import ItemButton from './button.vue'; -import { modalTypes } from '../../constants'; import NewModal from './modal.vue'; export default { @@ -108,6 +108,7 @@ export default { class="d-flex" icon="remove" icon-classes="mr-2" + data-qa-selector="delete_button" @click="deleteEntry(path)" /> </li> diff --git a/app/assets/javascripts/ide/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue index 5704129c10f..76d8a0aff3d 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/upload.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue @@ -1,6 +1,6 @@ <script> -import ItemButton from './button.vue'; import { isTextFile } from '~/ide/utils'; +import ItemButton from './button.vue'; export default { components: { diff --git a/app/assets/javascripts/ide/components/panes/right.vue b/app/assets/javascripts/ide/components/panes/right.vue index 46ef08a45a9..da484893530 100644 --- a/app/assets/javascripts/ide/components/panes/right.vue +++ b/app/assets/javascripts/ide/components/panes/right.vue @@ -1,13 +1,13 @@ <script> import { mapGetters, mapState } from 'vuex'; import { __ } from '~/locale'; -import CollapsibleSidebar from './collapsible_sidebar.vue'; import ResizablePanel from '../resizable_panel.vue'; import { rightSidebarViews, SIDEBAR_INIT_WIDTH, SIDEBAR_NAV_WIDTH } from '../../constants'; import PipelinesList from '../pipelines/list.vue'; import JobsDetail from '../jobs/detail.vue'; import Clientside from '../preview/clientside.vue'; import TerminalView from '../terminal/view.vue'; +import CollapsibleSidebar from './collapsible_sidebar.vue'; // Need to add the width of the nav buttons since the resizable container contains those as well const WIDTH = SIDEBAR_INIT_WIDTH + SIDEBAR_NAV_WIDTH; diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue index a4a13389fbf..4c13dfc7a1d 100644 --- a/app/assets/javascripts/ide/components/pipelines/list.vue +++ b/app/assets/javascripts/ide/components/pipelines/list.vue @@ -10,13 +10,12 @@ import { GlBadge, GlAlert, } from '@gitlab/ui'; +import IDEServices from '~/ide/services'; import { sprintf, __ } from '../../../locale'; import CiIcon from '../../../vue_shared/components/ci_icon.vue'; import EmptyState from '../../../pipelines/components/pipelines_list/empty_state.vue'; import JobsList from '../jobs/list.vue'; -import IDEServices from '~/ide/services'; - export default { components: { GlIcon, diff --git a/app/assets/javascripts/ide/components/preview/clientside.vue b/app/assets/javascripts/ide/components/preview/clientside.vue index 4c2a369226e..3bfb8f918af 100644 --- a/app/assets/javascripts/ide/components/preview/clientside.vue +++ b/app/assets/javascripts/ide/components/preview/clientside.vue @@ -4,10 +4,10 @@ import { isEmpty, debounce } from 'lodash'; import { Manager } from 'smooshpack'; import { listen } from 'codesandbox-api'; import { GlLoadingIcon } from '@gitlab/ui'; -import Navigator from './navigator.vue'; import { packageJsonPath, LIVE_PREVIEW_DEBOUNCE } from '../../constants'; import { createPathWithExt } from '../../utils'; import eventHub from '../../eventhub'; +import Navigator from './navigator.vue'; export default { components: { @@ -165,7 +165,7 @@ export default { </p> <a :href="links.webIDEHelpPagePath" - class="btn btn-primary" + class="btn gl-button btn-confirm" target="_blank" rel="noopener noreferrer" > diff --git a/app/assets/javascripts/ide/components/preview/navigator.vue b/app/assets/javascripts/ide/components/preview/navigator.vue index 8986359427f..ad4e6adf125 100644 --- a/app/assets/javascripts/ide/components/preview/navigator.vue +++ b/app/assets/javascripts/ide/components/preview/navigator.vue @@ -78,6 +78,7 @@ export default { this.visitPath(this.path); }, visitPath(path) { + // eslint-disable-next-line vue/no-mutating-props this.manager.iframe.src = `${this.manager.bundlerURL}${path}`; }, }, diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue index 8092ef3bce6..c5ff9fbbc01 100644 --- a/app/assets/javascripts/ide/components/repo_commit_section.vue +++ b/app/assets/javascripts/ide/components/repo_commit_section.vue @@ -1,8 +1,8 @@ <script> import { mapState, mapActions, mapGetters } from 'vuex'; +import { stageKeys } from '../constants'; import CommitFilesList from './commit_sidebar/list.vue'; import EmptyState from './commit_sidebar/empty_state.vue'; -import { stageKeys } from '../constants'; export default { components: { diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index a9c05f2e1ac..9f8bd299d52 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -12,21 +12,21 @@ import { WEBIDE_MEASURE_FILE_AFTER_INTERACTION, } from '~/performance/constants'; import { performanceMarkAndMeasure } from '~/performance/utils'; -import eventHub from '../eventhub'; +import { __ } from '~/locale'; +import Editor from '../lib/editor'; import { leftSidebarViews, viewerTypes, FILE_VIEW_MODE_EDITOR, FILE_VIEW_MODE_PREVIEW, } from '../constants'; -import Editor from '../lib/editor'; -import FileTemplatesBar from './file_templates/bar.vue'; -import { __ } from '~/locale'; +import eventHub from '../eventhub'; import { extractMarkdownImagesFromEntries } from '../stores/utils'; import { getFileEditorOrDefault } from '../stores/modules/editor/utils'; import { getPathParent, readFileAsDataURL, registerSchema, isTextFile } from '../utils'; import { getRulesWithTraversal } from '../lib/editorconfig/parser'; import mapRulesToMonaco from '../lib/editorconfig/rules_mapper'; +import FileTemplatesBar from './file_templates/bar.vue'; export default { name: 'RepoEditor', diff --git a/app/assets/javascripts/ide/components/terminal/session.vue b/app/assets/javascripts/ide/components/terminal/session.vue index 0e67a2ab45f..4089d2e4ba9 100644 --- a/app/assets/javascripts/ide/components/terminal/session.vue +++ b/app/assets/javascripts/ide/components/terminal/session.vue @@ -2,8 +2,8 @@ import { mapActions, mapState } from 'vuex'; import { GlButton } from '@gitlab/ui'; import { __ } from '~/locale'; -import Terminal from './terminal.vue'; import { isEndingStatus } from '../../stores/modules/terminal/utils'; +import Terminal from './terminal.vue'; export default { components: { diff --git a/app/assets/javascripts/ide/components/terminal/terminal.vue b/app/assets/javascripts/ide/components/terminal/terminal.vue index 0ee4107f9ab..9cfc5504c83 100644 --- a/app/assets/javascripts/ide/components/terminal/terminal.vue +++ b/app/assets/javascripts/ide/components/terminal/terminal.vue @@ -3,9 +3,9 @@ import { mapState } from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import GLTerminal from '~/terminal/terminal'; -import TerminalControls from './terminal_controls.vue'; import { RUNNING, STOPPING } from '../../stores/modules/terminal/constants'; import { isStartingStatus } from '../../stores/modules/terminal/utils'; +import TerminalControls from './terminal_controls.vue'; export default { components: { diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index e5618466395..6bd74b143e2 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -16,6 +16,13 @@ export const PERMISSION_CREATE_MR = 'createMergeRequestIn'; export const PERMISSION_READ_MR = 'readMergeRequest'; export const PERMISSION_PUSH_CODE = 'pushCode'; +// The default permission object to use when the project data isn't available yet. +// This helps us encapsulate checks like `canPushCode` without requiring an +// additional check like `currentProject && canPushCode`. +export const DEFAULT_PERMISSIONS = { + [PERMISSION_PUSH_CODE]: true, +}; + export const viewerTypes = { mr: 'mrdiff', edit: 'editor', diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index af408c06556..bf0d6b32850 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -3,11 +3,11 @@ import { mapActions } from 'vuex'; import { identity } from 'lodash'; import Translate from '~/vue_shared/translate'; import PerformancePlugin from '~/performance/vue_performance_plugin'; +import { parseBoolean } from '../lib/utils/common_utils'; +import { resetServiceWorkersPublicPath } from '../lib/utils/webpack'; import ide from './components/ide.vue'; import { createStore } from './stores'; import { createRouter } from './ide_router'; -import { parseBoolean } from '../lib/utils/common_utils'; -import { resetServiceWorkersPublicPath } from '../lib/utils/webpack'; import { DEFAULT_THEME } from './lib/themes'; Vue.use(Translate); diff --git a/app/assets/javascripts/ide/lib/common/model.js b/app/assets/javascripts/ide/lib/common/model.js index 4969875439e..46128651547 100644 --- a/app/assets/javascripts/ide/lib/common/model.js +++ b/app/assets/javascripts/ide/lib/common/model.js @@ -1,9 +1,9 @@ import { editor as monacoEditor, Uri } from 'monaco-editor'; -import Disposable from './disposable'; +import { insertFinalNewline } from '~/lib/utils/text_utility'; import eventHub from '../../eventhub'; import { trimTrailingWhitespace } from '../../utils'; -import { insertFinalNewline } from '~/lib/utils/text_utility'; import { defaultModelOptions } from '../editor_options'; +import Disposable from './disposable'; export default class Model { constructor(file, head = null) { diff --git a/app/assets/javascripts/ide/lib/diff/controller.js b/app/assets/javascripts/ide/lib/diff/controller.js index 3efe692be13..ee4fdd10c97 100644 --- a/app/assets/javascripts/ide/lib/diff/controller.js +++ b/app/assets/javascripts/ide/lib/diff/controller.js @@ -1,7 +1,7 @@ import { Range } from 'monaco-editor'; import { throttle } from 'lodash'; -import DirtyDiffWorker from './diff_worker'; import Disposable from '../common/disposable'; +import DirtyDiffWorker from './diff_worker'; export const getDiffChangeType = (change) => { if (change.modified) { diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js index 4fad0c09ce7..f3c572bfeed 100644 --- a/app/assets/javascripts/ide/lib/editor.js +++ b/app/assets/javascripts/ide/lib/editor.js @@ -1,5 +1,7 @@ import { debounce } from 'lodash'; import { editor as monacoEditor, KeyCode, KeyMod, Range } from 'monaco-editor'; +import { clearDomElement } from '~/editor/utils'; +import { registerLanguages } from '../utils'; import DecorationsController from './decorations/controller'; import DirtyDiffController from './diff/controller'; import Disposable from './common/disposable'; @@ -8,8 +10,6 @@ import { editorOptions, defaultEditorOptions, defaultDiffEditorOptions } from '. import { themes } from './themes'; import languages from './languages'; import keymap from './keymap.json'; -import { clearDomElement } from '~/editor/utils'; -import { registerLanguages } from '../utils'; function setupThemes() { themes.forEach((theme) => { diff --git a/app/assets/javascripts/ide/lib/errors.js b/app/assets/javascripts/ide/lib/errors.js index f975034a872..a8a048e588f 100644 --- a/app/assets/javascripts/ide/lib/errors.js +++ b/app/assets/javascripts/ide/lib/errors.js @@ -1,6 +1,6 @@ import { escape } from 'lodash'; import { __ } from '~/locale'; -import consts from '../stores/modules/commit/constants'; +import { COMMIT_TO_NEW_BRANCH } from '../stores/modules/commit/constants'; const CODEOWNERS_REGEX = /Push.*protected branches.*CODEOWNERS/; const BRANCH_CHANGED_REGEX = /changed.*since.*start.*edit/; @@ -8,7 +8,7 @@ const BRANCH_ALREADY_EXISTS = /branch.*already.*exists/; const createNewBranchAndCommit = (store) => store - .dispatch('commit/updateCommitAction', consts.COMMIT_TO_NEW_BRANCH) + .dispatch('commit/updateCommitAction', COMMIT_TO_NEW_BRANCH) .then(() => store.dispatch('commit/commitChanges')); export const createUnexpectedCommitError = (message) => ({ diff --git a/app/assets/javascripts/ide/lib/mirror.js b/app/assets/javascripts/ide/lib/mirror.js index 6f9cfec9465..78990953beb 100644 --- a/app/assets/javascripts/ide/lib/mirror.js +++ b/app/assets/javascripts/ide/lib/mirror.js @@ -1,6 +1,6 @@ -import createDiff from './create_diff'; import { getWebSocketUrl, mergeUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; +import createDiff from './create_diff'; export const SERVICE_NAME = 'webide-file-sync'; export const PROTOCOL = 'webfilesync.gitlab.com'; diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index d62dfc35d15..d4ae460d78b 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -9,11 +9,11 @@ import { WEBIDE_MARK_FETCH_BRANCH_DATA_FINISH, WEBIDE_MEASURE_FETCH_BRANCH_DATA, } from '~/performance/constants'; -import * as types from './mutation_types'; import { decorateFiles } from '../lib/files'; import { stageKeys, commitActionTypes } from '../constants'; import service from '../services'; import eventHub from '../eventhub'; +import * as types from './mutation_types'; export const redirectToUrl = (self, url) => visitUrl(url); diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index 59e8d37a92a..9600c1a1b8c 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -1,13 +1,14 @@ -import { getChangesCountForFiles, filePathMatches } from './utils'; +import { addNumericSuffix } from '~/ide/utils'; +import Api from '~/api'; import { leftSidebarViews, packageJsonPath, + DEFAULT_PERMISSIONS, PERMISSION_READ_MR, PERMISSION_CREATE_MR, PERMISSION_PUSH_CODE, } from '../constants'; -import { addNumericSuffix } from '~/ide/utils'; -import Api from '~/api'; +import { getChangesCountForFiles, filePathMatches } from './utils'; export const activeFile = (state) => state.openFiles.find((file) => file.active) || null; @@ -150,7 +151,7 @@ export const getDiffInfo = (state, getters) => (path) => { }; export const findProjectPermissions = (state, getters) => (projectId) => - getters.findProject(projectId)?.userPermissions || {}; + getters.findProject(projectId)?.userPermissions || DEFAULT_PERMISSIONS; export const canReadMergeRequests = (state, getters) => Boolean(getters.findProjectPermissions(state.currentProjectId)[PERMISSION_READ_MR]); diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js index 29b9a8a9521..57a1e4f133a 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/actions.js +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -1,14 +1,14 @@ import { sprintf, __ } from '~/locale'; import { deprecatedCreateFlash as flash } from '~/flash'; +import { addNumericSuffix } from '~/ide/utils'; import * as rootTypes from '../../mutation_types'; import { createCommitPayload, createNewMergeRequestUrl } from '../../utils'; import service from '../../../services'; -import * as types from './mutation_types'; -import consts from './constants'; import { leftSidebarViews } from '../../../constants'; import eventHub from '../../../eventhub'; import { parseCommitError } from '../../../lib/errors'; -import { addNumericSuffix } from '~/ide/utils'; +import { COMMIT_TO_CURRENT_BRANCH } from './constants'; +import * as types from './mutation_types'; export const updateCommitMessage = ({ commit }, message) => { commit(types.UPDATE_COMMIT_MESSAGE, message); @@ -112,7 +112,7 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo // Pull commit options out because they could change // During some of the pre and post commit processing const { shouldCreateMR, shouldHideNewMrOption, isCreatingNewBranch, branchName } = getters; - const newBranch = state.commitAction !== consts.COMMIT_TO_CURRENT_BRANCH; + const newBranch = state.commitAction !== COMMIT_TO_CURRENT_BRANCH; const stageFilesPromise = rootState.stagedFiles.length ? Promise.resolve() : dispatch('stageAllChanges', null, { root: true }); @@ -206,7 +206,7 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo dispatch('updateViewer', 'editor', { root: true }); } }) - .then(() => dispatch('updateCommitAction', consts.COMMIT_TO_CURRENT_BRANCH)) + .then(() => dispatch('updateCommitAction', COMMIT_TO_CURRENT_BRANCH)) .then(() => { if (newBranch) { const path = rootGetters.activeFile ? rootGetters.activeFile.path : ''; diff --git a/app/assets/javascripts/ide/stores/modules/commit/constants.js b/app/assets/javascripts/ide/stores/modules/commit/constants.js index c6c3701effe..9f4299e5537 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/constants.js +++ b/app/assets/javascripts/ide/stores/modules/commit/constants.js @@ -1,7 +1,2 @@ -const COMMIT_TO_CURRENT_BRANCH = '1'; -const COMMIT_TO_NEW_BRANCH = '2'; - -export default { - COMMIT_TO_CURRENT_BRANCH, - COMMIT_TO_NEW_BRANCH, -}; +export const COMMIT_TO_CURRENT_BRANCH = '1'; +export const COMMIT_TO_NEW_BRANCH = '2'; diff --git a/app/assets/javascripts/ide/stores/modules/commit/getters.js b/app/assets/javascripts/ide/stores/modules/commit/getters.js index 2301cf23f9f..f5e367e16f5 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/getters.js +++ b/app/assets/javascripts/ide/stores/modules/commit/getters.js @@ -1,5 +1,5 @@ import { sprintf, n__, __ } from '../../../../locale'; -import consts from './constants'; +import { COMMIT_TO_NEW_BRANCH } from './constants'; const BRANCH_SUFFIX_COUNT = 5; const createTranslatedTextForFiles = (files, text) => { @@ -48,7 +48,7 @@ export const preBuiltCommitMessage = (state, _, rootState) => { .join('\n'); }; -export const isCreatingNewBranch = (state) => state.commitAction === consts.COMMIT_TO_NEW_BRANCH; +export const isCreatingNewBranch = (state) => state.commitAction === COMMIT_TO_NEW_BRANCH; export const shouldHideNewMrOption = (_state, getters, _rootState, rootGetters) => !getters.isCreatingNewBranch && diff --git a/app/assets/javascripts/ide/stores/modules/file_templates/actions.js b/app/assets/javascripts/ide/stores/modules/file_templates/actions.js index 6800f824da0..ad5b01e7040 100644 --- a/app/assets/javascripts/ide/stores/modules/file_templates/actions.js +++ b/app/assets/javascripts/ide/stores/modules/file_templates/actions.js @@ -1,8 +1,8 @@ import Api from '~/api'; import { __ } from '~/locale'; import { normalizeHeaders } from '~/lib/utils/common_utils'; -import * as types from './mutation_types'; import eventHub from '../../../eventhub'; +import * as types from './mutation_types'; export const requestTemplateTypes = ({ commit }) => commit(types.REQUEST_TEMPLATE_TYPES); export const receiveTemplateTypesError = ({ commit, dispatch }) => { diff --git a/app/assets/javascripts/ide/stores/modules/file_templates/getters.js b/app/assets/javascripts/ide/stores/modules/file_templates/getters.js index 0613fe9b12b..9708e5e588c 100644 --- a/app/assets/javascripts/ide/stores/modules/file_templates/getters.js +++ b/app/assets/javascripts/ide/stores/modules/file_templates/getters.js @@ -1,5 +1,5 @@ -import { leftSidebarViews } from '../../../constants'; import { __ } from '~/locale'; +import { leftSidebarViews } from '../../../constants'; export const templateTypes = () => [ { diff --git a/app/assets/javascripts/ide/stores/modules/terminal_sync/actions.js b/app/assets/javascripts/ide/stores/modules/terminal_sync/actions.js index 006800f58c2..a2cb0666a99 100644 --- a/app/assets/javascripts/ide/stores/modules/terminal_sync/actions.js +++ b/app/assets/javascripts/ide/stores/modules/terminal_sync/actions.js @@ -1,5 +1,5 @@ -import * as types from './mutation_types'; import mirror, { canConnect } from '../../../lib/mirror'; +import * as types from './mutation_types'; export const upload = ({ rootState, commit }) => { commit(types.START_LOADING); diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js index 4446971d5d6..d678c5e280c 100644 --- a/app/assets/javascripts/ide/stores/mutations/file.js +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -1,7 +1,7 @@ +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import * as types from '../mutation_types'; import { sortTree } from '../utils'; import { diffModes } from '../../constants'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; export default { [types.SET_FILE_ACTIVE](state, { path, active }) { diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index 04eacf271b8..4019703b296 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -1,10 +1,10 @@ -import { commitActionTypes } from '../constants'; import { relativePathToAbsolute, isAbsolute, isRootRelative, isBlobUrl, } from '~/lib/utils/url_utility'; +import { commitActionTypes } from '../constants'; export const dataStructure = () => ({ id: '', diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js index 8eb2d17b876..99d6ccde770 100644 --- a/app/assets/javascripts/ide/utils.js +++ b/app/assets/javascripts/ide/utils.js @@ -1,7 +1,7 @@ import { languages } from 'monaco-editor'; import { flatten, isString } from 'lodash'; -import { SIDE_LEFT, SIDE_RIGHT } from './constants'; import { performanceMarkAndMeasure } from '~/performance/utils'; +import { SIDE_LEFT, SIDE_RIGHT } from './constants'; const toLowerCase = (x) => x.toLowerCase(); diff --git a/app/assets/javascripts/image_diff/image_diff.js b/app/assets/javascripts/image_diff/image_diff.js index 079f4a63f6e..a0dd8e6f894 100644 --- a/app/assets/javascripts/image_diff/image_diff.js +++ b/app/assets/javascripts/image_diff/image_diff.js @@ -1,7 +1,7 @@ import $ from 'jquery'; +import { isImageLoaded } from '../lib/utils/image_utility'; import imageDiffHelper from './helpers/index'; import ImageBadge from './image_badge'; -import { isImageLoaded } from '../lib/utils/image_utility'; export default class ImageDiff { constructor(el, options) { 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 80e2e73f420..c4859d4f60e 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 @@ -1,5 +1,14 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; +import { + GlEmptyState, + GlIcon, + GlLink, + GlLoadingIcon, + GlSearchBoxByClick, + GlSprintf, +} from '@gitlab/ui'; +import { s__ } from '~/locale'; +import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; import bulkImportSourceGroupsQuery from '../graphql/queries/bulk_import_source_groups.query.graphql'; import availableNamespacesQuery from '../graphql/queries/available_namespaces.query.graphql'; import setTargetNamespaceMutation from '../graphql/mutations/set_target_namespace.mutation.graphql'; @@ -7,72 +16,169 @@ import setNewNameMutation from '../graphql/mutations/set_new_name.mutation.graph import importGroupMutation from '../graphql/mutations/import_group.mutation.graphql'; import ImportTableRow from './import_table_row.vue'; -const mapApolloMutations = (mutations) => - Object.fromEntries( - Object.entries(mutations).map(([key, mutation]) => [ - key, - function mutate(config) { - return this.$apollo.mutate({ - mutation, - ...config, - }); - }, - ]), - ); - export default { components: { + GlEmptyState, + GlIcon, + GlLink, GlLoadingIcon, + GlSearchBoxByClick, + GlSprintf, ImportTableRow, + PaginationLinks, + }, + + props: { + sourceUrl: { + type: String, + required: true, + }, + }, + + data() { + return { + filter: '', + page: 1, + }; }, apollo: { - bulkImportSourceGroups: bulkImportSourceGroupsQuery, + bulkImportSourceGroups: { + query: bulkImportSourceGroupsQuery, + variables() { + return { page: this.page, filter: this.filter }; + }, + }, availableNamespaces: availableNamespacesQuery, }, + computed: { + hasGroups() { + return this.bulkImportSourceGroups?.nodes?.length > 0; + }, + + hasEmptyFilter() { + return this.filter.length > 0 && !this.hasGroups; + }, + + statusMessage() { + return this.filter.length === 0 + ? s__('BulkImport|Showing %{start}-%{end} of %{total} from %{link}') + : s__( + 'BulkImport|Showing %{start}-%{end} of %{total} matching filter "%{filter}" from %{link}', + ); + }, + + paginationInfo() { + const { page, perPage, total } = this.bulkImportSourceGroups?.pageInfo ?? { + page: 1, + perPage: 0, + total: 0, + }; + const start = (page - 1) * perPage + 1; + const end = start + (this.bulkImportSourceGroups.nodes?.length ?? 0) - 1; + + return { start, end, total }; + }, + }, + + watch: { + filter() { + this.page = 1; + }, + }, + methods: { - ...mapApolloMutations({ - setTargetNamespace: setTargetNamespaceMutation, - setNewName: setNewNameMutation, - importGroup: importGroupMutation, - }), + setPage(page) { + this.page = page; + }, + + updateTargetNamespace(sourceGroupId, targetNamespace) { + this.$apollo.mutate({ + mutation: setTargetNamespaceMutation, + variables: { sourceGroupId, targetNamespace }, + }); + }, + + updateNewName(sourceGroupId, newName) { + this.$apollo.mutate({ + mutation: setNewNameMutation, + variables: { sourceGroupId, newName }, + }); + }, + + importGroup(sourceGroupId) { + this.$apollo.mutate({ + mutation: importGroupMutation, + variables: { sourceGroupId }, + }); + }, }, }; </script> <template> <div> - <gl-loading-icon v-if="$apollo.loading" size="md" class="gl-mt-5" /> - <div v-else-if="bulkImportSourceGroups.length"> - <table class="gl-w-full"> - <thead class="gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1"> - <th class="gl-py-4 import-jobs-from-col">{{ s__('BulkImport|From source group') }}</th> - <th class="gl-py-4 import-jobs-to-col">{{ s__('BulkImport|To new group') }}</th> - <th class="gl-py-4 import-jobs-status-col">{{ __('Status') }}</th> - <th class="gl-py-4 import-jobs-cta-col"></th> - </thead> - <tbody> - <template v-for="group in bulkImportSourceGroups"> - <import-table-row - :key="group.id" - :group="group" - :available-namespaces="availableNamespaces" - @update-target-namespace=" - setTargetNamespace({ - variables: { sourceGroupId: group.id, targetNamespace: $event }, - }) - " - @update-new-name=" - setNewName({ - variables: { sourceGroupId: group.id, newName: $event }, - }) - " - @import-group="importGroup({ variables: { sourceGroupId: group.id } })" - /> + <div + class="gl-py-5 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex gl-align-items-center" + > + <span> + <gl-sprintf v-if="!$apollo.loading && hasGroups" :message="statusMessage"> + <template #start> + <strong>{{ paginationInfo.start }}</strong> + </template> + <template #end> + <strong>{{ paginationInfo.end }}</strong> + </template> + <template #total> + <strong>{{ n__('%d group', '%d groups', paginationInfo.total) }}</strong> </template> - </tbody> - </table> + <template #filter> + <strong>{{ filter }}</strong> + </template> + <template #link> + <gl-link class="gl-display-inline-block" :href="sourceUrl" target="_blank"> + {{ sourceUrl }} <gl-icon name="external-link" class="vertical-align-middle" /> + </gl-link> + </template> + </gl-sprintf> + </span> + <gl-search-box-by-click class="gl-ml-auto" @submit="filter = $event" @clear="filter = ''" /> </div> + <gl-loading-icon v-if="$apollo.loading" size="md" class="gl-mt-5" /> + <template v-else> + <gl-empty-state v-if="hasEmptyFilter" :title="__('Sorry, your filter produced no results')" /> + <gl-empty-state + v-else-if="!hasGroups" + :title="s__('BulkImport|No groups available for import')" + /> + <div v-else class="gl-display-flex gl-flex-direction-column gl-align-items-center"> + <table class="gl-w-full"> + <thead class="gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1"> + <th class="gl-py-4 import-jobs-from-col">{{ s__('BulkImport|From source group') }}</th> + <th class="gl-py-4 import-jobs-to-col">{{ s__('BulkImport|To new group') }}</th> + <th class="gl-py-4 import-jobs-status-col">{{ __('Status') }}</th> + <th class="gl-py-4 import-jobs-cta-col"></th> + </thead> + <tbody> + <template v-for="group in bulkImportSourceGroups.nodes"> + <import-table-row + :key="group.id" + :group="group" + :available-namespaces="availableNamespaces" + @update-target-namespace="updateTargetNamespace(group.id, $event)" + @update-new-name="updateNewName(group.id, $event)" + @import-group="importGroup(group.id)" + /> + </template> + </tbody> + </table> + <pagination-links + :change="setPage" + :page-info="bulkImportSourceGroups.pageInfo" + class="gl-mt-3" + /> + </div> + </template> </div> </template> diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js index 8f2d488d661..beb058417e5 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js +++ b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js @@ -1,4 +1,5 @@ import axios from '~/lib/utils/axios_utils'; +import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import createDefaultClient from '~/lib/graphql'; import { s__ } from '~/locale'; import createFlash from '~/flash'; @@ -8,8 +9,10 @@ import { SourceGroupsManager } from './services/source_groups_manager'; import { StatusPoller } from './services/status_poller'; export const clientTypenames = { + BulkImportSourceGroupConnection: 'ClientBulkImportSourceGroupConnection', BulkImportSourceGroup: 'ClientBulkImportSourceGroup', AvailableNamespace: 'ClientAvailableNamespace', + BulkImportPageInfo: 'ClientBulkImportPageInfo', }; export function createResolvers({ endpoints }) { @@ -17,22 +20,39 @@ export function createResolvers({ endpoints }) { return { Query: { - async bulkImportSourceGroups(_, __, { client }) { + async bulkImportSourceGroups(_, vars, { client }) { const { data: { availableNamespaces }, } = await client.query({ query: availableNamespacesQuery }); - return axios.get(endpoints.status).then(({ data }) => { - return data.importable_data.map((group) => ({ - __typename: clientTypenames.BulkImportSourceGroup, - ...group, - status: STATUSES.NONE, - import_target: { - new_name: group.full_path, - target_namespace: availableNamespaces[0].full_path, + return axios + .get(endpoints.status, { + params: { + page: vars.page, + per_page: vars.perPage, + filter: vars.filter, }, - })); - }); + }) + .then(({ headers, data }) => { + const pagination = parseIntPagination(normalizeHeaders(headers)); + + return { + __typename: clientTypenames.BulkImportSourceGroupConnection, + nodes: data.importable_data.map((group) => ({ + __typename: clientTypenames.BulkImportSourceGroup, + ...group, + status: STATUSES.NONE, + import_target: { + new_name: group.full_path, + target_namespace: availableNamespaces[0].full_path, + }, + })), + pageInfo: { + __typename: clientTypenames.BulkImportPageInfo, + ...pagination, + }, + }; + }); }, availableNamespaces: () => diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql index 8d52d94925c..28dfefdf8a7 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql +++ b/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql @@ -1,7 +1,15 @@ #import "../fragments/bulk_import_source_group_item.fragment.graphql" -query bulkImportSourceGroups { - bulkImportSourceGroups @client { - ...BulkImportSourceGroupItem +query bulkImportSourceGroups($page: Int = 1, $perPage: Int = 20, $filter: String = "") { + bulkImportSourceGroups(page: $page, filter: $filter, perPage: $perPage) @client { + nodes { + ...BulkImportSourceGroupItem + } + pageInfo { + page + perPage + total + totalPages + } } } diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js b/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js index 41dd25b9150..886cf24081b 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js +++ b/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js @@ -46,7 +46,10 @@ export class StatusPoller { const { bulkImportSourceGroups } = this.client.readQuery({ query: bulkImportSourceGroupsQuery, }); - const groupsInProgress = bulkImportSourceGroups.filter((g) => g.status === STATUSES.STARTED); + + const groupsInProgress = bulkImportSourceGroups.nodes.filter( + (g) => g.status === STATUSES.STARTED, + ); if (groupsInProgress.length) { const { data: results } = await this.client.query({ query: generateGroupsQuery(groupsInProgress), diff --git a/app/assets/javascripts/import_entities/import_groups/index.js b/app/assets/javascripts/import_entities/import_groups/index.js index bf427075564..1ce74bf4f60 100644 --- a/app/assets/javascripts/import_entities/import_groups/index.js +++ b/app/assets/javascripts/import_entities/import_groups/index.js @@ -10,7 +10,12 @@ Vue.use(VueApollo); export function mountImportGroupsApp(mountElement) { if (!mountElement) return undefined; - const { statusPath, availableNamespacesPath, createBulkImportPath } = mountElement.dataset; + const { + statusPath, + availableNamespacesPath, + createBulkImportPath, + sourceUrl, + } = mountElement.dataset; const apolloProvider = new VueApollo({ defaultClient: createApolloClient({ endpoints: { @@ -25,7 +30,11 @@ export function mountImportGroupsApp(mountElement) { el: mountElement, apolloProvider, render(createElement) { - return createElement(ImportTable); + return createElement(ImportTable, { + props: { + sourceUrl, + }, + }); }, }); } 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 a8217ff1033..a5d6fa175fc 100644 --- a/app/assets/javascripts/import_entities/import_projects/store/actions.js +++ b/app/assets/javascripts/import_entities/import_projects/store/actions.js @@ -1,6 +1,4 @@ import Visibility from 'visibilityjs'; -import * as types from './mutation_types'; -import { isProjectImportable } from '../utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import Poll from '~/lib/utils/poll'; import { visitUrl, objectToQuery } from '~/lib/utils/url_utility'; @@ -9,6 +7,8 @@ import { s__, sprintf } from '~/locale'; import axios from '~/lib/utils/axios_utils'; import httpStatusCodes from '~/lib/utils/http_status'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { isProjectImportable } from '../utils'; +import * as types from './mutation_types'; let eTagPoll; diff --git a/app/assets/javascripts/import_entities/import_projects/store/mutations.js b/app/assets/javascripts/import_entities/import_projects/store/mutations.js index 1a96508bd48..c5e1922597a 100644 --- a/app/assets/javascripts/import_entities/import_projects/store/mutations.js +++ b/app/assets/javascripts/import_entities/import_projects/store/mutations.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import * as types from './mutation_types'; import { STATUSES } from '../../constants'; +import * as types from './mutation_types'; const makeNewImportedProject = (importedProject) => ({ importSource: { diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue index 7d44a28b4bb..b848f7adc9d 100644 --- a/app/assets/javascripts/incidents/components/incidents_list.vue +++ b/app/assets/javascripts/incidents/components/incidents_list.vue @@ -22,10 +22,10 @@ import { import { convertToSnakeCase } from '~/lib/utils/text_utility'; import { s__ } from '~/locale'; import { visitUrl, mergeUrlParams, joinPaths } from '~/lib/utils/url_utility'; -import getIncidents from '../graphql/queries/get_incidents.query.graphql'; -import getIncidentsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql'; import SeverityToken from '~/sidebar/components/severity/severity.vue'; import { INCIDENT_SEVERITY } from '~/sidebar/components/severity/constants'; +import getIncidents from '../graphql/queries/get_incidents.query.graphql'; +import getIncidentsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql'; import { I18N, INCIDENT_STATUS_TABS, @@ -102,7 +102,7 @@ export default { GlIcon, PublishedCell: () => import('ee_component/incidents/components/published_cell.vue'), ServiceLevelAgreementCell: () => - import('ee_component/incidents/components/service_level_agreement_cell.vue'), + import('ee_component/vue_shared/components/incidents/service_level_agreement.vue'), GlEmptyState, SeverityToken, PaginatedTableWithSearchAndTabs, diff --git a/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue b/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue index c90ff8079b8..9d5f37dc3b7 100644 --- a/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue +++ b/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue @@ -1,8 +1,8 @@ <script> import { GlButton, GlTabs, GlTab } from '@gitlab/ui'; +import { INTEGRATION_TABS_CONFIG, I18N_INTEGRATION_TABS } from '../constants'; import AlertsSettingsForm from './alerts_form.vue'; import PagerDutySettingsForm from './pagerduty_form.vue'; -import { INTEGRATION_TABS_CONFIG, I18N_INTEGRATION_TABS } from '../constants'; export default { components: { @@ -26,7 +26,7 @@ export default { class="settings no-animate qa-incident-management-settings" > <div class="settings-header"> - <h4 ref="sectionHeader" class="gl-my-3! gl-py-1"> + <h4 ref="sectionHeader"> {{ $options.i18n.headerText }} </h4> <gl-button ref="toggleBtn" class="js-settings-toggle">{{ diff --git a/app/assets/javascripts/init_changes_dropdown.js b/app/assets/javascripts/init_changes_dropdown.js index 22667d8ae88..b42264c870b 100644 --- a/app/assets/javascripts/init_changes_dropdown.js +++ b/app/assets/javascripts/init_changes_dropdown.js @@ -1,6 +1,6 @@ import $ from 'jquery'; -import { stickyMonitor } from './lib/utils/sticky'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; +import { stickyMonitor } from './lib/utils/sticky'; export default (stickyTop) => { stickyMonitor(document.querySelector('.js-diff-files-changed'), stickyTop); diff --git a/app/assets/javascripts/init_issuable_sidebar.js b/app/assets/javascripts/init_issuable_sidebar.js index 1e82ecb05b5..63ef6a01844 100644 --- a/app/assets/javascripts/init_issuable_sidebar.js +++ b/app/assets/javascripts/init_issuable_sidebar.js @@ -1,11 +1,11 @@ /* eslint-disable no-new */ +import { mountSidebarLabels, getSidebarOptions } from '~/sidebar/mount_sidebar'; import MilestoneSelect from './milestone_select'; import LabelsSelect from './labels_select'; import IssuableContext from './issuable_context'; import Sidebar from './right_sidebar'; import DueDateSelectors from './due_date_select'; -import { mountSidebarLabels, getSidebarOptions } from '~/sidebar/mount_sidebar'; export default () => { const sidebarOptEl = document.querySelector('.js-sidebar-options'); diff --git a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue index f568f7e6d3d..80815972006 100644 --- a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue +++ b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue @@ -3,8 +3,8 @@ import { mapGetters } from 'vuex'; import { capitalize, lowerCase, isEmpty } from 'lodash'; import { GlFormGroup, GlFormCheckbox, GlFormInput, GlFormSelect, GlFormTextarea } from '@gitlab/ui'; -import eventHub from '../event_hub'; import { __, sprintf } from '~/locale'; +import eventHub from '../event_hub'; export default { name: 'DynamicField', diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue index ac8a64d5f3b..e5c4368c32d 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue @@ -1,6 +1,6 @@ <script> import { mapState, mapActions, mapGetters } from 'vuex'; -import { GlButton, GlModalDirective } from '@gitlab/ui'; +import { GlButton, GlModalDirective, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import eventHub from '../event_hub'; import { integrationLevels } from '../constants'; @@ -28,9 +28,17 @@ export default { GlButton, }, directives: { - 'gl-modal': GlModalDirective, + GlModal: GlModalDirective, + SafeHtml, }, mixins: [glFeatureFlagsMixin()], + props: { + helpHtml: { + type: String, + required: false, + default: '', + }, + }, computed: { ...mapGetters(['currentKey', 'propsSource', 'isDisabled']), ...mapState([ @@ -80,11 +88,14 @@ export default { this.fetchResetIntegration(); }, }, + helpHtmlConfig: { + ADD_TAGS: ['use'], // to support icon SVGs + }, }; </script> <template> - <div> + <div class="gl-mb-3"> <override-dropdown v-if="defaultState !== null" :inherit-from-id="defaultState.id" @@ -92,80 +103,92 @@ export default { :learn-more-path="propsSource.learnMorePath" @change="setOverride" /> - <active-checkbox v-if="propsSource.showActive" :key="`${currentKey}-active-checkbox`" /> - <jira-trigger-fields - v-if="isJira" - :key="`${currentKey}-jira-trigger-fields`" - v-bind="propsSource.triggerFieldsProps" - /> - <trigger-fields - v-else-if="propsSource.triggerEvents.length" - :key="`${currentKey}-trigger-fields`" - :events="propsSource.triggerEvents" - :type="propsSource.type" - /> - <dynamic-field - v-for="field in propsSource.fields" - :key="`${currentKey}-${field.name}`" - v-bind="field" - /> - <jira-issues-fields - v-if="showJiraIssuesFields" - :key="`${currentKey}-jira-issues-fields`" - v-bind="propsSource.jiraIssuesProps" - /> - <div v-if="isEditable" class="footer-block row-content-block"> - <template v-if="isInstanceOrGroupLevel"> - <gl-button - v-gl-modal.confirmSaveIntegration - category="primary" - variant="success" - :loading="isSaving" - :disabled="isDisabled" - data-qa-selector="save_changes_button" - > - {{ __('Save changes') }} - </gl-button> - <confirmation-modal @submit="onSaveClick" /> - </template> - <gl-button - v-else - category="primary" - variant="success" - type="submit" - :loading="isSaving" - :disabled="isDisabled" - data-qa-selector="save_changes_button" - @click.prevent="onSaveClick" - > - {{ __('Save changes') }} - </gl-button> - <gl-button - v-if="propsSource.canTest" - :loading="isTesting" - :disabled="isDisabled" - :href="propsSource.testPath" - @click.prevent="onTestClick" - > - {{ __('Test settings') }} - </gl-button> + <div class="row"> + <div class="col-lg-4"></div> + + <div class="col-lg-8"> + <!-- helpHtml is trusted input --> + <div v-if="helpHtml" v-safe-html:[$options.helpHtmlConfig]="helpHtml"></div> + + <active-checkbox v-if="propsSource.showActive" :key="`${currentKey}-active-checkbox`" /> + <jira-trigger-fields + v-if="isJira" + :key="`${currentKey}-jira-trigger-fields`" + v-bind="propsSource.triggerFieldsProps" + /> + <trigger-fields + v-else-if="propsSource.triggerEvents.length" + :key="`${currentKey}-trigger-fields`" + :events="propsSource.triggerEvents" + :type="propsSource.type" + /> + <dynamic-field + v-for="field in propsSource.fields" + :key="`${currentKey}-${field.name}`" + v-bind="field" + /> + <jira-issues-fields + v-if="showJiraIssuesFields" + :key="`${currentKey}-jira-issues-fields`" + v-bind="propsSource.jiraIssuesProps" + /> + <div v-if="isEditable" class="footer-block row-content-block"> + <template v-if="isInstanceOrGroupLevel"> + <gl-button + v-gl-modal.confirmSaveIntegration + category="primary" + variant="success" + :loading="isSaving" + :disabled="isDisabled" + data-qa-selector="save_changes_button" + > + {{ __('Save changes') }} + </gl-button> + <confirmation-modal @submit="onSaveClick" /> + </template> + <gl-button + v-else + category="primary" + variant="success" + type="submit" + :loading="isSaving" + :disabled="isDisabled" + data-qa-selector="save_changes_button" + @click.prevent="onSaveClick" + > + {{ __('Save changes') }} + </gl-button> + + <gl-button + v-if="propsSource.canTest" + :loading="isTesting" + :disabled="isDisabled" + :href="propsSource.testPath" + @click.prevent="onTestClick" + > + {{ __('Test settings') }} + </gl-button> - <template v-if="showReset"> - <gl-button - v-gl-modal.confirmResetIntegration - category="secondary" - variant="default" - :loading="isResetting" - :disabled="isDisabled" - data-testid="reset-button" - > - {{ __('Reset') }} - </gl-button> - <reset-confirmation-modal @reset="onResetClick" /> - </template> + <template v-if="showReset"> + <gl-button + v-gl-modal.confirmResetIntegration + category="secondary" + variant="default" + :loading="isResetting" + :disabled="isDisabled" + data-testid="reset-button" + > + {{ __('Reset') }} + </gl-button> + <reset-confirmation-modal @reset="onResetClick" /> + </template> - <gl-button class="btn-cancel" :href="propsSource.cancelPath">{{ __('Cancel') }}</gl-button> + <gl-button class="btn-cancel" :href="propsSource.cancelPath">{{ + __('Cancel') + }}</gl-button> + </div> + </div> </div> </div> </template> diff --git a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue index 1baa2b440b0..f8a44f0c2a2 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue @@ -8,6 +8,7 @@ import { GlButton, GlCard, } from '@gitlab/ui'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import eventHub from '../event_hub'; export default { @@ -20,18 +21,36 @@ export default { GlLink, GlButton, GlCard, + JiraIssueCreationVulnerabilities: () => + import('ee_component/integrations/edit/components/jira_issue_creation_vulnerabilities.vue'), }, + mixins: [glFeatureFlagsMixin()], props: { showJiraIssuesIntegration: { type: Boolean, required: false, default: false, }, + showJiraVulnerabilitiesIntegration: { + type: Boolean, + required: false, + default: false, + }, initialEnableJiraIssues: { type: Boolean, required: false, default: null, }, + initialEnableJiraVulnerabilities: { + type: Boolean, + required: false, + default: false, + }, + initialVulnerabilitiesIssuetype: { + type: String, + required: false, + default: '', + }, initialProjectKey: { type: String, required: false, @@ -45,12 +64,12 @@ export default { upgradePlanPath: { type: String, required: false, - default: null, + default: '', }, editProjectPath: { type: String, required: false, - default: null, + default: '', }, }, data() { @@ -64,6 +83,13 @@ export default { validProjectKey() { return !this.enableJiraIssues || Boolean(this.projectKey) || !this.validated; }, + showJiraVulnerabilitiesOptions() { + return ( + this.enableJiraIssues && + this.showJiraVulnerabilitiesIntegration && + this.glFeatures.jiraForVulnerabilities + ); + }, }, created() { eventHub.$on('validateForm', this.validateForm); @@ -75,6 +101,9 @@ export default { validateForm() { this.validated = true; }, + getJiraIssueTypes() { + eventHub.$emit('getJiraIssueTypes'); + }, }, }; </script> @@ -105,6 +134,14 @@ export default { }} </template> </gl-form-checkbox> + <jira-issue-creation-vulnerabilities + v-if="showJiraVulnerabilitiesOptions" + :project-key="projectKey" + :initial-is-enabled="initialEnableJiraVulnerabilities" + :initial-issue-type-id="initialVulnerabilitiesIssuetype" + data-testid="jira-for-vulnerabilities" + @request-get-issue-types="getJiraIssueTypes" + /> </template> <gl-card v-else class="gl-mt-7"> <strong>{{ __('This is a Premium feature') }}</strong> diff --git a/app/assets/javascripts/integrations/edit/index.js b/app/assets/javascripts/integrations/edit/index.js index 95a53f1beab..deca11d07d9 100644 --- a/app/assets/javascripts/integrations/edit/index.js +++ b/app/assets/javascripts/integrations/edit/index.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import { createStore } from './store'; import { parseBoolean } from '~/lib/utils/common_utils'; +import { createStore } from './store'; import IntegrationForm from './components/integration_form.vue'; function parseBooleanInData(data) { @@ -27,6 +27,7 @@ function parseDatasetToProps(data) { cancelPath, testPath, resetPath, + vulnerabilitiesIssuetype, ...booleanAttributes } = data; const { @@ -38,7 +39,9 @@ function parseDatasetToProps(data) { mergeRequestEvents, enableComments, showJiraIssuesIntegration, + showJiraVulnerabilitiesIntegration, enableJiraIssues, + enableJiraVulnerabilities, gitlabIssuesEnabled, } = parseBooleanInData(booleanAttributes); @@ -59,7 +62,10 @@ function parseDatasetToProps(data) { }, jiraIssuesProps: { showJiraIssuesIntegration, + showJiraVulnerabilitiesIntegration, initialEnableJiraIssues: enableJiraIssues, + initialEnableJiraVulnerabilities: enableJiraVulnerabilities, + initialVulnerabilitiesIssuetype: vulnerabilitiesIssuetype, initialProjectKey: projectKey, gitlabIssuesEnabled, upgradePlanPath, @@ -80,21 +86,29 @@ export default (el, defaultEl) => { } const props = parseDatasetToProps(el.dataset); - const initialState = { defaultState: null, customState: props, }; - if (defaultEl) { initialState.defaultState = Object.freeze(parseDatasetToProps(defaultEl.dataset)); } + // Here, we capture the "helpHtml", so we can pass it to the Vue component + // to position it where ever it wants. + // Because this node is a _child_ of `el`, it will be removed when the Vue component is mounted, + // so we don't need to manually remove it. + const helpHtml = el.querySelector('.js-integration-help-html')?.innerHTML; + return new Vue({ el, store: createStore(initialState), render(createElement) { - return createElement(IntegrationForm); + return createElement(IntegrationForm, { + props: { + helpHtml, + }, + }); }, }); }; diff --git a/app/assets/javascripts/integrations/edit/store/actions.js b/app/assets/javascripts/integrations/edit/store/actions.js index 421917b720a..400397c050c 100644 --- a/app/assets/javascripts/integrations/edit/store/actions.js +++ b/app/assets/javascripts/integrations/edit/store/actions.js @@ -26,3 +26,18 @@ export const fetchResetIntegration = ({ dispatch, getters }) => { .then(() => dispatch('receiveResetIntegrationSuccess')) .catch(() => dispatch('receiveResetIntegrationError')); }; + +export const requestJiraIssueTypes = ({ commit }) => { + commit(types.SET_JIRA_ISSUE_TYPES_ERROR_MESSAGE, ''); + commit(types.SET_IS_LOADING_JIRA_ISSUE_TYPES, true); +}; +export const receiveJiraIssueTypesSuccess = ({ commit }, issueTypes = []) => { + commit(types.SET_IS_LOADING_JIRA_ISSUE_TYPES, false); + commit(types.SET_JIRA_ISSUE_TYPES, issueTypes); +}; + +export const receiveJiraIssueTypesError = ({ commit }, errorMessage) => { + commit(types.SET_IS_LOADING_JIRA_ISSUE_TYPES, false); + commit(types.SET_JIRA_ISSUE_TYPES, []); + commit(types.SET_JIRA_ISSUE_TYPES_ERROR_MESSAGE, errorMessage); +}; diff --git a/app/assets/javascripts/integrations/edit/store/mutation_types.js b/app/assets/javascripts/integrations/edit/store/mutation_types.js index 54928148b22..c681056a515 100644 --- a/app/assets/javascripts/integrations/edit/store/mutation_types.js +++ b/app/assets/javascripts/integrations/edit/store/mutation_types.js @@ -3,5 +3,9 @@ export const SET_IS_SAVING = 'SET_IS_SAVING'; export const SET_IS_TESTING = 'SET_IS_TESTING'; export const SET_IS_RESETTING = 'SET_IS_RESETTING'; +export const SET_IS_LOADING_JIRA_ISSUE_TYPES = 'SET_IS_LOADING_JIRA_ISSUE_TYPES'; +export const SET_JIRA_ISSUE_TYPES_ERROR_MESSAGE = 'SET_JIRA_ISSUE_TYPES_ERROR_MESSAGE'; +export const SET_JIRA_ISSUE_TYPES = 'SET_JIRA_ISSUE_TYPES'; + export const REQUEST_RESET_INTEGRATION = 'REQUEST_RESET_INTEGRATION'; export const RECEIVE_RESET_INTEGRATION_ERROR = 'RECEIVE_RESET_INTEGRATION_ERROR'; diff --git a/app/assets/javascripts/integrations/edit/store/mutations.js b/app/assets/javascripts/integrations/edit/store/mutations.js index 826757e665b..279df1b9266 100644 --- a/app/assets/javascripts/integrations/edit/store/mutations.js +++ b/app/assets/javascripts/integrations/edit/store/mutations.js @@ -19,4 +19,13 @@ export default { [types.RECEIVE_RESET_INTEGRATION_ERROR](state) { state.isResetting = false; }, + [types.SET_JIRA_ISSUE_TYPES](state, jiraIssueTypes) { + state.jiraIssueTypes = jiraIssueTypes; + }, + [types.SET_IS_LOADING_JIRA_ISSUE_TYPES](state, isLoadingJiraIssueTypes) { + state.isLoadingJiraIssueTypes = isLoadingJiraIssueTypes; + }, + [types.SET_JIRA_ISSUE_TYPES_ERROR_MESSAGE](state, errorMessage) { + state.loadingJiraIssueTypesErrorMessage = errorMessage; + }, }; diff --git a/app/assets/javascripts/integrations/edit/store/state.js b/app/assets/javascripts/integrations/edit/store/state.js index aae3db1583f..1c0b274e4ef 100644 --- a/app/assets/javascripts/integrations/edit/store/state.js +++ b/app/assets/javascripts/integrations/edit/store/state.js @@ -8,5 +8,8 @@ export default ({ defaultState = null, customState = {} } = {}) => { isSaving: false, isTesting: false, isResetting: false, + isLoadingJiraIssueTypes: false, + loadingJiraIssueTypesErrorMessage: '', + jiraIssueTypes: [], }; }; diff --git a/app/assets/javascripts/integrations/integration_settings_form.js b/app/assets/javascripts/integrations/integration_settings_form.js index 861655a6a64..801cf3ed27e 100644 --- a/app/assets/javascripts/integrations/integration_settings_form.js +++ b/app/assets/javascripts/integrations/integration_settings_form.js @@ -1,8 +1,8 @@ import $ from 'jquery'; import { delay } from 'lodash'; -import axios from '../lib/utils/axios_utils'; import { __, s__ } from '~/locale'; import toast from '~/vue_shared/plugins/global_toast'; +import axios from '../lib/utils/axios_utils'; import initForm from './edit'; import eventHub from './edit/event_hub'; @@ -33,6 +33,12 @@ export default class IntegrationSettingsForm { eventHub.$on('saveIntegration', () => { this.saveIntegration(); }); + eventHub.$on('getJiraIssueTypes', () => { + // eslint-disable-next-line no-jquery/no-serialize + this.getJiraIssueTypes(this.$form.serialize()); + }); + + eventHub.$emit('formInitialized'); } saveIntegration() { @@ -80,15 +86,58 @@ export default class IntegrationSettingsForm { } /** + * Get a list of Jira issue types for the currently configured project + * + * @param {string} formData - URL encoded string containing the form data + * + * @return {Promise} + */ + getJiraIssueTypes(formData) { + const { + $store: { dispatch }, + } = this.vue; + + dispatch('requestJiraIssueTypes'); + + return this.fetchTestSettings(formData) + .then( + ({ + data: { + issuetypes, + error, + message = s__('Integrations|Connection failed. Please check your settings.'), + }, + }) => { + if (error || !issuetypes?.length) { + eventHub.$emit('validateForm'); + throw new Error(message); + } + + dispatch('receiveJiraIssueTypesSuccess', issuetypes); + }, + ) + .catch(({ message = __('Something went wrong on our end.') }) => { + dispatch('receiveJiraIssueTypesError', message); + }); + } + + /** + * Send request to the test endpoint which checks if the current config is valid + */ + fetchTestSettings(formData) { + return axios.put(this.testEndPoint, formData); + } + + /** * Test Integration config */ testSettings(formData) { - return axios - .put(this.testEndPoint, formData) + return this.fetchTestSettings(formData) .then(({ data }) => { if (data.error) { toast(`${data.message} ${data.service_response}`); } else { + this.vue.$store.dispatch('receiveJiraIssueTypesSuccess', data.issuetypes); toast(s__('Integrations|Connection successful.')); } }) diff --git a/app/assets/javascripts/invite_member/components/invite_member_modal.vue b/app/assets/javascripts/invite_member/components/invite_member_modal.vue index 3df99bccdb0..ce79e1b58d2 100644 --- a/app/assets/javascripts/invite_member/components/invite_member_modal.vue +++ b/app/assets/javascripts/invite_member/components/invite_member_modal.vue @@ -1,7 +1,8 @@ <script> import { GlModal, GlLink } from '@gitlab/ui'; -import eventHub from '../event_hub'; import { s__, __ } from '~/locale'; +import { BV_SHOW_MODAL } from '~/lib/utils/constants'; +import eventHub from '../event_hub'; import { OPEN_MODAL, MODAL_ID } from '../constants'; export default { @@ -38,7 +39,7 @@ export default { }, methods: { openModal() { - this.$root.$emit('bv::show::modal', MODAL_ID); + this.$root.$emit(BV_SHOW_MODAL, MODAL_ID); }, }, }; diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue index a92289ca8c1..e70bbfe152d 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -10,10 +10,11 @@ import { GlFormInput, } from '@gitlab/ui'; import { partition, isString } from 'lodash'; -import eventHub from '../event_hub'; import { s__, __, sprintf } from '~/locale'; import Api from '~/api'; import MembersTokenSelect from '~/invite_members/components/members_token_select.vue'; +import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; +import eventHub from '../event_hub'; export default { name: 'InviteMembersModal', @@ -113,10 +114,10 @@ export default { ]; }, openModal() { - this.$root.$emit('bv::show::modal', this.modalId); + this.$root.$emit(BV_SHOW_MODAL, this.modalId); }, closeModal() { - this.$root.$emit('bv::hide::modal', this.modalId); + this.$root.$emit(BV_HIDE_MODAL, this.modalId); }, sendInvite() { this.submitForm(); diff --git a/app/assets/javascripts/invite_members/components/members_token_select.vue b/app/assets/javascripts/invite_members/components/members_token_select.vue index 627d4ab2771..ae27aaddb62 100644 --- a/app/assets/javascripts/invite_members/components/members_token_select.vue +++ b/app/assets/javascripts/invite_members/components/members_token_select.vue @@ -2,8 +2,8 @@ import { debounce } from 'lodash'; import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlSprintf } from '@gitlab/ui'; import { __ } from '~/locale'; -import { USER_SEARCH_DELAY } from '../constants'; import { getUsers } from '~/rest_api'; +import { USER_SEARCH_DELAY } from '../constants'; export default { components: { diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js index 8bb76edbd47..b9e8c015d19 100644 --- a/app/assets/javascripts/issuable_bulk_update_actions.js +++ b/app/assets/javascripts/issuable_bulk_update_actions.js @@ -50,6 +50,7 @@ export default { subscription_event: this.form.find('input[name="update[subscription_event]"]').val(), health_status: this.form.find('input[name="update[health_status]"]').val(), epic_id: this.form.find('input[name="update[epic_id]"]').val(), + sprint_id: this.form.find('input[name="update[iteration_id]"]').val(), add_label_ids: [], remove_label_ids: [], }, diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar.js index b9daa16874a..d6fa0f42bd4 100644 --- a/app/assets/javascripts/issuable_bulk_update_sidebar.js +++ b/app/assets/javascripts/issuable_bulk_update_sidebar.js @@ -79,6 +79,16 @@ export default class IssuableBulkUpdateSidebar { }) .catch(() => {}); } + + if (IS_EE) { + import('ee/vue_shared/components/sidebar/iterations_dropdown_bundle') + .then(({ default: iterationsDropdown }) => { + iterationsDropdown(); + }) + .catch((e) => { + throw e; + }); + } } setupBulkUpdateActions() { diff --git a/app/assets/javascripts/issuable_list/components/issuable_item.vue b/app/assets/javascripts/issuable_list/components/issuable_item.vue index 583e5cb703d..df303665538 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_item.vue +++ b/app/assets/javascripts/issuable_list/components/issuable_item.vue @@ -139,6 +139,12 @@ export default { <div class="issuable-main-info"> <div data-testid="issuable-title" class="issue-title title"> <span class="issue-title-text" dir="auto"> + <gl-icon + v-if="issuable.confidential" + v-gl-tooltip + name="eye-slash" + :title="__('Confidential')" + /> <gl-link :href="issuable.webUrl" v-bind="issuableTitleProps" >{{ issuable.title }}<gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" @@ -196,7 +202,7 @@ export default { <li v-if="showDiscussions" data-testid="issuable-discussions" - class="issuable-comments gl-display-none gl-display-sm-block" + class="issuable-comments gl-display-none gl-sm-display-block" > <gl-link v-gl-tooltip:tooltipcontainer.top diff --git a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue index c5475a34d3c..8a4c487f119 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue +++ b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue @@ -5,12 +5,11 @@ import { uniqueId } from 'lodash'; import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +import { DEFAULT_SKELETON_COUNT } from '../constants'; import IssuableTabs from './issuable_tabs.vue'; import IssuableItem from './issuable_item.vue'; import IssuableBulkEditSidebar from './issuable_bulk_edit_sidebar.vue'; -import { DEFAULT_SKELETON_COUNT } from '../constants'; - export default { components: { GlSkeletonLoading, @@ -230,7 +229,7 @@ export default { :initial-sort-by="initialSortBy" :show-checkbox="showBulkEditSidebar" :checkbox-checked="allIssuablesChecked" - class="gl-flex-grow-1 row-content-block" + class="gl-flex-grow-1 gl-border-t-none row-content-block" @checked-input="handleAllIssuablesCheckedInput" @onFilter="$emit('filter', $event)" @onSort="$emit('sort', $event)" diff --git a/app/assets/javascripts/issuable_list/components/issuable_tabs.vue b/app/assets/javascripts/issuable_list/components/issuable_tabs.vue index d9aab004077..57da030e22e 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_tabs.vue +++ b/app/assets/javascripts/issuable_list/components/issuable_tabs.vue @@ -32,7 +32,10 @@ export default { <template> <div class="top-area"> - <gl-tabs class="nav-links mobile-separator issuable-state-filters"> + <gl-tabs + class="gl-display-flex gl-flex-fill-1 gl-p-0 gl-m-0 mobile-separator issuable-state-filters" + nav-class="gl-border-b-0" + > <gl-tab v-for="tab in tabs" :key="tab.id" @@ -41,7 +44,7 @@ export default { > <template #title> <span :title="tab.titleTooltip">{{ tab.title }}</span> - <gl-badge v-if="tabCounts" variant="neutral" size="sm" class="gl-px-2 gl-py-1!">{{ + <gl-badge v-if="tabCounts" variant="neutral" size="sm" class="gl-tab-counter-badge">{{ tabCounts[tab.name] }}</gl-badge> </template> diff --git a/app/assets/javascripts/issuable_show/components/issuable_header.vue b/app/assets/javascripts/issuable_show/components/issuable_header.vue index 5404753631d..4c6df31a0f3 100644 --- a/app/assets/javascripts/issuable_show/components/issuable_header.vue +++ b/app/assets/javascripts/issuable_show/components/issuable_header.vue @@ -112,7 +112,7 @@ export default { </div> <div data-testid="header-actions" - class="detail-page-header-actions gl-display-flex gl-display-md-block" + class="detail-page-header-actions gl-display-flex gl-md-display-block" > <slot name="header-actions"></slot> </div> diff --git a/app/assets/javascripts/issuable_suggestions/components/app.vue b/app/assets/javascripts/issuable_suggestions/components/app.vue index ac5f04147d3..d0642b64e7e 100644 --- a/app/assets/javascripts/issuable_suggestions/components/app.vue +++ b/app/assets/javascripts/issuable_suggestions/components/app.vue @@ -1,8 +1,8 @@ <script> import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; import { __ } from '~/locale'; -import Suggestion from './item.vue'; import query from '../queries/issues.query.graphql'; +import Suggestion from './item.vue'; export default { components: { diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index 91912c684ad..576bd1f93ed 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -1,9 +1,9 @@ import $ from 'jquery'; +import { joinPaths } from '~/lib/utils/url_utility'; import axios from './lib/utils/axios_utils'; import { addDelimiter } from './lib/utils/text_utility'; import { deprecatedCreateFlash as flash } from './flash'; import CreateMergeRequestDropdown from './create_merge_request_dropdown'; -import { joinPaths } from '~/lib/utils/url_utility'; import { __ } from './locale'; export default class Issue { diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index d569ad573a2..1720fdf8eec 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -5,16 +5,16 @@ import { __, s__, sprintf } from '~/locale'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { visitUrl } from '~/lib/utils/url_utility'; import Poll from '~/lib/utils/poll'; +import recaptchaModalImplementor from '~/vue_shared/mixins/recaptcha_modal_implementor'; import eventHub from '../event_hub'; import Service from '../services/index'; import Store from '../stores'; +import { IssuableStatus, IssuableStatusText, IssuableType } from '../constants'; import titleComponent from './title.vue'; import descriptionComponent from './description.vue'; import editedComponent from './edited.vue'; import formComponent from './form.vue'; import PinnedLinks from './pinned_links.vue'; -import recaptchaModalImplementor from '~/vue_shared/mixins/recaptcha_modal_implementor'; -import { IssuableStatus, IssuableStatusText, IssuableType } from '../constants'; export default { components: { diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue index 2a6468c783b..ec454ef9e8d 100644 --- a/app/assets/javascripts/issue_show/components/description.vue +++ b/app/assets/javascripts/issue_show/components/description.vue @@ -154,6 +154,7 @@ export default { }" class="md" ></div> + <!-- eslint-disable vue/no-mutating-props --> <textarea v-if="descriptionText" ref="textarea" @@ -163,6 +164,7 @@ export default { dir="auto" > </textarea> + <!-- eslint-enable vue/no-mutating-props --> <recaptcha-modal v-show="showRecaptcha" :html="recaptchaHTML" @close="closeRecaptcha" /> </div> diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue index 8d417e32d62..5476a1ef897 100644 --- a/app/assets/javascripts/issue_show/components/fields/description.vue +++ b/app/assets/javascripts/issue_show/components/fields/description.vue @@ -1,7 +1,7 @@ <script> -import updateMixin from '../../mixins/update'; import markdownField from '~/vue_shared/components/markdown/field.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import updateMixin from '../../mixins/update'; export default { components: { @@ -49,6 +49,7 @@ export default { :textarea-value="formState.description" > <template #textarea> + <!-- eslint-disable vue/no-mutating-props --> <textarea id="issue-description" ref="textarea" @@ -62,6 +63,7 @@ export default { @keydown.ctrl.enter="updateIssuable" > </textarea> + <!-- eslint-enable vue/no-mutating-props --> </template> </markdown-field> </div> diff --git a/app/assets/javascripts/issue_show/components/fields/description_template.vue b/app/assets/javascripts/issue_show/components/fields/description_template.vue index 71299381aae..a3669cd40bd 100644 --- a/app/assets/javascripts/issue_show/components/fields/description_template.vue +++ b/app/assets/javascripts/issue_show/components/fields/description_template.vue @@ -35,6 +35,7 @@ export default { // Create the editor for the template const editor = document.querySelector('.detail-page-description .note-textarea') || {}; editor.setValue = (val) => { + // eslint-disable-next-line vue/no-mutating-props this.formState.description = val; }; editor.getValue = () => this.formState.description; diff --git a/app/assets/javascripts/issue_show/components/fields/title.vue b/app/assets/javascripts/issue_show/components/fields/title.vue index 34eb0451d53..d331fb47077 100644 --- a/app/assets/javascripts/issue_show/components/fields/title.vue +++ b/app/assets/javascripts/issue_show/components/fields/title.vue @@ -15,6 +15,7 @@ export default { <template> <fieldset> <label class="sr-only" for="issuable-title">{{ __('Title') }}</label> + <!-- eslint-disable vue/no-mutating-props --> <input id="issuable-title" ref="input" @@ -27,5 +28,6 @@ export default { @keydown.meta.enter="updateIssuable" @keydown.ctrl.enter="updateIssuable" /> + <!-- eslint-enable vue/no-mutating-props --> </fieldset> </template> diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue index d48bf1fe7a9..2688089fa89 100644 --- a/app/assets/javascripts/issue_show/components/form.vue +++ b/app/assets/javascripts/issue_show/components/form.vue @@ -1,12 +1,12 @@ <script> import $ from 'jquery'; +import Autosave from '~/autosave'; +import eventHub from '../event_hub'; import lockedWarning from './locked_warning.vue'; import titleField from './fields/title.vue'; import descriptionField from './fields/description.vue'; import editActions from './edit_actions.vue'; import descriptionTemplate from './fields/description_template.vue'; -import Autosave from '~/autosave'; -import eventHub from '../event_hub'; export default { components: { diff --git a/app/assets/javascripts/issue_show/components/header_actions.vue b/app/assets/javascripts/issue_show/components/header_actions.vue index 998f740be0e..9c3988d0469 100644 --- a/app/assets/javascripts/issue_show/components/header_actions.vue +++ b/app/assets/javascripts/issue_show/components/header_actions.vue @@ -193,7 +193,7 @@ export default { <template> <div class="detail-page-header-actions"> <gl-dropdown - class="gl-display-block gl-display-sm-none!" + class="gl-display-block gl-sm-display-none!" block :text="dropdownText" :loading="isToggleStateButtonLoading" @@ -222,7 +222,7 @@ export default { <gl-button v-if="showToggleIssueStateButton" - class="gl-display-none gl-display-sm-inline-flex!" + class="gl-display-none gl-sm-display-inline-flex!" category="secondary" :data-qa-selector="qaSelector" :loading="isToggleStateButtonLoading" @@ -233,7 +233,7 @@ export default { </gl-button> <gl-dropdown - class="gl-display-none gl-display-sm-inline-flex!" + class="gl-display-none gl-sm-display-inline-flex!" toggle-class="gl-border-0! gl-shadow-none!" no-caret right diff --git a/app/assets/javascripts/issue_show/components/incidents/incident_tabs.vue b/app/assets/javascripts/issue_show/components/incidents/incident_tabs.vue index f9f06c3ad5a..9fc90b501eb 100644 --- a/app/assets/javascripts/issue_show/components/incidents/incident_tabs.vue +++ b/app/assets/javascripts/issue_show/components/incidents/incident_tabs.vue @@ -1,13 +1,13 @@ <script> import { GlTab, GlTabs } from '@gitlab/ui'; -import DescriptionComponent from '../description.vue'; -import HighlightBar from './highlight_bar.vue'; import createFlash from '~/flash'; import { s__ } from '~/locale'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; import Tracking from '~/tracking'; -import getAlert from './graphql/queries/get_alert.graphql'; import { trackIncidentDetailsViewsOptions } from '~/incidents/constants'; +import DescriptionComponent from '../description.vue'; +import getAlert from './graphql/queries/get_alert.graphql'; +import HighlightBar from './highlight_bar.vue'; export default { components: { diff --git a/app/assets/javascripts/issue_show/incident.js b/app/assets/javascripts/issue_show/incident.js index ccac38811b5..0c81ecdc843 100644 --- a/app/assets/javascripts/issue_show/incident.js +++ b/app/assets/javascripts/issue_show/incident.js @@ -1,9 +1,9 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; +import { parseBoolean } from '~/lib/utils/common_utils'; import issuableApp from './components/app.vue'; import incidentTabs from './components/incidents/incident_tabs.vue'; -import { parseBoolean } from '~/lib/utils/common_utils'; Vue.use(VueApollo); diff --git a/app/assets/javascripts/issue_status_select.js b/app/assets/javascripts/issue_status_select.js index 02b10730153..2ede0837930 100644 --- a/app/assets/javascripts/issue_status_select.js +++ b/app/assets/javascripts/issue_status_select.js @@ -1,6 +1,6 @@ import $ from 'jquery'; -import { __ } from './locale'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; +import { __ } from './locale'; export default function issueStatusSelect() { $('.js-issue-status').each((i, el) => { diff --git a/app/assets/javascripts/issues_list/components/issuable.vue b/app/assets/javascripts/issues_list/components/issuable.vue index 3965fd6b0c7..6cadcd2043b 100644 --- a/app/assets/javascripts/issues_list/components/issuable.vue +++ b/app/assets/javascripts/issues_list/components/issuable.vue @@ -414,7 +414,7 @@ export default { v-if="meta.visible" :key="meta.key" v-gl-tooltip - class="gl-display-none gl-display-sm-flex gl-align-items-center gl-ml-3" + class="gl-display-none gl-sm-display-flex gl-align-items-center gl-ml-3" :class="meta.class" :data-testid="meta.dataTestId" :title="meta.title" diff --git a/app/assets/javascripts/issues_list/components/issuables_list_app.vue b/app/assets/javascripts/issues_list/components/issuables_list_app.vue index eda8bc2b61f..225d56c11f0 100644 --- a/app/assets/javascripts/issues_list/components/issuables_list_app.vue +++ b/app/assets/javascripts/issues_list/components/issuables_list_app.vue @@ -16,8 +16,8 @@ import { } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import initManualOrdering from '~/manual_ordering'; -import Issuable from './issuable.vue'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +import { setUrlParams } from '~/lib/utils/url_utility'; import { sortOrderMap, availableSortOptionsJira, @@ -26,9 +26,9 @@ import { PAGE_SIZE_MANUAL, LOADING_LIST_ITEMS_LENGTH, } from '../constants'; -import { setUrlParams } from '~/lib/utils/url_utility'; import issueableEventHub from '../eventhub'; import { emptyStateHelper } from '../service_desk_helper'; +import Issuable from './issuable.vue'; export default { LOADING_LIST_ITEMS_LENGTH, diff --git a/app/assets/javascripts/issues_list/components/jira_issues_list_root.vue b/app/assets/javascripts/issues_list/components/jira_issues_list_root.vue index 61781c576c0..534f478cb7e 100644 --- a/app/assets/javascripts/issues_list/components/jira_issues_list_root.vue +++ b/app/assets/javascripts/issues_list/components/jira_issues_list_root.vue @@ -2,13 +2,13 @@ import { GlAlert, GlLabel } from '@gitlab/ui'; import { last } from 'lodash'; import { n__ } from '~/locale'; -import getIssuesListDetailsQuery from '../queries/get_issues_list_details.query.graphql'; import { calculateJiraImportLabel, isInProgress, setFinishedAlertHideMap, shouldShowFinishedAlert, } from '~/jira_import/utils/jira_import_utils'; +import getIssuesListDetailsQuery from '../queries/get_issues_list_details.query.graphql'; export default { name: 'JiraIssuesList', diff --git a/app/assets/javascripts/jira_connect/api.js b/app/assets/javascripts/jira_connect/api.js index d689a2d1962..d78aba0a3f7 100644 --- a/app/assets/javascripts/jira_connect/api.js +++ b/app/assets/javascripts/jira_connect/api.js @@ -1,7 +1,23 @@ import axios from 'axios'; -const getJwt = async () => { - return AP.context.getToken(); +export const getJwt = () => { + return new Promise((resolve) => { + AP.context.getToken((token) => { + resolve(token); + }); + }); +}; + +export const getLocation = () => { + return new Promise((resolve) => { + if (typeof AP.getLocation !== 'function') { + resolve(); + } + + AP.getLocation((location) => { + resolve(location); + }); + }); }; export const addSubscription = async (addPath, namespace) => { diff --git a/app/assets/javascripts/jira_connect/components/app.vue b/app/assets/javascripts/jira_connect/components/app.vue index f5bf30f4488..ed01c188752 100644 --- a/app/assets/javascripts/jira_connect/components/app.vue +++ b/app/assets/javascripts/jira_connect/components/app.vue @@ -3,6 +3,8 @@ import { mapState } from 'vuex'; import { GlAlert, GlButton, GlModal, GlModalDirective } from '@gitlab/ui'; import { __ } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; + +import { getLocation } from '~/jira_connect/api'; import GroupsList from './groups_list.vue'; export default { @@ -17,17 +19,42 @@ export default { GlModalDirective, }, mixins: [glFeatureFlagsMixin()], + inject: { + usersPath: { + default: '', + }, + }, + data() { + return { + location: '', + }; + }, computed: { ...mapState(['errorMessage']), showNewUI() { return this.glFeatures.newJiraConnectUi; }, + usersPathWithReturnTo() { + if (this.location) { + return `${this.usersPath}?return_to=${this.location}`; + } + + return this.usersPath; + }, }, modal: { cancelProps: { text: __('Cancel'), }, }, + created() { + this.setLocation(); + }, + methods: { + async setLocation() { + this.location = await getLocation(); + }, + }, }; </script> @@ -37,27 +64,40 @@ export default { {{ errorMessage }} </gl-alert> - <h1>GitLab for Jira Configuration</h1> + <h2>{{ s__('JiraService|GitLab for Jira Configuration') }}</h2> <div v-if="showNewUI" - class="gl-display-flex gl-justify-content-space-between gl-my-5 gl-pb-4 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200" + class="gl-display-flex gl-justify-content-space-between gl-my-7 gl-pb-4 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200" > - <h3 data-testid="new-jira-connect-ui-heading">{{ s__('Integrations|Linked namespaces') }}</h3> + <h5 class="gl-align-self-center gl-mb-0" data-testid="new-jira-connect-ui-heading"> + {{ s__('Integrations|Linked namespaces') }} + </h5> <gl-button - v-gl-modal-directive="'add-namespace-modal'" + v-if="usersPath" category="primary" variant="info" class="gl-align-self-center" - >{{ s__('Integrations|Add namespace') }}</gl-button - > - <gl-modal - modal-id="add-namespace-modal" - :title="s__('Integrations|Link namespaces')" - :action-cancel="$options.modal.cancelProps" + :href="usersPathWithReturnTo" + target="_blank" + >{{ s__('Integrations|Sign in to add namespaces') }}</gl-button > - <groups-list /> - </gl-modal> + <template v-else> + <gl-button + v-gl-modal-directive="'add-namespace-modal'" + category="primary" + variant="info" + class="gl-align-self-center" + >{{ s__('Integrations|Add namespace') }}</gl-button + > + <gl-modal + modal-id="add-namespace-modal" + :title="s__('Integrations|Link namespaces')" + :action-cancel="$options.modal.cancelProps" + > + <groups-list /> + </gl-modal> + </template> </div> </div> </template> diff --git a/app/assets/javascripts/jira_connect/components/groups_list.vue b/app/assets/javascripts/jira_connect/components/groups_list.vue index eeddd32addc..8671ecaa78a 100644 --- a/app/assets/javascripts/jira_connect/components/groups_list.vue +++ b/app/assets/javascripts/jira_connect/components/groups_list.vue @@ -1,5 +1,5 @@ <script> -import { GlTabs, GlTab, GlLoadingIcon, GlPagination } from '@gitlab/ui'; +import { GlTabs, GlTab, GlLoadingIcon, GlPagination, GlAlert } from '@gitlab/ui'; import { s__ } from '~/locale'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { fetchGroups } from '~/jira_connect/api'; @@ -12,6 +12,7 @@ export default { GlTab, GlLoadingIcon, GlPagination, + GlAlert, GroupsListItem, }, inject: { @@ -26,6 +27,7 @@ export default { page: 1, perPage: defaultPerPage, totalItems: 0, + errorMessage: null, }; }, mounted() { @@ -46,8 +48,7 @@ export default { this.groups = response.data; }) .catch(() => { - // eslint-disable-next-line no-alert - alert(s__('Integrations|Failed to load namespaces. Please try again.')); + this.errorMessage = s__('Integrations|Failed to load namespaces. Please try again.'); }) .finally(() => { this.isLoading = false; @@ -58,31 +59,42 @@ export default { </script> <template> - <gl-tabs> - <gl-tab :title="__('Groups and subgroups')" class="gl-pt-3"> - <gl-loading-icon v-if="isLoading" size="md" /> - <div v-else-if="groups.length === 0" class="gl-text-center"> - <h5>{{ s__('Integrations|No available namespaces.') }}</h5> - <p class="gl-mt-5"> - {{ - s__('Integrations|You must have owner or maintainer permissions to link namespaces.') - }} - </p> - </div> - <ul v-else class="gl-list-style-none gl-pl-0"> - <groups-list-item v-for="group in groups" :key="group.id" :group="group" /> - </ul> + <div> + <gl-alert v-if="errorMessage" class="gl-mb-6" variant="danger" @dismiss="errorMessage = null"> + {{ errorMessage }} + </gl-alert> - <div class="gl-display-flex gl-justify-content-center gl-mt-5"> - <gl-pagination - v-if="totalItems > perPage && groups.length > 0" - v-model="page" - class="gl-mb-0" - :per-page="perPage" - :total-items="totalItems" - @input="loadGroups" - /> - </div> - </gl-tab> - </gl-tabs> + <gl-tabs> + <gl-tab :title="__('Groups and subgroups')" class="gl-pt-3"> + <gl-loading-icon v-if="isLoading" size="md" /> + <div v-else-if="groups.length === 0" class="gl-text-center"> + <h5>{{ s__('Integrations|No available namespaces.') }}</h5> + <p class="gl-mt-5"> + {{ + s__('Integrations|You must have owner or maintainer permissions to link namespaces.') + }} + </p> + </div> + <ul v-else class="gl-list-style-none gl-pl-0"> + <groups-list-item + v-for="group in groups" + :key="group.id" + :group="group" + @error="errorMessage = $event" + /> + </ul> + + <div class="gl-display-flex gl-justify-content-center gl-mt-5"> + <gl-pagination + v-if="totalItems > perPage && groups.length > 0" + v-model="page" + class="gl-mb-0" + :per-page="perPage" + :total-items="totalItems" + @input="loadGroups" + /> + </div> + </gl-tab> + </gl-tabs> + </div> </template> diff --git a/app/assets/javascripts/jira_connect/components/groups_list_item.vue b/app/assets/javascripts/jira_connect/components/groups_list_item.vue index 15e37ab3cb0..305f440707e 100644 --- a/app/assets/javascripts/jira_connect/components/groups_list_item.vue +++ b/app/assets/javascripts/jira_connect/components/groups_list_item.vue @@ -1,10 +1,19 @@ <script> -import { GlIcon, GlAvatar } from '@gitlab/ui'; +import { GlAvatar, GlButton, GlIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +import { addSubscription } from '~/jira_connect/api'; export default { components: { - GlIcon, GlAvatar, + GlButton, + GlIcon, + }, + inject: { + subscriptionsPath: { + default: '', + }, }, props: { group: { @@ -12,6 +21,31 @@ export default { required: true, }, }, + data() { + return { + isLoading: false, + }; + }, + methods: { + onClick() { + this.isLoading = true; + + addSubscription(this.subscriptionsPath, this.group.full_path) + .then(() => { + AP.navigator.reload(); + }) + .catch((error) => { + this.$emit( + 'error', + error?.response?.data?.error || + s__('Integrations|Failed to link namespace. Please try again.'), + ); + }) + .finally(() => { + this.isLoading = false; + }); + }, + }, }; </script> @@ -19,7 +53,7 @@ export default { <li class="gl-border-b-1 gl-border-b-solid gl-border-b-gray-200"> <div class="gl-display-flex gl-align-items-center gl-py-3"> <gl-icon name="folder-o" class="gl-mr-3" /> - <div class="gl-display-none gl-flex-shrink-0 gl-display-sm-flex gl-mr-3"> + <div class="gl-display-none gl-flex-shrink-0 gl-sm-display-flex gl-mr-3"> <gl-avatar :size="32" shape="rect" :entity-name="group.name" :src="group.avatar_url" /> </div> <div class="gl-min-w-0 gl-display-flex gl-flex-grow-1 gl-flex-shrink-1 gl-align-items-center"> @@ -36,6 +70,14 @@ export default { <p class="gl-mt-2! gl-mb-0 gl-text-gray-600" v-text="group.description"></p> </div> </div> + + <gl-button + category="secondary" + variant="success" + :loading="isLoading" + @click.prevent="onClick" + >{{ __('Link') }}</gl-button + > </div> </div> </li> diff --git a/app/assets/javascripts/jira_connect/index.js b/app/assets/javascripts/jira_connect/index.js index dc2a77f4e0c..082c74150c5 100644 --- a/app/assets/javascripts/jira_connect/index.js +++ b/app/assets/javascripts/jira_connect/index.js @@ -1,70 +1,73 @@ import Vue from 'vue'; -import Vuex from 'vuex'; -import $ from 'jquery'; import setConfigs from '@gitlab/ui/dist/config'; import Translate from '~/vue_shared/translate'; import GlFeatureFlagsPlugin from '~/vue_shared/gl_feature_flags_plugin'; +import { addSubscription, removeSubscription, getLocation } from '~/jira_connect/api'; import JiraConnectApp from './components/app.vue'; -import { addSubscription, removeSubscription } from '~/jira_connect/api'; import createStore from './store'; import { SET_ERROR_MESSAGE } from './store/mutation_types'; -Vue.use(Vuex); - const store = createStore(); -/** - * Initialize form handlers for the Jira Connect app - */ -const initJiraFormHandlers = () => { - const reqComplete = () => { - AP.navigator.reload(); - }; - - const reqFailed = (res, fallbackErrorMessage) => { - const { error = fallbackErrorMessage } = res || {}; - - store.commit(SET_ERROR_MESSAGE, error); - }; - - if (typeof AP.getLocation === 'function') { - AP.getLocation((location) => { - $('.js-jira-connect-sign-in').each(function updateSignInLink() { - const updatedLink = `${$(this).attr('href')}?return_to=${location}`; - $(this).attr('href', updatedLink); - }); - }); - } +const reqComplete = () => { + AP.navigator.reload(); +}; - $('#add-subscription-form').on('submit', function onAddSubscriptionForm(e) { - const addPath = $(this).attr('action'); - const namespace = $('#namespace-input').val(); +const reqFailed = (res, fallbackErrorMessage) => { + const { error = fallbackErrorMessage } = res || {}; - e.preventDefault(); + store.commit(SET_ERROR_MESSAGE, error); +}; - addSubscription(addPath, namespace) - .then(reqComplete) - .catch((err) => reqFailed(err.response.data, 'Failed to add namespace. Please try again.')); +const updateSignInLinks = async () => { + const location = await getLocation(); + Array.from(document.querySelectorAll('.js-jira-connect-sign-in')).forEach((el) => { + const updatedLink = `${el.getAttribute('href')}?return_to=${location}`; + el.setAttribute('href', updatedLink); }); +}; - $('.remove-subscription').on('click', function onRemoveSubscriptionClick(e) { - const removePath = $(this).attr('href'); +const initRemoveSubscriptionButtonHandlers = () => { + Array.from(document.querySelectorAll('.js-jira-connect-remove-subscription')).forEach((el) => { + el.addEventListener('click', function onRemoveSubscriptionClick(e) { + e.preventDefault(); + + const removePath = e.target.getAttribute('href'); + removeSubscription(removePath) + .then(reqComplete) + .catch((err) => + reqFailed(err.response.data, 'Failed to remove namespace. Please try again.'), + ); + }); + }); +}; + +const initAddSubscriptionFormHandler = () => { + const formEl = document.querySelector('#add-subscription-form'); + if (!formEl) { + return; + } + + formEl.addEventListener('submit', function onAddSubscriptionForm(e) { e.preventDefault(); - removeSubscription(removePath) + const addPath = e.target.getAttribute('action'); + const namespace = (e.target.querySelector('#namespace-input') || {}).value; + + addSubscription(addPath, namespace) .then(reqComplete) - .catch((err) => - reqFailed(err.response.data, 'Failed to remove namespace. Please try again.'), - ); + .catch((err) => reqFailed(err.response.data, 'Failed to add namespace. Please try again.')); }); }; -function initJiraConnect() { - const el = document.querySelector('.js-jira-connect-app'); +export async function initJiraConnect() { + initAddSubscriptionFormHandler(); + initRemoveSubscriptionButtonHandlers(); - initJiraFormHandlers(); + await updateSignInLinks(); + const el = document.querySelector('.js-jira-connect-app'); if (!el) { return null; } @@ -73,13 +76,15 @@ function initJiraConnect() { Vue.use(Translate); Vue.use(GlFeatureFlagsPlugin); - const { groupsPath } = el.dataset; + const { groupsPath, subscriptionsPath, usersPath } = el.dataset; return new Vue({ el, store, provide: { groupsPath, + subscriptionsPath, + usersPath, }, render(createElement) { return createElement(JiraConnectApp); diff --git a/app/assets/javascripts/jira_connect/store/index.js b/app/assets/javascripts/jira_connect/store/index.js index aa7e14269a4..de830e3891a 100644 --- a/app/assets/javascripts/jira_connect/store/index.js +++ b/app/assets/javascripts/jira_connect/store/index.js @@ -1,9 +1,12 @@ +import Vue from 'vue'; import Vuex from 'vuex'; import mutations from './mutations'; import state from './state'; +Vue.use(Vuex); + export default () => new Vuex.Store({ - state, mutations, + state, }); diff --git a/app/assets/javascripts/jira_import/components/jira_import_form.vue b/app/assets/javascripts/jira_import/components/jira_import_form.vue index ab475c3c85a..6f2fb41ca15 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_form.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_form.vue @@ -15,10 +15,11 @@ import { GlTable, } from '@gitlab/ui'; import { debounce } from 'lodash'; -import axios from '~/lib/utils/axios_utils'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; import getJiraUserMappingMutation from '../queries/get_jira_user_mapping.mutation.graphql'; import initiateJiraImportMutation from '../queries/initiate_jira_import.mutation.graphql'; +import searchProjectMembersQuery from '../queries/search_project_members.query.graphql'; import { addInProgressImportToStore } from '../utils/cache_update'; import { debounceWait, @@ -155,19 +156,23 @@ export default { }); }, searchUsers() { - const params = { - active: true, - project_id: this.projectId, - search: this.searchTerm, - }; - this.isFetching = true; - return axios - .get('/-/autocomplete/users.json', { params }) + return this.$apollo + .query({ + query: searchProjectMembersQuery, + variables: { + fullPath: this.projectPath, + search: this.searchTerm, + }, + }) .then(({ data }) => { - this.users = data; - return data; + this.users = + data?.project?.projectMembers?.nodes?.map(({ user }) => ({ + ...user, + id: getIdFromGraphQLId(user.id), + })) || []; + return this.users; }) .finally(() => { this.isFetching = false; diff --git a/app/assets/javascripts/jira_import/queries/search_project_members.query.graphql b/app/assets/javascripts/jira_import/queries/search_project_members.query.graphql new file mode 100644 index 00000000000..06f119e75ed --- /dev/null +++ b/app/assets/javascripts/jira_import/queries/search_project_members.query.graphql @@ -0,0 +1,13 @@ +query searchProjectMembers($fullPath: ID!, $search: String) { + project(fullPath: $fullPath) { + projectMembers(search: $search) { + nodes { + user { + id + name + username + } + } + } + } +} diff --git a/app/assets/javascripts/jobs/components/artifacts_block.vue b/app/assets/javascripts/jobs/components/artifacts_block.vue index 2850a8e86fd..0f34926f689 100644 --- a/app/assets/javascripts/jobs/components/artifacts_block.vue +++ b/app/assets/javascripts/jobs/components/artifacts_block.vue @@ -1,13 +1,15 @@ <script> -import { GlIcon, GlLink } from '@gitlab/ui'; +import { GlButton, GlButtonGroup, GlIcon, GlLink } from '@gitlab/ui'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; export default { components: { - TimeagoTooltip, + GlButton, + GlButtonGroup, GlIcon, GlLink, + TimeagoTooltip, }, mixins: [timeagoMixin], props: { @@ -36,7 +38,7 @@ export default { </script> <template> <div class="block"> - <div class="title font-weight-bold">{{ s__('Job|Job artifacts') }}</div> + <div class="title gl-font-weight-bold">{{ s__('Job|Job artifacts') }}</div> <p v-if="isExpired || willExpire" class="build-detail-row" @@ -61,32 +63,29 @@ export default { ) }}</span> </p> - <div class="btn-group d-flex gl-mt-3" role="group"> - <gl-link + <gl-button-group class="gl-display-flex gl-mt-3"> + <gl-button v-if="artifact.keep_path" :href="artifact.keep_path" - class="btn btn-sm btn-default" data-method="post" data-testid="keep-artifacts" - >{{ s__('Job|Keep') }}</gl-link + >{{ s__('Job|Keep') }}</gl-button > - <gl-link + <gl-button v-if="artifact.download_path" :href="artifact.download_path" - class="btn btn-sm btn-default" - download rel="nofollow" data-testid="download-artifacts" - >{{ s__('Job|Download') }}</gl-link + download + >{{ s__('Job|Download') }}</gl-button > - <gl-link + <gl-button v-if="artifact.browse_path" :href="artifact.browse_path" - class="btn btn-sm btn-default" data-testid="browse-artifacts" data-qa-selector="browse_artifacts_button" - >{{ s__('Job|Browse') }}</gl-link + >{{ s__('Job|Browse') }}</gl-button > - </div> + </gl-button-group> </div> </template> diff --git a/app/assets/javascripts/jobs/components/environments_block.vue b/app/assets/javascripts/jobs/components/environments_block.vue index ec7868d9235..c78d36355e2 100644 --- a/app/assets/javascripts/jobs/components/environments_block.vue +++ b/app/assets/javascripts/jobs/components/environments_block.vue @@ -201,9 +201,7 @@ export default { /> <template v-else>{{ clusterNameOrLink.name }}</template> </template> - <template #kubernetesNamespace> - <template>{{ kubernetesNamespace }}</template> - </template> + <template #kubernetesNamespace>{{ kubernetesNamespace }}</template> <template #deploymentLink> <gl-link :href="deploymentLink.path" diff --git a/app/assets/javascripts/jobs/components/erased_block.vue b/app/assets/javascripts/jobs/components/erased_block.vue index a6d1b41c275..91c36f38447 100644 --- a/app/assets/javascripts/jobs/components/erased_block.vue +++ b/app/assets/javascripts/jobs/components/erased_block.vue @@ -1,12 +1,14 @@ <script> import { isEmpty } from 'lodash'; -import { GlLink } from '@gitlab/ui'; +import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; export default { components: { - TimeagoTooltip, + GlAlert, GlLink, + GlSprintf, + TimeagoTooltip, }, props: { user: { @@ -27,17 +29,21 @@ export default { }; </script> <template> - <div class="gl-mt-3 js-build-erased"> - <div class="erased alert alert-warning"> + <div class="gl-mt-3"> + <gl-alert variant="warning" :dismissible="false"> <template v-if="isErasedByUser"> - {{ s__('Job|Job has been erased by') }} - <gl-link :href="user.web_url"> {{ user.username }} </gl-link> + <gl-sprintf :message="s__('Job|Job has been erased by %{userLink}')"> + <template #userLink> + <gl-link :href="user.web_url" target="_blank">{{ user.username }}</gl-link> + </template> + </gl-sprintf> </template> + <template v-else> {{ s__('Job|Job has been erased') }} </template> <timeago-tooltip :time="erasedAt" /> - </div> + </gl-alert> </div> </template> diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue index b0ba6ce52d1..52b60a1effd 100644 --- a/app/assets/javascripts/jobs/components/job_app.vue +++ b/app/assets/javascripts/jobs/components/job_app.vue @@ -6,6 +6,8 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { isScrolledToBottom } from '~/lib/utils/scroll_utils'; import { polyfillSticky } from '~/lib/utils/sticky'; import CiHeader from '~/vue_shared/components/header_ci_component.vue'; +import { sprintf } from '~/locale'; +import delayedJobMixin from '../mixins/delayed_job_mixin'; import EmptyState from './empty_state.vue'; import EnvironmentsBlock from './environments_block.vue'; import ErasedBlock from './erased_block.vue'; @@ -13,8 +15,6 @@ import LogTopBar from './job_log_controllers.vue'; import StuckBlock from './stuck_block.vue'; import UnmetPrerequisitesBlock from './unmet_prerequisites_block.vue'; import Sidebar from './sidebar.vue'; -import { sprintf } from '~/locale'; -import delayedJobMixin from '../mixins/delayed_job_mixin'; import Log from './log/log.vue'; export default { @@ -54,11 +54,6 @@ export default { required: false, default: null, }, - runnerHelpUrl: { - type: String, - required: false, - default: null, - }, deploymentHelpUrl: { type: String, required: false, @@ -250,7 +245,6 @@ export default { v-if="shouldRenderSharedRunnerLimitWarning" :quota-used="job.runners.quota.used" :quota-limit="job.runners.quota.limit" - :runners-path="runnerHelpUrl" :project-path="projectPath" :subscriptions-more-minutes-url="subscriptionsMoreMinutesUrl" /> @@ -330,7 +324,6 @@ export default { 'right-sidebar-collapsed': !isSidebarOpen, }" :artifact-help-url="artifactHelpUrl" - :runner-help-url="runnerHelpUrl" data-testid="job-sidebar" /> </div> diff --git a/app/assets/javascripts/jobs/components/log/duration_badge.vue b/app/assets/javascripts/jobs/components/log/duration_badge.vue index 8e5dcdcc902..54b76fd9edd 100644 --- a/app/assets/javascripts/jobs/components/log/duration_badge.vue +++ b/app/assets/javascripts/jobs/components/log/duration_badge.vue @@ -1,5 +1,10 @@ <script> +import { GlBadge } from '@gitlab/ui'; + export default { + components: { + GlBadge, + }, props: { duration: { type: String, @@ -9,7 +14,7 @@ export default { }; </script> <template> - <div class="log-duration-badge rounded align-self-start px-2 ml-2 flex-shrink-0 ws-normal"> + <gl-badge> {{ duration }} - </div> + </gl-badge> </template> diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue index 0789bb54f0f..83eddc232a1 100644 --- a/app/assets/javascripts/jobs/components/sidebar.vue +++ b/app/assets/javascripts/jobs/components/sidebar.vue @@ -3,6 +3,7 @@ import { isEmpty } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; import { GlButton, GlIcon, GlLink } from '@gitlab/ui'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import { JOB_SIDEBAR } from '../constants'; import ArtifactsBlock from './artifacts_block.vue'; import JobSidebarRetryButton from './job_sidebar_retry_button.vue'; import JobRetryForwardDeploymentModal from './job_retry_forward_deployment_modal.vue'; @@ -11,7 +12,6 @@ import CommitBlock from './commit_block.vue'; import StagesDropdown from './stages_dropdown.vue'; import JobsContainer from './jobs_container.vue'; import JobSidebarDetailsContainer from './sidebar_job_details_container.vue'; -import { JOB_SIDEBAR } from '../constants'; export const forwardDeploymentFailureModalId = 'forward-deployment-failure'; @@ -41,11 +41,6 @@ export default { required: false, default: '', }, - runnerHelpUrl: { - type: String, - required: false, - default: '', - }, }, computed: { ...mapGetters(['hasForwardDeploymentFailure']), @@ -110,7 +105,7 @@ export default { <gl-button :aria-label="$options.i18n.toggleSidebar" category="tertiary" - class="gl-display-md-none gl-ml-2 js-sidebar-build-toggle" + class="gl-md-display-none gl-ml-2 js-sidebar-build-toggle" icon="chevron-double-lg-right" @click="toggleSidebar" /> @@ -135,7 +130,7 @@ export default { <gl-icon :size="14" name="external-link" /> </gl-link> </div> - <job-sidebar-details-container :runner-help-url="runnerHelpUrl" /> + <job-sidebar-details-container /> <artifacts-block v-if="hasArtifact" :artifact="job.artifact" :help-url="artifactHelpUrl" /> <trigger-block v-if="hasTriggers" :trigger="job.trigger" /> <commit-block diff --git a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue index 8ad1008278e..84ce6674104 100644 --- a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue +++ b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue @@ -1,9 +1,10 @@ <script> import { mapState } from 'vuex'; -import DetailRow from './sidebar_detail_row.vue'; import { __, sprintf } from '~/locale'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import { timeIntervalInWords } from '~/lib/utils/datetime_utility'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import DetailRow from './sidebar_detail_row.vue'; export default { name: 'JobSidebarDetailsContainer', @@ -11,13 +12,6 @@ export default { DetailRow, }, mixins: [timeagoMixin], - props: { - runnerHelpUrl: { - type: String, - required: false, - default: '', - }, - }, computed: { ...mapState(['job']), coverage() { @@ -51,6 +45,11 @@ export default { queued() { return timeIntervalInWords(this.job.queued); }, + runnerHelpUrl() { + return helpPagePath('ci/runners/README.html', { + anchor: 'set-maximum-job-timeout-for-a-runner', + }); + }, runnerId() { return `${this.job.runner.description} (#${this.job.runner.id})`; }, diff --git a/app/assets/javascripts/jobs/components/stuck_block.vue b/app/assets/javascripts/jobs/components/stuck_block.vue index 8e8202246a2..abd0c13702a 100644 --- a/app/assets/javascripts/jobs/components/stuck_block.vue +++ b/app/assets/javascripts/jobs/components/stuck_block.vue @@ -1,6 +1,6 @@ <script> import { GlAlert, GlBadge, GlLink } from '@gitlab/ui'; -import { s__ } from '../../locale'; +import { s__ } from '~/locale'; /** * Renders Stuck Runners block for job's view. */ diff --git a/app/assets/javascripts/jobs/components/trigger_block.vue b/app/assets/javascripts/jobs/components/trigger_block.vue index 1d46dd8cea4..f6b98777011 100644 --- a/app/assets/javascripts/jobs/components/trigger_block.vue +++ b/app/assets/javascripts/jobs/components/trigger_block.vue @@ -1,12 +1,31 @@ <script> -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlTable } from '@gitlab/ui'; import { __ } from '~/locale'; -const HIDDEN_VALUE = '••••••'; +const DEFAULT_TD_CLASSES = 'gl-w-half gl-font-sm! gl-border-gray-200!'; +const DEFAULT_TH_CLASSES = + 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-200! gl-border-b-1!'; export default { + fields: [ + { + key: 'key', + label: __('Key'), + tdAttr: { 'data-testid': 'trigger-build-key' }, + tdClass: DEFAULT_TD_CLASSES, + thClass: DEFAULT_TH_CLASSES, + }, + { + key: 'value', + label: __('Value'), + tdAttr: { 'data-testid': 'trigger-build-value' }, + tdClass: DEFAULT_TD_CLASSES, + thClass: DEFAULT_TH_CLASSES, + }, + ], components: { GlButton, + GlTable, }, props: { trigger: { @@ -21,7 +40,7 @@ export default { }, computed: { hasVariables() { - return this.trigger.variables && this.trigger.variables.length > 0; + return this.trigger.variables.length > 0; }, getToggleButtonText() { return this.showVariableValues ? __('Hide values') : __('Reveal values'); @@ -35,45 +54,41 @@ export default { this.showVariableValues = !this.showVariableValues; }, getDisplayValue(value) { - return this.showVariableValues ? value : HIDDEN_VALUE; + return this.showVariableValues ? value : '••••••'; }, }, }; </script> <template> - <div class="build-widget block"> + <div class="block"> <p v-if="trigger.short_token" - class="js-short-token" :class="{ 'gl-mb-2': hasVariables, 'gl-mb-0': !hasVariables }" + data-testid="trigger-short-token" > - <span class="font-weight-bold">{{ __('Trigger token:') }}</span> {{ trigger.short_token }} + <span class="gl-font-weight-bold">{{ __('Trigger token:') }}</span> {{ trigger.short_token }} </p> <template v-if="hasVariables"> - <p class="trigger-variables-btn-container d-flex"> - <span class="font-weight-bold">{{ __('Trigger variables:') }}</span> + <p class="gl-display-flex gl-justify-content-space-between gl-align-items-center"> + <span class="gl-font-weight-bold">{{ __('Trigger variables:') }}</span> <gl-button v-if="hasValues" - class="group js-reveal-variables trigger-variables-btn" + class="gl-mt-2" size="small" + data-testid="trigger-reveal-values-button" @click="toggleValues" >{{ getToggleButtonText }}</gl-button > </p> - <table class="js-build-variables trigger-build-variables"> - <tr v-for="(variable, index) in trigger.variables" :key="`${variable.key}-${index}`"> - <td class="js-build-variable trigger-build-variable trigger-variables-table-cell"> - {{ variable.key }} - </td> - <td class="js-build-value trigger-build-value trigger-variables-table-cell"> - {{ getDisplayValue(variable.value) }} - </td> - </tr> - </table> + <gl-table :items="trigger.variables" :fields="$options.fields" small bordered> + <template #cell(value)="data"> + {{ getDisplayValue(data.value) }} + </template> + </gl-table> </template> </div> </template> diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js index 1ad6292a030..3e00056ee81 100644 --- a/app/assets/javascripts/jobs/index.js +++ b/app/assets/javascripts/jobs/index.js @@ -13,7 +13,6 @@ export default () => { const { artifactHelpUrl, deploymentHelpUrl, - runnerHelpUrl, runnerSettingsUrl, variablesSettingsUrl, subscriptionsMoreMinutesUrl, @@ -39,7 +38,6 @@ export default () => { props: { artifactHelpUrl, deploymentHelpUrl, - runnerHelpUrl, runnerSettingsUrl, variablesSettingsUrl, subscriptionsMoreMinutesUrl, diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/jobs/store/actions.js index e76a3693db9..6b0ca8ab986 100644 --- a/app/assets/javascripts/jobs/store/actions.js +++ b/app/assets/javascripts/jobs/store/actions.js @@ -1,5 +1,4 @@ import Visibility from 'visibilityjs'; -import * as types from './mutation_types'; import axios from '~/lib/utils/axios_utils'; import Poll from '~/lib/utils/poll'; import { setFaviconOverlay, resetFavicon } from '~/lib/utils/favicon'; @@ -14,6 +13,7 @@ import { scrollUp, } from '~/lib/utils/scroll_utils'; import httpStatusCodes from '~/lib/utils/http_status'; +import * as types from './mutation_types'; export const init = ({ dispatch }, { endpoint, logState, pagePath }) => { dispatch('setJobEndpoint', endpoint); diff --git a/app/assets/javascripts/jobs/store/getters.js b/app/assets/javascripts/jobs/store/getters.js index 30a4a247dc4..930a225857d 100644 --- a/app/assets/javascripts/jobs/store/getters.js +++ b/app/assets/javascripts/jobs/store/getters.js @@ -1,7 +1,7 @@ import { isEmpty, isString } from 'lodash'; import { isScrolledToBottom } from '~/lib/utils/scroll_utils'; -export const headerTime = (state) => (state.job.started ? state.job.started : state.job.created_at); +export const headerTime = (state) => state.job.started ?? state.job.created_at; export const hasForwardDeploymentFailure = (state) => state?.job?.failure_reason === 'forward_deployment_failure'; @@ -28,11 +28,9 @@ export const hasEnvironment = (state) => !isEmpty(state.job.deployment_status); export const hasTrace = (state) => state.job.has_trace || (!isEmpty(state.job.status) && state.job.status.group === 'running'); -export const emptyStateIllustration = (state) => - (state.job && state.job.status && state.job.status.illustration) || {}; +export const emptyStateIllustration = (state) => state?.job?.status?.illustration || {}; -export const emptyStateAction = (state) => - (state.job && state.job.status && state.job.status.action) || null; +export const emptyStateAction = (state) => state?.job?.status?.action || null; /** * Shared runners limit is only rendered when @@ -48,4 +46,4 @@ export const shouldRenderSharedRunnerLimitWarning = (state) => export const isScrollingDown = (state) => isScrolledToBottom() && !state.isTraceComplete; export const hasRunnersForProject = (state) => - state.job.runners.available && !state.job.runners.online; + state?.job?.runners?.available && !state?.job?.runners?.online; diff --git a/app/assets/javascripts/label_manager.js b/app/assets/javascripts/label_manager.js index 92dffb87e1a..aa197edd449 100644 --- a/app/assets/javascripts/label_manager.js +++ b/app/assets/javascripts/label_manager.js @@ -3,10 +3,10 @@ import $ from 'jquery'; import Sortable from 'sortablejs'; +import { hide, dispose } from '~/tooltips'; import { deprecatedCreateFlash as flash } from './flash'; import axios from './lib/utils/axios_utils'; import { __ } from './locale'; -import { hide, dispose } from '~/tooltips'; export default class LabelManager { constructor({ togglePriorityButton, prioritizedLabels, otherLabels } = {}) { diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 337d063b02a..dd5bba45c21 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -4,6 +4,8 @@ import $ from 'jquery'; import { difference, isEqual, escape, sortBy, template, union } from 'lodash'; +import { isScopedLabel } from '~/lib/utils/common_utils'; +import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import { sprintf, __ } from './locale'; import axios from './lib/utils/axios_utils'; import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; @@ -11,8 +13,6 @@ import CreateLabelDropdown from './create_label'; import { deprecatedCreateFlash as flash } from './flash'; import ModalStore from './boards/stores/modal_store'; import boardsStore from './boards/stores/boards_store'; -import { isScopedLabel } from '~/lib/utils/common_utils'; -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; export default class LabelsSelect { constructor(els, options = {}) { diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index 5c4bb5ea01f..d974e36f6f0 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -35,6 +35,16 @@ export default (resolvers = {}, config = {}) => { batchMax: config.batchMax || 10, }; + const requestCounterLink = new ApolloLink((operation, forward) => { + window.pendingApolloRequests = window.pendingApolloRequests || 0; + window.pendingApolloRequests += 1; + + return forward(operation).map((response) => { + window.pendingApolloRequests -= 1; + return response; + }); + }); + const uploadsLink = ApolloLink.split( (operation) => operation.getContext().hasUpload || operation.getContext().isSingleRequest, createUploadLink(httpOptions), @@ -63,7 +73,12 @@ export default (resolvers = {}, config = {}) => { return new ApolloClient({ typeDefs: config.typeDefs, - link: ApolloLink.from([performanceBarLink, new StartupJSLink(), uploadsLink]), + link: ApolloLink.from([ + requestCounterLink, + performanceBarLink, + new StartupJSLink(), + uploadsLink, + ]), cache: new InMemoryCache({ ...config.cacheConfig, freezeResults: config.assumeImmutableResults, diff --git a/app/assets/javascripts/lib/utils/array_utility.js b/app/assets/javascripts/lib/utils/array_utility.js new file mode 100644 index 00000000000..197e7790ed7 --- /dev/null +++ b/app/assets/javascripts/lib/utils/array_utility.js @@ -0,0 +1,20 @@ +/** + * Return a shallow copy of an array with two items swapped. + * + * @param {Array} array - The source array + * @param {Number} leftIndex - Index of the first item + * @param {Number} rightIndex - Index of the second item + * @returns {Array} new array with the left and right items swapped + */ +export const swapArrayItems = (array, leftIndex = 0, rightIndex = 0) => { + const copy = array.slice(); + + if (leftIndex >= array.length || leftIndex < 0 || rightIndex >= array.length || rightIndex < 0) { + return copy; + } + + const temp = copy[leftIndex]; + copy[leftIndex] = copy[rightIndex]; + copy[rightIndex] = temp; + return copy; +}; diff --git a/app/assets/javascripts/lib/utils/color_utils.js b/app/assets/javascripts/lib/utils/color_utils.js index a1f56b15631..ff176f11867 100644 --- a/app/assets/javascripts/lib/utils/color_utils.js +++ b/app/assets/javascripts/lib/utils/color_utils.js @@ -23,3 +23,23 @@ export const textColorForBackground = (backgroundColor) => { } return '#FFFFFF'; }; + +/** + * Check whether a color matches the expected hex format + * + * This matches any hex (0-9 and A-F) value which is either 3 or 6 characters in length + * + * An empty string will return `null` which means that this is neither valid nor invalid. + * This is useful for forms resetting the validation state + * + * @param color string = '' + * + * @returns {null|boolean} + */ +export const validateHexColor = (color = '') => { + if (!color) { + return null; + } + + return /^#([0-9A-F]{3}){1,2}$/i.test(color); +}; diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 128ef5b335e..d0528204fd5 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -45,6 +45,7 @@ export const checkPageAndAction = (page, action) => { export const isInIncidentPage = () => checkPageAndAction('incidents', 'show'); export const isInIssuePage = () => checkPageAndAction('issues', 'show'); +export const isInDesignPage = () => checkPageAndAction('issues', 'designs'); export const isInMRPage = () => checkPageAndAction('merge_requests', 'show'); export const isInEpicPage = () => checkPageAndAction('epics', 'show'); @@ -801,3 +802,12 @@ export const removeCookie = (name) => Cookies.remove(name); * @returns {Boolean} on/off */ export const isFeatureFlagEnabled = (flag) => window.gon.features?.[flag]; + +/** + * This method takes in array with snake_case strings + * and returns a new array with camelCase strings + * + * @param {Array[String]} array - Array to be converted + * @returns {Array[String]} Converted array + */ +export const convertArrayToCamelCase = (array) => array.map((i) => convertToCamelCase(i)); diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js index 993d51370ec..b19a4a01a5f 100644 --- a/app/assets/javascripts/lib/utils/constants.js +++ b/app/assets/javascripts/lib/utils/constants.js @@ -10,3 +10,9 @@ export const DATETIME_RANGE_TYPES = { open: 'open', invalid: 'invalid', }; + +export const BV_SHOW_MODAL = 'bv::show::modal'; +export const BV_HIDE_MODAL = 'bv::hide::modal'; +export const BV_HIDE_TOOLTIP = 'bv::hide::tooltip'; +export const BV_DROPDOWN_SHOW = 'bv::dropdown::show'; +export const BV_DROPDOWN_HIDE = 'bv::dropdown::hide'; diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 15f7c0c874e..d475dd6b9e6 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -4,7 +4,9 @@ import * as timeago from 'timeago.js'; import dateFormat from 'dateformat'; import { languageCode, s__, __, n__ } from '../../locale'; -const MILLISECONDS_IN_DAY = 24 * 60 * 60 * 1000; +const MILLISECONDS_IN_HOUR = 60 * 60 * 1000; +const MILLISECONDS_IN_DAY = 24 * MILLISECONDS_IN_HOUR; +const DAYS_IN_WEEK = 7; window.timeago = timeago; @@ -674,50 +676,127 @@ export const secondsToHours = (offset) => { }; /** - * Returns the date n days after the date provided + * Returns the date `n` days after the date provided * * @param {Date} date the initial date * @param {Number} numberOfDays number of days after - * @return {Date} the date following the date provided + * @param {Object} [options={}] Additional options for this calculation + * @param {boolean} [options.utc=false] Performs the calculation using UTC dates. + * This will cause Daylight Saving Time to be ignored. Defaults to `false` + * if not provided, which causes the calculation to be performed in the + * user's timezone. + * + * @return {Date} A `Date` object `n` days after the provided `Date` */ -export const nDaysAfter = (date, numberOfDays) => - new Date(newDate(date)).setDate(date.getDate() + numberOfDays); +export const nDaysAfter = (date, numberOfDays, { utc = false } = {}) => { + const clone = newDate(date); + + const cloneValue = utc + ? clone.setUTCDate(date.getUTCDate() + numberOfDays) + : clone.setDate(date.getDate() + numberOfDays); + + return new Date(cloneValue); +}; /** - * Returns the date n days before the date provided + * Returns the date `n` days before the date provided * * @param {Date} date the initial date * @param {Number} numberOfDays number of days before - * @return {Date} the date preceding the date provided + * @param {Object} [options={}] Additional options for this calculation + * @param {boolean} [options.utc=false] Performs the calculation using UTC dates. + * This will cause Daylight Saving Time to be ignored. Defaults to `false` + * if not provided, which causes the calculation to be performed in the + * user's timezone. + * @return {Date} A `Date` object `n` days before the provided `Date` + */ +export const nDaysBefore = (date, numberOfDays, options) => + nDaysAfter(date, -numberOfDays, options); + +/** + * Returns the date `n` weeks after the date provided + * + * @param {Date} date the initial date + * @param {Number} numberOfWeeks number of weeks after + * @param {Object} [options={}] Additional options for this calculation + * @param {boolean} [options.utc=false] Performs the calculation using UTC dates. + * This will cause Daylight Saving Time to be ignored. Defaults to `false` + * if not provided, which causes the calculation to be performed in the + * user's timezone. + * + * @return {Date} A `Date` object `n` weeks after the provided `Date` + */ +export const nWeeksAfter = (date, numberOfWeeks, options) => + nDaysAfter(date, DAYS_IN_WEEK * numberOfWeeks, options); + +/** + * Returns the date `n` weeks before the date provided + * + * @param {Date} date the initial date + * @param {Number} numberOfWeeks number of weeks before + * @param {Object} [options={}] Additional options for this calculation + * @param {boolean} [options.utc=false] Performs the calculation using UTC dates. + * This will cause Daylight Saving Time to be ignored. Defaults to `false` + * if not provided, which causes the calculation to be performed in the + * user's timezone. + * + * @return {Date} A `Date` object `n` weeks before the provided `Date` */ -export const nDaysBefore = (date, numberOfDays) => nDaysAfter(date, -numberOfDays); +export const nWeeksBefore = (date, numberOfWeeks, options) => + nWeeksAfter(date, -numberOfWeeks, options); /** - * Returns the date n months after the date provided + * Returns the date `n` months after the date provided * * @param {Date} date the initial date * @param {Number} numberOfMonths number of months after - * @return {Date} the date following the date provided + * @param {Object} [options={}] Additional options for this calculation + * @param {boolean} [options.utc=false] Performs the calculation using UTC dates. + * This will cause Daylight Saving Time to be ignored. Defaults to `false` + * if not provided, which causes the calculation to be performed in the + * user's timezone. + * + * @return {Date} A `Date` object `n` months after the provided `Date` */ -export const nMonthsAfter = (date, numberOfMonths) => - new Date(newDate(date)).setMonth(date.getMonth() + numberOfMonths); +export const nMonthsAfter = (date, numberOfMonths, { utc = false } = {}) => { + const clone = newDate(date); + + const cloneValue = utc + ? clone.setUTCMonth(date.getUTCMonth() + numberOfMonths) + : clone.setMonth(date.getMonth() + numberOfMonths); + + return new Date(cloneValue); +}; /** - * Returns the date n months before the date provided + * Returns the date `n` months before the date provided * * @param {Date} date the initial date * @param {Number} numberOfMonths number of months before - * @return {Date} the date preceding the date provided + * @param {Object} [options={}] Additional options for this calculation + * @param {boolean} [options.utc=false] Performs the calculation using UTC dates. + * This will cause Daylight Saving Time to be ignored. Defaults to `false` + * if not provided, which causes the calculation to be performed in the + * user's timezone. + * + * @return {Date} A `Date` object `n` months before the provided `Date` */ -export const nMonthsBefore = (date, numberOfMonths) => nMonthsAfter(date, -numberOfMonths); +export const nMonthsBefore = (date, numberOfMonths, options) => + nMonthsAfter(date, -numberOfMonths, options); /** * Returns the date after the date provided * * @param {Date} date the initial date + * @param {Object} [options={}] Additional options for this calculation + * @param {boolean} [options.utc=false] Performs the calculation using UTC dates. + * This will cause Daylight Saving Time to be ignored. Defaults to `false` + * if not provided, which causes the calculation to be performed in the + * user's timezone. + * * @return {Date} the date following the date provided */ -export const dayAfter = (date) => new Date(newDate(date).setDate(date.getDate() + 1)); +export const dayAfter = (date, options) => nDaysAfter(date, 1, options); /** * Mimics the behaviour of the rails distance_of_time_in_words function @@ -859,17 +938,17 @@ export const format24HourTimeStringFromInt = (time) => { * * @param {Object} givenPeriodLeft - the first period to compare. * @param {Object} givenPeriodRight - the second period to compare. - * @returns {Object} { overlap: number of days the overlap is present, overlapStartDate: the start date of the overlap in time format, overlapEndDate: the end date of the overlap in time format } + * @returns {Object} { daysOverlap: number of days the overlap is present, hoursOverlap: number of hours the overlap is present, overlapStartDate: the start date of the overlap in time format, overlapEndDate: the end date of the overlap in time format } * @throws {Error} Uncaught Error: Invalid period * * @example - * getOverlappingDaysInPeriods( + * getOverlapDateInPeriods( * { start: new Date(2021, 0, 11), end: new Date(2021, 0, 13) }, * { start: new Date(2021, 0, 11), end: new Date(2021, 0, 14) } - * ) => { daysOverlap: 2, overlapStartDate: 1610323200000, overlapEndDate: 1610496000000 } + * ) => { daysOverlap: 2, hoursOverlap: 48, overlapStartDate: 1610323200000, overlapEndDate: 1610496000000 } * */ -export const getOverlappingDaysInPeriods = (givenPeriodLeft = {}, givenPeriodRight = {}) => { +export const getOverlapDateInPeriods = (givenPeriodLeft = {}, givenPeriodRight = {}) => { const leftStartTime = new Date(givenPeriodLeft.start).getTime(); const leftEndTime = new Date(givenPeriodLeft.end).getTime(); const rightStartTime = new Date(givenPeriodRight.start).getTime(); @@ -890,8 +969,44 @@ export const getOverlappingDaysInPeriods = (givenPeriodLeft = {}, givenPeriodRig const differenceInMs = overlapEndDate - overlapStartDate; return { + hoursOverlap: Math.ceil(differenceInMs / MILLISECONDS_IN_HOUR), daysOverlap: Math.ceil(differenceInMs / MILLISECONDS_IN_DAY), overlapStartDate, overlapEndDate, }; }; + +/** + * A utility function that checks that the date is today + * + * @param {Date} date + * + * @return {Boolean} true if provided date is today + */ +export const isToday = (date) => { + const today = new Date(); + return ( + date.getDate() === today.getDate() && + date.getMonth() === today.getMonth() && + date.getFullYear() === today.getFullYear() + ); +}; + +/** + * Returns the start of the provided day + * + * @param {Object} [options={}] Additional options for this calculation + * @param {boolean} [options.utc=false] Performs the calculation using UTC time. + * If `true`, the time returned will be midnight UTC. If `false` (the default) + * the time returned will be midnight in the user's local time. + * + * @returns {Date} A new `Date` object that represents the start of the day + * of the provided date + */ +export const getStartOfDay = (date, { utc = false } = {}) => { + const clone = newDate(date); + + const cloneValue = utc ? clone.setUTCHours(0, 0, 0, 0) : clone.setHours(0, 0, 0, 0); + + return new Date(cloneValue); +}; diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js index d49382733c0..0f29f538b07 100644 --- a/app/assets/javascripts/lib/utils/number_utils.js +++ b/app/assets/javascripts/lib/utils/number_utils.js @@ -1,5 +1,5 @@ -import { BYTES_IN_KIB } from './constants'; import { sprintf, __ } from '~/locale'; +import { BYTES_IN_KIB } from './constants'; /** * Function that allows a number with an X amount of decimals diff --git a/app/assets/javascripts/lib/utils/unit_format/formatter_factory.js b/app/assets/javascripts/lib/utils/unit_format/formatter_factory.js index 9d47a1b7132..15f9512fe92 100644 --- a/app/assets/javascripts/lib/utils/unit_format/formatter_factory.js +++ b/app/assets/javascripts/lib/utils/unit_format/formatter_factory.js @@ -20,8 +20,9 @@ function formatNumber( return ''; } + const locale = document.documentElement.lang || undefined; const num = value * valueFactor; - const formatted = num.toLocaleString(undefined, { + const formatted = num.toLocaleString(locale, { minimumFractionDigits: fractionDigits, maximumFractionDigits: fractionDigits, style, diff --git a/app/assets/javascripts/logs/components/environment_logs.vue b/app/assets/javascripts/logs/components/environment_logs.vue index a114b3c7d4d..2ce8d9815b5 100644 --- a/app/assets/javascripts/logs/components/environment_logs.vue +++ b/app/assets/javascripts/logs/components/environment_logs.vue @@ -11,13 +11,12 @@ import { GlInfiniteScroll, } from '@gitlab/ui'; -import LogSimpleFilters from './log_simple_filters.vue'; -import LogAdvancedFilters from './log_advanced_filters.vue'; -import LogControlButtons from './log_control_buttons.vue'; - import { defaultTimeRange } from '~/vue_shared/constants'; import { timeRangeFromUrl } from '~/monitoring/utils'; import { formatDate } from '../utils'; +import LogSimpleFilters from './log_simple_filters.vue'; +import LogAdvancedFilters from './log_advanced_filters.vue'; +import LogControlButtons from './log_control_buttons.vue'; export default { components: { @@ -176,7 +175,7 @@ export default { id="environments-dropdown" :text="environments.current || managedApps.current" :disabled="environments.isLoading" - class="gl-mr-3 gl-mb-3 gl-display-flex gl-display-md-block js-environments-dropdown" + class="gl-mr-3 gl-mb-3 gl-display-flex gl-md-display-block js-environments-dropdown" > <gl-dropdown-section-header> {{ s__('Environments|Environments') }} diff --git a/app/assets/javascripts/logs/components/log_simple_filters.vue b/app/assets/javascripts/logs/components/log_simple_filters.vue index ba30d4628c9..6ee1df3c3fe 100644 --- a/app/assets/javascripts/logs/components/log_simple_filters.vue +++ b/app/assets/javascripts/logs/components/log_simple_filters.vue @@ -42,7 +42,7 @@ export default { ref="podsDropdown" :text="podDropdownText" :disabled="disabled" - class="gl-mr-3 gl-mb-3 gl-display-flex gl-display-md-block qa-pods-dropdown" + class="gl-mr-3 gl-mb-3 gl-display-flex gl-md-display-block qa-pods-dropdown" > <gl-dropdown-section-header> {{ s__('Environments|Select pod') }} diff --git a/app/assets/javascripts/logs/stores/mutations.js b/app/assets/javascripts/logs/stores/mutations.js index 147f562057f..21031838adf 100644 --- a/app/assets/javascripts/logs/stores/mutations.js +++ b/app/assets/javascripts/logs/stores/mutations.js @@ -1,5 +1,5 @@ -import * as types from './mutation_types'; import { convertToFixedRange } from '~/lib/utils/datetime_range'; +import * as types from './mutation_types'; const mapLine = ({ timestamp, pod, message }) => ({ timestamp, diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index ef0fef6085b..f1900ea5f7e 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -12,6 +12,8 @@ import './behaviors'; import applyGitLabUIConfig from '@gitlab/ui/dist/config'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { initRails } from '~/lib/utils/rails_ujs'; +import * as tooltips from '~/tooltips'; +import * as popovers from '~/popovers'; import { handleLocationHash, addSelectOnFocusBehaviour, @@ -38,9 +40,6 @@ import initPersistentUserCallouts from './persistent_user_callouts'; import { initUserTracking, initDefaultTrackers } from './tracking'; import { __ } from './locale'; -import * as tooltips from '~/tooltips'; -import * as popovers from '~/popovers'; - import 'ee_else_ce/main_ee'; applyGitLabUIConfig(); diff --git a/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue index fcb70dd45a6..77b41996ab5 100644 --- a/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue +++ b/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue @@ -1,8 +1,8 @@ <script> +import { s__, sprintf } from '~/locale'; import ActionButtonGroup from './action_button_group.vue'; import RemoveMemberButton from './remove_member_button.vue'; import ApproveAccessRequestButton from './approve_access_request_button.vue'; -import { s__, sprintf } from '~/locale'; export default { name: 'AccessRequestActionButtons', diff --git a/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue index 9a27348f146..0bcc85157f1 100644 --- a/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue +++ b/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue @@ -1,8 +1,8 @@ <script> +import { s__, sprintf } from '~/locale'; import ActionButtonGroup from './action_button_group.vue'; import RemoveMemberButton from './remove_member_button.vue'; import ResendInviteButton from './resend_invite_button.vue'; -import { s__, sprintf } from '~/locale'; export default { name: 'InviteActionButtons', diff --git a/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue index 9d89cb40676..0120ab08556 100644 --- a/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue +++ b/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue @@ -31,6 +31,7 @@ export default { :title="$options.i18n.buttonTitle" :aria-label="$options.i18n.buttonTitle" icon="remove" + data-qa-selector="delete_group_access_link" @click="showRemoveGroupLinkModal(groupLink)" /> </template> diff --git a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue index 0e5df961782..70e7a2a6948 100644 --- a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue +++ b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue @@ -1,8 +1,8 @@ <script> +import { s__, sprintf } from '~/locale'; import ActionButtonGroup from './action_button_group.vue'; import RemoveMemberButton from './remove_member_button.vue'; import LeaveButton from './leave_button.vue'; -import { s__, sprintf } from '~/locale'; export default { name: 'UserActionButtons', diff --git a/app/assets/javascripts/groups/members/components/app.vue b/app/assets/javascripts/members/components/app.vue index 34a2c67fa9f..0aee50e529d 100644 --- a/app/assets/javascripts/groups/members/components/app.vue +++ b/app/assets/javascripts/members/components/app.vue @@ -1,13 +1,13 @@ <script> import { mapState, mapMutations } from 'vuex'; import { GlAlert } from '@gitlab/ui'; -import MembersTable from '~/members/components/table/members_table.vue'; -import FilterSortContainer from '~/members/components/filter_sort/filter_sort_container.vue'; import { scrollToElement } from '~/lib/utils/common_utils'; -import { HIDE_ERROR } from '~/members/store/mutation_types'; +import { HIDE_ERROR } from '../store/mutation_types'; +import MembersTable from './table/members_table.vue'; +import FilterSortContainer from './filter_sort/filter_sort_container.vue'; export default { - name: 'GroupMembersApp', + name: 'MembersApp', components: { MembersTable, FilterSortContainer, GlAlert }, computed: { ...mapState(['showError', 'errorMessage']), diff --git a/app/assets/javascripts/members/components/avatars/user_avatar.vue b/app/assets/javascripts/members/components/avatars/user_avatar.vue index e2264085e67..991f77cf3da 100644 --- a/app/assets/javascripts/members/components/avatars/user_avatar.vue +++ b/app/assets/javascripts/members/components/avatars/user_avatar.vue @@ -7,8 +7,8 @@ import { } from '@gitlab/ui'; import { generateBadges } from 'ee_else_ce/members/utils'; import { __ } from '~/locale'; -import { AVATAR_SIZE } from '../../constants'; import { glEmojiTag } from '~/emoji'; +import { AVATAR_SIZE } from '../../constants'; export default { name: 'UserAvatar', @@ -69,7 +69,10 @@ export default { > <template #meta> <div v-if="statusEmoji" class="gl-p-1"> - <span v-safe-html:[$options.safeHtmlConfig]="glEmojiTag(statusEmoji)"></span> + <span + v-safe-html:[$options.safeHtmlConfig]="glEmojiTag(statusEmoji)" + class="user-status-emoji gl-mr-0" + ></span> </div> <div v-for="badge in badges" :key="badge.text" class="gl-p-1"> <gl-badge size="sm" :variant="badge.variant"> diff --git a/app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue b/app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue index f869ecd392f..812a8626949 100644 --- a/app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue +++ b/app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue @@ -19,7 +19,7 @@ export default { </script> <template> - <div v-if="showContainer" class="gl-bg-gray-10 gl-p-3 gl-display-md-flex"> + <div v-if="showContainer" class="gl-bg-gray-10 gl-p-3 gl-md-display-flex"> <members-filtered-search-bar v-if="filteredSearchBar.show" class="gl-p-3 gl-flex-grow-1" /> <sort-dropdown v-if="showSortDropdown" class="gl-p-3 gl-flex-shrink-0" /> </div> diff --git a/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue b/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue index 231d014a4ec..2bf61c7795a 100644 --- a/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue +++ b/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue @@ -52,6 +52,7 @@ export default { :action-primary="$options.actionPrimary" :action-cancel="$options.actionCancel" size="sm" + data-qa-selector="remove_group_link_modal_content" @primary="handlePrimary" @hide="hideRemoveGroupLinkModal" > diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue index 16e0cd5ad4e..cfe9e40cde2 100644 --- a/app/assets/javascripts/members/components/table/members_table.vue +++ b/app/assets/javascripts/members/components/table/members_table.vue @@ -3,15 +3,15 @@ import { mapState } from 'vuex'; import { GlTable, GlBadge } from '@gitlab/ui'; 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 { FIELDS } from '../../constants'; import initUserPopovers from '~/user_popovers'; +import { FIELDS } from '../../constants'; +import RemoveGroupLinkModal from '../modals/remove_group_link_modal.vue'; import MemberAvatar from './member_avatar.vue'; import MemberSource from './member_source.vue'; import CreatedAt from './created_at.vue'; import ExpiresAt from './expires_at.vue'; import MemberActionButtons from './member_action_buttons.vue'; import RoleDropdown from './role_dropdown.vue'; -import RemoveGroupLinkModal from '../modals/remove_group_link_modal.vue'; import ExpirationDatepicker from './expiration_datepicker.vue'; export default { @@ -32,7 +32,7 @@ export default { import('ee_component/members/components/ldap/ldap_override_confirmation_modal.vue'), }, computed: { - ...mapState(['members', 'tableFields', 'tableAttrs', 'currentUserId', 'sourceId']), + ...mapState(['members', 'tableFields', 'tableAttrs', 'currentUserId']), filteredFields() { return FIELDS.filter( (field) => this.tableFields.includes(field.key) && this.showField(field), @@ -55,9 +55,9 @@ export default { methods: { hasActionButtons(member) { return ( - canRemove(member, this.sourceId) || + canRemove(member) || canResend(member) || - canUpdate(member, this.currentUserId, this.sourceId) || + canUpdate(member, this.currentUserId) || canOverride(member) ); }, @@ -80,7 +80,7 @@ export default { return 'col-actions'; } - return ['col-actions', 'gl-display-none!', 'gl-display-lg-table-cell!']; + return ['col-actions', 'gl-display-none!', 'gl-lg-display-table-cell!']; }, tbodyTrAttr(member) { return { diff --git a/app/assets/javascripts/members/components/table/members_table_cell.vue b/app/assets/javascripts/members/components/table/members_table_cell.vue index 20aa01b96bc..1f537740f94 100644 --- a/app/assets/javascripts/members/components/table/members_table_cell.vue +++ b/app/assets/javascripts/members/components/table/members_table_cell.vue @@ -19,7 +19,7 @@ export default { }, }, computed: { - ...mapState(['sourceId', 'currentUserId']), + ...mapState(['currentUserId']), isGroup() { return isGroup(this.member); }, @@ -41,19 +41,19 @@ export default { return MEMBER_TYPES.user; }, isDirectMember() { - return isDirectMember(this.member, this.sourceId); + return isDirectMember(this.member); }, isCurrentUser() { return isCurrentUser(this.member, this.currentUserId); }, canRemove() { - return canRemove(this.member, this.sourceId); + return canRemove(this.member); }, canResend() { return canResend(this.member); }, canUpdate() { - return canUpdate(this.member, this.currentUserId, this.sourceId); + return canUpdate(this.member, this.currentUserId); }, }, render() { diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js index 77cb150bff6..f68a8814fee 100644 --- a/app/assets/javascripts/members/constants.js +++ b/app/assets/javascripts/members/constants.js @@ -98,3 +98,8 @@ export const REMOVE_GROUP_LINK_MODAL_ID = 'remove-group-link-modal-id'; export const SEARCH_TOKEN_TYPE = 'filtered-search-term'; export const SORT_PARAM = 'sort'; + +export const MEMBER_ACCESS_LEVEL_PROPERTY_NAME = 'access_level'; + +export const GROUP_LINK_BASE_PROPERTY_NAME = 'group_link'; +export const GROUP_LINK_ACCESS_LEVEL_PROPERTY_NAME = 'group_access'; diff --git a/app/assets/javascripts/groups/members/index.js b/app/assets/javascripts/members/index.js index 3ec874b8d36..bd80bb2485b 100644 --- a/app/assets/javascripts/groups/members/index.js +++ b/app/assets/javascripts/members/index.js @@ -1,11 +1,11 @@ import Vue from 'vue'; import Vuex from 'vuex'; import { GlToast } from '@gitlab/ui'; -import { parseDataAttributes } from 'ee_else_ce/groups/members/utils'; +import { parseDataAttributes } from 'ee_else_ce/members/utils'; import App from './components/app.vue'; -import membersStore from '~/members/store'; +import membersStore from './store'; -export const initGroupMembersApp = ( +export const initMembersApp = ( el, { tableFields = [], diff --git a/app/assets/javascripts/members/store/actions.js b/app/assets/javascripts/members/store/actions.js index 4c31b3c9744..7b191dd85d0 100644 --- a/app/assets/javascripts/members/store/actions.js +++ b/app/assets/javascripts/members/store/actions.js @@ -1,6 +1,6 @@ -import * as types from './mutation_types'; import axios from '~/lib/utils/axios_utils'; import { formatDate } from '~/lib/utils/datetime_utility'; +import * as types from './mutation_types'; export const updateMemberRole = async ({ state, commit }, { memberId, accessLevel }) => { try { @@ -11,7 +11,7 @@ export const updateMemberRole = async ({ state, commit }, { memberId, accessLeve commit(types.RECEIVE_MEMBER_ROLE_SUCCESS, { memberId, accessLevel }); } catch (error) { - commit(types.RECEIVE_MEMBER_ROLE_ERROR); + commit(types.RECEIVE_MEMBER_ROLE_ERROR, { error }); throw error; } @@ -37,7 +37,7 @@ export const updateMemberExpiration = async ({ state, commit }, { memberId, expi expiresAt: expiresAt ? formatDate(expiresAt, 'isoUtcDateTime') : null, }); } catch (error) { - commit(types.RECEIVE_MEMBER_EXPIRATION_ERROR); + commit(types.RECEIVE_MEMBER_EXPIRATION_ERROR, { error }); throw error; } diff --git a/app/assets/javascripts/members/store/mutations.js b/app/assets/javascripts/members/store/mutations.js index 2415e744290..f4aac1571d6 100644 --- a/app/assets/javascripts/members/store/mutations.js +++ b/app/assets/javascripts/members/store/mutations.js @@ -13,10 +13,10 @@ export default { Vue.set(member, 'accessLevel', accessLevel); }, - [types.RECEIVE_MEMBER_ROLE_ERROR](state) { - state.errorMessage = s__( - "Members|An error occurred while updating the member's role, please try again.", - ); + [types.RECEIVE_MEMBER_ROLE_ERROR](state, { error }) { + state.errorMessage = + error.response?.data?.message || + s__("Members|An error occurred while updating the member's role, please try again."); state.showError = true; }, [types.RECEIVE_MEMBER_EXPIRATION_SUCCESS](state, { memberId, expiresAt }) { @@ -28,10 +28,12 @@ export default { Vue.set(member, 'expiresAt', expiresAt); }, - [types.RECEIVE_MEMBER_EXPIRATION_ERROR](state) { - state.errorMessage = s__( - "Members|An error occurred while updating the member's expiration date, please try again.", - ); + [types.RECEIVE_MEMBER_EXPIRATION_ERROR](state, { error }) { + state.errorMessage = + error.response?.data?.message || + s__( + "Members|An error occurred while updating the member's expiration date, please try again.", + ); state.showError = true; }, [types.HIDE_ERROR](state) { diff --git a/app/assets/javascripts/members/utils.js b/app/assets/javascripts/members/utils.js index 780b5a9df57..bac83533214 100644 --- a/app/assets/javascripts/members/utils.js +++ b/app/assets/javascripts/members/utils.js @@ -1,7 +1,17 @@ +import { isUndefined } from 'lodash'; import { __ } from '~/locale'; -import { getParameterByName } from '~/lib/utils/common_utils'; +import { + getParameterByName, + convertObjectPropsToCamelCase, + parseBoolean, +} from '~/lib/utils/common_utils'; import { setUrlParams } from '~/lib/utils/url_utility'; -import { FIELDS, DEFAULT_SORT } from './constants'; +import { + FIELDS, + DEFAULT_SORT, + GROUP_LINK_BASE_PROPERTY_NAME, + GROUP_LINK_ACCESS_LEVEL_PROPERTY_NAME, +} from './constants'; export const generateBadges = (member, isCurrentUser) => [ { @@ -25,26 +35,24 @@ export const isGroup = (member) => { return Boolean(member.sharedWithGroup); }; -export const isDirectMember = (member, sourceId) => { - return isGroup(member) || member.source?.id === sourceId; +export const isDirectMember = (member) => { + return isGroup(member) || member.isDirectMember; }; export const isCurrentUser = (member, currentUserId) => { return member.user?.id === currentUserId; }; -export const canRemove = (member, sourceId) => { - return isDirectMember(member, sourceId) && member.canRemove; +export const canRemove = (member) => { + return isDirectMember(member) && member.canRemove; }; export const canResend = (member) => { return Boolean(member.invite?.canResend); }; -export const canUpdate = (member, currentUserId, sourceId) => { - return ( - !isCurrentUser(member, currentUserId) && isDirectMember(member, sourceId) && member.canUpdate - ); +export const canUpdate = (member, currentUserId) => { + return !isCurrentUser(member, currentUserId) && isDirectMember(member) && member.canUpdate; }; export const parseSortParam = (sortableFields) => { @@ -95,3 +103,35 @@ export const buildSortHref = ({ // Defined in `ee/app/assets/javascripts/vue_shared/components/members/utils.js` export const canOverride = () => false; + +export const parseDataAttributes = (el) => { + const { members, sourceId, memberPath, canManageMembers } = el.dataset; + + return { + members: convertObjectPropsToCamelCase(JSON.parse(members), { deep: true }), + sourceId: parseInt(sourceId, 10), + memberPath, + canManageMembers: parseBoolean(canManageMembers), + }; +}; + +export const baseRequestFormatter = (basePropertyName, accessLevelPropertyName) => ({ + accessLevel, + ...otherProperties +}) => { + const accessLevelProperty = !isUndefined(accessLevel) + ? { [accessLevelPropertyName]: accessLevel } + : {}; + + return { + [basePropertyName]: { + ...accessLevelProperty, + ...otherProperties, + }, + }; +}; + +export const groupLinkRequestFormatter = baseRequestFormatter( + GROUP_LINK_BASE_PROPERTY_NAME, + GROUP_LINK_ACCESS_LEVEL_PROPERTY_NAME, +); diff --git a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js index 338fbd9078a..87642c7a698 100644 --- a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js +++ b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js @@ -1,4 +1,7 @@ -/* eslint-disable no-param-reassign */ +// This is a true violation of @gitlab/no-runtime-template-compiler, as it relies on +// app/views/projects/merge_requests/conflicts/components/_diff_file_editor.html.haml +// for its template. +/* eslint-disable no-param-reassign, @gitlab/no-runtime-template-compiler */ import Vue from 'vue'; import { debounce } from 'lodash'; @@ -90,9 +93,11 @@ import { __ } from '~/locale'; this.saved = true; // This probably be better placed in the data provider + /* eslint-disable vue/no-mutating-props */ this.file.content = this.editor.getValue(); this.file.resolveEditChanged = this.file.content !== this.originalContent; this.file.promptDiscardConfirmation = false; + /* eslint-enable vue/no-mutating-props */ }, resetEditorContent() { if (this.fileLoaded) { diff --git a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.js b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.js index bc926cb9155..47214e288ae 100644 --- a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.js +++ b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.js @@ -1,4 +1,7 @@ -/* eslint-disable no-param-reassign */ +// This is a true violation of @gitlab/no-runtime-template-compiler, as it relies on +// app/views/projects/merge_requests/conflicts/components/_inline_conflict_lines.html.haml +// for its template. +/* eslint-disable no-param-reassign, @gitlab/no-runtime-template-compiler */ import Vue from 'vue'; import actionsMixin from '../mixins/line_conflict_actions'; diff --git a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js index bb306e74825..1d5946cd78a 100644 --- a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js +++ b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js @@ -15,6 +15,9 @@ import utilsMixin from '../mixins/line_conflict_utils'; required: true, }, }, + // This is a true violation of @gitlab/no-runtime-template-compiler, as it + // has a template string. + // eslint-disable-next-line @gitlab/no-runtime-template-compiler template: ` <table class="diff-wrap-lines code js-syntax-highlight"> <tr class="line_holder parallel" v-for="section in file.parallelLines"> diff --git a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js index 229f6f3e339..96b2e2a2240 100644 --- a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js +++ b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js @@ -1,15 +1,19 @@ +// This is a true violation of @gitlab/no-runtime-template-compiler, as it +// relies on app/views/projects/merge_requests/conflicts/show.html.haml for its +// template. +/* eslint-disable @gitlab/no-runtime-template-compiler */ import $ from 'jquery'; import Vue from 'vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; +import { __ } from '~/locale'; import { deprecatedCreateFlash as createFlash } from '../flash'; import initIssuableSidebar from '../init_issuable_sidebar'; import './merge_conflict_store'; +import syntaxHighlight from '../syntax_highlight'; import MergeConflictsService from './merge_conflict_service'; import './components/diff_file_editor'; import './components/inline_conflict_lines'; import './components/parallel_conflict_lines'; -import syntaxHighlight from '../syntax_highlight'; -import { __ } from '~/locale'; export default function initMergeConflicts() { const INTERACTIVE_RESOLVE_MODE = 'interactive'; diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index bf9e0a309dd..7bcfe529175 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -1,10 +1,10 @@ /* eslint-disable func-names, no-underscore-dangle, consistent-return */ import $ from 'jquery'; -import axios from './lib/utils/axios_utils'; import { __ } from '~/locale'; import eventHub from '~/vue_merge_request_widget/event_hub'; import { deprecatedCreateFlash as createFlash } from '~/flash'; +import axios from './lib/utils/axios_utils'; import TaskList from './task_list'; import MergeRequestTabs from './merge_request_tabs'; import { addDelimiter } from './lib/utils/text_utility'; diff --git a/app/assets/javascripts/merge_request/components/status_box.vue b/app/assets/javascripts/merge_request/components/status_box.vue index fd99802caff..5d2660d65e6 100644 --- a/app/assets/javascripts/merge_request/components/status_box.vue +++ b/app/assets/javascripts/merge_request/components/status_box.vue @@ -5,12 +5,14 @@ import mrEventHub from '../eventhub'; const CLASSES = { opened: 'status-box-open', + locked: 'status-box-open', closed: 'status-box-mr-closed', merged: 'status-box-mr-merged', }; const STATUS = { opened: [__('Open'), 'issue-open-m'], + locked: [__('Open'), 'issue-open-m'], closed: [__('Closed'), 'close'], merged: [__('Merged'), 'git-merge'], }; @@ -59,10 +61,10 @@ export default { <div :class="statusBoxClass" class="issuable-status-box status-box"> <gl-icon :name="statusIconName" - class="gl-display-block gl-display-sm-none!" + class="gl-display-block gl-sm-display-none!" data-testid="status-icon" /> - <span class="gl-display-none gl-display-sm-block"> + <span class="gl-display-none gl-sm-display-block"> {{ statusHumanName }} </span> </div> diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index 921925e15c5..badd87921d4 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -95,14 +95,19 @@ export default class MilestoneSelect { name: m.title, })) .sort((mA, mB) => { + const dueDateA = mA.due_date ? parsePikadayDate(mA.due_date) : null; + const dueDateB = mB.due_date ? parsePikadayDate(mB.due_date) : null; + // Move all expired milestones to the bottom. - if (mA.expired) { - return 1; - } - if (mB.expired) { - return -1; - } - return 0; + if (mA.expired) return 1; + if (mB.expired) return -1; + + // Move milestones without due dates just above expired milestones. + if (!dueDateA) return 1; + if (!dueDateB) return -1; + + // Sort by due date in ascending order. + return dueDateA - dueDateB; }), ) .then((data) => { diff --git a/app/assets/javascripts/milestones/components/milestone_results_section.vue b/app/assets/javascripts/milestones/components/milestone_results_section.vue index d53a59e58d4..b866977b974 100644 --- a/app/assets/javascripts/milestones/components/milestone_results_section.vue +++ b/app/assets/javascripts/milestones/components/milestone_results_section.vue @@ -77,12 +77,7 @@ export default { </div> </template> <template v-else> - <gl-dropdown-item - v-for="{ title } in items" - :key="title" - role="milestone option" - @click="$emit('selected', title)" - > + <gl-dropdown-item v-for="{ title } in items" :key="title" @click="$emit('selected', title)"> <span class="gl-pl-6" :class="{ 'selected-item': isSelectedMilestone(title) }"> {{ title }} </span> diff --git a/app/assets/javascripts/mirrors/mirror_repos.js b/app/assets/javascripts/mirrors/mirror_repos.js index f7200f22471..b4e34021e36 100644 --- a/app/assets/javascripts/mirrors/mirror_repos.js +++ b/app/assets/javascripts/mirrors/mirror_repos.js @@ -3,8 +3,8 @@ import { debounce } from 'lodash'; import { __ } from '~/locale'; import { deprecatedCreateFlash as Flash } from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import SSHMirror from './ssh_mirror'; import { hide } from '~/tooltips'; +import SSHMirror from './ssh_mirror'; export default class MirrorRepos { constructor(container) { diff --git a/app/assets/javascripts/monitoring/components/alert_widget.vue b/app/assets/javascripts/monitoring/components/alert_widget.vue index bf31b86561a..0ffbf169941 100644 --- a/app/assets/javascripts/monitoring/components/alert_widget.vue +++ b/app/assets/javascripts/monitoring/components/alert_widget.vue @@ -3,10 +3,11 @@ import { GlBadge, GlLoadingIcon, GlModalDirective, GlIcon, GlTooltip, GlSprintf import { values, get } from 'lodash'; import { s__ } from '~/locale'; import { deprecatedCreateFlash as createFlash } from '~/flash'; -import AlertWidgetForm from './alert_widget_form.vue'; +import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; import AlertsService from '../services/alerts_service'; import { alertsValidator, queriesValidator } from '../validators'; import { OPERATORS } from '../constants'; +import AlertWidgetForm from './alert_widget_form.vue'; export default { components: { @@ -165,11 +166,11 @@ export default { return get(alertQuery, 'result[0].values', []).map((value) => get(value, '[1]', null)); }, showModal() { - this.$root.$emit('bv::show::modal', this.modalId); + this.$root.$emit(BV_SHOW_MODAL, this.modalId); }, hideModal() { this.errorMessage = null; - this.$root.$emit('bv::hide::modal', this.modalId); + this.$root.$emit(BV_HIDE_MODAL, this.modalId); }, handleSetApiAction(apiAction) { this.apiAction = apiAction; diff --git a/app/assets/javascripts/monitoring/components/charts/column.vue b/app/assets/javascripts/monitoring/components/charts/column.vue index ba947c2fa9c..bb1882c90af 100644 --- a/app/assets/javascripts/monitoring/components/charts/column.vue +++ b/app/assets/javascripts/monitoring/components/charts/column.vue @@ -2,11 +2,11 @@ import { GlResizeObserverDirective } from '@gitlab/ui'; import { GlColumnChart } from '@gitlab/ui/dist/charts'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; -import { chartHeight } from '../../constants'; import { makeDataSeries } from '~/helpers/monitor_helper'; +import { chartHeight } from '../../constants'; import { graphDataValidatorForValues } from '../../utils'; -import { getTimeAxisOptions, getYAxisOptions, getChartGrid } from './options'; import { timezones } from '../../format_date'; +import { getTimeAxisOptions, getYAxisOptions, getChartGrid } from './options'; export default { components: { diff --git a/app/assets/javascripts/monitoring/components/charts/gauge.vue b/app/assets/javascripts/monitoring/components/charts/gauge.vue index 63fa60bbdf0..461ff06be72 100644 --- a/app/assets/javascripts/monitoring/components/charts/gauge.vue +++ b/app/assets/javascripts/monitoring/components/charts/gauge.vue @@ -2,9 +2,9 @@ import { GlResizeObserverDirective } from '@gitlab/ui'; import { GlGaugeChart } from '@gitlab/ui/dist/charts'; import { isFinite, isArray, isInteger } from 'lodash'; +import { getFormatter, SUPPORTED_FORMATS } from '~/lib/utils/unit_format'; import { graphDataValidatorForValues } from '../../utils'; import { getValidThresholds } from './options'; -import { getFormatter, SUPPORTED_FORMATS } from '~/lib/utils/unit_format'; export default { components: { diff --git a/app/assets/javascripts/monitoring/components/charts/single_stat.vue b/app/assets/javascripts/monitoring/components/charts/single_stat.vue index a8ab41ebf26..4cf583fed18 100644 --- a/app/assets/javascripts/monitoring/components/charts/single_stat.vue +++ b/app/assets/javascripts/monitoring/components/charts/single_stat.vue @@ -45,12 +45,18 @@ export default { } if (this.graphData?.maxValue) { - formatter = getFormatter(SUPPORTED_FORMATS.percent); - return formatter(this.queryResult / Number(this.graphData.maxValue), defaultPrecision); + formatter = getFormatter(SUPPORTED_FORMATS.number); + return formatter( + (this.queryResult / Number(this.graphData.maxValue)) * 100, + defaultPrecision, + ); } formatter = getFormatter(SUPPORTED_FORMATS.number); - return `${formatter(this.queryResult, defaultPrecision)}${this.queryInfo.unit ?? ''}`; + return `${formatter(this.queryResult, defaultPrecision)}`; + }, + unit() { + return this.graphData?.maxValue ? '%' : this.queryInfo.unit; }, graphTitle() { return this.queryInfo.label; @@ -60,6 +66,6 @@ export default { </script> <template> <div> - <gl-single-stat :value="statValue" :title="graphTitle" variant="success" /> + <gl-single-stat :value="statValue" :title="graphTitle" :unit="unit" variant="success" /> </div> </template> diff --git a/app/assets/javascripts/monitoring/components/charts/stacked_column.vue b/app/assets/javascripts/monitoring/components/charts/stacked_column.vue index b5ae6bcfd13..bfa0bd5333b 100644 --- a/app/assets/javascripts/monitoring/components/charts/stacked_column.vue +++ b/app/assets/javascripts/monitoring/components/charts/stacked_column.vue @@ -2,11 +2,11 @@ import { GlResizeObserverDirective } from '@gitlab/ui'; import { GlStackedColumnChart } from '@gitlab/ui/dist/charts'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; -import { chartHeight, legendLayoutTypes } from '../../constants'; import { s__ } from '~/locale'; +import { chartHeight, legendLayoutTypes } from '../../constants'; import { graphDataValidatorForValues } from '../../utils'; -import { getTimeAxisOptions, axisTypes } from './options'; import { formats, timezones } from '../../format_date'; +import { getTimeAxisOptions, axisTypes } from './options'; export default { components: { diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue index e9f7b11c977..b720700d36d 100644 --- a/app/assets/javascripts/monitoring/components/charts/time_series.vue +++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue @@ -4,12 +4,12 @@ import { GlLink, GlTooltip, GlResizeObserverDirective, GlIcon } from '@gitlab/ui import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; import { s__ } from '~/locale'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; -import { panelTypes, chartHeight, lineTypes, lineWidths, legendLayoutTypes } from '../../constants'; -import { getYAxisOptions, getTimeAxisOptions, getChartGrid, getTooltipFormatter } from './options'; -import { annotationsYAxis, generateAnnotationsSeries } from './annotations'; import { makeDataSeries } from '~/helpers/monitor_helper'; +import { panelTypes, chartHeight, lineTypes, lineWidths, legendLayoutTypes } from '../../constants'; import { graphDataValidatorForValues } from '../../utils'; import { formatDate, timezones } from '../../format_date'; +import { getYAxisOptions, getTimeAxisOptions, getChartGrid, getTooltipFormatter } from './options'; +import { annotationsYAxis, generateAnnotationsSeries } from './annotations'; export const timestampToISODate = (timestamp) => new Date(timestamp).toISOString(); diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 16c2c87a4b7..5ec87332288 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -3,21 +3,13 @@ import { mapActions, mapState, mapGetters } from 'vuex'; import VueDraggable from 'vuedraggable'; import Mousetrap from 'mousetrap'; import { GlButton, GlModalDirective, GlTooltipDirective, GlIcon } from '@gitlab/ui'; -import DashboardHeader from './dashboard_header.vue'; -import DashboardPanel from './dashboard_panel.vue'; import { s__ } from '~/locale'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { ESC_KEY } from '~/lib/utils/keys'; import { mergeUrlParams, updateHistory } from '~/lib/utils/url_utility'; import invalidUrl from '~/lib/utils/invalid_url'; - -import GraphGroup from './graph_group.vue'; -import EmptyState from './empty_state.vue'; -import GroupEmptyState from './group_empty_state.vue'; -import VariablesSection from './variables_section.vue'; -import LinksSection from './links_section.vue'; - import TrackEventDirective from '~/vue_shared/directives/track_event'; +import { defaultTimeRange } from '~/vue_shared/constants'; import { timeRangeFromUrl, panelToUrl, @@ -25,7 +17,14 @@ import { convertVariablesForURL, } from '../utils'; import { metricStates, keyboardShortcutKeys } from '../constants'; -import { defaultTimeRange } from '~/vue_shared/constants'; +import DashboardHeader from './dashboard_header.vue'; +import DashboardPanel from './dashboard_panel.vue'; + +import GraphGroup from './graph_group.vue'; +import EmptyState from './empty_state.vue'; +import GroupEmptyState from './group_empty_state.vue'; +import VariablesSection from './variables_section.vue'; +import LinksSection from './links_section.vue'; export default { components: { diff --git a/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue b/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue index 9d1926dca54..77d3766d4bc 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue @@ -11,14 +11,14 @@ import { GlTooltipDirective, } from '@gitlab/ui'; import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue'; -import { PANEL_NEW_PAGE } from '../router/constants'; -import DuplicateDashboardModal from './duplicate_dashboard_modal.vue'; -import CreateDashboardModal from './create_dashboard_modal.vue'; import { s__ } from '~/locale'; import invalidUrl from '~/lib/utils/invalid_url'; import { redirectTo } from '~/lib/utils/url_utility'; import TrackEventDirective from '~/vue_shared/directives/track_event'; +import { PANEL_NEW_PAGE } from '../router/constants'; import { getAddMetricTrackingOptions } from '../utils'; +import CreateDashboardModal from './create_dashboard_modal.vue'; +import DuplicateDashboardModal from './duplicate_dashboard_modal.vue'; export default { components: { diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue index 0f6a9ce3814..0303c47203f 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_header.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_header.vue @@ -16,14 +16,13 @@ import { mergeUrlParams, redirectTo } from '~/lib/utils/url_utility'; import invalidUrl from '~/lib/utils/invalid_url'; import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue'; +import { timeRanges } from '~/vue_shared/constants'; +import { timeRangeToUrl } from '../utils'; +import { timezones } from '../format_date'; import DashboardsDropdown from './dashboards_dropdown.vue'; import RefreshButton from './refresh_button.vue'; import ActionsMenu from './dashboard_actions_menu.vue'; -import { timeRangeToUrl } from '../utils'; -import { timeRanges } from '~/vue_shared/constants'; -import { timezones } from '../format_date'; - export default { components: { GlIcon, diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel.vue b/app/assets/javascripts/monitoring/components/dashboard_panel.vue index 2b0c3d03b8d..bf4f52bb4b9 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_panel.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_panel.vue @@ -19,8 +19,12 @@ import invalidUrl from '~/lib/utils/invalid_url'; import { convertToFixedRange } from '~/lib/utils/datetime_range'; import { relativePathToAbsolute, getBaseURL, visitUrl, isSafeURL } from '~/lib/utils/url_utility'; import { __, n__ } from '~/locale'; +import { BV_SHOW_MODAL } from '~/lib/utils/constants'; +import TrackEventDirective from '~/vue_shared/directives/track_event'; import { panelTypes } from '../constants'; +import { timeRangeToUrl, downloadCSVOptions, generateLinkToChartOptions } from '../utils'; +import { graphDataToCsv } from '../csv_export'; import MonitorEmptyChart from './charts/empty_chart.vue'; import MonitorTimeSeriesChart from './charts/time_series.vue'; import MonitorAnomalyChart from './charts/anomaly.vue'; @@ -31,10 +35,7 @@ import MonitorColumnChart from './charts/column.vue'; import MonitorBarChart from './charts/bar.vue'; import MonitorStackedColumnChart from './charts/stacked_column.vue'; -import TrackEventDirective from '~/vue_shared/directives/track_event'; import AlertWidget from './alert_widget.vue'; -import { timeRangeToUrl, downloadCSVOptions, generateLinkToChartOptions } from '../utils'; -import { graphDataToCsv } from '../csv_export'; const events = { timeRangeZoom: 'timerangezoom', @@ -318,7 +319,7 @@ export default { return isSafeURL(url) ? url : '#'; }, showAlertModal() { - this.$root.$emit('bv::show::modal', this.alertModalId); + this.$root.$emit(BV_SHOW_MODAL, this.alertModalId); }, showAlertModalFromKeyboardShortcut() { if (this.isContextualMenuShown) { diff --git a/app/assets/javascripts/monitoring/components/links_section.vue b/app/assets/javascripts/monitoring/components/links_section.vue index ca1e9c4d0d4..1fa36c14242 100644 --- a/app/assets/javascripts/monitoring/components/links_section.vue +++ b/app/assets/javascripts/monitoring/components/links_section.vue @@ -15,12 +15,12 @@ export default { <template> <div ref="linksSection" - class="gl-display-sm-flex gl-flex-sm-wrap gl-mt-5 gl-p-3 gl-bg-gray-10 border gl-rounded-base links-section" + class="gl-sm-display-flex gl-flex-sm-wrap gl-mt-5 gl-p-3 gl-bg-gray-10 border gl-rounded-base links-section" > <div v-for="(link, key) in links" :key="key" - class="gl-mb-1 gl-mr-5 gl-display-flex gl-display-sm-block gl-hover-text-blue-600-children gl-word-break-all" + class="gl-mb-1 gl-mr-5 gl-display-flex gl-sm-display-block gl-hover-text-blue-600-children gl-word-break-all" > <gl-link :href="link.url" class="gl-text-gray-900 gl-text-decoration-none!" ><gl-icon name="link" class="gl-text-gray-500 gl-vertical-align-text-bottom gl-mr-2" />{{ diff --git a/app/assets/javascripts/monitoring/components/variables_section.vue b/app/assets/javascripts/monitoring/components/variables_section.vue index 7c4fb135ec8..a4aa00abbd1 100644 --- a/app/assets/javascripts/monitoring/components/variables_section.vue +++ b/app/assets/javascripts/monitoring/components/variables_section.vue @@ -1,9 +1,9 @@ <script> import { mapState, mapActions } from 'vuex'; -import DropdownField from './variables/dropdown_field.vue'; -import TextField from './variables/text_field.vue'; import { setCustomVariablesFromUrl } from '../utils'; import { VARIABLE_TYPES } from '../constants'; +import DropdownField from './variables/dropdown_field.vue'; +import TextField from './variables/text_field.vue'; export default { components: { diff --git a/app/assets/javascripts/monitoring/monitoring_app.js b/app/assets/javascripts/monitoring/monitoring_app.js index 307154c9a84..2f8c3e2a0c4 100644 --- a/app/assets/javascripts/monitoring/monitoring_app.js +++ b/app/assets/javascripts/monitoring/monitoring_app.js @@ -26,7 +26,24 @@ export default (props = {}) => { dashboardProps: { ...dataProps, ...props }, }; }, - template: `<router-view :dashboardProps="dashboardProps"/>`, + render(h) { + return h('RouterView', { + // This is attrs rather than props because: + // 1. RouterView only actually defines one prop: `name`. + // 2. The RouterView [throws away other props][1] given to it, in + // favour of those configured in the route config/params. + // 3. The Vue template compiler itself in general compiles anything + // like <some-component :foo="bar" /> into roughly + // h('some-component', { attrs: { foo: bar } }). Then later, Vue + // [extract props from attrs and merges them with props][2], + // matching them up according to the component's definition. + // [1]: https://github.com/vuejs/vue-router/blob/v3.4.9/src/components/view.js#L124 + // [2]: https://github.com/vuejs/vue/blob/v2.6.12/src/core/vdom/helpers/extract-props.js#L12-L50 + attrs: { + dashboardProps: this.dashboardProps, + }, + }); + }, }); } }; diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 44c200cdb54..391cb35882c 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -1,14 +1,7 @@ import * as Sentry from '~/sentry/wrapper'; -import * as types from './mutation_types'; import axios from '~/lib/utils/axios_utils'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { convertToFixedRange } from '~/lib/utils/datetime_range'; -import { - gqClient, - parseEnvironmentsResponse, - parseAnnotationsResponse, - removeLeadingSlash, -} from './utils'; import trackDashboardLoad from '../monitoring_tracking_helper'; import getEnvironments from '../queries/getEnvironments.query.graphql'; import getAnnotations from '../queries/getAnnotations.query.graphql'; @@ -18,6 +11,13 @@ import { s__, sprintf } from '../../locale'; import { getDashboard, getPrometheusQueryData } from '../requests'; import { ENVIRONMENT_AVAILABLE_STATE, OVERVIEW_DASHBOARD_PATH, VARIABLE_TYPES } from '../constants'; +import { + gqClient, + parseEnvironmentsResponse, + parseAnnotationsResponse, + removeLeadingSlash, +} from './utils'; +import * as types from './mutation_types'; const axiosCancelToken = axios.CancelToken; let cancelTokenSource; diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index 5c5a7d03b97..37febd062ff 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -1,10 +1,10 @@ import Vue from 'vue'; import { pick } from 'lodash'; -import * as types from './mutation_types'; -import { mapToDashboardViewModel, mapPanelToViewModel, normalizeQueryResponseData } from './utils'; import httpStatusCodes from '~/lib/utils/http_status'; import { BACKOFF_TIMEOUT } from '~/lib/utils/common_utils'; import { dashboardEmptyStates, endpointKeys, initialStateKeys, metricStates } from '../constants'; +import { mapToDashboardViewModel, mapPanelToViewModel, normalizeQueryResponseData } from './utils'; +import * as types from './mutation_types'; import { optionsFromSeriesData } from './variable_mapping'; /** diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js index ef8b1adb624..b8ecb15a72f 100644 --- a/app/assets/javascripts/monitoring/stores/state.js +++ b/app/assets/javascripts/monitoring/stores/state.js @@ -1,7 +1,7 @@ import invalidUrl from '~/lib/utils/invalid_url'; +import { defaultTimeRange } from '~/vue_shared/constants'; import { timezones } from '../format_date'; import { dashboardEmptyStates } from '../constants'; -import { defaultTimeRange } from '~/vue_shared/constants'; export default () => ({ // API endpoints diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js index 36e5a135d59..305080cc104 100644 --- a/app/assets/javascripts/monitoring/stores/utils.js +++ b/app/assets/javascripts/monitoring/stores/utils.js @@ -2,11 +2,11 @@ import { slugify } from '~/lib/utils/text_utility'; import createGqClient, { fetchPolicies } from '~/lib/graphql'; import { SUPPORTED_FORMATS } from '~/lib/utils/unit_format'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { mergeURLVariables, parseTemplatingVariables } from './variable_mapping'; import { DATETIME_RANGE_TYPES } from '~/lib/utils/constants'; import { timeRangeToParams, getRangeType } from '~/lib/utils/datetime_range'; import { isSafeURL, mergeUrlParams } from '~/lib/utils/url_utility'; import { NOT_IN_DB_PREFIX, linkTypes, OUT_OF_THE_BOX_DASHBOARDS_PATH_PREFIX } from '../constants'; +import { mergeURLVariables, parseTemplatingVariables } from './variable_mapping'; export const gqClient = createGqClient( {}, diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js index fb6ef0249bb..e59991e638b 100644 --- a/app/assets/javascripts/mr_notes/index.js +++ b/app/assets/javascripts/mr_notes/index.js @@ -1,12 +1,13 @@ import Vue from 'vue'; import store from '~/mr_notes/stores'; -import initNotesApp from './init_notes'; +import initRevertCommitModal from '~/projects/commit/init_revert_commit_modal'; import initDiffsApp from '../diffs'; import discussionCounter from '../notes/components/discussion_counter.vue'; import initDiscussionFilters from '../notes/discussion_filters'; import initSortDiscussions from '../notes/sort_discussions'; import MergeRequest from '../merge_request'; import { resetServiceWorkersPublicPath } from '../lib/utils/webpack'; +import initNotesApp from './init_notes'; export default function initMrNotes() { resetServiceWorkersPublicPath(); @@ -19,6 +20,10 @@ export default function initMrNotes() { initNotesApp(); + document.addEventListener('merged:UpdateActions', () => { + initRevertCommitModal(); + }); + // eslint-disable-next-line no-new new Vue({ el: '#js-vue-discussion-counter', diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js index ab88a610469..958ee8e226a 100644 --- a/app/assets/javascripts/mr_notes/init_notes.js +++ b/app/assets/javascripts/mr_notes/init_notes.js @@ -2,10 +2,10 @@ import $ from 'jquery'; import Vue from 'vue'; import { mapActions, mapState, mapGetters } from 'vuex'; import store from '~/mr_notes/stores'; +import { parseBoolean } from '~/lib/utils/common_utils'; import notesApp from '../notes/components/notes_app.vue'; import discussionNavigator from '../notes/components/discussion_navigator.vue'; import initWidget from '../vue_merge_request_widget'; -import { parseBoolean } from '~/lib/utils/common_utils'; export default () => { // eslint-disable-next-line no-new diff --git a/app/assets/javascripts/mr_popover/index.js b/app/assets/javascripts/mr_popover/index.js index 03ddfd13d50..714cf67e0bd 100644 --- a/app/assets/javascripts/mr_popover/index.js +++ b/app/assets/javascripts/mr_popover/index.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import MRPopover from './components/mr_popover.vue'; import createDefaultClient from '~/lib/graphql'; +import MRPopover from './components/mr_popover.vue'; let renderedPopover; let renderFn; diff --git a/app/assets/javascripts/namespace_select.js b/app/assets/javascripts/namespace_select.js index e668b492ebe..16acf130c9f 100644 --- a/app/assets/javascripts/namespace_select.js +++ b/app/assets/javascripts/namespace_select.js @@ -1,9 +1,9 @@ import $ from 'jquery'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import Api from './api'; import { mergeUrlParams } from './lib/utils/url_utility'; -import { parseBoolean } from '~/lib/utils/common_utils'; import { __ } from './locale'; -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; export default class NamespaceSelect { constructor(opts) { diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 857e5a34db6..78e89f28542 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -904,18 +904,7 @@ export default class Notes { // DiffNote form.find('#note_position').val(dataHolder.attr('data-position')); - form - .prepend( - `<a href="${escape( - gon.current_username, - )}" class="user-avatar-link d-none d-sm-block"><img class="avatar s40" src="${encodeURI( - gon.current_user_avatar_url || gon.default_avatar_url, - )}" alt="${escape(gon.current_user_fullname)}" /></a>`, - ) - .append('</div>') - .find('.js-close-discussion-note-form') - .show() - .removeClass('hide'); + form.append('</div>').find('.js-close-discussion-note-form').show().removeClass('hide'); form.find('.js-note-target-close').remove(); form.find('.js-note-new-discussion').remove(); this.setupNoteForm(form); diff --git a/app/assets/javascripts/notes/components/comment_field_layout.vue b/app/assets/javascripts/notes/components/comment_field_layout.vue index aaf64702ffd..47d14783d5d 100644 --- a/app/assets/javascripts/notes/components/comment_field_layout.vue +++ b/app/assets/javascripts/notes/components/comment_field_layout.vue @@ -1,6 +1,6 @@ <script> -import EmailParticipantsWarning from './email_participants_warning.vue'; import NoteableWarning from '~/vue_shared/components/notes/noteable_warning.vue'; +import EmailParticipantsWarning from './email_participants_warning.vue'; const DEFAULT_NOTEABLE_TYPE = 'Issue'; diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 111af977ec5..bc2b2d6d5d0 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -15,14 +15,13 @@ import { slugifyWithUnderscore, } from '~/lib/utils/text_utility'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; -import * as constants from '../constants'; -import eventHub from '../event_hub'; import markdownField from '~/vue_shared/components/markdown/field.vue'; -import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import * as constants from '../constants'; +import eventHub from '../event_hub'; +import issuableStateMixin from '../mixins/issuable_state'; import noteSignedOutWidget from './note_signed_out_widget.vue'; import discussionLockedWidget from './discussion_locked_widget.vue'; -import issuableStateMixin from '../mixins/issuable_state'; import CommentFieldLayout from './comment_field_layout.vue'; export default { @@ -31,7 +30,6 @@ export default { noteSignedOutWidget, discussionLockedWidget, markdownField, - userAvatarLink, GlButton, TimelineEntryItem, GlIcon, @@ -145,6 +143,9 @@ export default { trackingLabel() { return slugifyWithUnderscore(`${this.commentButtonTitle} button`); }, + hasCloseAndCommentButton() { + return !this.glFeatures.removeCommentCloseReopen; + }, }, watch: { note(newNote) { @@ -301,15 +302,6 @@ export default { <ul v-else-if="canCreateNote" class="notes notes-form timeline"> <timeline-entry-item class="note-form"> <div class="flash-container error-alert timeline-content"></div> - <div class="timeline-icon d-none d-md-block"> - <user-avatar-link - v-if="author" - :link-href="author.path" - :img-src="author.avatar_url" - :img-alt="author.name" - :img-size="40" - /> - </div> <div class="timeline-content timeline-content-form"> <form ref="commentForm" class="new-note common-note-form gfm-form js-main-target-form"> <comment-field-layout @@ -384,7 +376,7 @@ export default { class="btn btn-transparent" @click.prevent="setNoteType('comment')" > - <gl-icon name="check" class="icon" /> + <gl-icon name="check" class="icon gl-flex-shrink-0" /> <div class="description"> <strong>{{ __('Comment') }}</strong> <p> @@ -400,10 +392,12 @@ export default { <li class="divider droplab-item-ignore"></li> <li :class="{ 'droplab-item-selected': noteType === 'discussion' }"> <button + type="button" + class="btn btn-transparent" data-qa-selector="discussion_menu_item" @click.prevent="setNoteType('discussion')" > - <gl-icon name="check" class="icon" /> + <gl-icon name="check" class="icon gl-flex-shrink-0" /> <div class="description"> <strong>{{ __('Start thread') }}</strong> <p>{{ startDiscussionDescription }}</p> @@ -414,7 +408,7 @@ export default { </div> <gl-button - v-if="canToggleIssueState" + v-if="hasCloseAndCommentButton && canToggleIssueState" :loading="isToggleStateButtonLoading" category="secondary" :variant="buttonVariant" diff --git a/app/assets/javascripts/notes/components/discussion_actions.vue b/app/assets/javascripts/notes/components/discussion_actions.vue index da4134ab2c4..27408bc3354 100644 --- a/app/assets/javascripts/notes/components/discussion_actions.vue +++ b/app/assets/javascripts/notes/components/discussion_actions.vue @@ -1,8 +1,8 @@ <script> +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import ReplyPlaceholder from './discussion_reply_placeholder.vue'; import ResolveDiscussionButton from './discussion_resolve_button.vue'; import ResolveWithIssueButton from './discussion_resolve_with_issue_button.vue'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { name: 'DiscussionActions', diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue index 8ac915c3c03..fc82f8e6b89 100644 --- a/app/assets/javascripts/notes/components/discussion_notes.vue +++ b/app/assets/javascripts/notes/components/discussion_notes.vue @@ -1,15 +1,15 @@ <script> import { mapGetters, mapActions } from 'vuex'; -import { SYSTEM_NOTE } from '../constants'; import { __ } from '~/locale'; import PlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue'; import PlaceholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue'; import SystemNote from '~/vue_shared/components/notes/system_note.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { SYSTEM_NOTE } from '../constants'; import NoteableNote from './noteable_note.vue'; import ToggleRepliesWidget from './toggle_replies_widget.vue'; import NoteEditedText from './note_edited_text.vue'; import DiscussionNotesRepliesWrapper from './discussion_notes_replies_wrapper.vue'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { name: 'DiscussionNotes', diff --git a/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue b/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue index 2ddca56ddd5..dfeda4aae7c 100644 --- a/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue +++ b/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue @@ -16,9 +16,14 @@ export default { }, render(h, { props, children }) { if (props.isDiffDiscussion) { - return h('li', { class: 'discussion-collapsible bordered-box clearfix' }, [ - h('ul', { class: 'notes' }, children), - ]); + return h( + 'li', + { + class: + 'discussion-collapsible gl-border-solid gl-border-gray-100 gl-border-1 gl-rounded-base gl-overflow-hidden clearfix', + }, + [h('ul', { class: 'notes' }, children)], + ); } return children; diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index b85cfa83e09..bc8e1d3fec6 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -3,11 +3,12 @@ import { mapGetters } from 'vuex'; import { GlTooltipDirective, GlIcon, GlButton, GlDropdownItem } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import resolvedStatusMixin from '~/batch_comments/mixins/resolved_status'; -import ReplyButton from './note_actions/reply_button.vue'; import eventHub from '~/sidebar/event_hub'; import Api from '~/api'; import { deprecatedCreateFlash as flash } from '~/flash'; +import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { splitCamelCase } from '../../lib/utils/text_utility'; +import ReplyButton from './note_actions/reply_button.vue'; export default { name: 'NoteActions', @@ -193,7 +194,7 @@ export default { }, closeTooltip() { this.$nextTick(() => { - this.$root.$emit('bv::hide::tooltip'); + this.$root.$emit(BV_HIDE_TOOLTIP); }); }, handleAssigneeUpdate(assignees) { diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue index cf3e991986c..ff0bf5bd142 100644 --- a/app/assets/javascripts/notes/components/note_awards_list.vue +++ b/app/assets/javascripts/notes/components/note_awards_list.vue @@ -1,8 +1,8 @@ <script> import { mapActions, mapGetters } from 'vuex'; import AwardsList from '~/vue_shared/components/awards_list.vue'; -import { deprecatedCreateFlash as Flash } from '../../flash'; import { __ } from '~/locale'; +import { deprecatedCreateFlash as Flash } from '../../flash'; export default { components: { diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index 8855ceac3d5..03a8a8f9376 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -3,12 +3,12 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import $ from 'jquery'; import '~/behaviors/markdown/render_gfm'; +import Suggestions from '~/vue_shared/components/markdown/suggestions.vue'; +import autosave from '../mixins/autosave'; import noteEditedText from './note_edited_text.vue'; import noteAwardsList from './note_awards_list.vue'; import noteAttachment from './note_attachment.vue'; import noteForm from './note_form.vue'; -import autosave from '../mixins/autosave'; -import Suggestions from '~/vue_shared/components/markdown/suggestions.vue'; export default { components: { @@ -156,6 +156,7 @@ export default { @handleFormUpdate="handleFormUpdate" @cancelForm="formCancelHandler" /> + <!-- eslint-disable vue/no-mutating-props --> <textarea v-if="canEdit" v-model="note.note" @@ -163,6 +164,7 @@ export default { class="hidden js-task-list-field" dir="auto" ></textarea> + <!-- eslint-enable vue/no-mutating-props --> <note-edited-text v-if="note.last_edited_at" :edited-at="note.last_edited_at" diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 9acb837c27f..03ee5547fc8 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -1,14 +1,15 @@ <script> /* eslint-disable vue/no-v-html */ import { mapGetters, mapActions, mapState } from 'vuex'; +import { GlButton } from '@gitlab/ui'; import { mergeUrlParams } from '~/lib/utils/url_utility'; -import eventHub from '../event_hub'; import markdownField from '~/vue_shared/components/markdown/field.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import issuableStateMixin from '../mixins/issuable_state'; -import resolvable from '../mixins/resolvable'; import { __, sprintf } from '~/locale'; import { getDraft, updateDraft } from '~/lib/utils/autosave'; +import issuableStateMixin from '../mixins/issuable_state'; +import resolvable from '../mixins/resolvable'; +import eventHub from '../event_hub'; import CommentFieldLayout from './comment_field_layout.vue'; export default { @@ -16,6 +17,7 @@ export default { components: { markdownField, CommentFieldLayout, + GlButton, }, mixins: [glFeatureFlagsMixin(), issuableStateMixin, resolvable], props: { @@ -378,61 +380,70 @@ export default { </template> </label> </p> - <div> - <button + <div class="gl-display-sm-flex gl-flex-wrap"> + <gl-button :disabled="isDisabled" - type="button" - class="btn btn-success" + category="primary" + variant="success" + class="gl-mr-3" data-qa-selector="start_review_button" @click="handleAddToReview" > <template v-if="hasDrafts">{{ __('Add to review') }}</template> <template v-else>{{ __('Start a review') }}</template> - </button> - <button + </gl-button> + <gl-button :disabled="isDisabled" - type="button" - class="btn js-comment-button" + category="secondary" + variant="default" data-qa-selector="comment_now_button" + class="gl-mr-3 js-comment-button" @click="handleUpdate()" > {{ __('Add comment now') }} - </button> - <button - class="btn note-edit-cancel js-close-discussion-note-form" - type="button" + </gl-button> + <gl-button + class="note-edit-cancel js-close-discussion-note-form" + category="secondary" + variant="default" data-testid="cancelBatchCommentsEnabled" @click="cancelHandler(true)" > {{ __('Cancel') }} - </button> + </gl-button> </div> </template> <template v-else> - <button - :disabled="isDisabled" - type="button" - class="js-vue-issue-save btn btn-success js-comment-button" - data-qa-selector="reply_comment_button" - @click="handleUpdate()" - > - {{ saveButtonTitle }} - </button> - <button - v-if="discussion.resolvable" - class="btn btn-default gl-mr-3 js-comment-resolve-button" - @click.prevent="handleUpdate(true)" - > - {{ resolveButtonTitle }} - </button> - <button - class="btn btn-cancel note-edit-cancel js-close-discussion-note-form" - type="button" - data-testid="cancel" - @click="cancelHandler(true)" - > - {{ __('Cancel') }} - </button> + <div class="gl-display-sm-flex gl-flex-wrap"> + <gl-button + :disabled="isDisabled" + category="primary" + variant="success" + data-qa-selector="reply_comment_button" + class="gl-mr-3 js-vue-issue-save js-comment-button" + @click="handleUpdate()" + > + {{ saveButtonTitle }} + </gl-button> + <gl-button + v-if="discussion.resolvable" + category="secondary" + variant="default" + class="gl-mr-3 js-comment-resolve-button" + @click.prevent="handleUpdate(true)" + > + {{ resolveButtonTitle }} + </gl-button> + <gl-button + class="note-edit-cancel js-close-discussion-note-form" + category="secondary" + variant="default" + data-testid="cancel" + @click="cancelHandler(true)" + > + {{ __('Cancel') }} + </gl-button> + </div> </template> </div> </form> diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 0a9a3da6069..c9722ebb8b6 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -8,13 +8,13 @@ import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item import DraftNote from '~/batch_comments/components/draft_note.vue'; import { deprecatedCreateFlash as Flash } from '../../flash'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; +import noteable from '../mixins/noteable'; +import resolvable from '../mixins/resolvable'; +import eventHub from '../event_hub'; import diffDiscussionHeader from './diff_discussion_header.vue'; import noteSignedOutWidget from './note_signed_out_widget.vue'; import noteForm from './note_form.vue'; import diffWithNote from './diff_with_note.vue'; -import noteable from '../mixins/noteable'; -import resolvable from '../mixins/resolvable'; -import eventHub from '../event_hub'; import DiscussionNotes from './discussion_notes.vue'; import DiscussionActions from './discussion_actions.vue'; @@ -265,16 +265,8 @@ export default { <div v-else-if="showReplies" :class="{ 'is-replying': isReplying }" - class="discussion-reply-holder clearfix" + class="discussion-reply-holder gl-border-t-0! clearfix" > - <user-avatar-link - v-if="!isReplying && userCanReply" - :link-href="currentUser.path" - :img-src="currentUser.avatar_url" - :img-alt="currentUser.name" - :img-size="40" - class="d-none d-sm-block" - /> <discussion-actions v-if="!isReplying && userCanReply" :discussion="discussion" @@ -285,27 +277,18 @@ export default { @showReplyForm="showReplyForm" @resolve="resolveHandler" /> - <div v-if="isReplying" class="avatar-note-form-holder"> - <user-avatar-link - v-if="currentUser" - :link-href="currentUser.path" - :img-src="currentUser.avatar_url" - :img-alt="currentUser.name" - :img-size="40" - class="d-none d-sm-block" - /> - <note-form - ref="noteForm" - :discussion="discussion" - :is-editing="false" - :line="diffLine" - save-button-title="Comment" - :autosave-key="autosaveKey" - @handleFormUpdateAddToReview="addReplyToReview" - @handleFormUpdate="saveReply" - @cancelForm="cancelReplyForm" - /> - </div> + <note-form + v-if="isReplying" + ref="noteForm" + :discussion="discussion" + :is-editing="false" + :line="diffLine" + save-button-title="Comment" + :autosave-key="autosaveKey" + @handleFormUpdateAddToReview="addReplyToReview" + @handleFormUpdate="saveReply" + @cancelForm="cancelReplyForm" + /> <note-signed-out-widget v-if="!isLoggedIn" /> </div> </template> diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index eaa64cf7c01..a1738b993d7 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -6,16 +6,17 @@ import { GlSprintf, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { truncateSha } from '~/lib/utils/text_utility'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; +import httpStatusCodes from '~/lib/utils/http_status'; +import { INLINE_DIFF_LINES_KEY } from '~/diffs/constants'; import { __, s__, sprintf } from '../../locale'; import { deprecatedCreateFlash as Flash } from '../../flash'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; -import noteHeader from './note_header.vue'; -import noteActions from './note_actions.vue'; -import NoteBody from './note_body.vue'; import eventHub from '../event_hub'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; -import httpStatusCodes from '~/lib/utils/http_status'; +import noteHeader from './note_header.vue'; +import noteActions from './note_actions.vue'; +import NoteBody from './note_body.vue'; import { getStartLineNumber, getEndLineNumber, @@ -23,7 +24,6 @@ import { commentLineOptions, formatLineRange, } from './multiline_comment_utils'; -import { INLINE_DIFF_LINES_KEY } from '~/diffs/constants'; export default { name: 'NoteableNote', @@ -289,6 +289,7 @@ export default { }; this.isRequesting = true; this.oldContent = this.note.note_html; + // eslint-disable-next-line vue/no-mutating-props this.note.note_html = escape(noteText); this.updateNote(data) @@ -321,6 +322,7 @@ export default { } this.$refs.noteBody.resetAutoSave(); if (this.oldContent) { + // eslint-disable-next-line vue/no-mutating-props this.note.note_html = this.oldContent; this.oldContent = null; } @@ -330,6 +332,7 @@ export default { recoverNoteContent(noteText) { // we need to do this to prevent noteForm inconsistent content warning // this is something we intentionally do so we need to recover the content + // eslint-disable-next-line vue/no-mutating-props this.note.note = noteText; const { noteBody } = this.$refs; if (noteBody) { diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index e9e687a8743..c0468e5df0f 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -1,21 +1,22 @@ <script> import { mapGetters, mapActions } from 'vuex'; +import OrderedLayout from '~/vue_shared/components/ordered_layout.vue'; +import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user'; +import { __ } from '~/locale'; +import initUserPopovers from '~/user_popovers'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { getLocationHash, doesHashExistInUrl } from '../../lib/utils/url_utility'; import { deprecatedCreateFlash as Flash } from '../../flash'; import * as constants from '../constants'; import eventHub from '../event_hub'; -import noteableNote from './noteable_note.vue'; -import noteableDiscussion from './noteable_discussion.vue'; -import discussionFilterNote from './discussion_filter_note.vue'; import systemNote from '../../vue_shared/components/notes/system_note.vue'; -import commentForm from './comment_form.vue'; import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue'; -import OrderedLayout from '~/vue_shared/components/ordered_layout.vue'; -import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user'; -import { __ } from '~/locale'; -import initUserPopovers from '~/user_popovers'; +import noteableNote from './noteable_note.vue'; +import noteableDiscussion from './noteable_discussion.vue'; +import discussionFilterNote from './discussion_filter_note.vue'; +import commentForm from './comment_form.vue'; export default { name: 'NotesApp', @@ -30,6 +31,7 @@ export default { discussionFilterNote, OrderedLayout, }, + mixins: [glFeatureFlagsMixin()], props: { noteableData: { type: Object, @@ -57,7 +59,6 @@ export default { }, data() { return { - isFetching: false, currentFilter: null, }; }, @@ -68,6 +69,7 @@ export default { 'convertedDisscussionIds', 'getNotesDataByProp', 'isLoading', + 'isFetching', 'commentsDisabled', 'getNoteableData', 'userCanReply', @@ -103,6 +105,13 @@ export default { }, }, watch: { + async isFetching() { + if (!this.isFetching) { + await this.$nextTick(); + await this.startTaskList(); + await this.checkLocationHash(); + } + }, shouldShow() { if (!this.isNotesFetched) { this.fetchNotes(); @@ -153,6 +162,7 @@ export default { }, methods: { ...mapActions([ + 'setFetchingState', 'setLoadingState', 'fetchDiscussions', 'poll', @@ -183,7 +193,11 @@ export default { fetchNotes() { if (this.isFetching) return null; - this.isFetching = true; + this.setFetchingState(true); + + if (this.glFeatures.paginatedNotes) { + return this.initPolling(); + } return this.fetchDiscussions(this.getFetchDiscussionsConfig()) .then(this.initPolling) @@ -191,11 +205,8 @@ export default { this.setLoadingState(false); this.setNotesFetchedState(true); eventHub.$emit('fetchedNotesData'); - this.isFetching = false; + this.setFetchingState(false); }) - .then(this.$nextTick) - .then(this.startTaskList) - .then(this.checkLocationHash) .catch(() => { this.setLoadingState(false); this.setNotesFetchedState(true); diff --git a/app/assets/javascripts/notes/components/timeline_toggle.vue b/app/assets/javascripts/notes/components/timeline_toggle.vue index 8162878f80d..87d22e5b986 100644 --- a/app/assets/javascripts/notes/components/timeline_toggle.vue +++ b/app/assets/javascripts/notes/components/timeline_toggle.vue @@ -2,9 +2,9 @@ import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import { mapActions, mapGetters } from 'vuex'; import { s__ } from '~/locale'; +import TrackEventDirective from '~/vue_shared/directives/track_event'; import { COMMENTS_ONLY_FILTER_VALUE, DESC } from '../constants'; import notesEventHub from '../event_hub'; -import TrackEventDirective from '~/vue_shared/directives/track_event'; import { trackToggleTimelineView } from '../utils'; export const timelineEnabledTooltip = s__('Timeline|Turn timeline view off'); diff --git a/app/assets/javascripts/notes/components/toggle_replies_widget.vue b/app/assets/javascripts/notes/components/toggle_replies_widget.vue index ab7fa793bdc..06de3104a47 100644 --- a/app/assets/javascripts/notes/components/toggle_replies_widget.vue +++ b/app/assets/javascripts/notes/components/toggle_replies_widget.vue @@ -39,13 +39,17 @@ export default { this.$emit('toggle'); }, }, + ICON_CLASS: 'gl-mr-3 gl-cursor-pointer', }; </script> <template> - <li :class="className" class="replies-toggle js-toggle-replies"> + <li + :class="className" + class="replies-toggle js-toggle-replies gl-display-flex! gl-align-items-center gl-flex-wrap" + > <template v-if="collapsed"> - <gl-icon name="chevron-right" @click.native="toggle" /> + <gl-icon :class="$options.ICON_CLASS" name="chevron-right" @click.native="toggle" /> <div> <user-avatar-link v-for="author in uniqueAuthors" @@ -59,7 +63,7 @@ export default { /> </div> <gl-button - class="js-replies-text" + class="js-replies-text gl-mr-2" category="tertiary" variant="link" data-qa-selector="expand_replies_button" @@ -68,18 +72,19 @@ export default { {{ replies.length }} {{ n__('reply', 'replies', replies.length) }} </gl-button> {{ __('Last reply by') }} - <a :href="lastReply.author.path" class="btn btn-link author-link"> + <a :href="lastReply.author.path" class="btn btn-link author-link gl-mx-2"> {{ lastReply.author.name }} </a> <time-ago-tooltip :time="lastReply.created_at" tooltip-placement="bottom" /> </template> - <span + <div v-else - class="collapse-replies-btn js-collapse-replies" + class="collapse-replies-btn js-collapse-replies gl-display-flex align-items-center" data-qa-selector="collapse_replies_button" @click="toggle" > - <gl-icon name="chevron-down" /> {{ s__('Notes|Collapse replies') }} - </span> + <gl-icon :class="$options.ICON_CLASS" name="chevron-down" /> + <span class="gl-cursor-pointer">{{ s__('Notes|Collapse replies') }}</span> + </div> </li> </template> diff --git a/app/assets/javascripts/notes/mixins/autosave.js b/app/assets/javascripts/notes/mixins/autosave.js index b161773f5f1..d670d0bd4c5 100644 --- a/app/assets/javascripts/notes/mixins/autosave.js +++ b/app/assets/javascripts/notes/mixins/autosave.js @@ -1,7 +1,7 @@ import $ from 'jquery'; +import { s__ } from '~/locale'; import Autosave from '../../autosave'; import { capitalizeFirstCharacter } from '../../lib/utils/text_utility'; -import { s__ } from '~/locale'; export default { methods: { diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index c6684efed4d..ddc6c44a4e5 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -2,22 +2,23 @@ import Vue from 'vue'; import $ from 'jquery'; import Visibility from 'visibilityjs'; import axios from '~/lib/utils/axios_utils'; +import updateIssueConfidentialMutation from '~/sidebar/components/confidential/mutations/update_issue_confidential.mutation.graphql'; +import updateMergeRequestLockMutation from '~/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql'; +import updateIssueLockMutation from '~/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql'; +import { __, sprintf } from '~/locale'; +import Api from '~/api'; import TaskList from '../../task_list'; import { deprecatedCreateFlash as Flash } from '../../flash'; import Poll from '../../lib/utils/poll'; -import * as types from './mutation_types'; -import * as utils from './utils'; import * as constants from '../constants'; import loadAwardsHandler from '../../awards_handler'; import sidebarTimeTrackingEventHub from '../../sidebar/event_hub'; import { isInViewport, scrollToElement, isInMRPage } from '../../lib/utils/common_utils'; import { mergeUrlParams } from '../../lib/utils/url_utility'; +import eventHub from '../event_hub'; import mrWidgetEventHub from '../../vue_merge_request_widget/event_hub'; -import updateIssueConfidentialMutation from '~/sidebar/components/confidential/mutations/update_issue_confidential.mutation.graphql'; -import updateMergeRequestLockMutation from '~/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql'; -import updateIssueLockMutation from '~/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql'; -import { __, sprintf } from '~/locale'; -import Api from '~/api'; +import * as utils from './utils'; +import * as types from './mutation_types'; let eTagPoll; @@ -420,14 +421,25 @@ export const saveNote = ({ commit, dispatch }, noteData) => { .catch(processErrors); }; -const pollSuccessCallBack = (resp, commit, state, getters, dispatch) => { +export const setFetchingState = ({ commit }, fetchingState) => + commit(types.SET_NOTES_FETCHING_STATE, fetchingState); + +const pollSuccessCallBack = async (resp, commit, state, getters, dispatch) => { if (state.isResolvingDiscussion) { return null; } + if (window.gon?.features?.paginatedNotes && !resp.more && state.isFetching) { + eventHub.$emit('fetchedNotesData'); + dispatch('setFetchingState', false); + dispatch('setNotesFetchedState', true); + dispatch('setLoadingState', false); + } + if (resp.notes?.length) { - dispatch('updateOrCreateNotes', resp.notes); + await dispatch('updateOrCreateNotes', resp.notes); dispatch('startTaskList'); + dispatch('updateResolvableDiscussionsCounts'); } commit(types.SET_LAST_FETCHED_AT, resp.last_fetched_at); diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index 5891a2e63e3..43d99937b8d 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -48,6 +48,8 @@ export const persistSortOrder = (state) => state.persistSortOrder; export const timelineEnabled = (state) => state.isTimelineEnabled; +export const isFetching = (state) => state.isFetching; + export const isLoading = (state) => state.isLoading; export const getNotesDataByProp = (state) => (prop) => state.notesData[prop]; diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js index 144a3d7ba90..c1738eb20da 100644 --- a/app/assets/javascripts/notes/stores/modules/index.js +++ b/app/assets/javascripts/notes/stores/modules/index.js @@ -47,6 +47,7 @@ export default () => ({ unresolvedDiscussionsCount: 0, descriptionVersions: {}, isTimelineEnabled: false, + isFetching: false, }, actions, getters, diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index 5c4f62f4575..2e8b728e013 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -14,6 +14,7 @@ export const UPDATE_NOTE = 'UPDATE_NOTE'; export const UPDATE_DISCUSSION = 'UPDATE_DISCUSSION'; export const UPDATE_DISCUSSION_POSITION = 'UPDATE_DISCUSSION_POSITION'; export const SET_DISCUSSION_DIFF_LINES = 'SET_DISCUSSION_DIFF_LINES'; +export const SET_NOTES_FETCHING_STATE = 'SET_NOTES_FETCHING_STATE'; export const SET_NOTES_FETCHED_STATE = 'SET_NOTES_FETCHED_STATE'; export const SET_NOTES_LOADING_STATE = 'SET_NOTES_LOADING_STATE'; export const DISABLE_COMMENTS = 'DISABLE_COMMENTS'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index 2c51ce0d970..536b47667c2 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -1,7 +1,8 @@ -import * as utils from './utils'; -import * as types from './mutation_types'; +import { isEqual } from 'lodash'; import * as constants from '../constants'; import { isInMRPage } from '../../lib/utils/common_utils'; +import * as utils from './utils'; +import * as types from './mutation_types'; export default { [types.ADD_NEW_NOTE](state, data) { @@ -31,7 +32,22 @@ export default { } } - note.base_discussion = undefined; // No point keeping a reference to this + if (window.gon?.features?.paginatedNotes && note.base_discussion) { + if (discussion.diff_file) { + discussion.file_hash = discussion.diff_file.file_hash; + + discussion.truncated_diff_lines = utils.prepareDiffLines( + discussion.truncated_diff_lines || [], + ); + } + + discussion.resolvable = note.resolvable; + discussion.expanded = note.base_discussion.expanded; + discussion.resolved = note.resolved; + } + + // note.base_discussion = undefined; // No point keeping a reference to this + delete note.base_discussion; discussion.notes = [note]; state.discussions.push(discussion); @@ -220,6 +236,11 @@ export default { [types.UPDATE_NOTE](state, note) { const noteObj = utils.findNoteObjectById(state.discussions, note.discussion_id); + // Disable eslint here so we can delete the property that we no longer need + // in the note object + // eslint-disable-next-line no-param-reassign + delete note.base_discussion; + if (noteObj.individual_note) { if (note.type === constants.DISCUSSION_NOTE) { noteObj.individual_note = false; @@ -228,7 +249,10 @@ export default { noteObj.notes.splice(0, 1, note); } else { const comment = utils.findNoteObjectById(noteObj.notes, note.id); - noteObj.notes.splice(noteObj.notes.indexOf(comment), 1, note); + + if (!isEqual(comment, note)) { + noteObj.notes.splice(noteObj.notes.indexOf(comment), 1, note); + } } }, @@ -313,6 +337,10 @@ export default { state.isLoading = value; }, + [types.SET_NOTES_FETCHING_STATE](state, value) { + state.isFetching = value; + }, + [types.SET_DISCUSSION_DIFF_LINES](state, { discussionId, diffLines }) { const discussion = utils.findNoteObjectById(state.discussions, discussionId); diff --git a/app/assets/javascripts/notifications/components/notifications_dropdown.vue b/app/assets/javascripts/notifications/components/notifications_dropdown.vue new file mode 100644 index 00000000000..f6a83d643f9 --- /dev/null +++ b/app/assets/javascripts/notifications/components/notifications_dropdown.vue @@ -0,0 +1,180 @@ +<script> +import { + GlButtonGroup, + GlButton, + GlDropdown, + GlDropdownDivider, + GlTooltipDirective, +} from '@gitlab/ui'; +import { sprintf } from '~/locale'; +import Api from '~/api'; +import { CUSTOM_LEVEL, i18n } from '../constants'; +import NotificationsDropdownItem from './notifications_dropdown_item.vue'; + +export default { + name: 'NotificationsDropdown', + components: { + GlButtonGroup, + GlButton, + GlDropdown, + GlDropdownDivider, + NotificationsDropdownItem, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: { + containerClass: { + default: '', + }, + disabled: { + default: false, + }, + dropdownItems: { + default: [], + }, + buttonSize: { + default: 'medium', + }, + initialNotificationLevel: { + default: '', + }, + projectId: { + default: null, + }, + groupId: { + default: null, + }, + showLabel: { + default: false, + }, + }, + data() { + return { + selectedNotificationLevel: this.initialNotificationLevel, + isLoading: false, + }; + }, + computed: { + notificationLevels() { + return this.dropdownItems.map((level) => ({ + level, + title: this.$options.i18n.notificationTitles[level] || '', + description: this.$options.i18n.notificationDescriptions[level] || '', + })); + }, + isCustomNotification() { + return this.selectedNotificationLevel === CUSTOM_LEVEL; + }, + buttonIcon() { + if (this.isLoading) { + return null; + } + + return this.selectedNotificationLevel === 'disabled' ? 'notifications-off' : 'notifications'; + }, + buttonText() { + return this.showLabel + ? this.$options.i18n.notificationTitles[this.selectedNotificationLevel] + : null; + }, + buttonTooltip() { + const notificationTitle = + this.$options.i18n.notificationTitles[this.selectedNotificationLevel] || + this.selectedNotificationLevel; + + return this.disabled + ? this.$options.i18n.notificationDescriptions.owner_disabled + : sprintf(this.$options.i18n.notificationTooltipTitle, { + notification_title: notificationTitle, + }); + }, + }, + methods: { + selectItem(level) { + if (level !== this.selectedNotificationLevel) { + this.updateNotificationLevel(level); + } + }, + async updateNotificationLevel(level) { + this.isLoading = true; + + try { + await Api.updateNotificationSettings(this.projectId, this.groupId, { level }); + this.selectedNotificationLevel = level; + } catch (error) { + this.$toast.show(this.$options.i18n.updateNotificationLevelErrorMessage, { type: 'error' }); + } finally { + this.isLoading = false; + } + }, + }, + customLevel: CUSTOM_LEVEL, + i18n, +}; +</script> + +<template> + <div :class="containerClass"> + <gl-button-group + v-if="isCustomNotification" + v-gl-tooltip="{ title: buttonTooltip }" + data-testid="notificationButton" + :size="buttonSize" + > + <gl-button :size="buttonSize" :icon="buttonIcon" :loading="isLoading" :disabled="disabled"> + <template v-if="buttonText">{{ buttonText }}</template> + </gl-button> + <gl-dropdown :size="buttonSize" :disabled="disabled"> + <notifications-dropdown-item + v-for="item in notificationLevels" + :key="item.level" + :level="item.level" + :title="item.title" + :description="item.description" + :notification-level="selectedNotificationLevel" + @item-selected="selectItem" + /> + <gl-dropdown-divider /> + <notifications-dropdown-item + :key="$options.customLevel" + :level="$options.customLevel" + :title="$options.i18n.notificationTitles.custom" + :description="$options.i18n.notificationDescriptions.custom" + :notification-level="selectedNotificationLevel" + @item-selected="selectItem" + /> + </gl-dropdown> + </gl-button-group> + + <gl-dropdown + v-else + v-gl-tooltip="{ title: buttonTooltip }" + data-testid="notificationButton" + :text="buttonText" + :icon="buttonIcon" + :loading="isLoading" + :size="buttonSize" + :disabled="disabled" + > + <notifications-dropdown-item + v-for="item in notificationLevels" + :key="item.level" + :level="item.level" + :title="item.title" + :description="item.description" + :notification-level="selectedNotificationLevel" + @item-selected="selectItem" + /> + <gl-dropdown-divider /> + <notifications-dropdown-item + :key="$options.customLevel" + :level="$options.customLevel" + :title="$options.i18n.notificationTitles.custom" + :description="$options.i18n.notificationDescriptions.custom" + :notification-level="selectedNotificationLevel" + @item-selected="selectItem" + /> + </gl-dropdown> + </div> +</template> diff --git a/app/assets/javascripts/notifications/components/notifications_dropdown_item.vue b/app/assets/javascripts/notifications/components/notifications_dropdown_item.vue new file mode 100644 index 00000000000..73bb9c1b36f --- /dev/null +++ b/app/assets/javascripts/notifications/components/notifications_dropdown_item.vue @@ -0,0 +1,42 @@ +<script> +import { GlDropdownItem } from '@gitlab/ui'; + +export default { + name: 'NotificationsDropdownItem', + components: { + GlDropdownItem, + }, + props: { + level: { + type: String, + required: true, + }, + title: { + type: String, + required: true, + }, + description: { + type: String, + required: true, + }, + notificationLevel: { + type: String, + required: true, + }, + }, + computed: { + isActive() { + return this.notificationLevel === this.level; + }, + }, +}; +</script> + +<template> + <gl-dropdown-item is-check-item :is-checked="isActive" @click="$emit('item-selected', level)"> + <div class="gl-display-flex gl-flex-direction-column"> + <span class="gl-font-weight-bold">{{ title }}</span> + <span class="gl-text-gray-500">{{ description }}</span> + </div> + </gl-dropdown-item> +</template> diff --git a/app/assets/javascripts/notifications/constants.js b/app/assets/javascripts/notifications/constants.js new file mode 100644 index 00000000000..f730ee59e84 --- /dev/null +++ b/app/assets/javascripts/notifications/constants.js @@ -0,0 +1,27 @@ +import { __, s__ } from '~/locale'; + +export const CUSTOM_LEVEL = 'custom'; + +export const i18n = { + notificationTitles: { + participating: s__('NotificationLevel|Participate'), + mention: s__('NotificationLevel|On mention'), + watch: s__('NotificationLevel|Watch'), + global: s__('NotificationLevel|Global'), + disabled: s__('NotificationLevel|Disabled'), + custom: s__('NotificationLevel|Custom'), + }, + notificationTooltipTitle: __('Notification setting - %{notification_title}'), + notificationDescriptions: { + participating: __('You will only receive notifications for threads you have participated in'), + mention: __('You will receive notifications only for comments in which you were @mentioned'), + watch: __('You will receive notifications for any activity'), + disabled: __('You will not get any notifications via email'), + global: __('Use your global notification setting'), + custom: __('You will only receive notifications for the events you choose'), + owner_disabled: __('Notifications have been disabled by the project or group owner'), + }, + updateNotificationLevelErrorMessage: __( + 'An error occured while updating the notification settings. Please try again.', + ), +}; diff --git a/app/assets/javascripts/notifications/index.js b/app/assets/javascripts/notifications/index.js new file mode 100644 index 00000000000..dc5b8e7fbb1 --- /dev/null +++ b/app/assets/javascripts/notifications/index.js @@ -0,0 +1,42 @@ +import Vue from 'vue'; +import { GlToast } from '@gitlab/ui'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import NotificationsDropdown from './components/notifications_dropdown.vue'; + +Vue.use(GlToast); + +export default () => { + const containers = document.querySelectorAll('.js-vue-notification-dropdown'); + + if (!containers.length) return false; + + return containers.forEach((el) => { + const { + containerClass, + buttonSize, + disabled, + dropdownItems, + notificationLevel, + projectId, + groupId, + showLabel, + } = el.dataset; + + return new Vue({ + el, + provide: { + containerClass, + buttonSize, + disabled: parseBoolean(disabled), + dropdownItems: JSON.parse(dropdownItems), + initialNotificationLevel: notificationLevel, + projectId, + groupId, + showLabel: parseBoolean(showLabel), + }, + render(h) { + return h(NotificationsDropdown); + }, + }); + }); +}; diff --git a/app/assets/javascripts/notifications_dropdown.js b/app/assets/javascripts/notifications_dropdown.js index eaa5ec3a2e4..d61defed14d 100644 --- a/app/assets/javascripts/notifications_dropdown.js +++ b/app/assets/javascripts/notifications_dropdown.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import { Rails } from '~/lib/utils/rails_ujs'; -import { deprecatedCreateFlash as Flash } from './flash'; import { __ } from '~/locale'; +import { deprecatedCreateFlash as Flash } from './flash'; export default function notificationsDropdown() { $(document).on('click', '.update-notification', function updateNotificationCallback(e) { diff --git a/app/assets/javascripts/onboarding_issues/index.js b/app/assets/javascripts/onboarding_issues/index.js deleted file mode 100644 index b23a10c9254..00000000000 --- a/app/assets/javascripts/onboarding_issues/index.js +++ /dev/null @@ -1,128 +0,0 @@ -import $ from 'jquery'; -import { parseBoolean, getCookie, setCookie, removeCookie } from '~/lib/utils/common_utils'; -import { __, sprintf } from '~/locale'; -import Tracking from '~/tracking'; - -const COOKIE_NAME = 'onboarding_issues_settings'; - -const POPOVER_LOCATIONS = { - GROUPS_SHOW: 'groups#show', - PROJECTS_SHOW: 'projects#show', - ISSUES_INDEX: 'issues#index', -}; - -const removeLearnGitLabCookie = () => { - removeCookie(COOKIE_NAME); -}; - -function disposePopover(event) { - event.preventDefault(); - this.popover('dispose'); - removeLearnGitLabCookie(); - Tracking.event('Growth::Conversion::Experiment::OnboardingIssues', 'dismiss_popover'); -} - -const showPopover = (el, path, footer, options) => { - // Cookie value looks like `{ 'groups#show': true, 'projects#show': true, 'issues#index': true }`. When it doesn't exist, don't show the popover. - const cookie = getCookie(COOKIE_NAME); - if (!cookie) return; - - // When the popover action has already been taken, don't show the popover. - const settings = JSON.parse(cookie); - if (!parseBoolean(settings[path])) return; - - const defaultOptions = { - boundary: 'window', - html: true, - placement: 'top', - template: `<div class="popover blue learn-gitlab d-none d-xl-block" role="tooltip"> - <div class="arrow"></div> - <div class="close cursor-pointer gl-font-base text-white gl-opacity-10 p-2">✕</div> - <div class="popover-body gl-font-base gl-line-height-20 pb-0 px-3"></div> - <div class="bold text-right text-white p-2">${footer}</div> - </div>`, - }; - - // When one of the popovers is dismissed, remove the cookie. - const closeButton = () => document.querySelector('.learn-gitlab.popover .close'); - - // We still have to use jQuery, since Bootstrap's Popover is based on jQuery. - const jQueryEl = $(el); - const clickCloseButton = disposePopover.bind(jQueryEl); - - jQueryEl - .popover({ ...defaultOptions, ...options }) - .on('inserted.bs.popover', () => closeButton().addEventListener('click', clickCloseButton)) - .on('hide.bs.dropdown', () => closeButton().removeEventListener('click', clickCloseButton)) - .popover('show'); - - // The previous popover actions have been taken, don't show those popovers anymore. - Object.keys(settings).forEach((pathSetting) => { - if (path !== pathSetting) { - settings[pathSetting] = false; - } else { - setCookie(COOKIE_NAME, settings); - } - }); - - // The final popover action will be taken on click, we then no longer need the cookie. - if (path === POPOVER_LOCATIONS.ISSUES_INDEX) { - el.addEventListener('click', removeLearnGitLabCookie); - } -}; - -export const showLearnGitLabGroupItemPopover = (id) => { - const el = document.querySelector(`#group-${id} .group-text a`); - - if (!el) return; - - const options = { - content: __( - 'Here are all your projects in your group, including the one you just created. To start, let’s take a look at your personalized learning project which will help you learn about GitLab at your own pace.', - ), - }; - - showPopover(el, POPOVER_LOCATIONS.GROUPS_SHOW, '1 / 2', options); -}; - -export const showLearnGitLabProjectPopover = () => { - // Do not show a popover if we are not viewing the 'Learn GitLab' project. - if (!window.location.pathname.includes('learn-gitlab')) return; - - const el = document.querySelector('a.shortcuts-issues'); - - if (!el) return; - - const options = { - content: sprintf( - __( - 'Go to %{strongStart}Issues%{strongEnd} > %{strongStart}Boards%{strongEnd} to access your personalized learning issue board.', - ), - { strongStart: '<strong>', strongEnd: '</strong>' }, - false, - ), - }; - - showPopover(el, POPOVER_LOCATIONS.PROJECTS_SHOW, '2 / 2', options); -}; - -export const showLearnGitLabIssuesPopover = () => { - // Do not show a popover if we are not viewing the 'Learn GitLab' project. - if (!window.location.pathname.includes('learn-gitlab')) return; - - const el = document.querySelector('a[data-qa-selector="issue_boards_link"]'); - - if (!el) return; - - const options = { - content: sprintf( - __( - 'Go to %{strongStart}Issues%{strongEnd} > %{strongStart}Boards%{strongEnd} to access your personalized learning issue board.', - ), - { strongStart: '<strong>', strongEnd: '</strong>' }, - false, - ), - }; - - showPopover(el, POPOVER_LOCATIONS.ISSUES_INDEX, '2 / 2', options); -}; diff --git a/app/assets/javascripts/operation_settings/components/metrics_settings.vue b/app/assets/javascripts/operation_settings/components/metrics_settings.vue index 2e972dd7154..2d2e625d3d3 100644 --- a/app/assets/javascripts/operation_settings/components/metrics_settings.vue +++ b/app/assets/javascripts/operation_settings/components/metrics_settings.vue @@ -31,9 +31,9 @@ export default { <template> <section class="settings no-animate"> <div class="settings-header"> - <h3 class="js-section-header h4"> + <h4 class="js-section-header"> {{ s__('MetricsSettings|Metrics dashboard') }} - </h3> + </h4> <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button> <p class="js-section-sub-header"> {{ s__('MetricsSettings|Manage Metrics Dashboard settings.') }} diff --git a/app/assets/javascripts/packages/details/components/app.vue b/app/assets/javascripts/packages/details/components/app.vue index c9f1c8b903c..e8b9ecfd616 100644 --- a/app/assets/javascripts/packages/details/components/app.vue +++ b/app/assets/javascripts/packages/details/components/app.vue @@ -15,12 +15,12 @@ import Tracking from '~/tracking'; import { s__ } from '~/locale'; import { objectToQueryString } from '~/lib/utils/common_utils'; import { numberToHumanSize } from '~/lib/utils/number_utils'; -import PackageHistory from './package_history.vue'; -import PackageTitle from './package_title.vue'; import PackagesListLoader from '../../shared/components/packages_list_loader.vue'; import PackageListRow from '../../shared/components/package_list_row.vue'; import { packageTypeToTrackCategory } from '../../shared/utils'; import { PackageType, TrackingActions, SHOW_DELETE_SUCCESS_ALERT } from '../../shared/constants'; +import PackageTitle from './package_title.vue'; +import PackageHistory from './package_history.vue'; import DependencyRow from './dependency_row.vue'; import AdditionalMetadata from './additional_metadata.vue'; import InstallationCommands from './installation_commands.vue'; diff --git a/app/assets/javascripts/packages/details/components/installation_commands.vue b/app/assets/javascripts/packages/details/components/installation_commands.vue index 138103020a7..ec97ef216f1 100644 --- a/app/assets/javascripts/packages/details/components/installation_commands.vue +++ b/app/assets/javascripts/packages/details/components/installation_commands.vue @@ -1,11 +1,11 @@ <script> +import { PackageType } from '../../shared/constants'; import ConanInstallation from './conan_installation.vue'; import MavenInstallation from './maven_installation.vue'; import NpmInstallation from './npm_installation.vue'; import NugetInstallation from './nuget_installation.vue'; import PypiInstallation from './pypi_installation.vue'; import ComposerInstallation from './composer_installation.vue'; -import { PackageType } from '../../shared/constants'; export default { name: 'InstallationCommands', diff --git a/app/assets/javascripts/packages/details/components/package_title.vue b/app/assets/javascripts/packages/details/components/package_title.vue index 6b7eeacb964..cded8708b0c 100644 --- a/app/assets/javascripts/packages/details/components/package_title.vue +++ b/app/assets/javascripts/packages/details/components/package_title.vue @@ -3,12 +3,12 @@ import { mapState, mapGetters } from 'vuex'; import { GlIcon, GlSprintf, GlTooltipDirective, GlBadge } from '@gitlab/ui'; import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; -import PackageTags from '../../shared/components/package_tags.vue'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; import { __ } from '~/locale'; +import PackageTags from '../../shared/components/package_tags.vue'; export default { name: 'PackageTitle', diff --git a/app/assets/javascripts/packages/details/index.js b/app/assets/javascripts/packages/details/index.js index 233da3e4a99..5b9d58a3860 100644 --- a/app/assets/javascripts/packages/details/index.js +++ b/app/assets/javascripts/packages/details/index.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import PackagesApp from './components/app.vue'; import Translate from '~/vue_shared/translate'; +import PackagesApp from './components/app.vue'; import createStore from './store'; Vue.use(Translate); diff --git a/app/assets/javascripts/packages/details/store/actions.js b/app/assets/javascripts/packages/details/store/actions.js index 340f60258a0..87216366c8b 100644 --- a/app/assets/javascripts/packages/details/store/actions.js +++ b/app/assets/javascripts/packages/details/store/actions.js @@ -1,7 +1,7 @@ import Api from '~/api'; import { deprecatedCreateFlash as createFlash } from '~/flash'; -import { FETCH_PACKAGE_VERSIONS_ERROR } from '../constants'; import { DELETE_PACKAGE_ERROR_MESSAGE } from '~/packages/shared/constants'; +import { FETCH_PACKAGE_VERSIONS_ERROR } from '../constants'; import * as types from './mutation_types'; export const fetchPackageVersions = ({ commit, state }) => { diff --git a/app/assets/javascripts/packages/list/components/package_search.vue b/app/assets/javascripts/packages/list/components/package_search.vue new file mode 100644 index 00000000000..1befc440ac8 --- /dev/null +++ b/app/assets/javascripts/packages/list/components/package_search.vue @@ -0,0 +1,97 @@ +<script> +import { GlSorting, GlSortingItem, GlFilteredSearch } from '@gitlab/ui'; +import { mapState, mapActions } from 'vuex'; +import { __, s__ } from '~/locale'; +import { ASCENDING_ODER, DESCENDING_ORDER } from '../constants'; +import getTableHeaders from '../utils'; +import PackageTypeToken from './tokens/package_type_token.vue'; + +export default { + components: { + GlSorting, + GlSortingItem, + GlFilteredSearch, + }, + computed: { + ...mapState({ + isGroupPage: (state) => state.config.isGroupPage, + orderBy: (state) => state.sorting.orderBy, + sort: (state) => state.sorting.sort, + filter: (state) => state.filter, + }), + internalFilter: { + get() { + return this.filter; + }, + set(value) { + this.setFilter(value); + }, + }, + sortText() { + const field = this.sortableFields.find((s) => s.orderBy === this.orderBy); + return field ? field.label : ''; + }, + sortableFields() { + return getTableHeaders(this.isGroupPage); + }, + isSortAscending() { + return this.sort === ASCENDING_ODER; + }, + tokens() { + return [ + { + type: 'type', + icon: 'package', + title: s__('PackageRegistry|Type'), + unique: true, + token: PackageTypeToken, + operators: [{ value: '=', description: __('is'), default: 'true' }], + }, + ]; + }, + }, + methods: { + ...mapActions(['setSorting', 'setFilter']), + onDirectionChange() { + const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ODER; + this.setSorting({ sort }); + this.$emit('sort:changed'); + }, + onSortItemClick(item) { + this.setSorting({ orderBy: item }); + this.$emit('sort:changed'); + }, + clearSearch() { + this.setFilter([]); + this.$emit('filter:changed'); + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-p-5 gl-bg-gray-10 gl-border-solid gl-border-1 gl-border-gray-100"> + <gl-filtered-search + v-model="internalFilter" + class="gl-mr-4 gl-flex-fill-1" + :placeholder="__('Filter results')" + :available-tokens="tokens" + @submit="$emit('filter:changed')" + @clear="clearSearch" + /> + <gl-sorting + :text="sortText" + :is-ascending="isSortAscending" + @sortDirectionChange="onDirectionChange" + > + <gl-sorting-item + v-for="item in sortableFields" + ref="packageListSortItem" + :key="item.orderBy" + @click="onSortItemClick(item.orderBy)" + > + {{ item.label }} + </gl-sorting-item> + </gl-sorting> + </div> +</template> diff --git a/app/assets/javascripts/packages/list/components/packages_filter.vue b/app/assets/javascripts/packages/list/components/packages_filter.vue deleted file mode 100644 index 17398071217..00000000000 --- a/app/assets/javascripts/packages/list/components/packages_filter.vue +++ /dev/null @@ -1,21 +0,0 @@ -<script> -import { GlSearchBoxByClick } from '@gitlab/ui'; -import { mapActions } from 'vuex'; - -export default { - components: { - GlSearchBoxByClick, - }, - methods: { - ...mapActions(['setFilter']), - }, -}; -</script> - -<template> - <gl-search-box-by-click - :placeholder="s__('PackageRegistry|Filter by name')" - @submit="$emit('filter')" - @input="setFilter" - /> -</template> diff --git a/app/assets/javascripts/packages/list/components/packages_list_app.vue b/app/assets/javascripts/packages/list/components/packages_list_app.vue index 2a786b92515..ee93ed2ad89 100644 --- a/app/assets/javascripts/packages/list/components/packages_list_app.vue +++ b/app/assets/javascripts/packages/list/components/packages_list_app.vue @@ -1,39 +1,43 @@ <script> import { mapActions, mapState } from 'vuex'; -import { GlEmptyState, GlTab, GlTabs, GlLink, GlSprintf } from '@gitlab/ui'; -import { s__, sprintf } from '~/locale'; +import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; import createFlash from '~/flash'; import { historyReplaceState } from '~/lib/utils/common_utils'; import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants'; -import PackageFilter from './packages_filter.vue'; +import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '../constants'; import PackageList from './packages_list.vue'; -import PackageSort from './packages_sort.vue'; -import { PACKAGE_REGISTRY_TABS, DELETE_PACKAGE_SUCCESS_MESSAGE } from '../constants'; import PackageTitle from './package_title.vue'; +import PackageSearch from './package_search.vue'; export default { components: { GlEmptyState, - GlTab, - GlTabs, GlLink, GlSprintf, - PackageFilter, PackageList, - PackageSort, PackageTitle, + PackageSearch, }, computed: { ...mapState({ emptyListIllustration: (state) => state.config.emptyListIllustration, emptyListHelpUrl: (state) => state.config.emptyListHelpUrl, - filterQuery: (state) => state.filterQuery, + filter: (state) => state.filter, selectedType: (state) => state.selectedType, packageHelpUrl: (state) => state.config.packageHelpUrl, packagesCount: (state) => state.pagination?.total, }), - tabsToRender() { - return PACKAGE_REGISTRY_TABS; + emptySearch() { + return ( + this.filter.filter((f) => f.type !== 'filtered-search-term' || f.value?.data).length === 0 + ); + }, + + emptyStateTitle() { + return this.emptySearch + ? s__('PackageRegistry|There are no packages yet') + : s__('PackageRegistry|Sorry, your filter produced no results'); }, }, mounted() { @@ -48,27 +52,6 @@ export default { onPackageDeleteRequest(item) { return this.requestDeletePackage(item); }, - tabChanged(index) { - const selected = PACKAGE_REGISTRY_TABS[index]; - - if (selected !== this.selectedType) { - this.setSelectedType(selected); - this.requestPackagesList(); - } - }, - emptyStateTitle({ title, type }) { - if (this.filterQuery) { - return s__('PackageRegistry|Sorry, your filter produced no results'); - } - - if (type) { - return sprintf(s__('PackageRegistry|There are no %{packageType} packages yet'), { - packageType: title, - }); - } - - return s__('PackageRegistry|There are no packages yet'); - }, checkDeleteAlert() { const urlParams = new URLSearchParams(window.location.search); const showAlert = urlParams.get(SHOW_DELETE_SUCCESS_ALERT); @@ -92,33 +75,21 @@ export default { <template> <div> <package-title :package-help-url="packageHelpUrl" :packages-count="packagesCount" /> + <package-search @sort:changed="requestPackagesList" @filter:changed="requestPackagesList" /> - <gl-tabs @input="tabChanged"> - <template #tabs-end> - <div - class="gl-display-flex gl-align-self-center gl-py-2 gl-flex-grow-1 gl-justify-content-end" - > - <package-filter class="gl-mr-2" @filter="requestPackagesList" /> - <package-sort @sort:changed="requestPackagesList" /> - </div> - </template> - - <gl-tab v-for="(tab, index) in tabsToRender" :key="index" :title="tab.title"> - <package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest"> - <template #empty-state> - <gl-empty-state :title="emptyStateTitle(tab)" :svg-path="emptyListIllustration"> - <template #description> - <gl-sprintf v-if="filterQuery" :message="$options.i18n.widenFilters" /> - <gl-sprintf v-else :message="$options.i18n.noResults"> - <template #noPackagesLink="{ content }"> - <gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> + <package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest"> + <template #empty-state> + <gl-empty-state :title="emptyStateTitle" :svg-path="emptyListIllustration"> + <template #description> + <gl-sprintf v-if="!emptySearch" :message="$options.i18n.widenFilters" /> + <gl-sprintf v-else :message="$options.i18n.noResults"> + <template #noPackagesLink="{ content }"> + <gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link> </template> - </gl-empty-state> + </gl-sprintf> </template> - </package-list> - </gl-tab> - </gl-tabs> + </gl-empty-state> + </template> + </package-list> </div> </template> diff --git a/app/assets/javascripts/packages/list/components/packages_sort.vue b/app/assets/javascripts/packages/list/components/packages_sort.vue deleted file mode 100644 index 4b2d9091f8f..00000000000 --- a/app/assets/javascripts/packages/list/components/packages_sort.vue +++ /dev/null @@ -1,60 +0,0 @@ -<script> -import { GlSorting, GlSortingItem } from '@gitlab/ui'; -import { mapState, mapActions } from 'vuex'; -import { ASCENDING_ODER, DESCENDING_ORDER } from '../constants'; -import getTableHeaders from '../utils'; - -export default { - name: 'PackageSort', - components: { - GlSorting, - GlSortingItem, - }, - computed: { - ...mapState({ - isGroupPage: (state) => state.config.isGroupPage, - orderBy: (state) => state.sorting.orderBy, - sort: (state) => state.sorting.sort, - }), - sortText() { - const field = this.sortableFields.find((s) => s.orderBy === this.orderBy); - return field ? field.label : ''; - }, - sortableFields() { - return getTableHeaders(this.isGroupPage); - }, - isSortAscending() { - return this.sort === ASCENDING_ODER; - }, - }, - methods: { - ...mapActions(['setSorting']), - onDirectionChange() { - const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ODER; - this.setSorting({ sort }); - this.$emit('sort:changed'); - }, - onSortItemClick(item) { - this.setSorting({ orderBy: item }); - this.$emit('sort:changed'); - }, - }, -}; -</script> - -<template> - <gl-sorting - :text="sortText" - :is-ascending="isSortAscending" - @sortDirectionChange="onDirectionChange" - > - <gl-sorting-item - v-for="item in sortableFields" - ref="packageListSortItem" - :key="item.orderBy" - @click="onSortItemClick(item.orderBy)" - > - {{ item.label }} - </gl-sorting-item> - </gl-sorting> -</template> diff --git a/app/assets/javascripts/packages/list/components/tokens/package_type_token.vue b/app/assets/javascripts/packages/list/components/tokens/package_type_token.vue new file mode 100644 index 00000000000..74b6774712e --- /dev/null +++ b/app/assets/javascripts/packages/list/components/tokens/package_type_token.vue @@ -0,0 +1,26 @@ +<script> +import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui'; +import { PACKAGE_TYPES } from '../../constants'; + +export default { + components: { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + }, + PACKAGE_TYPES, +}; +</script> + +<template> + <gl-filtered-search-token v-bind="{ ...$attrs }" v-on="$listeners"> + <template #suggestions> + <gl-filtered-search-suggestion + v-for="(type, index) in $options.PACKAGE_TYPES" + :key="index" + :value="type.type" + > + {{ type.title }} + </gl-filtered-search-suggestion> + </template> + </gl-filtered-search-token> +</template> diff --git a/app/assets/javascripts/packages/list/constants.js b/app/assets/javascripts/packages/list/constants.js index e14696e0d1c..9c78778d9f8 100644 --- a/app/assets/javascripts/packages/list/constants.js +++ b/app/assets/javascripts/packages/list/constants.js @@ -55,11 +55,7 @@ export const SORT_FIELDS = [ }, ]; -export const PACKAGE_REGISTRY_TABS = [ - { - title: __('All'), - type: null, - }, +export const PACKAGE_TYPES = [ { title: s__('PackageRegistry|Composer'), type: PackageType.COMPOSER, diff --git a/app/assets/javascripts/packages/list/packages_list_app_bundle.js b/app/assets/javascripts/packages/list/packages_list_app_bundle.js index 2f240cff143..73840035728 100644 --- a/app/assets/javascripts/packages/list/packages_list_app_bundle.js +++ b/app/assets/javascripts/packages/list/packages_list_app_bundle.js @@ -1,9 +1,9 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import Translate from '~/vue_shared/translate'; +import createDefaultClient from '~/lib/graphql'; import { createStore } from './stores'; import PackagesListApp from './components/packages_list_app.vue'; -import createDefaultClient from '~/lib/graphql'; Vue.use(VueApollo); Vue.use(Translate); diff --git a/app/assets/javascripts/packages/list/stores/actions.js b/app/assets/javascripts/packages/list/stores/actions.js index bbc11e3cf13..b123cbaa531 100644 --- a/app/assets/javascripts/packages/list/stores/actions.js +++ b/app/assets/javascripts/packages/list/stores/actions.js @@ -2,7 +2,6 @@ import Api from '~/api'; import axios from '~/lib/utils/axios_utils'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { DELETE_PACKAGE_ERROR_MESSAGE } from '~/packages/shared/constants'; -import * as types from './mutation_types'; import { FETCH_PACKAGES_LIST_ERROR_MESSAGE, DELETE_PACKAGE_SUCCESS_MESSAGE, @@ -11,11 +10,11 @@ import { MISSING_DELETE_PATH_ERROR, } from '../constants'; import { getNewPaginationPage } from '../utils'; +import * as types from './mutation_types'; export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data); export const setLoading = ({ commit }, data) => commit(types.SET_MAIN_LOADING, data); export const setSorting = ({ commit }, data) => commit(types.SET_SORTING, data); -export const setSelectedType = ({ commit }, data) => commit(types.SET_SELECTED_TYPE, data); export const setFilter = ({ commit }, data) => commit(types.SET_FILTER, data); export const receivePackagesListSuccess = ({ commit }, { data, headers }) => { @@ -29,9 +28,9 @@ export const requestPackagesList = ({ dispatch, state }, params = {}) => { const { page = DEFAULT_PAGE, per_page = DEFAULT_PAGE_SIZE } = params; const { sort, orderBy } = state.sorting; - const type = state.selectedType?.type?.toLowerCase(); - const nameFilter = state.filterQuery?.toLowerCase(); - const packageFilters = { package_type: type, package_name: nameFilter }; + const type = state.filter.find((f) => f.type === 'type'); + const name = state.filter.find((f) => f.type === 'filtered-search-term'); + const packageFilters = { package_type: type?.value?.data, package_name: name?.value?.data }; const apiMethod = state.config.isGroupPage ? 'groupPackages' : 'projectPackages'; diff --git a/app/assets/javascripts/packages/list/stores/mutation_types.js b/app/assets/javascripts/packages/list/stores/mutation_types.js index a5a584ccf1f..561ad97f7e3 100644 --- a/app/assets/javascripts/packages/list/stores/mutation_types.js +++ b/app/assets/javascripts/packages/list/stores/mutation_types.js @@ -4,5 +4,4 @@ export const SET_PACKAGE_LIST_SUCCESS = 'SET_PACKAGE_LIST_SUCCESS'; export const SET_PAGINATION = 'SET_PAGINATION'; export const SET_MAIN_LOADING = 'SET_MAIN_LOADING'; export const SET_SORTING = 'SET_SORTING'; -export const SET_SELECTED_TYPE = 'SET_SELECTED_TYPE'; export const SET_FILTER = 'SET_FILTER'; diff --git a/app/assets/javascripts/packages/list/stores/mutations.js b/app/assets/javascripts/packages/list/stores/mutations.js index 2fe7981b3d9..4ce13cfcb29 100644 --- a/app/assets/javascripts/packages/list/stores/mutations.js +++ b/app/assets/javascripts/packages/list/stores/mutations.js @@ -1,6 +1,6 @@ -import * as types from './mutation_types'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { GROUP_PAGE_TYPE } from '../constants'; +import * as types from './mutation_types'; export default { [types.SET_INITIAL_STATE](state, config) { @@ -28,11 +28,7 @@ export default { state.sorting = { ...state.sorting, ...sorting }; }, - [types.SET_SELECTED_TYPE](state, type) { - state.selectedType = type; - }, - - [types.SET_FILTER](state, query) { - state.filterQuery = query; + [types.SET_FILTER](state, filter) { + state.filter = filter; }, }; diff --git a/app/assets/javascripts/packages/list/stores/state.js b/app/assets/javascripts/packages/list/stores/state.js index 18ab2390b87..60f02eddc9f 100644 --- a/app/assets/javascripts/packages/list/stores/state.js +++ b/app/assets/javascripts/packages/list/stores/state.js @@ -1,5 +1,3 @@ -import { PACKAGE_REGISTRY_TABS } from '../constants'; - export default () => ({ /** * Determine if the component is loading data from the API @@ -49,9 +47,8 @@ export default () => ({ /** * The search query that is used to filter packages by name */ - filterQuery: '', + filter: [], /** * The selected TAB of the package types tabs */ - selectedType: PACKAGE_REGISTRY_TABS[0], }); diff --git a/app/assets/javascripts/packages/shared/components/package_list_row.vue b/app/assets/javascripts/packages/shared/components/package_list_row.vue index d55ca80a7fc..89b217b7d42 100644 --- a/app/assets/javascripts/packages/shared/components/package_list_row.vue +++ b/app/assets/javascripts/packages/shared/components/package_list_row.vue @@ -1,11 +1,11 @@ <script> import { GlButton, GlIcon, GlLink, GlSprintf, GlTooltipDirective, GlTruncate } from '@gitlab/ui'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import ListItem from '~/vue_shared/components/registry/list_item.vue'; +import { getPackageTypeLabel } from '../utils'; import PackageTags from './package_tags.vue'; import PackagePath from './package_path.vue'; import PublishMethod from './publish_method.vue'; -import { getPackageTypeLabel } from '../utils'; -import timeagoMixin from '~/vue_shared/mixins/timeago'; -import ListItem from '~/vue_shared/components/registry/list_item.vue'; export default { name: 'PackageListRow', @@ -88,7 +88,7 @@ export default { <div class="gl-display-flex"> <span>{{ packageEntity.version }}</span> - <div v-if="hasPipeline" class="gl-display-none gl-display-sm-flex gl-ml-2"> + <div v-if="hasPipeline" class="gl-display-none gl-sm-display-flex gl-ml-2"> <gl-sprintf :message="s__('PackageRegistry|published by %{author}')"> <template #author>{{ packageEntity.pipeline.user.name }}</template> </gl-sprintf> diff --git a/app/assets/javascripts/packages/shared/components/package_tags.vue b/app/assets/javascripts/packages/shared/components/package_tags.vue index 5172b855fc3..5ec950e4d45 100644 --- a/app/assets/javascripts/packages/shared/components/package_tags.vue +++ b/app/assets/javascripts/packages/shared/components/package_tags.vue @@ -91,7 +91,7 @@ export default { variant="muted" :title="moreTagsTooltip" size="sm" - class="gl-display-none gl-display-md-flex gl-ml-2" + class="gl-display-none gl-md-display-flex gl-ml-2" ><gl-sprintf :message="__('+%{tags} more')"> <template #tags> {{ moreTagsDisplay }} @@ -103,7 +103,7 @@ export default { v-if="moreTagsDisplay && hideLabel" data-testid="moreBadge" variant="muted" - class="gl-display-md-none gl-ml-2" + class="gl-md-display-none gl-ml-2" >{{ tagsDisplay }}</gl-badge > </div> diff --git a/app/assets/javascripts/packages/shared/components/packages_list_loader.vue b/app/assets/javascripts/packages/shared/components/packages_list_loader.vue index efd9f8db908..cf555f46f8c 100644 --- a/app/assets/javascripts/packages/shared/components/packages_list_loader.vue +++ b/app/assets/javascripts/packages/shared/components/packages_list_loader.vue @@ -21,7 +21,7 @@ export default { <template> <div> - <div class="gl-flex-direction-column gl-display-sm-none" data-testid="mobile-loader"> + <div class="gl-flex-direction-column gl-sm-display-none" data-testid="mobile-loader"> <gl-skeleton-loader v-for="index in $options.rowsToRender.mobile" :key="index" @@ -37,7 +37,7 @@ export default { </gl-skeleton-loader> </div> <div - class="gl-display-none gl-display-sm-flex gl-flex-direction-column" + class="gl-display-none gl-sm-display-flex gl-flex-direction-column" data-testid="desktop-loader" > <gl-skeleton-loader diff --git a/app/assets/javascripts/packages_and_registries/settings/group/bundle.js b/app/assets/javascripts/packages_and_registries/settings/group/bundle.js index a3d507180c6..b2ff75fe1bd 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/bundle.js +++ b/app/assets/javascripts/packages_and_registries/settings/group/bundle.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import Translate from '~/vue_shared/translate'; +import { parseBoolean } from '~/lib/utils/common_utils'; import SettingsApp from './components/group_settings_app.vue'; import { apolloProvider } from './graphql'; @@ -13,6 +14,10 @@ export default () => { return new Vue({ el, apolloProvider, + provide: { + defaultExpanded: parseBoolean(el.dataset.defaultExpanded), + groupPath: el.dataset.groupPath, + }, render(createElement) { return createElement(SettingsApp); }, diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue index 6bcecf43a13..31abdc730f8 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue @@ -1,9 +1,75 @@ <script> +import { GlSprintf, GlLink } from '@gitlab/ui'; +import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; + +import { + PACKAGE_SETTINGS_HEADER, + PACKAGE_SETTINGS_DESCRIPTION, + PACKAGES_DOCS_PATH, +} from '../constants'; +import getGroupPackagesSettingsQuery from '../graphql/queries/get_group_packages_settings.query.graphql'; + export default { name: 'GroupSettingsApp', + i18n: { + PACKAGE_SETTINGS_HEADER, + PACKAGE_SETTINGS_DESCRIPTION, + }, + links: { + PACKAGES_DOCS_PATH, + }, + components: { + GlSprintf, + GlLink, + SettingsBlock, + }, + inject: { + defaultExpanded: { + type: Boolean, + default: false, + required: true, + }, + groupPath: { + type: String, + required: true, + }, + }, + apollo: { + packageSettings: { + query: getGroupPackagesSettingsQuery, + variables() { + return { + fullPath: this.groupPath, + }; + }, + update(data) { + return data.group?.packageSettings; + }, + }, + }, + data() { + return { + packageSettings: {}, + }; + }, }; </script> <template> - <section></section> + <div> + <settings-block :default-expanded="defaultExpanded"> + <template #title> {{ $options.i18n.PACKAGE_SETTINGS_HEADER }}</template> + <template #description> + <span data-testid="description"> + <gl-sprintf :message="$options.i18n.PACKAGE_SETTINGS_DESCRIPTION"> + <template #link="{ content }"> + <gl-link :href="$options.links.PACKAGES_DOCS_PATH" target="_blank">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </span> + </template> + </settings-block> + </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/constants.js b/app/assets/javascripts/packages_and_registries/settings/group/constants.js new file mode 100644 index 00000000000..b0c4bf821f9 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/group/constants.js @@ -0,0 +1,9 @@ +import { s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; + +export const PACKAGE_SETTINGS_HEADER = s__('PackageRegistry|Package Registry'); +export const PACKAGE_SETTINGS_DESCRIPTION = s__( + 'PackageRegistry|GitLab Packages allows organizations to utilize GitLab as a private repository for a variety of common package formats. %{linkStart}More Information%{linkEnd}', +); + +export const PACKAGES_DOCS_PATH = helpPagePath('user/packages'); diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql b/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql new file mode 100644 index 00000000000..1fc59bd3496 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql @@ -0,0 +1,9 @@ +mutation updateNamespacePackageSettings($input: UpdateNamespacePackageSettingsInput!) { + updateNamespacePackageSettings(input: $input) { + packageSettings { + mavenDuplicatesAllowed + mavenDuplicateExceptionRegex + } + errors + } +} diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql b/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql new file mode 100644 index 00000000000..2011659887d --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql @@ -0,0 +1,8 @@ +query getGroupPackagesSettings($fullPath: ID!) { + group(fullPath: $fullPath) { + packageSettings { + mavenDuplicatesAllowed + mavenDuplicateExceptionRegex + } + } +} diff --git a/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js b/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js index da7f81759ea..e78b3f9ec95 100644 --- a/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js +++ b/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js @@ -1,6 +1,6 @@ import $ from 'jquery'; -import { truncate } from '../../../lib/utils/text_utility'; import { parseBoolean } from '~/lib/utils/common_utils'; +import { truncate } from '../../../lib/utils/text_utility'; const MAX_MESSAGE_LENGTH = 500; const MESSAGE_CELL_SELECTOR = '.abuse-reports .message'; diff --git a/app/assets/javascripts/pages/admin/abuse_reports/index.js b/app/assets/javascripts/pages/admin/abuse_reports/index.js index d97e24d9e0b..5649c47d7e8 100644 --- a/app/assets/javascripts/pages/admin/abuse_reports/index.js +++ b/app/assets/javascripts/pages/admin/abuse_reports/index.js @@ -1,6 +1,6 @@ /* eslint-disable no-new */ -import AbuseReports from './abuse_reports'; import UsersSelect from '~/users_select'; +import AbuseReports from './abuse_reports'; document.addEventListener('DOMContentLoaded', () => { new AbuseReports(); diff --git a/app/assets/javascripts/pages/admin/application_settings/general/index.js b/app/assets/javascripts/pages/admin/application_settings/general/index.js index af1595398a8..f7bd32880ff 100644 --- a/app/assets/javascripts/pages/admin/application_settings/general/index.js +++ b/app/assets/javascripts/pages/admin/application_settings/general/index.js @@ -1,6 +1,10 @@ +// This is a true violation of @gitlab/no-runtime-template-compiler, as it +// relies on app/views/admin/application_settings/_gitpod.html.haml for its +// template. +/* eslint-disable @gitlab/no-runtime-template-compiler */ import Vue from 'vue'; -import initUserInternalRegexPlaceholder from '../account_and_limits'; import IntegrationHelpText from '~/vue_shared/components/integrations_help_text.vue'; +import initUserInternalRegexPlaceholder from '../account_and_limits'; document.addEventListener('DOMContentLoaded', () => { initUserInternalRegexPlaceholder(); diff --git a/app/assets/javascripts/pages/admin/cohorts/index.js b/app/assets/javascripts/pages/admin/cohorts/index.js deleted file mode 100644 index 1cc54df15a1..00000000000 --- a/app/assets/javascripts/pages/admin/cohorts/index.js +++ /dev/null @@ -1,22 +0,0 @@ -import Vue from 'vue'; -import UsagePingDisabled from '~/admin/cohorts/components/usage_ping_disabled.vue'; - -document.addEventListener('DOMContentLoaded', () => { - const emptyStateContainer = document.getElementById('js-cohorts-empty-state'); - - if (!emptyStateContainer) return false; - - const { emptyStateSvgPath, enableUsagePingLink, docsLink } = emptyStateContainer.dataset; - - return new Vue({ - el: emptyStateContainer, - provide: { - svgPath: emptyStateSvgPath, - primaryButtonPath: enableUsagePingLink, - docsLink, - }, - render(h) { - return h(UsagePingDisabled); - }, - }); -}); diff --git a/app/assets/javascripts/pages/admin/groups/new/index.js b/app/assets/javascripts/pages/admin/groups/new/index.js index b94c999ed12..94f7cfd55be 100644 --- a/app/assets/javascripts/pages/admin/groups/new/index.js +++ b/app/assets/javascripts/pages/admin/groups/new/index.js @@ -1,6 +1,6 @@ +import initFilePickers from '~/file_pickers'; import BindInOut from '../../../../behaviors/bind_in_out'; import Group from '../../../../group'; -import initFilePickers from '~/file_pickers'; document.addEventListener('DOMContentLoaded', () => { BindInOut.initAll(); diff --git a/app/assets/javascripts/pages/admin/index.js b/app/assets/javascripts/pages/admin/index.js index 3f4e658fc8d..792a6eda14e 100644 --- a/app/assets/javascripts/pages/admin/index.js +++ b/app/assets/javascripts/pages/admin/index.js @@ -1,6 +1,6 @@ -import initAdmin from './admin'; import initAdminStatisticsPanel from '../../admin/statistics_panel/index'; import initVueAlerts from '../../vue_alerts'; +import initAdmin from './admin'; document.addEventListener('DOMContentLoaded', initVueAlerts); diff --git a/app/assets/javascripts/pages/admin/jobs/index/index.js b/app/assets/javascripts/pages/admin/jobs/index/index.js index 4df210debb5..9db2868e051 100644 --- a/app/assets/javascripts/pages/admin/jobs/index/index.js +++ b/app/assets/javascripts/pages/admin/jobs/index/index.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import Translate from '~/vue_shared/translate'; +import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import stopJobsModal from './components/stop_jobs_modal.vue'; Vue.use(Translate); @@ -18,7 +19,7 @@ document.addEventListener('DOMContentLoaded', () => { mounted() { stopJobsButton.classList.remove('disabled'); stopJobsButton.addEventListener('click', () => { - this.$root.$emit('bv::show::modal', modalId, `#${buttonId}`); + this.$root.$emit(BV_SHOW_MODAL, modalId, `#${buttonId}`); }); }, render(createElement) { diff --git a/app/assets/javascripts/pages/admin/projects/index/index.js b/app/assets/javascripts/pages/admin/projects/index/index.js index bf512ef395d..40f176d5eb2 100644 --- a/app/assets/javascripts/pages/admin/projects/index/index.js +++ b/app/assets/javascripts/pages/admin/projects/index/index.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import Translate from '~/vue_shared/translate'; import csrf from '~/lib/utils/csrf'; +import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import deleteProjectModal from './components/delete_project_modal.vue'; @@ -24,7 +25,7 @@ document.addEventListener('DOMContentLoaded', () => { deleteModal.deleteProjectUrl = buttonProps.deleteProjectUrl; deleteModal.projectName = buttonProps.projectName; - this.$root.$emit('bv::show::modal', 'delete-project-modal'); + this.$root.$emit(BV_SHOW_MODAL, 'delete-project-modal'); }); }); }, diff --git a/app/assets/javascripts/pages/admin/runners/index.js b/app/assets/javascripts/pages/admin/runners/index.js index e60c6133c7c..0b92f5ef90f 100644 --- a/app/assets/javascripts/pages/admin/runners/index.js +++ b/app/assets/javascripts/pages/admin/runners/index.js @@ -1,6 +1,7 @@ import initFilteredSearch from '~/pages/search/init_filtered_search'; import AdminRunnersFilteredSearchTokenKeys from '~/filtered_search/admin_runners_filtered_search_token_keys'; import { FILTERED_SEARCH } from '~/pages/constants'; +import { initInstallRunner } from '~/pages/shared/mount_runner_instructions'; document.addEventListener('DOMContentLoaded', () => { initFilteredSearch({ @@ -8,4 +9,6 @@ document.addEventListener('DOMContentLoaded', () => { filteredSearchTokenKeys: AdminRunnersFilteredSearchTokenKeys, useDefaultState: true, }); + + initInstallRunner(); }); diff --git a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue index 9c303cc6445..d2b83f980d7 100644 --- a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue +++ b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue @@ -85,38 +85,36 @@ export default { <template> <gl-modal ref="modal" modal-id="delete-user-modal" :title="modalTitle" kind="danger"> - <template> - <p> - <gl-sprintf :message="content"> - <template #username> - <strong>{{ username }}</strong> - </template> - <template #strong="props"> - <strong>{{ props.content }}</strong> - </template> - </gl-sprintf> - </p> + <p> + <gl-sprintf :message="content"> + <template #username> + <strong>{{ username }}</strong> + </template> + <template #strong="props"> + <strong>{{ props.content }}</strong> + </template> + </gl-sprintf> + </p> - <p> - <gl-sprintf :message="s__('AdminUsers|To confirm, type %{username}')"> - <template #username> - <code>{{ username }}</code> - </template> - </gl-sprintf> - </p> + <p> + <gl-sprintf :message="s__('AdminUsers|To confirm, type %{username}')"> + <template #username> + <code>{{ username }}</code> + </template> + </gl-sprintf> + </p> - <form ref="form" :action="deleteUserUrl" method="post" @submit.prevent> - <input ref="method" type="hidden" name="_method" value="delete" /> - <input :value="csrfToken" type="hidden" name="authenticity_token" /> - <gl-form-input - v-model="enteredUsername" - autofocus - type="text" - name="username" - autocomplete="off" - /> - </form> - </template> + <form ref="form" :action="deleteUserUrl" method="post" @submit.prevent> + <input ref="method" type="hidden" name="_method" value="delete" /> + <input :value="csrfToken" type="hidden" name="authenticity_token" /> + <gl-form-input + v-model="enteredUsername" + autofocus + type="text" + name="username" + autocomplete="off" + /> + </form> <template #modal-footer> <gl-button @click="onCancel">{{ s__('Cancel') }}</gl-button> <gl-button diff --git a/app/assets/javascripts/pages/admin/users/index.js b/app/assets/javascripts/pages/admin/users/index.js index 75a8284f5f8..1fd838e704c 100644 --- a/app/assets/javascripts/pages/admin/users/index.js +++ b/app/assets/javascripts/pages/admin/users/index.js @@ -1,10 +1,11 @@ import Vue from 'vue'; import Translate from '~/vue_shared/translate'; -import ModalManager from './components/user_modal_manager.vue'; import csrf from '~/lib/utils/csrf'; import initConfirmModal from '~/confirm_modal'; -import initAdminUsersApp from '~/admin/users'; +import { initAdminUsersApp, initCohortsEmptyState } from '~/admin/users'; +import initTabs from '~/admin/users/tabs'; +import ModalManager from './components/user_modal_manager.vue'; const MODAL_TEXTS_CONTAINER_SELECTOR = '#js-modal-texts'; const MODAL_MANAGER_SELECTOR = '#js-delete-user-modal'; @@ -58,4 +59,6 @@ document.addEventListener('DOMContentLoaded', () => { initConfirmModal(); initAdminUsersApp(); + initCohortsEmptyState(); + initTabs(); }); diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js index 5346e3720e8..516311dd841 100644 --- a/app/assets/javascripts/pages/groups/group_members/index.js +++ b/app/assets/javascripts/pages/groups/group_members/index.js @@ -3,10 +3,11 @@ import memberExpirationDate from '~/member_expiration_date'; import UsersSelect from '~/users_select'; import groupsSelect from '~/groups_select'; import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue'; -import { initGroupMembersApp } from '~/groups/members'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; -import { memberRequestFormatter, groupLinkRequestFormatter } from '~/groups/members/utils'; +import { initMembersApp } from '~/members/index'; +import { groupMemberRequestFormatter } from '~/groups/members/utils'; +import { groupLinkRequestFormatter } from '~/members/utils'; import { s__ } from '~/locale'; function mountRemoveMemberModal() { @@ -25,11 +26,11 @@ function mountRemoveMemberModal() { const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions']; -initGroupMembersApp(document.querySelector('.js-group-members-list'), { +initMembersApp(document.querySelector('.js-group-members-list'), { tableFields: SHARED_FIELDS.concat(['source', 'granted']), tableAttrs: { tr: { 'data-qa-selector': 'member_row' } }, tableSortableFields: ['account', 'granted', 'maxRole', 'lastSignIn'], - requestFormatter: memberRequestFormatter, + requestFormatter: groupMemberRequestFormatter, filteredSearchBar: { show: true, tokens: ['two_factor', 'with_inherited_permissions'], @@ -38,7 +39,8 @@ initGroupMembersApp(document.querySelector('.js-group-members-list'), { recentSearchesStorageKey: 'group_members', }, }); -initGroupMembersApp(document.querySelector('.js-group-linked-list'), { + +initMembersApp(document.querySelector('.js-group-group-links-list'), { tableFields: SHARED_FIELDS.concat('granted'), tableAttrs: { table: { 'data-qa-selector': 'groups_list' }, @@ -46,9 +48,9 @@ initGroupMembersApp(document.querySelector('.js-group-linked-list'), { }, requestFormatter: groupLinkRequestFormatter, }); -initGroupMembersApp(document.querySelector('.js-group-invited-members-list'), { +initMembersApp(document.querySelector('.js-group-invited-members-list'), { tableFields: SHARED_FIELDS.concat('invited'), - requestFormatter: memberRequestFormatter, + requestFormatter: groupMemberRequestFormatter, filteredSearchBar: { show: true, tokens: [], @@ -57,9 +59,9 @@ initGroupMembersApp(document.querySelector('.js-group-invited-members-list'), { recentSearchesStorageKey: 'group_invited_members', }, }); -initGroupMembersApp(document.querySelector('.js-group-access-requests-list'), { +initMembersApp(document.querySelector('.js-group-access-requests-list'), { tableFields: SHARED_FIELDS.concat('requested'), - requestFormatter: memberRequestFormatter, + requestFormatter: groupMemberRequestFormatter, }); groupsSelect(); 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 97f3d8cf7f5..7da196a34ab 100644 --- a/app/assets/javascripts/pages/groups/new/group_path_validator.js +++ b/app/assets/javascripts/pages/groups/new/group_path_validator.js @@ -1,9 +1,9 @@ import { debounce } from 'lodash'; import InputValidator from '~/validators/input_validator'; -import fetchGroupPathAvailability from './fetch_group_path_availability'; import { deprecatedCreateFlash as flash } from '~/flash'; import { __ } from '~/locale'; +import fetchGroupPathAvailability from './fetch_group_path_availability'; const debounceTimeoutDuration = 1000; const invalidInputClass = 'gl-field-error-outline'; diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js index 7021473b380..88118d07c9e 100644 --- a/app/assets/javascripts/pages/groups/new/index.js +++ b/app/assets/javascripts/pages/groups/new/index.js @@ -1,8 +1,8 @@ import $ from 'jquery'; import BindInOut from '~/behaviors/bind_in_out'; import Group from '~/group'; -import GroupPathValidator from './group_path_validator'; import initFilePickers from '~/file_pickers'; +import GroupPathValidator from './group_path_validator'; const parentId = $('#group_parent_id'); if (!parentId.val()) { 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 e8d8c985ade..19d81f61dba 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 @@ -4,6 +4,7 @@ import initFilteredSearch from '~/pages/search/init_filtered_search'; import GroupRunnersFilteredSearchTokenKeys from '~/filtered_search/group_runners_filtered_search_token_keys'; import { FILTERED_SEARCH } from '~/pages/constants'; import initSharedRunnersForm from '~/group_settings/mount_shared_runners'; +import { initInstallRunner } from '~/pages/shared/mount_runner_instructions'; document.addEventListener('DOMContentLoaded', () => { // Initialize expandable settings panels @@ -18,4 +19,6 @@ document.addEventListener('DOMContentLoaded', () => { initSharedRunnersForm(); initVariableList(); + + initInstallRunner(); }); diff --git a/app/assets/javascripts/pages/groups/shared/group_details.js b/app/assets/javascripts/pages/groups/shared/group_details.js index 8d956c694c0..add955ab1f2 100644 --- a/app/assets/javascripts/pages/groups/shared/group_details.js +++ b/app/assets/javascripts/pages/groups/shared/group_details.js @@ -6,8 +6,11 @@ import notificationsDropdown from '~/notifications_dropdown'; import NotificationsForm from '~/notifications_form'; import ProjectsList from '~/projects_list'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; -import GroupTabs from './group_tabs'; import initInviteMembersBanner from '~/groups/init_invite_members_banner'; +import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; +import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; +import initNotificationsDropdown from '~/notifications'; +import GroupTabs from './group_tabs'; export default function initGroupDetails(actionName = 'show') { const loadableActions = [ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED]; @@ -20,8 +23,16 @@ export default function initGroupDetails(actionName = 'show') { new GroupTabs({ parentEl: '.groups-listing', action }); new ShortcutsNavigation(); new NotificationsForm(); - notificationsDropdown(); + + if (gon.features?.vueNotificationDropdown) { + initNotificationsDropdown(); + } else { + notificationsDropdown(); + } + new ProjectsList(); initInviteMembersBanner(); + initInviteMembersModal(); + initInviteMembersTrigger(); } diff --git a/app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js b/app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js index e8b67891c42..829dfa7bb5e 100644 --- a/app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js +++ b/app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import Translate from '~/vue_shared/translate'; +import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import DeleteMilestoneModal from './components/delete_milestone_modal.vue'; import eventHub from './event_hub'; @@ -47,7 +48,7 @@ export default () => { deleteMilestoneButtons.forEach((button) => { button.removeAttribute('disabled'); button.addEventListener('click', () => { - this.$root.$emit('bv::show::modal', 'delete-milestone-modal'); + this.$root.$emit(BV_SHOW_MODAL, 'delete-milestone-modal'); eventHub.$once('deleteMilestoneModal.requestStarted', onRequestStarted); this.setModalProps({ diff --git a/app/assets/javascripts/pages/profiles/notifications/show/index.js b/app/assets/javascripts/pages/profiles/notifications/show/index.js index 2e24a10fa5c..cd1512df2da 100644 --- a/app/assets/javascripts/pages/profiles/notifications/show/index.js +++ b/app/assets/javascripts/pages/profiles/notifications/show/index.js @@ -1,7 +1,9 @@ +import initNotificationsDropdown from '~/notifications'; import NotificationsForm from '../../../../notifications_form'; import notificationsDropdown from '../../../../notifications_dropdown'; document.addEventListener('DOMContentLoaded', () => { new NotificationsForm(); // eslint-disable-line no-new notificationsDropdown(); + initNotificationsDropdown(); }); diff --git a/app/assets/javascripts/pages/profiles/show/index.js b/app/assets/javascripts/pages/profiles/show/index.js index b78f24ca2fb..f1f0b2c508b 100644 --- a/app/assets/javascripts/pages/profiles/show/index.js +++ b/app/assets/javascripts/pages/profiles/show/index.js @@ -1,10 +1,10 @@ import $ from 'jquery'; -import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; import emojiRegex from 'emoji-regex'; +import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; import { deprecatedCreateFlash as createFlash } from '~/flash'; -import EmojiMenu from './emoji_menu'; import { __ } from '~/locale'; import * as Emoji from '~/emoji'; +import EmojiMenu from './emoji_menu'; const defaultStatusEmoji = 'speech_balloon'; diff --git a/app/assets/javascripts/pages/projects/activity/index.js b/app/assets/javascripts/pages/projects/activity/index.js index d39ea3d10bf..03fbad0f1ec 100644 --- a/app/assets/javascripts/pages/projects/activity/index.js +++ b/app/assets/javascripts/pages/projects/activity/index.js @@ -1,7 +1,5 @@ import Activities from '~/activities'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; -document.addEventListener('DOMContentLoaded', () => { - new Activities(); // eslint-disable-line no-new - new ShortcutsNavigation(); // eslint-disable-line no-new -}); +new Activities(); // eslint-disable-line no-new +new ShortcutsNavigation(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/alert_management/details/index.js b/app/assets/javascripts/pages/projects/alert_management/details/index.js index a20f6713c9d..183e07ca1fc 100644 --- a/app/assets/javascripts/pages/projects/alert_management/details/index.js +++ b/app/assets/javascripts/pages/projects/alert_management/details/index.js @@ -1,3 +1,3 @@ -import AlertDetails from '~/alert_management/details'; +import AlertDetails from '~/vue_shared/alert_details'; AlertDetails('#js-alert_details'); diff --git a/app/assets/javascripts/pages/projects/blame/show/index.js b/app/assets/javascripts/pages/projects/blame/show/index.js index 80d0bff92fa..fa22c11d1d7 100644 --- a/app/assets/javascripts/pages/projects/blame/show/index.js +++ b/app/assets/javascripts/pages/projects/blame/show/index.js @@ -1,3 +1,3 @@ import initBlob from '~/pages/projects/init_blob'; -document.addEventListener('DOMContentLoaded', initBlob); +initBlob(); diff --git a/app/assets/javascripts/pages/projects/clusters/show/index.js b/app/assets/javascripts/pages/projects/clusters/show/index.js index a05ea8ae845..376268ec3f6 100644 --- a/app/assets/javascripts/pages/projects/clusters/show/index.js +++ b/app/assets/javascripts/pages/projects/clusters/show/index.js @@ -1,7 +1,7 @@ import ClustersBundle from '~/clusters/clusters_bundle'; import initGkeNamespace from '~/create_cluster/gke_cluster_namespace'; -import initClusterHealth from './cluster_health'; import initIntegrationForm from '~/clusters/forms/show'; +import initClusterHealth from './cluster_health'; document.addEventListener('DOMContentLoaded', () => { new ClustersBundle(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/commit/pipelines/index.js b/app/assets/javascripts/pages/projects/commit/pipelines/index.js index eaf340f2725..f542014c5b9 100644 --- a/app/assets/javascripts/pages/projects/commit/pipelines/index.js +++ b/app/assets/javascripts/pages/projects/commit/pipelines/index.js @@ -1,5 +1,7 @@ import { initCommitBoxInfo } from '~/projects/commit_box/info'; import initPipelines from '~/commit/pipelines/pipelines_bundle'; +import initCommitActions from '~/projects/commit'; initCommitBoxInfo(); initPipelines(); +initCommitActions(); diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js index 5cfdb125e4f..5a3b486fd40 100644 --- a/app/assets/javascripts/pages/projects/commit/show/index.js +++ b/app/assets/javascripts/pages/projects/commit/show/index.js @@ -14,8 +14,7 @@ import flash from '~/flash'; import { __ } from '~/locale'; import loadAwardsHandler from '~/awards_handler'; import { initCommitBoxInfo } from '~/projects/commit_box/info'; -import initRevertCommitTrigger from '~/projects/commit/init_revert_commit_trigger'; -import initRevertCommitModal from '~/projects/commit/init_revert_commit_modal'; +import initCommitActions from '~/projects/commit'; const hasPerfBar = document.querySelector('.with-performance-bar'); const performanceHeight = hasPerfBar ? 35 : 0; @@ -47,5 +46,4 @@ if (filesContainer.length) { new Diff(); } loadAwardsHandler(); -initRevertCommitModal(); -initRevertCommitTrigger(); +initCommitActions(); diff --git a/app/assets/javascripts/pages/projects/compare/index/index.js b/app/assets/javascripts/pages/projects/compare/index/index.js new file mode 100644 index 00000000000..b86c9ec442f --- /dev/null +++ b/app/assets/javascripts/pages/projects/compare/index/index.js @@ -0,0 +1,3 @@ +import initCompareSelector from '~/projects/compare'; + +initCompareSelector(); diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js index 5f1d3edc3ba..413e43c638b 100644 --- a/app/assets/javascripts/pages/projects/edit/index.js +++ b/app/assets/javascripts/pages/projects/edit/index.js @@ -5,12 +5,12 @@ import initConfirmDangerModal from '~/confirm_danger_modal'; import mountBadgeSettings from '~/pages/shared/mount_badge_settings'; import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory'; import initFilePickers from '~/file_pickers'; -import initProjectLoadingSpinner from '../shared/save_project_loader'; -import initProjectPermissionsSettings from '../shared/permissions'; import initProjectDeleteButton from '~/projects/project_delete_button'; import UserCallout from '~/user_callout'; import initServiceDesk from '~/projects/settings_service_desk'; -import mountSearchSettings from './mount_search_settings'; +import initSearchSettings from '~/search_settings'; +import initProjectPermissionsSettings from '../shared/permissions'; +import initProjectLoadingSpinner from '../shared/save_project_loader'; document.addEventListener('DOMContentLoaded', () => { initFilePickers(); @@ -32,5 +32,5 @@ document.addEventListener('DOMContentLoaded', () => { ), ); - mountSearchSettings(); + initSearchSettings(); }); diff --git a/app/assets/javascripts/pages/projects/edit/mount_search_settings.js b/app/assets/javascripts/pages/projects/edit/mount_search_settings.js deleted file mode 100644 index 6c477dd7e80..00000000000 --- a/app/assets/javascripts/pages/projects/edit/mount_search_settings.js +++ /dev/null @@ -1,12 +0,0 @@ -const mountSearchSettings = async () => { - const el = document.querySelector('.js-search-settings-app'); - - if (el) { - const { default: initSearch } = await import( - /* webpackChunkName: 'search_settings' */ '~/search_settings' - ); - initSearch({ el }); - } -}; - -export default mountSearchSettings; diff --git a/app/assets/javascripts/pages/projects/find_file/show/index.js b/app/assets/javascripts/pages/projects/find_file/show/index.js index 388d7d7bdda..7fb009e7dc9 100644 --- a/app/assets/javascripts/pages/projects/find_file/show/index.js +++ b/app/assets/javascripts/pages/projects/find_file/show/index.js @@ -2,12 +2,10 @@ import $ from 'jquery'; import ProjectFindFile from '~/project_find_file'; import ShortcutsFindFile from '~/behaviors/shortcuts/shortcuts_find_file'; -document.addEventListener('DOMContentLoaded', () => { - const findElement = document.querySelector('.js-file-finder'); - const projectFindFile = new ProjectFindFile($('.file-finder-holder'), { - url: findElement.dataset.fileFindUrl, - treeUrl: findElement.dataset.findTreeUrl, - blobUrlTemplate: findElement.dataset.blobUrlTemplate, - }); - new ShortcutsFindFile(projectFindFile); // eslint-disable-line no-new +const findElement = document.querySelector('.js-file-finder'); +const projectFindFile = new ProjectFindFile($('.file-finder-holder'), { + url: findElement.dataset.fileFindUrl, + treeUrl: findElement.dataset.findTreeUrl, + blobUrlTemplate: findElement.dataset.blobUrlTemplate, }); +new ShortcutsFindFile(projectFindFile); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue index 57838050d55..b596c862d8f 100644 --- a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue +++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue @@ -94,7 +94,7 @@ export default { </div> <gl-link :href="group.relative_path" - class="gl-display-none gl-flex-shrink-0 gl-display-sm-flex gl-mr-3" + class="gl-display-none gl-flex-shrink-0 gl-sm-display-flex gl-mr-3" > <gl-avatar :size="32" shape="rect" :entity-name="group.name" :src="group.avatarUrl" /> </gl-link> @@ -113,7 +113,7 @@ export default { <gl-badge v-if="isGroupPendingRemoval" variant="warning" - class="gl-display-none gl-display-sm-flex gl-mt-3 gl-mr-1" + class="gl-display-none gl-sm-display-flex gl-mt-3 gl-mr-1" >{{ __('pending removal') }}</gl-badge > <span v-if="group.permission" class="user-access-role gl-mt-3"> diff --git a/app/assets/javascripts/pages/projects/incidents/show/index.js b/app/assets/javascripts/pages/projects/incidents/show/index.js index 5b3f03cd57e..850bb8da5e7 100644 --- a/app/assets/javascripts/pages/projects/incidents/show/index.js +++ b/app/assets/javascripts/pages/projects/incidents/show/index.js @@ -3,7 +3,5 @@ import initRelatedIssues from '~/related_issues'; import initShow from '../../issues/show'; initShow(); -if (!gon.features?.vueIssuableSidebar) { - initSidebarBundle(); -} +initSidebarBundle(); initRelatedIssues(); diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js index 3e9962a4e72..45e9643b3f3 100644 --- a/app/assets/javascripts/pages/projects/index.js +++ b/app/assets/javascripts/pages/projects/index.js @@ -1,5 +1,5 @@ -import Project from './project'; import ShortcutsNavigation from '../../behaviors/shortcuts/shortcuts_navigation'; +import Project from './project'; new Project(); // eslint-disable-line no-new new ShortcutsNavigation(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js index f3ccedc47c8..5956933fd99 100644 --- a/app/assets/javascripts/pages/projects/issues/index/index.js +++ b/app/assets/javascripts/pages/projects/issues/index/index.js @@ -9,7 +9,6 @@ import { FILTERED_SEARCH } from '~/pages/constants'; import { ISSUABLE_INDEX } from '~/pages/projects/constants'; import initIssuablesList from '~/issues_list'; import initManualOrdering from '~/manual_ordering'; -import { showLearnGitLabIssuesPopover } from '~/onboarding_issues'; IssuableFilteredSearchTokenKeys.addExtraTokensForIssues(); @@ -25,4 +24,3 @@ new UsersSelect(); initManualOrdering(); initIssuablesList(); -showLearnGitLabIssuesPopover(); diff --git a/app/assets/javascripts/pages/projects/issues/service_desk/index.js b/app/assets/javascripts/pages/projects/issues/service_desk/index.js index 231ee6732e9..5be9f6117dc 100644 --- a/app/assets/javascripts/pages/projects/issues/service_desk/index.js +++ b/app/assets/javascripts/pages/projects/issues/service_desk/index.js @@ -1,5 +1,5 @@ -import FilteredSearchServiceDesk from './filtered_search'; import initIssuablesList from '~/issues_list'; +import FilteredSearchServiceDesk from './filtered_search'; const supportBotData = JSON.parse( document.querySelector('.js-service-desk-issues').dataset.supportBot, diff --git a/app/assets/javascripts/pages/projects/issues/show/index.js b/app/assets/javascripts/pages/projects/issues/show/index.js index 630add51a97..64f34633db3 100644 --- a/app/assets/javascripts/pages/projects/issues/show/index.js +++ b/app/assets/javascripts/pages/projects/issues/show/index.js @@ -3,7 +3,5 @@ import initRelatedIssues from '~/related_issues'; import initShow from '../show'; initShow(); -if (gon.features && !gon.features.vueIssuableSidebar) { - initSidebarBundle(); -} +initSidebarBundle(); initRelatedIssues(); diff --git a/app/assets/javascripts/pages/projects/jobs/index/index.js b/app/assets/javascripts/pages/projects/jobs/index/index.js index c343a37b292..f66c09cb1ac 100644 --- a/app/assets/javascripts/pages/projects/jobs/index/index.js +++ b/app/assets/javascripts/pages/projects/jobs/index/index.js @@ -7,10 +7,13 @@ document.addEventListener('DOMContentLoaded', () => { remainingTimeElements.forEach( (el) => new Vue({ - ...GlCountdown, el, - propsData: { - endDateString: el.dateTime, + render(h) { + return h(GlCountdown, { + props: { + endDateString: el.dateTime, + }, + }); }, }), ); diff --git a/app/assets/javascripts/pages/projects/labels/index/index.js b/app/assets/javascripts/pages/projects/labels/index/index.js index 4f5e5c8cceb..6954180c670 100644 --- a/app/assets/javascripts/pages/projects/labels/index/index.js +++ b/app/assets/javascripts/pages/projects/labels/index/index.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import Translate from '~/vue_shared/translate'; import initLabels from '~/init_labels'; +import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import eventHub from '../event_hub'; import PromoteLabelModal from '../components/promote_label_modal.vue'; @@ -49,7 +50,7 @@ const initLabelIndex = () => { promoteLabelButtons.forEach((button) => { button.removeAttribute('disabled'); button.addEventListener('click', () => { - this.$root.$emit('bv::show::modal', 'promote-label-modal'); + this.$root.$emit(BV_SHOW_MODAL, 'promote-label-modal'); eventHub.$once('promoteLabelModal.requestStarted', onRequestStarted); this.setModalProps({ diff --git a/app/assets/javascripts/pages/projects/merge_requests/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/show/index.js index 602d749ee07..e0476f181e3 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js @@ -1,14 +1,12 @@ import initMrNotes from '~/mr_notes'; import { initReviewBar } from '~/batch_comments'; import initSidebarBundle from '~/sidebar/sidebar_bundle'; -import initShow from '../init_merge_request_show'; import initIssuableHeaderWarning from '~/vue_shared/components/issuable/init_issuable_header_warning'; import store from '~/mr_notes/stores'; +import initShow from '../init_merge_request_show'; initShow(); -if (gon.features && !gon.features.vueIssuableSidebar) { - initSidebarBundle(); -} +initSidebarBundle(); initMrNotes(); initReviewBar(); initIssuableHeaderWarning(store); diff --git a/app/assets/javascripts/pages/projects/new/index.js b/app/assets/javascripts/pages/projects/new/index.js index 88f4db3ec08..259e62a4c4f 100644 --- a/app/assets/javascripts/pages/projects/new/index.js +++ b/app/assets/javascripts/pages/projects/new/index.js @@ -1,7 +1,7 @@ -import initProjectVisibilitySelector from '../../../project_visibility'; -import initProjectNew from '../../../projects/project_new'; import { __ } from '~/locale'; import { deprecatedCreateFlash as createFlash } from '~/flash'; +import initProjectVisibilitySelector from '../../../project_visibility'; +import initProjectNew from '../../../projects/project_new'; document.addEventListener('DOMContentLoaded', () => { initProjectVisibilitySelector(); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue index 8ee9d481466..e73f78bca78 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue @@ -2,8 +2,8 @@ import Vue from 'vue'; import Cookies from 'js-cookie'; import { GlButton } from '@gitlab/ui'; -import Translate from '../../../../../vue_shared/translate'; import { parseBoolean } from '~/lib/utils/common_utils'; +import Translate from '../../../../../vue_shared/translate'; Vue.use(Translate); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js index 497e2c9c0ae..d944cfbdbc7 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js @@ -2,10 +2,10 @@ import $ from 'jquery'; import Vue from 'vue'; import Translate from '../../../../vue_shared/translate'; import GlFieldErrors from '../../../../gl_field_errors'; +import setupNativeFormVariableList from '../../../../ci_variable_list/native_form_variable_list'; import intervalPatternInput from './components/interval_pattern_input.vue'; import TimezoneDropdown from './components/timezone_dropdown'; import TargetBranchDropdown from './components/target_branch_dropdown'; -import setupNativeFormVariableList from '../../../../ci_variable_list/native_form_variable_list'; Vue.use(Translate); diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js index ef6953db83b..7fd59012e83 100644 --- a/app/assets/javascripts/pages/projects/project.js +++ b/app/assets/javascripts/pages/projects/project.js @@ -7,9 +7,9 @@ import { mergeUrlParams } from '~/lib/utils/url_utility'; import { serializeForm } from '~/lib/utils/forms'; import axios from '~/lib/utils/axios_utils'; import { deprecatedCreateFlash as flash } from '~/flash'; -import projectSelect from '../../project_select'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import initClonePanel from '~/clone_panel'; +import projectSelect from '../../project_select'; export default class Project { constructor() { @@ -126,8 +126,9 @@ export default class Project { const refs = this.fullData.Branches.concat(this.fullData.Tags); const currentRef = refs.find((ref) => loc.indexOf(ref) > -1); if (currentRef) { - const targetPath = loc.split(currentRef)[1].slice(1); + const targetPath = loc.split(currentRef)[1].slice(1).split('#')[0]; selectedUrl.searchParams.set('path', targetPath); + selectedUrl.hash = window.location.hash; } } diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js index 3e0a48ee6a2..f029b26fa78 100644 --- a/app/assets/javascripts/pages/projects/project_members/index.js +++ b/app/assets/javascripts/pages/projects/project_members/index.js @@ -6,6 +6,8 @@ import groupsSelect from '~/groups_select'; import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; +import { __ } from '~/locale'; +import { deprecatedCreateFlash as flash } from '~/flash'; function mountRemoveMemberModal() { const el = document.querySelector('.js-remove-member-modal'); @@ -32,3 +34,65 @@ document.addEventListener('DOMContentLoaded', () => { new Members(); // eslint-disable-line no-new new UsersSelect(); // eslint-disable-line no-new }); + +if (window.gon.features.vueProjectMembersList) { + const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions']; + + Promise.all([ + import('~/members/index'), + import('~/members/utils'), + import('~/projects/members/utils'), + import('~/locale'), + ]) + .then( + ([ + { initMembersApp }, + { groupLinkRequestFormatter }, + { projectMemberRequestFormatter }, + { s__ }, + ]) => { + initMembersApp(document.querySelector('.js-project-members-list'), { + tableFields: SHARED_FIELDS.concat(['source', 'granted']), + tableAttrs: { tr: { 'data-qa-selector': 'member_row' } }, + tableSortableFields: ['account', 'granted', 'maxRole', 'lastSignIn'], + requestFormatter: projectMemberRequestFormatter, + filteredSearchBar: { + show: true, + tokens: ['with_inherited_permissions'], + searchParam: 'search', + placeholder: s__('Members|Filter members'), + recentSearchesStorageKey: 'project_members', + }, + }); + + initMembersApp(document.querySelector('.js-project-group-links-list'), { + tableFields: SHARED_FIELDS.concat('granted'), + tableAttrs: { + table: { 'data-qa-selector': 'groups_list' }, + tr: { 'data-qa-selector': 'group_row' }, + }, + requestFormatter: groupLinkRequestFormatter, + filteredSearchBar: { + show: true, + tokens: [], + searchParam: 'search_groups', + placeholder: s__('Members|Search groups'), + recentSearchesStorageKey: 'project_group_links', + }, + }); + + initMembersApp(document.querySelector('.js-project-invited-members-list'), { + tableFields: SHARED_FIELDS.concat('invited'), + requestFormatter: projectMemberRequestFormatter, + }); + + initMembersApp(document.querySelector('.js-project-access-requests-list'), { + tableFields: SHARED_FIELDS.concat('requested'), + requestFormatter: projectMemberRequestFormatter, + }); + }, + ) + .catch(() => { + flash(__('An error occurred while loading the members, please try again.')); + }); +} diff --git a/app/assets/javascripts/pages/projects/releases/index/index.js b/app/assets/javascripts/pages/projects/releases/index/index.js index 24c9cd528b3..caf95ae53c8 100644 --- a/app/assets/javascripts/pages/projects/releases/index/index.js +++ b/app/assets/javascripts/pages/projects/releases/index/index.js @@ -1,3 +1,3 @@ import initReleases from '~/releases/mount_index'; -document.addEventListener('DOMContentLoaded', initReleases); +initReleases(); 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 1321155b7ec..f2dd0da4d62 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 @@ -6,6 +6,7 @@ import initDeployFreeze from '~/deploy_freeze'; import initSettingsPipelinesTriggers from '~/ci_settings_pipeline_triggers'; import initSharedRunnersToggle from '~/projects/settings/mount_shared_runners_toggle'; import initArtifactsSettings from '~/artifacts_settings'; +import { initInstallRunner } from '~/pages/shared/mount_runner_instructions'; document.addEventListener('DOMContentLoaded', () => { // Initialize expandable settings panels @@ -39,4 +40,6 @@ document.addEventListener('DOMContentLoaded', () => { if (gon?.features?.vueifySharedRunnersToggle) { initSharedRunnersToggle(); } + + initInstallRunner(); }); 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 1ef4b460263..e90954c14c5 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,5 @@ -import initForm from '../form'; import MirrorRepos from '~/mirrors/mirror_repos'; +import initForm from '../form'; document.addEventListener('DOMContentLoaded', () => { initForm(); 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 4af476fbd68..d81c11ddbaf 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 @@ -3,9 +3,8 @@ import { GlIcon, GlSprintf, GlLink, GlFormCheckbox } from '@gitlab/ui'; import settingsMixin from 'ee_else_ce/pages/projects/shared/permissions/mixins/settings_pannel_mixin'; import { s__ } from '~/locale'; -import projectFeatureSetting from './project_feature_setting.vue'; import projectFeatureToggle from '~/vue_shared/components/toggle_button.vue'; -import projectSettingRow from './project_setting_row.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { visibilityOptions, visibilityLevelDescriptions, @@ -15,7 +14,8 @@ import { featureAccessLevelNone, } from '../constants'; import { toggleHiddenClassBySelector } from '../external'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import projectFeatureSetting from './project_feature_setting.vue'; +import projectSettingRow from './project_setting_row.vue'; const PAGE_FEATURE_ACCESS_LEVEL = s__('ProjectSettings|Everyone'); @@ -75,6 +75,11 @@ export default { required: false, default: false, }, + securityAndComplianceAvailable: { + type: Boolean, + required: false, + default: false, + }, visibilityHelpPath: { type: String, required: false, @@ -141,6 +146,7 @@ export default { metricsDashboardAccessLevel: featureAccessLevel.PROJECT_MEMBERS, analyticsAccessLevel: featureAccessLevel.EVERYONE, requirementsAccessLevel: featureAccessLevel.EVERYONE, + securityAndComplianceAccessLevel: featureAccessLevel.PROJECT_MEMBERS, operationsAccessLevel: featureAccessLevel.EVERYONE, containerRegistryEnabled: true, lfsEnabled: true, @@ -218,11 +224,11 @@ export default { repositoryHelpText() { if (this.visibilityLevel === visibilityOptions.PRIVATE) { - return s__('ProjectSettings|View and edit files in this project'); + return s__('ProjectSettings|View and edit files in this project.'); } return s__( - 'ProjectSettings|View and edit files in this project. Non-project members will only have read access', + 'ProjectSettings|View and edit files in this project. Non-project members will only have read access.', ); }, }, @@ -264,6 +270,10 @@ export default { featureAccessLevel.PROJECT_MEMBERS, this.requirementsAccessLevel, ); + this.securityAndComplianceAccessLevel = Math.min( + featureAccessLevel.PROJECT_MEMBERS, + this.securityAndComplianceAccessLevel, + ); this.operationsAccessLevel = Math.min( featureAccessLevel.PROJECT_MEMBERS, this.operationsAccessLevel, @@ -390,7 +400,7 @@ export default { name="project[request_access_enabled]" /> <input v-model="requestAccessEnabled" type="checkbox" /> - {{ s__('ProjectSettings|Allow users to request access') }} + {{ s__('ProjectSettings|Users can request access') }} </label> </project-setting-row> </div> @@ -401,7 +411,7 @@ export default { <project-setting-row ref="issues-settings" :label="s__('ProjectSettings|Issues')" - :help-text="s__('ProjectSettings|Lightweight issue tracking system for this project')" + :help-text="s__('ProjectSettings|Lightweight issue tracking system.')" > <project-feature-setting v-model="issuesAccessLevel" @@ -424,7 +434,7 @@ export default { <project-setting-row ref="merge-request-settings" :label="s__('ProjectSettings|Merge requests')" - :help-text="s__('ProjectSettings|Submit changes to be merged upstream')" + :help-text="s__('ProjectSettings|Submit changes to be merged upstream.')" > <project-feature-setting v-model="mergeRequestsAccessLevel" @@ -436,9 +446,7 @@ export default { <project-setting-row ref="fork-settings" :label="s__('ProjectSettings|Forks')" - :help-text=" - s__('ProjectSettings|Allow users to make copies of your repository to a new project') - " + :help-text="s__('ProjectSettings|Users can copy the repository to a new project.')" > <project-feature-setting v-model="forkingAccessLevel" @@ -450,7 +458,7 @@ export default { <project-setting-row ref="pipeline-settings" :label="s__('ProjectSettings|Pipelines')" - :help-text="s__('ProjectSettings|Build, test, and deploy your changes')" + :help-text="s__('ProjectSettings|Build, test, and deploy your changes.')" > <project-feature-setting v-model="buildsAccessLevel" @@ -487,7 +495,7 @@ export default { :help-path="lfsHelpPath" :label="s__('ProjectSettings|Git Large File Storage (LFS)')" :help-text=" - s__('ProjectSettings|Manages large files such as audio, video, and graphics files') + s__('ProjectSettings|Manages large files such as audio, video, and graphics files.') " > <project-feature-toggle @@ -499,7 +507,7 @@ export default { <gl-sprintf :message=" s__( - 'ProjectSettings|LFS objects from this repository are still available to forks. %{linkStart}How do I remove them?%{linkEnd}', + 'ProjectSettings|LFS objects from this repository are available to forks. %{linkStart}How do I remove them?%{linkEnd}', ) " > @@ -519,7 +527,7 @@ export default { :help-path="packagesHelpPath" :label="s__('ProjectSettings|Packages')" :help-text=" - s__('ProjectSettings|Every project can have its own space to store its packages') + s__('ProjectSettings|Every project can have its own space to store its packages.') " > <project-feature-toggle @@ -532,7 +540,7 @@ export default { <project-setting-row ref="analytics-settings" :label="s__('ProjectSettings|Analytics')" - :help-text="s__('ProjectSettings|View project analytics')" + :help-text="s__('ProjectSettings|View project analytics.')" > <project-feature-setting v-model="analyticsAccessLevel" @@ -544,7 +552,7 @@ export default { v-if="requirementsAvailable" ref="requirements-settings" :label="s__('ProjectSettings|Requirements')" - :help-text="s__('ProjectSettings|Requirements management system for this project')" + :help-text="s__('ProjectSettings|Requirements management system.')" > <project-feature-setting v-model="requirementsAccessLevel" @@ -553,9 +561,20 @@ export default { /> </project-setting-row> <project-setting-row + v-if="securityAndComplianceAvailable" + :label="s__('ProjectSettings|Security & Compliance')" + :help-text="s__('ProjectSettings|Security & Compliance for this project')" + > + <project-feature-setting + v-model="securityAndComplianceAccessLevel" + :options="featureAccessLevelOptions" + name="project[project_feature_attributes][security_and_compliance_access_level]" + /> + </project-setting-row> + <project-setting-row ref="wiki-settings" :label="s__('ProjectSettings|Wiki')" - :help-text="s__('ProjectSettings|Pages for project documentation')" + :help-text="s__('ProjectSettings|Pages for project documentation.')" > <project-feature-setting v-model="wikiAccessLevel" @@ -566,7 +585,7 @@ export default { <project-setting-row ref="snippet-settings" :label="s__('ProjectSettings|Snippets')" - :help-text="s__('ProjectSettings|Share code pastes with others out of Git repository')" + :help-text="s__('ProjectSettings|Share code with others outside the project.')" > <project-feature-setting v-model="snippetsAccessLevel" @@ -580,7 +599,7 @@ export default { :help-path="pagesHelpPath" :label="s__('ProjectSettings|Pages')" :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.') " > <project-feature-setting @@ -592,7 +611,7 @@ export default { <project-setting-row ref="operations-settings" :label="s__('ProjectSettings|Operations')" - :help-text="s__('ProjectSettings|Environments, logs, cluster management, and more')" + :help-text="s__('ProjectSettings|Environments, logs, cluster management, and more.')" > <project-feature-setting v-model="operationsAccessLevel" @@ -604,11 +623,7 @@ export default { <project-setting-row ref="metrics-visibility-settings" :label="__('Metrics Dashboard')" - :help-text=" - s__( - 'ProjectSettings|With Metrics Dashboard you can visualize this project performance metrics', - ) - " + :help-text="s__('ProjectSettings|Visualize the project\'s performance metrics.')" > <project-feature-setting v-model="metricsDashboardAccessLevel" @@ -626,9 +641,7 @@ export default { {{ s__('ProjectSettings|Disable email notifications') }} </label> <span class="form-text text-muted">{{ - s__( - 'ProjectSettings|This setting will override user notification preferences for all project members.', - ) + s__('ProjectSettings|Override user notification preferences for all project members.') }}</span> </project-setting-row> <project-setting-row class="mb-3"> @@ -644,7 +657,7 @@ export default { {{ s__('ProjectSettings|Show default award emojis') }} <template #help>{{ s__( - 'ProjectSettings|When enabled, issues, merge requests, and snippets will always show thumbs-up and thumbs-down award emoji buttons.', + 'ProjectSettings|Always show thumbs-up and thumbs-down award emoji buttons on issues, merge requests, and snippets.', ) }}</template> </gl-form-checkbox> @@ -662,9 +675,7 @@ export default { <gl-form-checkbox v-model="allowEditingCommitMessages"> {{ s__('ProjectSettings|Allow editing commit messages') }} <template #help>{{ - s__( - 'ProjectSettings|When enabled, commit authors will be able to edit commit messages on unprotected branches.', - ) + s__('ProjectSettings|Commit authors can edit commit messages on unprotected branches.') }}</template> </gl-form-checkbox> </project-setting-row> diff --git a/app/assets/javascripts/pages/projects/shared/permissions/mixins/settings_pannel_mixin.js b/app/assets/javascripts/pages/projects/shared/permissions/mixins/settings_pannel_mixin.js index ae0936417ad..b52e30dae39 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/mixins/settings_pannel_mixin.js +++ b/app/assets/javascripts/pages/projects/shared/permissions/mixins/settings_pannel_mixin.js @@ -3,6 +3,7 @@ export default { return { packagesEnabled: false, requirementsEnabled: false, + securityAndComplianceEnabled: false, }; }, watch: { diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js index cc676b98e49..5120b6bee27 100644 --- a/app/assets/javascripts/pages/projects/show/index.js +++ b/app/assets/javascripts/pages/projects/show/index.js @@ -7,11 +7,11 @@ import BlobViewer from '~/blob/viewer/index'; import Activities from '~/activities'; import initReadMore from '~/read_more'; import leaveByUrl from '~/namespaces/leave_by_url'; -import Star from '../../../star'; -import notificationsDropdown from '../../../notifications_dropdown'; -import { showLearnGitLabProjectPopover } from '~/onboarding_issues'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; +import initVueNotificationsDropdown from '~/notifications'; +import notificationsDropdown from '../../../notifications_dropdown'; +import Star from '../../../star'; initReadMore(); new Star(); // eslint-disable-line no-new @@ -40,9 +40,14 @@ if (document.querySelector('.project-show-activity')) { leaveByUrl('project'); -showLearnGitLabProjectPopover(); +if (gon.features?.vueNotificationDropdown) { + initVueNotificationsDropdown(); +} else { + notificationsDropdown(); +} + +initVueNotificationsDropdown(); -notificationsDropdown(); new ShortcutsNavigation(); // eslint-disable-line no-new initInviteMembersTrigger(); diff --git a/app/assets/javascripts/pages/projects/wikis/index.js b/app/assets/javascripts/pages/projects/wikis/index.js index 9c75531ca40..dead61cf358 100644 --- a/app/assets/javascripts/pages/projects/wikis/index.js +++ b/app/assets/javascripts/pages/projects/wikis/index.js @@ -1,3 +1,3 @@ import initWikis from '~/pages/shared/wikis'; -document.addEventListener('DOMContentLoaded', initWikis); +initWikis(); diff --git a/app/assets/javascripts/pages/search/show/index.js b/app/assets/javascripts/pages/search/show/index.js index b6171e08e01..a8c288c3663 100644 --- a/app/assets/javascripts/pages/search/show/index.js +++ b/app/assets/javascripts/pages/search/show/index.js @@ -1,7 +1,5 @@ -import Search from './search'; import { initSearchApp } from '~/search'; document.addEventListener('DOMContentLoaded', () => { - initSearchApp(); // Vue Bootstrap - return new Search(); // Legacy Search Methods + initSearchApp(); }); diff --git a/app/assets/javascripts/pages/search/show/search.js b/app/assets/javascripts/pages/search/show/search.js deleted file mode 100644 index cbef5ab1bbc..00000000000 --- a/app/assets/javascripts/pages/search/show/search.js +++ /dev/null @@ -1,54 +0,0 @@ -import $ from 'jquery'; -import setHighlightClass from 'ee_else_ce/search/highlight_blob_search_result'; -import Project from '~/pages/projects/project'; -import { visitUrl } from '~/lib/utils/url_utility'; -import refreshCounts from './refresh_counts'; - -export default class Search { - constructor() { - this.searchInput = '.js-search-input'; - this.searchClear = '.js-search-clear'; - - setHighlightClass(); // Code Highlighting - this.eventListeners(); // Search Form Actions - refreshCounts(); // Other Scope Tab Counts - Project.initRefSwitcher(); // Code Search Branch Picker - } - - eventListeners() { - $(document).off('keyup', this.searchInput).on('keyup', this.searchInput, this.searchKeyUp); - $(document) - .off('click', this.searchClear) - .on('click', this.searchClear, this.clearSearchField.bind(this)); - - $('a.js-search-clear').off('click', this.clearSearchFilter).on('click', this.clearSearchFilter); - } - - static submitSearch() { - return $('.js-search-form').submit(); - } - - searchKeyUp() { - const $input = $(this); - if ($input.val() === '') { - $('.js-search-clear').addClass('hidden'); - } else { - $('.js-search-clear').removeClass('hidden'); - } - } - - clearSearchField() { - return $(this.searchInput).val('').trigger('keyup').focus(); - } - - // We need to manually follow the link on the anchors - // that have this event bound, as their `click` default - // behavior is prevented by the toggle logic. - /* eslint-disable-next-line class-methods-use-this */ - clearSearchFilter(ev) { - const $target = $(ev.currentTarget); - - visitUrl($target.href); - ev.stopPropagation(); - } -} diff --git a/app/assets/javascripts/pages/sessions/new/index.js b/app/assets/javascripts/pages/sessions/new/index.js index 55bc93a2b13..84c825d129c 100644 --- a/app/assets/javascripts/pages/sessions/new/index.js +++ b/app/assets/javascripts/pages/sessions/new/index.js @@ -1,7 +1,7 @@ import $ from 'jquery'; +import NoEmojiValidator from '../../../emoji/no_emoji_validator'; import LengthValidator from './length_validator'; import UsernameValidator from './username_validator'; -import NoEmojiValidator from '../../../emoji/no_emoji_validator'; import SigninTabsMemoizer from './signin_tabs_memoizer'; import OAuthRememberMe from './oauth_remember_me'; import preserveUrlFragment from './preserve_url_fragment'; diff --git a/app/assets/javascripts/pages/shared/mount_runner_instructions.js b/app/assets/javascripts/pages/shared/mount_runner_instructions.js new file mode 100644 index 00000000000..51028e585b8 --- /dev/null +++ b/app/assets/javascripts/pages/shared/mount_runner_instructions.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import InstallRunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue'; + +Vue.use(VueApollo); + +export function initInstallRunner(componentId = 'js-install-runner') { + const installRunnerEl = document.getElementById(componentId); + + if (installRunnerEl) { + const defaultClient = createDefaultClient(); + const { projectPath, groupPath } = installRunnerEl.dataset; + + const apolloProvider = new VueApollo({ + defaultClient, + }); + + // eslint-disable-next-line no-new + new Vue({ + el: installRunnerEl, + apolloProvider, + provide: { + projectPath, + groupPath, + }, + render(createElement) { + return createElement(InstallRunnerInstructions); + }, + }); + } +} diff --git a/app/assets/javascripts/pages/shared/wikis/index.js b/app/assets/javascripts/pages/shared/wikis/index.js index 5e23b9bab4e..0679686110d 100644 --- a/app/assets/javascripts/pages/shared/wikis/index.js +++ b/app/assets/javascripts/pages/shared/wikis/index.js @@ -3,9 +3,9 @@ import Vue from 'vue'; import Translate from '~/vue_shared/translate'; import csrf from '~/lib/utils/csrf'; import ShortcutsWiki from '~/behaviors/shortcuts/shortcuts_wiki'; -import Wikis from './wikis'; import ZenMode from '../../../zen_mode'; import GLForm from '../../../gl_form'; +import Wikis from './wikis'; import deleteWikiModal from './components/delete_wiki_modal.vue'; export default () => { diff --git a/app/assets/javascripts/performance_bar/components/add_request.vue b/app/assets/javascripts/performance_bar/components/add_request.vue index 54bca8a1b67..d48a5acb85c 100644 --- a/app/assets/javascripts/performance_bar/components/add_request.vue +++ b/app/assets/javascripts/performance_bar/components/add_request.vue @@ -27,7 +27,7 @@ export default { <div id="peek-view-add-request" class="view"> <form class="form-inline" @submit.prevent> <button - class="btn-blank btn-link bold" + class="btn-blank btn-link bold gl-text-blue-300" type="button" :title="__(`Add request manually`)" @click="toggleInput" diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue index 930c5e50511..6d896a97455 100644 --- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue +++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue @@ -94,9 +94,9 @@ export default { data-qa-selector="detailed_metric_content" > <gl-button v-gl-modal="modalId" class="gl-mr-2" type="button" variant="link"> - {{ metricDetailsLabel }} + <span class="gl-text-blue-300 gl-font-weight-bold">{{ metricDetailsLabel }}</span> </gl-button> - <gl-modal :modal-id="modalId" :title="header" size="lg" modal-class="gl-mt-7" scrollable> + <gl-modal :modal-id="modalId" :title="header" size="lg" footer-class="d-none" scrollable> <table class="table"> <template v-if="detailsList.length"> <tr v-for="(item, index) in detailsList" :key="index"> @@ -116,7 +116,7 @@ export default { {{ item[key] }} <gl-button v-if="keyIndex == 0 && item.backtrace" - class="gl-ml-3" + class="btn-sm gl-ml-3" data-testid="backtrace-expand-btn" type="button" :aria-label="__('Toggle backtrace')" diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue index e38771785b7..85789cd1fdf 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -2,10 +2,10 @@ /* eslint-disable vue/no-v-html */ import { glEmojiTag } from '~/emoji'; +import { s__ } from '~/locale'; import AddRequest from './add_request.vue'; import DetailedMetric from './detailed_metric.vue'; import RequestSelector from './request_selector.vue'; -import { s__ } from '~/locale'; export default { components: { @@ -64,6 +64,11 @@ export default { keys: ['request', 'body'], }, { + metric: 'external-http', + header: s__('PerformanceBar|External Http calls'), + keys: ['label', 'code', 'proxy', 'error'], + }, + { metric: 'total', header: s__('PerformanceBar|Frontend resources'), keys: ['name', 'size'], @@ -120,7 +125,7 @@ export default { <div id="js-peek" :class="env"> <div v-if="currentRequest" - class="d-flex container-fluid container-limited" + class="d-flex container-fluid container-limited justify-content-center" data-qa-selector="performance_bar" > <div id="peek-view-host" class="view"> @@ -147,11 +152,15 @@ export default { id="peek-view-trace" class="view" > - <a :href="currentRequest.details.tracing.tracing_url">{{ s__('PerformanceBar|trace') }}</a> + <a class="gl-text-blue-300" :href="currentRequest.details.tracing.tracing_url">{{ + s__('PerformanceBar|trace') + }}</a> </div> <add-request v-on="$listeners" /> <div v-if="currentRequest.details" id="peek-download" class="view"> - <a :download="downloadName" :href="downloadPath">{{ s__('PerformanceBar|Download') }}</a> + <a class="gl-text-blue-300" :download="downloadName" :href="downloadPath">{{ + s__('PerformanceBar|Download') + }}</a> </div> <request-selector v-if="currentRequest" diff --git a/app/assets/javascripts/performance_bar/performance_bar_log.js b/app/assets/javascripts/performance_bar/performance_bar_log.js index c61b0cb32e8..aad99e2604e 100644 --- a/app/assets/javascripts/performance_bar/performance_bar_log.js +++ b/app/assets/javascripts/performance_bar/performance_bar_log.js @@ -43,7 +43,7 @@ const logUserTimingMetrics = () => { const initPerformanceBarLog = () => { console.log( `%c ${String.fromCodePoint(0x1f98a)} GitLab performance bar`, - 'width:100%;background-color: #292961; color: #FFFFFF; font-size:24px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto; padding: 10px;display:block;padding-right: 100px;', + 'width:100%; background-color: #292961; color: #FFFFFF; padding: 10px; display:block;', ); initVitalsLog(); diff --git a/app/assets/javascripts/performance_bar/services/performance_bar_service.js b/app/assets/javascripts/performance_bar/services/performance_bar_service.js index 38255b3a37d..a614342c858 100644 --- a/app/assets/javascripts/performance_bar/services/performance_bar_service.js +++ b/app/assets/javascripts/performance_bar/services/performance_bar_service.js @@ -1,5 +1,5 @@ -import axios from '../../lib/utils/axios_utils'; import { parseBoolean } from '~/lib/utils/common_utils'; +import axios from '../../lib/utils/axios_utils'; export default class PerformanceBarService { static interceptor = null; diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue new file mode 100644 index 00000000000..6e701f063a7 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue @@ -0,0 +1,114 @@ +<script> +import { mergeUrlParams, redirectTo } from '~/lib/utils/url_utility'; +import { __, s__, sprintf } from '~/locale'; +import commitCIFile from '../../graphql/mutations/commit_ci_file.mutation.graphql'; +import getCommitSha from '../../graphql/queries/client/commit_sha.graphql'; + +import { COMMIT_FAILURE, COMMIT_SUCCESS } from '../../constants'; +import CommitForm from './commit_form.vue'; + +const MR_SOURCE_BRANCH = 'merge_request[source_branch]'; +const MR_TARGET_BRANCH = 'merge_request[target_branch]'; + +export default { + alertTexts: { + [COMMIT_FAILURE]: s__('Pipelines|The GitLab CI configuration could not be updated.'), + [COMMIT_SUCCESS]: __('Your changes have been successfully committed.'), + }, + i18n: { + defaultCommitMessage: __('Update %{sourcePath} file'), + }, + components: { + CommitForm, + }, + inject: ['projectFullPath', 'ciConfigPath', 'defaultBranch', 'newMergeRequestPath'], + props: { + ciFileContent: { + type: String, + required: true, + }, + }, + data() { + return { + commit: {}, + isSaving: false, + }; + }, + apollo: { + commitSha: { + query: getCommitSha, + }, + }, + computed: { + defaultCommitMessage() { + return sprintf(this.$options.i18n.defaultCommitMessage, { sourcePath: this.ciConfigPath }); + }, + }, + methods: { + redirectToNewMergeRequest(sourceBranch) { + const url = mergeUrlParams( + { + [MR_SOURCE_BRANCH]: sourceBranch, + [MR_TARGET_BRANCH]: this.defaultBranch, + }, + this.newMergeRequestPath, + ); + redirectTo(url); + }, + async onCommitSubmit({ message, branch, openMergeRequest }) { + this.isSaving = true; + + try { + const { + data: { + commitCreate: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: commitCIFile, + variables: { + projectPath: this.projectFullPath, + branch, + startBranch: this.defaultBranch, + message, + filePath: this.ciConfigPath, + content: this.ciFileContent, + lastCommitId: this.commitSha, + }, + update(store, { data }) { + const commitSha = data?.commitCreate?.commit?.sha; + + if (commitSha) { + store.writeQuery({ query: getCommitSha, data: { commitSha } }); + } + }, + }); + + if (errors?.length) { + this.$emit('showError', { type: COMMIT_FAILURE, reasons: errors }); + } else if (openMergeRequest) { + this.redirectToNewMergeRequest(branch); + } else { + this.$emit('commit', { type: COMMIT_SUCCESS }); + } + } catch (error) { + this.$emit('showError', { type: COMMIT_FAILURE, reasons: [error?.message] }); + } finally { + this.isSaving = false; + } + }, + onCommitCancel() { + this.$emit('resetContent'); + }, + }, +}; +</script> + +<template> + <commit-form + :default-branch="defaultBranch" + :default-message="defaultCommitMessage" + :is-saving="isSaving" + @cancel="onCommitCancel" + @submit="onCommitSubmit" + /> +</template> diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue new file mode 100644 index 00000000000..ab41c0170e9 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue @@ -0,0 +1,30 @@ +<script> +import ValidationSegment from './validation_segment.vue'; + +export default { + validationSegmentClasses: + 'gl-p-5 gl-bg-gray-10 gl-border-solid gl-border-1 gl-border-gray-100 gl-rounded-base', + components: { + ValidationSegment, + }, + props: { + ciConfigData: { + type: Object, + required: true, + }, + isCiConfigDataLoading: { + type: Boolean, + required: true, + }, + }, +}; +</script> +<template> + <div class="gl-mb-5"> + <validation-segment + :class="$options.validationSegmentClasses" + :loading="isCiConfigDataLoading" + :ci-config="ciConfigData" + /> + </div> +</template> diff --git a/app/assets/javascripts/pipeline_editor/components/info/validation_segment.vue b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue index 22f378c571a..94fb3a66fdd 100644 --- a/app/assets/javascripts/pipeline_editor/components/info/validation_segment.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue @@ -1,8 +1,8 @@ <script> import { GlIcon, GlLink, GlLoadingIcon } from '@gitlab/ui'; import { __, s__, sprintf } from '~/locale'; -import { CI_CONFIG_STATUS_VALID } from '../../constants'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import { CI_CONFIG_STATUS_VALID } from '../../constants'; export const i18n = { learnMore: __('Learn more'), diff --git a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue index 58a96c3f725..23f8d3818ab 100644 --- a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue +++ b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue @@ -1,9 +1,9 @@ <script> import { GlAlert, GlLink, GlSprintf, GlTable } from '@gitlab/ui'; +import { __ } from '~/locale'; import CiLintWarnings from './ci_lint_warnings.vue'; import CiLintResultsValue from './ci_lint_results_value.vue'; import CiLintResultsParam from './ci_lint_results_param.vue'; -import { __ } from '~/locale'; const thBorderColor = 'gl-border-gray-100!'; diff --git a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue new file mode 100644 index 00000000000..760a8b7e232 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue @@ -0,0 +1,62 @@ +<script> +import { GlLoadingIcon, GlTabs, GlTab } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; +import CiLint from './lint/ci_lint.vue'; +import EditorTab from './ui/editor_tab.vue'; +import TextEditor from './text_editor.vue'; + +export default { + i18n: { + tabEdit: s__('Pipelines|Write pipeline configuration'), + tabGraph: s__('Pipelines|Visualize'), + tabLint: s__('Pipelines|Lint'), + }, + components: { + CiLint, + EditorTab, + GlLoadingIcon, + GlTab, + GlTabs, + PipelineGraph, + TextEditor, + }, + mixins: [glFeatureFlagsMixin()], + props: { + ciConfigData: { + type: Object, + required: true, + }, + ciFileContent: { + type: String, + required: true, + }, + isCiConfigDataLoading: { + type: Boolean, + required: false, + default: false, + }, + }, +}; +</script> +<template> + <gl-tabs class="file-editor gl-mb-3"> + <editor-tab :title="$options.i18n.tabEdit" lazy data-testid="editor-tab"> + <text-editor :value="ciFileContent" v-on="$listeners" /> + </editor-tab> + <gl-tab + v-if="glFeatures.ciConfigVisualizationTab" + :title="$options.i18n.tabGraph" + lazy + data-testid="visualization-tab" + > + <gl-loading-icon v-if="isCiConfigDataLoading" size="lg" class="gl-m-3" /> + <pipeline-graph v-else :pipeline-data="ciConfigData" /> + </gl-tab> + <editor-tab :title="$options.i18n.tabLint" data-testid="lint-tab"> + <gl-loading-icon v-if="isCiConfigDataLoading" size="lg" class="gl-m-3" /> + <ci-lint v-else :ci-config="ciConfigData" /> + </editor-tab> + </gl-tabs> +</template> diff --git a/app/assets/javascripts/pipeline_editor/components/text_editor.vue b/app/assets/javascripts/pipeline_editor/components/text_editor.vue index b8d49d77ea9..4e9ba47727d 100644 --- a/app/assets/javascripts/pipeline_editor/components/text_editor.vue +++ b/app/assets/javascripts/pipeline_editor/components/text_editor.vue @@ -1,26 +1,30 @@ <script> import EditorLite from '~/vue_shared/components/editor_lite.vue'; import { CiSchemaExtension } from '~/editor/extensions/editor_ci_schema_ext'; +import { EDITOR_READY_EVENT } from '~/editor/constants'; +import getCommitSha from '../graphql/queries/client/commit_sha.graphql'; export default { components: { EditorLite, }, - inject: ['projectPath', 'projectNamespace'], + inject: ['ciConfigPath', 'projectPath', 'projectNamespace'], inheritAttrs: false, - props: { - ciConfigPath: { - type: String, - required: true, - }, + data() { + return { + commitSha: '', + }; + }, + apollo: { commitSha: { - type: String, - required: false, - default: null, + query: getCommitSha, }, }, methods: { - onEditorReady() { + onCiConfigUpdate(content) { + this.$emit('updateCiConfig', content); + }, + registerCiSchema() { const editorInstance = this.$refs.editor.getEditor(); editorInstance.use(new CiSchemaExtension()); @@ -31,6 +35,7 @@ export default { }); }, }, + readyEvent: EDITOR_READY_EVENT, }; </script> <template> @@ -39,7 +44,8 @@ export default { ref="editor" :file-name="ciConfigPath" v-bind="$attrs" - @editor-ready="onEditorReady" + @[$options.readyEvent]="registerCiSchema" + @input="onCiConfigUpdate" v-on="$listeners" /> </div> diff --git a/app/assets/javascripts/pipeline_editor/components/ui/confirm_unsaved_changes_dialog.vue b/app/assets/javascripts/pipeline_editor/components/ui/confirm_unsaved_changes_dialog.vue new file mode 100644 index 00000000000..bc076fbe349 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/ui/confirm_unsaved_changes_dialog.vue @@ -0,0 +1,26 @@ +<script> +export default { + props: { + hasUnsavedChanges: { + type: Boolean, + required: true, + }, + }, + created() { + window.addEventListener('beforeunload', this.confirmChanges); + }, + destroyed() { + window.removeEventListener('beforeunload', this.confirmChanges); + }, + methods: { + confirmChanges(e = {}) { + if (this.hasUnsavedChanges) { + e.preventDefault(); + // eslint-disable-next-line no-param-reassign + e.returnValue = ''; // Chrome requires returnValue to be set + } + }, + }, + render: () => null, +}; +</script> diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js index 70bab8092c0..f15558363bf 100644 --- a/app/assets/javascripts/pipeline_editor/constants.js +++ b/app/assets/javascripts/pipeline_editor/constants.js @@ -1,2 +1,9 @@ export const CI_CONFIG_STATUS_VALID = 'VALID'; export const CI_CONFIG_STATUS_INVALID = 'INVALID'; + +export const COMMIT_FAILURE = 'COMMIT_FAILURE'; +export const COMMIT_SUCCESS = 'COMMIT_SUCCESS'; + +export const DEFAULT_FAILURE = 'DEFAULT_FAILURE'; +export const LOAD_FAILURE_NO_FILE = 'LOAD_FAILURE_NO_FILE'; +export const LOAD_FAILURE_UNKNOWN = 'LOAD_FAILURE_UNKNOWN'; diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql index 0c58749a8b2..2af0cd5f6d4 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql @@ -1,4 +1,4 @@ -mutation commitCIFileMutation( +mutation commitCIFile( $projectPath: ID! $branch: String! $startBranch: String diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/commit_sha.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/commit_sha.graphql new file mode 100644 index 00000000000..6c7635887ec --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/commit_sha.graphql @@ -0,0 +1,3 @@ +query getCommitSha { + commitSha @client +} diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js index 583ba555080..003feb16281 100644 --- a/app/assets/javascripts/pipeline_editor/index.js +++ b/app/assets/javascripts/pipeline_editor/index.js @@ -15,14 +15,13 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { } const { - // props - ciConfigPath, + // Add to apollo cache as it can be updated by future queries commitSha, + // Add to provide/inject API for static values + ciConfigPath, defaultBranch, - newMergeRequestPath, - - // `provide/inject` data lintHelpPagePath, + newMergeRequestPath, projectFullPath, projectPath, projectNamespace, @@ -35,25 +34,27 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { defaultClient: createDefaultClient(resolvers, { typeDefs }), }); + apolloProvider.clients.defaultClient.cache.writeData({ + data: { + commitSha, + }, + }); + return new Vue({ el, apolloProvider, provide: { + ciConfigPath, + defaultBranch, lintHelpPagePath, + newMergeRequestPath, projectFullPath, projectPath, projectNamespace, ymlHelpPagePath, }, render(h) { - return h(PipelineEditorApp, { - props: { - ciConfigPath, - commitSha, - defaultBranch, - newMergeRequestPath, - }, - }); + return h(PipelineEditorApp); }, }); }; diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue index 21993e2120a..266439c273a 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue @@ -1,84 +1,55 @@ <script> -import { GlAlert, GlLoadingIcon, GlTabs, GlTab } from '@gitlab/ui'; +import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; import { __, s__, sprintf } from '~/locale'; -import { mergeUrlParams, redirectTo } from '~/lib/utils/url_utility'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import httpStatusCodes from '~/lib/utils/http_status'; -import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; -import CiLint from './components/lint/ci_lint.vue'; -import CommitForm from './components/commit/commit_form.vue'; -import EditorTab from './components/ui/editor_tab.vue'; -import TextEditor from './components/text_editor.vue'; -import ValidationSegment from './components/info/validation_segment.vue'; - -import commitCiFileMutation from './graphql/mutations/commit_ci_file.mutation.graphql'; +import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils'; +import ConfirmUnsavedChangesDialog from './components/ui/confirm_unsaved_changes_dialog.vue'; +import { + COMMIT_FAILURE, + COMMIT_SUCCESS, + DEFAULT_FAILURE, + LOAD_FAILURE_NO_FILE, + LOAD_FAILURE_UNKNOWN, +} from './constants'; import getBlobContent from './graphql/queries/blob_content.graphql'; import getCiConfigData from './graphql/queries/ci_config.graphql'; -import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils'; - -const MR_SOURCE_BRANCH = 'merge_request[source_branch]'; -const MR_TARGET_BRANCH = 'merge_request[target_branch]'; - -const COMMIT_FAILURE = 'COMMIT_FAILURE'; -const COMMIT_SUCCESS = 'COMMIT_SUCCESS'; -const DEFAULT_FAILURE = 'DEFAULT_FAILURE'; -const LOAD_FAILURE_NO_FILE = 'LOAD_FAILURE_NO_FILE'; -const LOAD_FAILURE_UNKNOWN = 'LOAD_FAILURE_UNKNOWN'; +import PipelineEditorHome from './pipeline_editor_home.vue'; export default { components: { - CiLint, - CommitForm, - EditorTab, + ConfirmUnsavedChangesDialog, GlAlert, GlLoadingIcon, - GlTabs, - GlTab, - PipelineGraph, - TextEditor, - ValidationSegment, + PipelineEditorHome, }, - mixins: [glFeatureFlagsMixin()], - inject: ['projectFullPath'], - props: { - defaultBranch: { - type: String, - required: false, - default: null, + inject: { + ciConfigPath: { + default: '', }, - commitSha: { - type: String, - required: false, + defaultBranch: { default: null, }, - ciConfigPath: { - type: String, - required: true, - }, - newMergeRequestPath: { - type: String, - required: true, + projectFullPath: { + default: '', }, }, data() { return { ciConfigData: {}, - content: '', - contentModel: '', - lastCommitSha: this.commitSha, - isSaving: false, - // Success and failure state failureType: null, - showFailureAlert: false, failureReasons: [], - successType: null, + initialCiFileContent: '', + lastCommittedContent: '', + currentCiFileContent: '', + showFailureAlert: false, showSuccessAlert: false, + successType: null, }; }, apollo: { - content: { + initialCiFileContent: { query: getBlobContent, variables() { return { @@ -91,7 +62,10 @@ export default { return data?.blobContent?.rawData; }, result({ data }) { - this.contentModel = data?.blobContent?.rawData ?? ''; + const fileContent = data?.blobContent?.rawData ?? ''; + + this.lastCommittedContent = fileContent; + this.currentCiFileContent = fileContent; }, error(error) { this.handleBlobContentError(error); @@ -100,13 +74,13 @@ export default { ciConfigData: { query: getCiConfigData, // If content is not loaded, we can't lint the data - skip: ({ contentModel }) => { - return !contentModel; + skip: ({ currentCiFileContent }) => { + return !currentCiFileContent; }, variables() { return { projectPath: this.projectFullPath, - content: this.contentModel, + content: this.currentCiFileContent, }; }, update(data) { @@ -122,8 +96,11 @@ export default { }, }, computed: { + hasUnsavedChanges() { + return this.lastCommittedContent !== this.currentCiFileContent; + }, isBlobContentLoading() { - return this.$apollo.queries.content.loading; + return this.$apollo.queries.initialCiFileContent.loading; }, isBlobContentError() { return this.failureType === LOAD_FAILURE_NO_FILE; @@ -131,62 +108,60 @@ export default { isCiConfigDataLoading() { return this.$apollo.queries.ciConfigData.loading; }, - defaultCommitMessage() { - return sprintf(this.$options.i18n.defaultCommitMessage, { sourcePath: this.ciConfigPath }); - }, - success() { - switch (this.successType) { - case COMMIT_SUCCESS: - return { - text: this.$options.alertTexts[COMMIT_SUCCESS], - variant: 'info', - }; - default: - return null; - } - }, failure() { switch (this.failureType) { case LOAD_FAILURE_NO_FILE: return { - text: sprintf(this.$options.alertTexts[LOAD_FAILURE_NO_FILE], { + text: sprintf(this.$options.errorTexts[LOAD_FAILURE_NO_FILE], { filePath: this.ciConfigPath, }), variant: 'danger', }; case LOAD_FAILURE_UNKNOWN: return { - text: this.$options.alertTexts[LOAD_FAILURE_UNKNOWN], + text: this.$options.errorTexts[LOAD_FAILURE_UNKNOWN], variant: 'danger', }; case COMMIT_FAILURE: return { - text: this.$options.alertTexts[COMMIT_FAILURE], + text: this.$options.errorTexts[COMMIT_FAILURE], variant: 'danger', }; default: return { - text: this.$options.alertTexts[DEFAULT_FAILURE], + text: this.$options.errorTexts[DEFAULT_FAILURE], variant: 'danger', }; } }, + success() { + switch (this.successType) { + case COMMIT_SUCCESS: + return { + text: this.$options.successTexts[COMMIT_SUCCESS], + variant: 'info', + }; + default: + return null; + } + }, }, i18n: { - defaultCommitMessage: __('Update %{sourcePath} file'), tabEdit: s__('Pipelines|Write pipeline configuration'), tabGraph: s__('Pipelines|Visualize'), tabLint: s__('Pipelines|Lint'), }, - alertTexts: { + errorTexts: { [COMMIT_FAILURE]: s__('Pipelines|The GitLab CI configuration could not be updated.'), - [COMMIT_SUCCESS]: __('Your changes have been successfully committed.'), [DEFAULT_FAILURE]: __('Something went wrong on our end.'), [LOAD_FAILURE_NO_FILE]: s__( 'Pipelines|There is no %{filePath} file in this repository, please add one and visit the Pipeline Editor again.', ), [LOAD_FAILURE_UNKNOWN]: s__('Pipelines|The CI configuration was not loaded, please try again.'), }, + successTexts: { + [COMMIT_SUCCESS]: __('Your changes have been successfully committed.'), + }, methods: { handleBlobContentError(error = {}) { const { networkError } = error; @@ -207,72 +182,32 @@ export default { dismissFailure() { this.showFailureAlert = false; }, + dismissSuccess() { + this.showSuccessAlert = false; + }, reportFailure(type, reasons = []) { this.showFailureAlert = true; this.failureType = type; this.failureReasons = reasons; }, - dismissSuccess() { - this.showSuccessAlert = false; - }, reportSuccess(type) { this.showSuccessAlert = true; this.successType = type; }, - - redirectToNewMergeRequest(sourceBranch) { - const url = mergeUrlParams( - { - [MR_SOURCE_BRANCH]: sourceBranch, - [MR_TARGET_BRANCH]: this.defaultBranch, - }, - this.newMergeRequestPath, - ); - redirectTo(url); + resetContent() { + this.currentCiFileContent = this.lastCommittedContent; }, - async onCommitSubmit(event) { - this.isSaving = true; - const { message, branch, openMergeRequest } = event; - - try { - const { - data: { - commitCreate: { errors, commit }, - }, - } = await this.$apollo.mutate({ - mutation: commitCiFileMutation, - variables: { - projectPath: this.projectFullPath, - branch, - startBranch: this.defaultBranch, - message, - filePath: this.ciConfigPath, - content: this.contentModel, - lastCommitId: this.lastCommitSha, - }, - }); - - if (errors?.length) { - this.reportFailure(COMMIT_FAILURE, errors); - return; - } - - if (openMergeRequest) { - this.redirectToNewMergeRequest(branch); - } else { - this.reportSuccess(COMMIT_SUCCESS); - - // Update latest commit - this.lastCommitSha = commit.sha; - } - } catch (error) { - this.reportFailure(COMMIT_FAILURE, [error?.message]); - } finally { - this.isSaving = false; - } + showErrorAlert({ type, reasons = [] }) { + this.reportFailure(type, reasons); }, - onCommitCancel() { - this.contentModel = this.content; + updateCiConfig(ciFileContent) { + this.currentCiFileContent = ciFileContent; + }, + updateOnCommit({ type }) { + this.reportSuccess(type); + // Keep track of the latest commited content to know + // if the user has made changes to the file that are unsaved. + this.lastCommittedContent = this.currentCiFileContent; }, }, }; @@ -280,20 +215,10 @@ export default { <template> <div class="gl-mt-4"> - <gl-alert - v-if="showSuccessAlert" - :variant="success.variant" - :dismissible="true" - @dismiss="dismissSuccess" - > + <gl-alert v-if="showSuccessAlert" :variant="success.variant" @dismiss="dismissSuccess"> {{ success.text }} </gl-alert> - <gl-alert - v-if="showFailureAlert" - :variant="failure.variant" - :dismissible="true" - @dismiss="dismissFailure" - > + <gl-alert v-if="showFailureAlert" :variant="failure.variant" @dismiss="dismissFailure"> {{ failure.text }} <ul v-if="failureReasons.length" class="gl-mb-0"> <li v-for="reason in failureReasons" :key="reason">{{ reason }}</li> @@ -301,46 +226,16 @@ export default { </gl-alert> <gl-loading-icon v-if="isBlobContentLoading" size="lg" class="gl-m-3" /> <div v-else-if="!isBlobContentError" class="gl-mt-4"> - <div class="file-editor gl-mb-3"> - <div class="info-well gl-display-none gl-display-sm-block"> - <validation-segment - class="well-segment" - :loading="isCiConfigDataLoading" - :ci-config="ciConfigData" - /> - </div> - - <gl-tabs> - <editor-tab :lazy="true" :title="$options.i18n.tabEdit"> - <text-editor - v-model="contentModel" - :ci-config-path="ciConfigPath" - :commit-sha="lastCommitSha" - /> - </editor-tab> - <gl-tab - v-if="glFeatures.ciConfigVisualizationTab" - :lazy="true" - :title="$options.i18n.tabGraph" - data-testid="visualization-tab" - > - <gl-loading-icon v-if="isCiConfigDataLoading" size="lg" class="gl-m-3" /> - <pipeline-graph v-else :pipeline-data="ciConfigData" /> - </gl-tab> - - <editor-tab :title="$options.i18n.tabLint"> - <gl-loading-icon v-if="isCiConfigDataLoading" size="lg" class="gl-m-3" /> - <ci-lint v-else :ci-config="ciConfigData" /> - </editor-tab> - </gl-tabs> - </div> - <commit-form - :default-branch="defaultBranch" - :default-message="defaultCommitMessage" - :is-saving="isSaving" - @cancel="onCommitCancel" - @submit="onCommitSubmit" + <pipeline-editor-home + :is-ci-config-data-loading="isCiConfigDataLoading" + :ci-config-data="ciConfigData" + :ci-file-content="currentCiFileContent" + @commit="updateOnCommit" + @resetContent="resetContent" + @showError="showErrorAlert" + @updateCiConfig="updateCiConfig" /> </div> + <confirm-unsaved-changes-dialog :has-unsaved-changes="hasUnsavedChanges" /> </div> </template> diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue new file mode 100644 index 00000000000..b7535cc0964 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue @@ -0,0 +1,43 @@ +<script> +import CommitSection from './components/commit/commit_section.vue'; +import PipelineEditorTabs from './components/pipeline_editor_tabs.vue'; +import PipelineEditorHeader from './components/header/pipeline_editor_header.vue'; + +export default { + components: { + CommitSection, + PipelineEditorHeader, + PipelineEditorTabs, + }, + props: { + ciConfigData: { + type: Object, + required: true, + }, + ciFileContent: { + type: String, + required: true, + }, + isCiConfigDataLoading: { + type: Boolean, + required: true, + }, + }, +}; +</script> + +<template> + <div> + <pipeline-editor-header + :ci-config-data="ciConfigData" + :is-ci-config-data-loading="isCiConfigDataLoading" + /> + <pipeline-editor-tabs + :ci-config-data="ciConfigData" + :ci-file-content="ciFileContent" + :is-ci-config-data-loading="isCiConfigDataLoading" + v-on="$listeners" + /> + <commit-section :ci-file-content="ciFileContent" v-on="$listeners" /> + </div> +</template> 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 70c5713b216..69b02463235 100644 --- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue +++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue @@ -22,9 +22,9 @@ import * as Sentry from '~/sentry/wrapper'; import { s__, __, n__ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; import { redirectTo } from '~/lib/utils/url_utility'; -import { VARIABLE_TYPE, FILE_TYPE, CONFIG_VARIABLES_TIMEOUT } from '../constants'; import { backOff } from '~/lib/utils/common_utils'; import httpStatusCodes from '~/lib/utils/http_status'; +import { VARIABLE_TYPE, FILE_TYPE, CONFIG_VARIABLES_TIMEOUT } from '../constants'; export default { typeOptions: [ @@ -116,6 +116,7 @@ export default { totalWarnings: 0, isWarningDismissed: false, isLoading: false, + submitted: false, }; }, computed: { @@ -251,10 +252,6 @@ export default { return index < this.variables.length - 1; }, fetchConfigVariables(refValue) { - if (!gon?.features?.newPipelineFormPrefilledVars) { - return Promise.resolve({ params: {}, descriptions: {} }); - } - this.isLoading = true; return backOff((next, stop) => { @@ -298,6 +295,7 @@ export default { }); }, createPipeline() { + this.submitted = true; const filteredVariables = this.variables .filter(({ key, value }) => key !== '' && value !== '') .map(({ variable_type, key, value }) => ({ @@ -317,8 +315,16 @@ export default { redirectTo(`${this.pipelinesPath}/${data.id}`); }) .catch((err) => { - const { errors, warnings, total_warnings: totalWarnings } = err.response.data; + // always re-enable submit button + this.submitted = false; + + const { + errors = [], + warnings = [], + total_warnings: totalWarnings = 0, + } = err?.response?.data; const [error] = errors; + this.error = error; this.warnings = warnings; this.totalWarnings = totalWarnings; @@ -436,12 +442,12 @@ export default { category="secondary" @click="removeVariable(index)" > - <gl-icon class="gl-mr-0! gl-display-none gl-display-md-block" name="clear" /> - <span class="gl-display-md-none">{{ s__('CiVariables|Remove variable') }}</span> + <gl-icon class="gl-mr-0! gl-display-none gl-md-display-block" name="clear" /> + <span class="gl-md-display-none">{{ s__('CiVariables|Remove variable') }}</span> </gl-button> <gl-button v-else - class="gl-md-ml-3 gl-mb-3 gl-display-none gl-display-md-block gl-visibility-hidden" + class="gl-md-ml-3 gl-mb-3 gl-display-none gl-md-display-block gl-visibility-hidden" icon="clear" /> </template> @@ -468,6 +474,8 @@ export default { variant="success" class="js-no-auto-disable" data-qa-selector="run_pipeline_button" + data-testid="run_pipeline_button" + :disabled="submitted" >{{ s__('Pipeline|Run Pipeline') }}</gl-button > <gl-button :href="pipelinesPath">{{ __('Cancel') }}</gl-button> diff --git a/app/assets/javascripts/pipelines/components/dag/dag.vue b/app/assets/javascripts/pipelines/components/dag/dag.vue index 2482af2c7f0..23e7ce349cf 100644 --- a/app/assets/javascripts/pipelines/components/dag/dag.vue +++ b/app/assets/javascripts/pipelines/components/dag/dag.vue @@ -4,11 +4,11 @@ import { isEmpty } from 'lodash'; import { __ } from '~/locale'; import { fetchPolicies } from '~/lib/graphql'; import getDagVisData from '../../graphql/queries/get_dag_vis_data.query.graphql'; +import { parseData } from '../parsing_utils'; +import { DEFAULT, PARSE_FAILURE, LOAD_FAILURE, UNSUPPORTED_DATA } from '../../constants'; import DagGraph from './dag_graph.vue'; import DagAnnotations from './dag_annotations.vue'; import { ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from './constants'; -import { parseData } from '../parsing_utils'; -import { DEFAULT, PARSE_FAILURE, LOAD_FAILURE, UNSUPPORTED_DATA } from '../../constants'; export default { // eslint-disable-next-line @gitlab/require-i18n-strings diff --git a/app/assets/javascripts/pipelines/components/dag/dag_graph.vue b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue index 5ba0604fa01..76ccbd74bb6 100644 --- a/app/assets/javascripts/pipelines/components/dag/dag_graph.vue +++ b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue @@ -1,6 +1,8 @@ <script> import * as d3 from 'd3'; import { uniqueId } from 'lodash'; +import { getMaxNodes, removeOrphanNodes } from '../parsing_utils'; +import { PARSE_FAILURE } from '../../constants'; import { LINK_SELECTOR, NODE_SELECTOR, ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from './constants'; import { currentIsLive, @@ -10,9 +12,7 @@ import { toggleLinkHighlight, togglePathHighlights, } from './interactions'; -import { getMaxNodes, removeOrphanNodes } from '../parsing_utils'; import { calculateClip, createLinkPath, createSankey, labelPosition } from './drawing_utils'; -import { PARSE_FAILURE } from '../../constants'; export default { viewOptions: { diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue index 0ce94d4f02f..949d0d30297 100644 --- a/app/assets/javascripts/pipelines/components/graph/action_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue @@ -3,6 +3,7 @@ import { GlTooltipDirective, GlButton, GlLoadingIcon, GlIcon } from '@gitlab/ui' import axios from '~/lib/utils/axios_utils'; import { dasherize } from '~/lib/utils/text_utility'; import { __ } from '~/locale'; +import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { reportToSentry } from './utils'; @@ -62,7 +63,7 @@ export default { * */ onClickAction() { - this.$root.$emit('bv::hide::tooltip', `js-ci-action-${this.link}`); + this.$root.$emit(BV_HIDE_TOOLTIP, `js-ci-action-${this.link}`); this.isDisabled = true; this.isLoading = true; diff --git a/app/assets/javascripts/pipelines/components/graph/constants.js b/app/assets/javascripts/pipelines/components/graph/constants.js index 6f0deccfef6..caa269f5095 100644 --- a/app/assets/javascripts/pipelines/components/graph/constants.js +++ b/app/assets/javascripts/pipelines/components/graph/constants.js @@ -2,5 +2,11 @@ export const DOWNSTREAM = 'downstream'; export const MAIN = 'main'; export const UPSTREAM = 'upstream'; +/* + this value is based on the gl-pipeline-job-width class + plus some extra for the margins +*/ +export const ONE_COL_WIDTH = 180; + export const REST = 'rest'; export const GRAPHQL = 'graphql'; diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index cd403757fe6..cae26e6ee3f 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -3,7 +3,7 @@ import LinkedGraphWrapper from '../graph_shared/linked_graph_wrapper.vue'; import LinksLayer from '../graph_shared/links_layer.vue'; import LinkedPipelinesColumn from './linked_pipelines_column.vue'; import StageColumnComponent from './stage_column_component.vue'; -import { DOWNSTREAM, MAIN, UPSTREAM } from './constants'; +import { DOWNSTREAM, MAIN, UPSTREAM, ONE_COL_WIDTH } from './constants'; import { reportToSentry } from './utils'; export default { @@ -86,11 +86,11 @@ export default { reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); }, mounted() { - this.measurements = this.getMeasurements(); + this.getMeasurements(); }, methods: { getMeasurements() { - return { + this.measurements = { width: this.$refs[this.containerId].scrollWidth, height: this.$refs[this.containerId].scrollHeight, }; @@ -101,6 +101,13 @@ export default { setJob(jobName) { this.hoveredJobName = jobName; }, + slidePipelineContainer() { + this.$refs.mainPipelineContainer.scrollBy({ + left: ONE_COL_WIDTH, + top: 0, + behavior: 'smooth', + }); + }, togglePipelineExpanded(jobName, expanded) { this.pipelineExpanded = { expanded, @@ -116,8 +123,9 @@ export default { <template> <div class="js-pipeline-graph"> <div - class="gl-display-flex gl-position-relative gl-overflow-auto gl-bg-gray-10 gl-white-space-nowrap" - :class="{ 'gl-pipeline-min-h gl-py-5': !isLinkedPipeline }" + ref="mainPipelineContainer" + class="gl-display-flex gl-position-relative gl-bg-gray-10 gl-white-space-nowrap" + :class="{ 'gl-pipeline-min-h gl-py-5 gl-overflow-auto': !isLinkedPipeline }" > <linked-graph-wrapper> <template #upstream> @@ -153,6 +161,7 @@ export default { :pipeline-id="pipeline.id" @refreshPipelineGraph="$emit('refreshPipelineGraph')" @jobHover="setJob" + @updateMeasurements="getMeasurements" /> </links-layer> </div> @@ -160,11 +169,13 @@ export default { <template #downstream> <linked-pipelines-column v-if="showDownstreamPipelines" + class="gl-mr-6" :linked-pipelines="downstreamPipelines" :column-title="__('Downstream')" :type="$options.pipelineTypeConstants.DOWNSTREAM" @downstreamHovered="setJob" @pipelineExpandToggle="togglePipelineExpanded" + @scrollContainer="slidePipelineContainer" @error="onError" /> </template> diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue index 2164dbf4d55..818985a74d1 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue @@ -1,9 +1,9 @@ <script> import { escape, capitalize } from 'lodash'; import { GlLoadingIcon } from '@gitlab/ui'; +import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin'; import StageColumnComponentLegacy from './stage_column_component_legacy.vue'; import LinkedPipelinesColumnLegacy from './linked_pipelines_column_legacy.vue'; -import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin'; import { UPSTREAM, DOWNSTREAM, MAIN } from './constants'; import { reportToSentry } from './utils'; diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index 8262d728a24..52848fe3ade 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -1,9 +1,10 @@ <script> import { GlTooltipDirective, GlLink } from '@gitlab/ui'; -import ActionComponent from './action_component.vue'; -import JobNameComponent from './job_name_component.vue'; import { sprintf } from '~/locale'; import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; +import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; +import ActionComponent from './action_component.vue'; +import JobNameComponent from './job_name_component.vue'; import { accessValue } from './accessors'; import { REST } from './constants'; import { reportToSentry } from './utils'; @@ -144,7 +145,7 @@ export default { }, methods: { hideTooltips() { - this.$root.$emit('bv::hide::tooltip'); + this.$root.$emit(BV_HIDE_TOOLTIP); }, pipelineActionRequestComplete() { this.$emit('pipelineActionRequestComplete'); diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue index d18e604f087..22f9fb72159 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue @@ -2,6 +2,7 @@ import { GlTooltipDirective, GlButton, GlLink, GlLoadingIcon, GlBadge } from '@gitlab/ui'; import CiStatus from '~/vue_shared/components/ci_icon.vue'; import { __, sprintf } from '~/locale'; +import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { accessValue } from './accessors'; import { DOWNSTREAM, REST, UPSTREAM } from './constants'; import { reportToSentry } from './utils'; @@ -126,7 +127,7 @@ export default { this.$emit('pipelineExpandToggle', this.sourceJobName, !this.expanded); }, hideTooltips() { - this.$root.$emit('bv::hide::tooltip'); + this.$root.$emit(BV_HIDE_TOOLTIP); }, onDownstreamHovered() { this.$emit('downstreamHovered', this.sourceJobName); diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue index 40e6a01b88c..079e938ddd4 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue @@ -1,8 +1,8 @@ <script> import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; -import LinkedPipeline from './linked_pipeline.vue'; import { LOAD_FAILURE } from '../../constants'; -import { UPSTREAM } from './constants'; +import LinkedPipeline from './linked_pipeline.vue'; +import { ONE_COL_WIDTH, UPSTREAM } from './constants'; import { unwrapPipelineData, toggleQueryPollingByVisibility, reportToSentry } from './utils'; export default { @@ -39,6 +39,7 @@ export default { 'gl-pl-3', 'gl-mb-5', ], + minWidth: `${ONE_COL_WIDTH}px`, computed: { columnClass() { const positionValues = { @@ -47,12 +48,6 @@ export default { }; return `graph-position-${this.graphPosition} ${positionValues[this.graphPosition]}`; }, - graphPosition() { - return this.isUpstream ? 'left' : 'right'; - }, - isUpstream() { - return this.type === UPSTREAM; - }, computedTitleClasses() { const positionalClasses = this.isUpstream ? ['gl-w-full', 'gl-text-right', 'gl-linked-pipeline-padding'] @@ -60,6 +55,12 @@ export default { return [...this.$options.titleClasses, ...positionalClasses]; }, + graphPosition() { + return this.isUpstream ? 'left' : 'right'; + }, + isUpstream() { + return this.type === UPSTREAM; + }, }, methods: { getPipelineData(pipeline) { @@ -79,6 +80,7 @@ export default { }, result() { this.loadingPipelineId = null; + this.$emit('scrollContainer'); }, error(err, _vm, _key, type) { this.$emit('error', LOAD_FAILURE); @@ -130,6 +132,9 @@ export default { this.$emit('pipelineExpandToggle', jobName, expanded); }, + showDownstreamContainer(id) { + return !this.isUpstream && (this.isExpanded(id) || this.isLoadingPipeline(id)); + }, }, }; </script> @@ -158,9 +163,13 @@ export default { @pipelineClicked="onPipelineClick(pipeline)" @pipelineExpandToggle="onPipelineExpandToggle" /> - <div v-if="isExpanded(pipeline.id)" class="gl-display-inline-block"> + <div + v-if="showDownstreamContainer(pipeline.id)" + :style="{ minWidth: $options.minWidth }" + class="gl-display-inline-block" + > <pipeline-graph - v-if="currentPipeline" + v-if="isExpanded(pipeline.id)" :type="type" class="d-inline-block gl-mt-n2" :pipeline="currentPipeline" diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue index 65f8c231885..b258c885abb 100644 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -67,6 +67,9 @@ export default { errorCaptured(err, _vm, info) { reportToSentry('stage_column_component', `error: ${err}, info: ${info}`); }, + mounted() { + this.$emit('updateMeasurements'); + }, methods: { getGroupId(group) { return accessValue(GRAPHQL, 'groupId', group); @@ -75,11 +78,7 @@ export default { return `ci-badge-${escape(group.name)}`; }, isFadedOut(jobName) { - return ( - this.jobHovered && - this.highlightedJobs.length > 1 && - !this.highlightedJobs.includes(jobName) - ); + return this.highlightedJobs.length > 1 && !this.highlightedJobs.includes(jobName); }, }, }; @@ -123,12 +122,9 @@ export default { :class="{ 'gl-opacity-3': isFadedOut(group.name) }" @pipelineActionRequestComplete="$emit('refreshPipelineGraph')" /> - <job-group-dropdown - v-else - :group="group" - :pipeline-id="pipelineId" - :class="{ 'gl-opacity-3': isFadedOut(group.name) }" - /> + <div v-else :class="{ 'gl-opacity-3': isFadedOut(group.name) }"> + <job-group-dropdown :group="group" :pipeline-id="pipelineId" /> + </div> </div> </template> </main-graph-wrapper> diff --git a/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js b/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js index 65c215be794..202498fb188 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js +++ b/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js @@ -40,10 +40,10 @@ export const generateLinksData = ({ links }, containerID, modifier = '') => { // positioned in the center of the job node by adding half the height // of the job pill. const paddingLeft = parseFloat( - window.getComputedStyle(containerEl, null).getPropertyValue('padding-left'), + window.getComputedStyle(containerEl, null).getPropertyValue('padding-left') || 0, ); const paddingTop = parseFloat( - window.getComputedStyle(containerEl, null).getPropertyValue('padding-top'), + window.getComputedStyle(containerEl, null).getPropertyValue('padding-top') || 0, ); const sourceNodeX = sourceNodeCoordinates.right - containerCoordinates.x - paddingLeft; diff --git a/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue b/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue index 89444076ae0..289e04e02c5 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue +++ b/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue @@ -118,22 +118,20 @@ export default { <div class="gl-display-flex gl-relative"> <svg id="link-svg" - class="gl-absolute" + class="gl-absolute gl-pointer-events-none" :viewBox="viewBox" :width="`${containerMeasurements.width}px`" :height="`${containerMeasurements.height}px`" > - <template> - <path - v-for="link in links" - :key="link.path" - :ref="link.ref" - :d="link.path" - class="gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease" - :class="getLinkClasses(link)" - :stroke-width="$options.STROKE_WIDTH" - /> - </template> + <path + v-for="link in links" + :key="link.path" + :ref="link.ref" + :d="link.path" + class="gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease" + :class="getLinkClasses(link)" + :stroke-width="$options.STROKE_WIDTH" + /> </svg> <slot></slot> </div> diff --git a/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue b/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue index 0993892a574..1c1bc7ecb2a 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue +++ b/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue @@ -74,13 +74,15 @@ export default { <div v-else> <gl-alert v-if="showAlert" - class="gl-w-max-content gl-ml-4" + class="gl-ml-4 gl-mb-4" :primary-button-text="$options.i18n.showLinksAnyways" @primaryAction="overrideShowLinks" @dismiss="dismissAlert" > {{ $options.i18n.tooManyJobs }} </gl-alert> - <slot></slot> + <div class="gl-display-flex gl-relative"> + <slot></slot> + </div> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/legacy_header_component.vue b/app/assets/javascripts/pipelines/components/legacy_header_component.vue deleted file mode 100644 index c7b72be36ad..00000000000 --- a/app/assets/javascripts/pipelines/components/legacy_header_component.vue +++ /dev/null @@ -1,132 +0,0 @@ -<script> -import { GlLoadingIcon, GlModal, GlModalDirective, GlButton } from '@gitlab/ui'; -import ciHeader from '~/vue_shared/components/header_ci_component.vue'; -import eventHub from '../event_hub'; -import { __ } from '~/locale'; - -const DELETE_MODAL_ID = 'pipeline-delete-modal'; - -export default { - name: 'PipelineHeaderSection', - components: { - ciHeader, - GlLoadingIcon, - GlModal, - GlButton, - }, - directives: { - GlModal: GlModalDirective, - }, - props: { - pipeline: { - type: Object, - required: true, - }, - isLoading: { - type: Boolean, - required: true, - }, - }, - data() { - return { - isCanceling: false, - isRetrying: false, - isDeleting: false, - }; - }, - - computed: { - status() { - return this.pipeline.details && this.pipeline.details.status; - }, - shouldRenderContent() { - return !this.isLoading && Object.keys(this.pipeline).length; - }, - deleteModalConfirmationText() { - return __( - 'Are you sure you want to delete this pipeline? Doing so will expire all pipeline caches and delete all related objects, such as builds, logs, artifacts, and triggers. This action cannot be undone.', - ); - }, - }, - - methods: { - cancelPipeline() { - this.isCanceling = true; - eventHub.$emit('headerPostAction', this.pipeline.cancel_path); - }, - retryPipeline() { - this.isRetrying = true; - eventHub.$emit('headerPostAction', this.pipeline.retry_path); - }, - deletePipeline() { - this.isDeleting = true; - eventHub.$emit('headerDeleteAction', this.pipeline.delete_path); - }, - }, - DELETE_MODAL_ID, -}; -</script> -<template> - <div class="pipeline-header-container"> - <ci-header - v-if="shouldRenderContent" - :status="status" - :item-id="pipeline.id" - :time="pipeline.created_at" - :user="pipeline.user" - item-name="Pipeline" - > - <gl-button - v-if="pipeline.retry_path" - :loading="isRetrying" - :disabled="isRetrying" - data-testid="retryButton" - category="secondary" - variant="info" - @click="retryPipeline()" - > - {{ __('Retry') }} - </gl-button> - - <gl-button - v-if="pipeline.cancel_path" - :loading="isCanceling" - :disabled="isCanceling" - data-testid="cancelPipeline" - class="gl-ml-3" - category="primary" - variant="danger" - @click="cancelPipeline()" - > - {{ __('Cancel running') }} - </gl-button> - - <gl-button - v-if="pipeline.delete_path" - v-gl-modal="$options.DELETE_MODAL_ID" - :loading="isDeleting" - :disabled="isDeleting" - data-testid="deletePipeline" - class="gl-ml-3" - category="secondary" - variant="danger" - > - {{ __('Delete') }} - </gl-button> - </ci-header> - - <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3 gl-mb-3" /> - - <gl-modal - :modal-id="$options.DELETE_MODAL_ID" - :title="__('Delete pipeline')" - :ok-title="__('Delete pipeline')" - ok-variant="danger" - @ok="deletePipeline()" - > - <p> - {{ deleteModalConfirmationText }} - </p> - </gl-modal> - </div> -</template> diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue index 8636808b69e..678678b87eb 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue @@ -1,13 +1,13 @@ <script> import { GlAlert } from '@gitlab/ui'; import { __ } from '~/locale'; +import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants'; import { generateLinksData } from '../graph_shared/drawing_utils'; -import JobPill from './job_pill.vue'; -import StagePill from './stage_pill.vue'; import { parseData } from '../parsing_utils'; import { DRAW_FAILURE, DEFAULT, INVALID_CI_CONFIG, EMPTY_PIPELINE_DATA } from '../../constants'; import { createJobsHash, generateJobNeedsDict } from '../../utils'; -import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants'; +import StagePill from './stage_pill.vue'; +import JobPill from './job_pill.vue'; export default { components: { @@ -224,17 +224,15 @@ export default { data-testid="graph-container" > <svg :viewBox="viewBox" :width="width" :height="height" class="gl-absolute"> - <template> - <path - v-for="link in links" - :key="link.path" - :ref="link.ref" - :d="link.path" - class="gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease" - :class="getLinkClasses(link)" - :stroke-width="$options.STROKE_WIDTH" - /> - </template> + <path + v-for="link in links" + :key="link.path" + :ref="link.ref" + :d="link.path" + class="gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease" + :class="getLinkClasses(link)" + :stroke-width="$options.STROKE_WIDTH" + /> </svg> <div v-for="(stage, index) in pipelineStages" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue index d1bac078642..823ada133d2 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue @@ -77,6 +77,15 @@ export default { >{{ __('latest') }}</gl-badge > <gl-badge + v-if="pipeline.flags.merge_train_pipeline" + v-gl-tooltip + :title="__('This is a merge train pipeline')" + variant="info" + size="sm" + data-testid="pipeline-url-train" + >{{ __('train') }}</gl-badge + > + <gl-badge v-if="pipeline.flags.yaml_errors" v-gl-tooltip :title="pipeline.yaml_errors" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue index ec7c5764be1..b4eb429748f 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue @@ -3,16 +3,16 @@ import { isEqual } from 'lodash'; import { GlIcon } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import { deprecatedCreateFlash as createFlash } from '~/flash'; -import PipelinesService from '../../services/pipelines_service'; -import pipelinesMixin from '../../mixins/pipelines'; import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; -import NavigationControls from './nav_controls.vue'; import { getParameterByName } from '~/lib/utils/common_utils'; import CIPaginationMixin from '~/vue_shared/mixins/ci_pagination_api_mixin'; -import PipelinesFilteredSearch from './pipelines_filtered_search.vue'; +import pipelinesMixin from '../../mixins/pipelines'; +import PipelinesService from '../../services/pipelines_service'; import { validateParams } from '../../utils'; import { ANY_TRIGGER_AUTHOR, RAW_TEXT_WARNING, FILTER_TAG_IDENTIFIER } from '../../constants'; +import NavigationControls from './nav_controls.vue'; +import PipelinesFilteredSearch from './pipelines_filtered_search.vue'; export default { components: { diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue index 1ea71610897..13f314a3a45 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue @@ -1,7 +1,7 @@ <script> -import { GlTooltipDirective, GlButton, GlLoadingIcon, GlIcon } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import { s__, __, sprintf } from '~/locale'; import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; import eventHub from '../../event_hub'; @@ -11,10 +11,10 @@ export default { GlTooltip: GlTooltipDirective, }, components: { - GlIcon, GlCountdown, - GlButton, - GlLoadingIcon, + GlDropdown, + GlDropdownItem, + GlIcon, }, props: { actions: { @@ -61,7 +61,7 @@ export default { }) .catch(() => { this.isLoading = false; - flash(__('An error occurred while making the request.')); + createFlash({ message: __('An error occurred while making the request.') }); }); }, @@ -76,39 +76,27 @@ export default { }; </script> <template> - <div class="btn-group"> - <button - v-gl-tooltip - type="button" - :disabled="isLoading" - class="dropdown-new btn btn-default js-pipeline-dropdown-manual-actions" - :title="__('Run manual or delayed jobs')" - data-toggle="dropdown" - :aria-label="__('Run manual or delayed jobs')" + <gl-dropdown + v-gl-tooltip + :title="__('Run manual or delayed jobs')" + :loading="isLoading" + data-testid="pipelines-manual-actions-dropdown" + right + icon="play" + > + <gl-dropdown-item + v-for="action in actions" + :key="action.path" + :disabled="isActionDisabled(action)" + @click="onClickAction(action)" > - <gl-icon name="play" class="icon-play" /> - <gl-icon name="chevron-down" /> - <gl-loading-icon v-if="isLoading" /> - </button> - - <ul class="dropdown-menu dropdown-menu-right"> - <li v-for="action in actions" :key="action.path"> - <gl-button - category="tertiary" - :class="{ disabled: isActionDisabled(action) }" - :disabled="isActionDisabled(action)" - class="js-pipeline-action-link" - @click="onClickAction(action)" - > - <div class="d-flex justify-content-between flex-wrap"> - {{ action.name }} - <span v-if="action.scheduled_at"> - <gl-icon name="clock" /> - <gl-countdown :end-date-string="action.scheduled_at" /> - </span> - </div> - </gl-button> - </li> - </ul> - </div> + <div class="gl-display-flex gl-justify-content-space-between gl-flex-wrap"> + {{ action.name }} + <span v-if="action.scheduled_at"> + <gl-icon name="clock" /> + <gl-countdown :end-date-string="action.scheduled_at" /> + </span> + </div> + </gl-dropdown-item> + </gl-dropdown> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue index 6c60594efca..1b08f883b05 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue @@ -1,8 +1,8 @@ <script> import { GlTooltipDirective } from '@gitlab/ui'; +import eventHub from '../../event_hub'; import PipelinesTableRowComponent from './pipelines_table_row.vue'; import PipelineStopModal from './pipeline_stop_modal.vue'; -import eventHub from '../../event_hub'; /** * Pipelines Table Component. diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue index b6c4e617a90..5231fe0b112 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue @@ -1,16 +1,16 @@ <script> import { GlButton, GlTooltipDirective, GlModalDirective } from '@gitlab/ui'; -import eventHub from '../../event_hub'; import { __ } from '~/locale'; +import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import CommitComponent from '~/vue_shared/components/commit.vue'; +import eventHub from '../../event_hub'; +import { PIPELINES_TABLE } from '../../constants'; import PipelinesActionsComponent from './pipelines_actions.vue'; import PipelinesArtifactsComponent from './pipelines_artifacts.vue'; -import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; import PipelineStage from './stage.vue'; import PipelineUrl from './pipeline_url.vue'; import PipelineTriggerer from './pipeline_triggerer.vue'; import PipelinesTimeago from './time_ago.vue'; -import CommitComponent from '~/vue_shared/components/commit.vue'; -import { PIPELINES_TABLE } from '../../constants'; /** * Pipeline table row. diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue b/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue index a9154d93194..460aa427196 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue @@ -11,11 +11,11 @@ * 3. Merge request widget * 4. Commit widget */ - import $ from 'jquery'; -import { GlLoadingIcon, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { GlDropdown, GlLoadingIcon, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import { deprecatedCreateFlash as Flash } from '~/flash'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import axios from '~/lib/utils/axios_utils'; import eventHub from '../../event_hub'; import JobItem from '../graph/job_item.vue'; @@ -24,14 +24,14 @@ import { PIPELINES_TABLE } from '../../constants'; export default { components: { GlIcon, - JobItem, GlLoadingIcon, + GlDropdown, + JobItem, }, - directives: { GlTooltip: GlTooltipDirective, }, - + mixins: [glFeatureFlagsMixin()], props: { stage: { type: Object, @@ -50,30 +50,25 @@ export default { default: '', }, }, - data() { return { isLoading: false, - dropdownContent: '', + dropdownContent: [], }; }, - computed: { - dropdownClass() { - return this.dropdownContent.length > 0 - ? 'js-builds-dropdown-container' - : 'js-builds-dropdown-loading'; + isCiMiniPipelineGlDropdown() { + // Feature flag ci_mini_pipeline_gl_dropdown + // See more at https://gitlab.com/gitlab-org/gitlab/-/issues/300400 + return this.glFeatures?.ciMiniPipelineGlDropdown; }, - triggerButtonClass() { return `ci-status-icon-${this.stage.status.group}`; }, - borderlessIcon() { return `${this.stage.status.icon}_borderless`; }, }, - watch: { updateDropdown() { if (this.updateDropdown && this.isDropdownOpen() && !this.isLoading) { @@ -81,14 +76,17 @@ export default { } }, }, - updated() { - if (this.dropdownContent.length > 0) { + if (!this.isCiMiniPipelineGlDropdown && this.dropdownContent.length) { this.stopDropdownClickPropagation(); } }, - methods: { + onShowDropdown() { + eventHub.$emit('clickedDropdown'); + this.isLoading = true; + this.fetchJobs(); + }, onClickStage() { if (!this.isDropdownOpen()) { eventHub.$emit('clickedDropdown'); @@ -96,7 +94,6 @@ export default { this.fetchJobs(); } }, - fetchJobs() { axios .get(this.stage.dropdown_path) @@ -105,13 +102,16 @@ export default { this.isLoading = false; }) .catch(() => { - this.closeDropdown(); + if (this.isCiMiniPipelineGlDropdown) { + this.$refs.stageGlDropdown.hide(); + } else { + this.closeDropdown(); + } this.isLoading = false; Flash(__('Something went wrong on our end.')); }); }, - /** * When the user right clicks or cmd/ctrl + click in the job name * the dropdown should not be closed and the link should open in another tab, @@ -119,6 +119,8 @@ export default { * * Since this component is rendered multiple times per page we need to guarantee we only * target the click event of this component. + * + * Note: This should be removed once ci_mini_pipeline_gl_dropdown FF is removed as true. */ stopDropdownClickPropagation() { $( @@ -128,23 +130,24 @@ export default { e.stopPropagation(); }); }, - closeDropdown() { if (this.isDropdownOpen()) { $(this.$refs.dropdown).dropdown('toggle'); } }, - isDropdownOpen() { return this.$el.classList.contains('show'); }, - pipelineActionRequestComplete() { if (this.type === PIPELINES_TABLE) { // warn the table to update eventHub.$emit('refreshPipelinesTable'); + return; + } + // close the dropdown in mr widget + if (this.isCiMiniPipelineGlDropdown) { + this.$refs.stageGlDropdown.hide(); } else { - // close the dropdown in mr widget $(this.$refs.dropdown).dropdown('toggle'); } }, @@ -154,31 +157,30 @@ export default { <template> <div class="dropdown"> - <button - id="stageDropdown" - ref="dropdown" + <gl-dropdown + v-if="isCiMiniPipelineGlDropdown" + ref="stageGlDropdown" v-gl-tooltip.hover - :class="triggerButtonClass" + data-testid="mini-pipeline-graph-dropdown" :title="stage.title" - class="mini-pipeline-graph-dropdown-toggle js-builds-dropdown-button" - data-toggle="dropdown" - data-display="static" - type="button" - aria-haspopup="true" - aria-expanded="false" - @click="onClickStage" - > - <span :aria-label="stage.title" aria-hidden="true" class="gl-pointer-events-none"> - <gl-icon :name="borderlessIcon" /> - </span> - </button> - - <div - class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container" - aria-labelledby="stageDropdown" + variant="link" + :lazy="true" + :popper-opts="{ placement: 'bottom' }" + :toggle-class="['mini-pipeline-graph-gl-dropdown-toggle', triggerButtonClass]" + menu-class="mini-pipeline-graph-dropdown-menu" + @show="onShowDropdown" > + <template #button-content> + <span class="gl-pointer-events-none"> + <gl-icon :name="borderlessIcon" /> + </span> + </template> <gl-loading-icon v-if="isLoading" /> - <ul v-else class="js-builds-dropdown-list scrollable-menu"> + <ul + v-else + class="js-builds-dropdown-list scrollable-menu" + data-testid="mini-pipeline-graph-dropdown-menu-list" + > <li v-for="job in dropdownContent" :key="job.id"> <job-item :dropdown-length="dropdownContent.length" @@ -188,6 +190,45 @@ export default { /> </li> </ul> - </div> + </gl-dropdown> + + <template v-else> + <button + id="stageDropdown" + ref="dropdown" + v-gl-tooltip.hover + :class="triggerButtonClass" + :title="stage.title" + class="mini-pipeline-graph-dropdown-toggle" + data-testid="mini-pipeline-graph-dropdown-toggle" + data-toggle="dropdown" + data-display="static" + type="button" + aria-haspopup="true" + aria-expanded="false" + @click="onClickStage" + > + <span :aria-label="stage.title" aria-hidden="true" class="gl-pointer-events-none"> + <gl-icon :name="borderlessIcon" /> + </span> + </button> + + <div + class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container" + aria-labelledby="stageDropdown" + > + <gl-loading-icon v-if="isLoading" /> + <ul v-else class="js-builds-dropdown-list scrollable-menu"> + <li v-for="job in dropdownContent" :key="job.id"> + <job-item + :dropdown-length="dropdownContent.length" + :job="job" + css-class-job-name="mini-pipeline-graph-dropdown-item" + @pipelineActionRequestComplete="pipelineActionRequestComplete" + /> + </li> + </ul> + </div> + </template> </div> </template> 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 24456574a6f..20a232beb83 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,8 +2,8 @@ import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; import { debounce } from 'lodash'; import Api from '~/api'; -import { FETCH_BRANCH_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../../constants'; import { deprecatedCreateFlash as createFlash } from '~/flash'; +import { FETCH_BRANCH_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../../constants'; export default { components: { 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 1241803c612..4a8d89ebe37 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,8 +2,8 @@ import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; import { debounce } from 'lodash'; import Api from '~/api'; -import { FETCH_TAG_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../../constants'; import { deprecatedCreateFlash as createFlash } from '~/flash'; +import { FETCH_TAG_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../../constants'; export default { components: { diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue b/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue index 504cf138d07..08f296bec12 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue @@ -1,12 +1,13 @@ <script> -import { GlModal } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { GlBadge, GlModal } from '@gitlab/ui'; +import { __, n__, sprintf } from '~/locale'; import CodeBlock from '~/vue_shared/components/code_block.vue'; export default { name: 'TestCaseDetails', components: { CodeBlock, + GlBadge, GlModal, }, props: { @@ -21,9 +22,35 @@ export default { Boolean(classname) && Boolean(formattedTime) && Boolean(name), }, }, + computed: { + failureHistoryMessage() { + if (!this.hasRecentFailures) { + return null; + } + + return sprintf( + n__( + 'Reports|Failed %{count} time in %{baseBranch} in the last 14 days', + 'Reports|Failed %{count} times in %{baseBranch} in the last 14 days', + this.recentFailures.count, + ), + { + count: this.recentFailures.count, + baseBranch: this.recentFailures.base_branch, + }, + ); + }, + hasRecentFailures() { + return Boolean(this.recentFailures); + }, + recentFailures() { + return this.testCase.recent_failures; + }, + }, text: { name: __('Name'), duration: __('Execution time'), + history: __('History'), trace: __('System output'), }, modalCloseButton: { @@ -53,6 +80,13 @@ export default { </div> </div> + <div v-if="testCase.recent_failures" class="gl-display-flex gl-flex-wrap gl-mx-n4 gl-my-3"> + <strong class="gl-text-right col-sm-3">{{ $options.text.history }}</strong> + <div class="col-sm-9" data-testid="test-case-recent-failures"> + <gl-badge variant="warning">{{ failureHistoryMessage }}</gl-badge> + </div> + </div> + <div v-if="testCase.system_output" class="gl-display-flex gl-flex-wrap gl-mx-n4 gl-my-3" diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue index 4b4fb6082c6..6b7381883cc 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue @@ -114,7 +114,7 @@ export default { <div role="rowheader" class="table-mobile-header">{{ __('Status') }}</div> <div class="table-mobile-content text-center"> <div - class="add-border ci-status-icon d-flex align-items-center justify-content-end justify-content-md-center" + class="ci-status-icon d-flex align-items-center justify-content-end justify-content-md-center" :class="`ci-status-icon-${testCase.status}`" > <gl-icon :size="24" :name="testCase.icon" /> diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index 133608b9801..5ee5b45aac9 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -2,12 +2,9 @@ import Vue from 'vue'; import { deprecatedCreateFlash as Flash } from '~/flash'; import Translate from '~/vue_shared/translate'; import { __ } from '~/locale'; -import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility'; import PipelineGraphLegacy from './components/graph/graph_component_legacy.vue'; import createDagApp from './pipeline_details_dag'; import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin'; -import legacyPipelineHeader from './components/legacy_header_component.vue'; -import eventHub from './event_hub'; import TestReports from './components/test_reports/test_reports.vue'; import createTestReportsStore from './stores/test_reports'; import { reportToSentry } from './components/graph/utils'; @@ -59,58 +56,6 @@ const createLegacyPipelinesDetailApp = (mediator) => { }); }; -const createLegacyPipelineHeaderApp = (mediator) => { - if (!document.querySelector(SELECTORS.PIPELINE_HEADER)) { - return; - } - // eslint-disable-next-line no-new - new Vue({ - el: SELECTORS.PIPELINE_HEADER, - components: { - legacyPipelineHeader, - }, - data() { - return { - mediator, - }; - }, - created() { - eventHub.$on('headerPostAction', this.postAction); - eventHub.$on('headerDeleteAction', this.deleteAction); - }, - beforeDestroy() { - eventHub.$off('headerPostAction', this.postAction); - eventHub.$off('headerDeleteAction', this.deleteAction); - }, - errorCaptured(err, _vm, info) { - reportToSentry('pipeline_details_bundle_legacy', `error: ${err}, info: ${info}`); - }, - methods: { - postAction(path) { - this.mediator.service - .postAction(path) - .then(() => this.mediator.refreshPipeline()) - .catch(() => Flash(__('An error occurred while making the request.'))); - }, - deleteAction(path) { - this.mediator.stopPipelinePoll(); - this.mediator.service - .deleteAction(path) - .then(({ request }) => redirectTo(setUrlFragment(request.responseURL, 'delete_success'))) - .catch(() => Flash(__('An error occurred while deleting the pipeline.'))); - }, - }, - render(createElement) { - return createElement('legacy-pipeline-header', { - props: { - isLoading: this.mediator.state.isLoading, - pipeline: this.mediator.store.state.pipeline, - }, - }); - }, - }); -}; - const createTestDetails = () => { const el = document.querySelector(SELECTORS.PIPELINE_TESTS); const { summaryEndpoint, suiteEndpoint } = el?.dataset || {}; @@ -136,22 +81,12 @@ export default async function () { createTestDetails(); createDagApp(); - const { dataset } = document.querySelector(SELECTORS.PIPELINE_DETAILS); - let mediator; + const canShowNewPipelineDetails = + gon.features.graphqlPipelineDetails || gon.features.graphqlPipelineDetailsUsers; - if (!gon.features.graphqlPipelineHeader || !gon.features.graphqlPipelineDetails) { - try { - const { default: PipelinesMediator } = await import( - /* webpackChunkName: 'PipelinesMediator' */ './pipeline_details_mediator' - ); - mediator = new PipelinesMediator({ endpoint: dataset.endpoint }); - mediator.fetchPipeline(); - } catch { - Flash(__('An error occurred while loading the pipeline.')); - } - } + const { dataset } = document.querySelector(SELECTORS.PIPELINE_DETAILS); - if (gon.features.graphqlPipelineDetails) { + if (canShowNewPipelineDetails) { try { const { createPipelinesDetailApp } = await import( /* webpackChunkName: 'createPipelinesDetailApp' */ './pipeline_details_graph' @@ -163,19 +98,21 @@ export default async function () { Flash(__('An error occurred while loading the pipeline.')); } } else { + const { default: PipelinesMediator } = await import( + /* webpackChunkName: 'PipelinesMediator' */ './pipeline_details_mediator' + ); + const mediator = new PipelinesMediator({ endpoint: dataset.endpoint }); + mediator.fetchPipeline(); + createLegacyPipelinesDetailApp(mediator); } - if (gon.features.graphqlPipelineHeader) { - try { - const { createPipelineHeaderApp } = await import( - /* webpackChunkName: 'createPipelineHeaderApp' */ './pipeline_details_header' - ); - createPipelineHeaderApp(SELECTORS.PIPELINE_HEADER); - } catch { - Flash(__('An error occurred while loading a section of this page.')); - } - } else { - createLegacyPipelineHeaderApp(mediator); + try { + const { createPipelineHeaderApp } = await import( + /* webpackChunkName: 'createPipelineHeaderApp' */ './pipeline_details_header' + ); + createPipelineHeaderApp(SELECTORS.PIPELINE_HEADER); + } catch { + Flash(__('An error occurred while loading a section of this page.')); } } diff --git a/app/assets/javascripts/pipelines/pipeline_details_dag.js b/app/assets/javascripts/pipelines/pipeline_details_dag.js index d37c72a4f2a..4ee0ad462d2 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_dag.js +++ b/app/assets/javascripts/pipelines/pipeline_details_dag.js @@ -12,7 +12,7 @@ const apolloProvider = new VueApollo({ const createDagApp = () => { const el = document.querySelector('#js-pipeline-dag-vue'); - if (!window.gon?.features?.dagPipelineTab || !el) { + if (!el) { return; } diff --git a/app/assets/javascripts/pipelines/pipeline_details_mediator.js b/app/assets/javascripts/pipelines/pipeline_details_mediator.js index 74c5fc45644..474dc828e5e 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_mediator.js +++ b/app/assets/javascripts/pipelines/pipeline_details_mediator.js @@ -1,8 +1,8 @@ import Visibility from 'visibilityjs'; -import PipelineStore from './stores/pipeline_store'; import { deprecatedCreateFlash as Flash } from '../flash'; import Poll from '../lib/utils/poll'; import { __ } from '../locale'; +import PipelineStore from './stores/pipeline_store'; import PipelineService from './services/pipeline_service'; export default class pipelinesMediator { diff --git a/app/assets/javascripts/pipelines/services/pipelines_service.js b/app/assets/javascripts/pipelines/services/pipelines_service.js index 0b06bcf243a..523ca13b6c3 100644 --- a/app/assets/javascripts/pipelines/services/pipelines_service.js +++ b/app/assets/javascripts/pipelines/services/pipelines_service.js @@ -1,5 +1,5 @@ -import axios from '../../lib/utils/axios_utils'; import Api from '~/api'; +import axios from '../../lib/utils/axios_utils'; import { validateParams } from '../utils'; export default class PipelinesService { diff --git a/app/assets/javascripts/pipelines/stores/test_reports/actions.js b/app/assets/javascripts/pipelines/stores/test_reports/actions.js index 3c664457756..3512734e528 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/actions.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/actions.js @@ -1,7 +1,7 @@ import axios from '~/lib/utils/axios_utils'; -import * as types from './mutation_types'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { s__ } from '~/locale'; +import * as types from './mutation_types'; export const fetchSummary = ({ state, commit, dispatch }) => { dispatch('toggleLoading'); @@ -28,16 +28,12 @@ export const fetchTestSuite = ({ state, commit, dispatch }, index) => { dispatch('toggleLoading'); - const { name = '', build_ids = [] } = state.testReports?.test_suites?.[index] || {}; + const { build_ids = [] } = state.testReports?.test_suites?.[index] || {}; // Replacing `/:suite_name.json` with the name of the suite. Including the extra characters // to ensure that we replace exactly the template part of the URL string - const endpoint = state.suiteEndpoint?.replace( - '/:suite_name.json', - `/${encodeURIComponent(name)}.json`, - ); return axios - .get(endpoint, { params: { build_ids } }) + .get(state.suiteEndpoint, { params: { build_ids } }) .then(({ data }) => commit(types.SET_SUITE, { suite: data, index })) .catch(() => { createFlash(s__('TestReports|There was an error fetching the test suite.')); diff --git a/app/assets/javascripts/pipelines/stores/test_reports/utils.js b/app/assets/javascripts/pipelines/stores/test_reports/utils.js index 5c1f27b166a..8d1a941058c 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/utils.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/utils.js @@ -4,16 +4,16 @@ import { TestStatus } from '../../constants'; export function iconForTestStatus(status) { switch (status) { case TestStatus.SUCCESS: - return 'status_success_borderless'; + return 'status_success'; case TestStatus.FAILED: - return 'status_failed_borderless'; + return 'status_failed'; case TestStatus.ERROR: - return 'status_warning_borderless'; + return 'status_warning'; case TestStatus.SKIPPED: - return 'status_skipped_borderless'; + return 'status_skipped'; case TestStatus.UNKNOWN: default: - return 'status_notfound_borderless'; + return 'status_notfound'; } } diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js index 50bb23b7e63..23046586de7 100644 --- a/app/assets/javascripts/pipelines/utils.js +++ b/app/assets/javascripts/pipelines/utils.js @@ -49,10 +49,10 @@ export const generateJobNeedsDict = (jobs = {}) => { // to the list of `needs` to ensure we can properly reference it. const group = jobs[job]; if (group.size > 1) { - return [job, group.name, ...newNeeds]; + return [job, group.name, newNeeds]; } - return [job, ...newNeeds]; + return [job, newNeeds]; }) .flat(Infinity); }; diff --git a/app/assets/javascripts/profile/account/index.js b/app/assets/javascripts/profile/account/index.js index 5e89002b3bc..f6f424db7bd 100644 --- a/app/assets/javascripts/profile/account/index.js +++ b/app/assets/javascripts/profile/account/index.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import Translate from '~/vue_shared/translate'; +import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import UpdateUsername from './components/update_username.vue'; import deleteAccountModal from './components/delete_account_modal.vue'; @@ -31,7 +32,7 @@ export default () => { mounted() { deleteAccountButton.classList.remove('disabled'); deleteAccountButton.addEventListener('click', () => { - this.$root.$emit('bv::show::modal', 'delete-account-modal', '#delete-account-button'); + this.$root.$emit(BV_SHOW_MODAL, 'delete-account-modal', '#delete-account-button'); }); }, render(createElement) { diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js index bfeeff47163..e6c41c7615c 100644 --- a/app/assets/javascripts/profile/profile.js +++ b/app/assets/javascripts/profile/profile.js @@ -1,11 +1,11 @@ import $ from 'jquery'; import axios from '~/lib/utils/axios_utils'; import { Rails } from '~/lib/utils/rails_ujs'; -import { deprecatedCreateFlash as flash } from '../flash'; import { parseBoolean } from '~/lib/utils/common_utils'; import TimezoneDropdown, { formatTimezone, } from '~/pages/projects/pipeline_schedules/shared/components/timezone_dropdown'; +import { deprecatedCreateFlash as flash } from '../flash'; export default class Profile { constructor({ form } = {}) { diff --git a/app/assets/javascripts/project_label_subscription.js b/app/assets/javascripts/project_label_subscription.js index 4fefc2ed569..666f6e15e61 100644 --- a/app/assets/javascripts/project_label_subscription.js +++ b/app/assets/javascripts/project_label_subscription.js @@ -1,8 +1,8 @@ import $ from 'jquery'; +import { fixTitle } from '~/tooltips'; import { __ } from './locale'; import axios from './lib/utils/axios_utils'; import { deprecatedCreateFlash as flash } from './flash'; -import { fixTitle } from '~/tooltips'; const tooltipTitles = { group: { diff --git a/app/assets/javascripts/projects/commit/components/branches_dropdown.vue b/app/assets/javascripts/projects/commit/components/branches_dropdown.vue index 3ecc3f1d1d3..36da4128450 100644 --- a/app/assets/javascripts/projects/commit/components/branches_dropdown.vue +++ b/app/assets/javascripts/projects/commit/components/branches_dropdown.vue @@ -68,6 +68,7 @@ export default { autocomplete="off" :debounce="250" :placeholder="$options.i18n.searchPlaceholder" + data-testid="dropdown-search-box" @input="searchTermChanged" /> <gl-dropdown-item diff --git a/app/assets/javascripts/projects/commit/components/form_modal.vue b/app/assets/javascripts/projects/commit/components/form_modal.vue index 6411b1ca921..22f9d04d87c 100644 --- a/app/assets/javascripts/projects/commit/components/form_modal.vue +++ b/app/assets/javascripts/projects/commit/components/form_modal.vue @@ -1,8 +1,9 @@ <script> import { GlModal, GlForm, GlFormCheckbox, GlSprintf, GlFormGroup } from '@gitlab/ui'; import { mapActions, mapState } from 'vuex'; -import eventHub from '../event_hub'; import csrf from '~/lib/utils/csrf'; +import { BV_SHOW_MODAL } from '~/lib/utils/constants'; +import eventHub from '../event_hub'; import BranchesDropdown from './branches_dropdown.vue'; export default { @@ -67,7 +68,7 @@ export default { methods: { ...mapActions(['clearModal', 'setBranch', 'setSelectedBranch']), show() { - this.$root.$emit('bv::show::modal', this.modalId); + this.$root.$emit(BV_SHOW_MODAL, this.modalId); }, handlePrimary() { this.$refs.form.$el.submit(); diff --git a/app/assets/javascripts/projects/commit/components/form_trigger.vue b/app/assets/javascripts/projects/commit/components/form_trigger.vue index e92854c1ac3..3561b5c2473 100644 --- a/app/assets/javascripts/projects/commit/components/form_trigger.vue +++ b/app/assets/javascripts/projects/commit/components/form_trigger.vue @@ -10,6 +10,9 @@ export default { displayText: { default: '', }, + testId: { + default: '', + }, }, props: { openModal: { @@ -26,7 +29,7 @@ export default { </script> <template> - <gl-link data-is-link="true" data-testid="revert-commit-link" @click="showModal"> + <gl-link data-is-link="true" :data-testid="testId" @click="showModal"> {{ displayText }} </gl-link> </template> diff --git a/app/assets/javascripts/projects/commit/constants.js b/app/assets/javascripts/projects/commit/constants.js index 233f43d56b9..b47c744e5fb 100644 --- a/app/assets/javascripts/projects/commit/constants.js +++ b/app/assets/javascripts/projects/commit/constants.js @@ -2,6 +2,10 @@ import { s__, __ } from '~/locale'; export const OPEN_REVERT_MODAL = 'openRevertModal'; export const REVERT_MODAL_ID = 'revert-commit-modal'; +export const REVERT_LINK_TEST_ID = 'revert-commit-link'; +export const OPEN_CHERRY_PICK_MODAL = 'openCherryPickModal'; +export const CHERRY_PICK_MODAL_ID = 'cherry-pick-commit-modal'; +export const CHERRY_PICK_LINK_TEST_ID = 'cherry-pick-commit-link'; export const I18N_MODAL = { startMergeRequest: s__('ChangeTypeAction|Start a %{newMergeRequest} with these changes'), @@ -20,6 +24,11 @@ export const I18N_REVERT_MODAL = { actionPrimaryText: s__('ChangeTypeAction|Revert'), }; +export const I18N_CHERRY_PICK_MODAL = { + branchLabel: s__('ChangeTypeAction|Pick into branch'), + actionPrimaryText: s__('ChangeTypeAction|Cherry-pick'), +}; + export const PREPENDED_MODAL_TEXT = s__( 'ChangeTypeAction|This will create a new commit in order to revert the existing changes.', ); diff --git a/app/assets/javascripts/projects/commit/index.js b/app/assets/javascripts/projects/commit/index.js new file mode 100644 index 00000000000..d5d7e62ce32 --- /dev/null +++ b/app/assets/javascripts/projects/commit/index.js @@ -0,0 +1,11 @@ +import initRevertCommitTrigger from './init_revert_commit_trigger'; +import initRevertCommitModal from './init_revert_commit_modal'; +import initCherryPickCommitTrigger from './init_cherry_pick_commit_trigger'; +import initCherryPickCommitModal from './init_cherry_pick_commit_modal'; + +export default () => { + initRevertCommitModal(); + initRevertCommitTrigger(); + initCherryPickCommitModal(); + initCherryPickCommitTrigger(); +}; diff --git a/app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js b/app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js new file mode 100644 index 00000000000..33184d25c9b --- /dev/null +++ b/app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js @@ -0,0 +1,51 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import CommitFormModal from './components/form_modal.vue'; +import createStore from './store'; +import { + I18N_MODAL, + I18N_CHERRY_PICK_MODAL, + OPEN_CHERRY_PICK_MODAL, + CHERRY_PICK_MODAL_ID, +} from './constants'; + +export default function initInviteMembersModal() { + const el = document.querySelector('.js-cherry-pick-commit-modal'); + if (!el) { + return false; + } + + const { + title, + endpoint, + branch, + pushCode, + branchCollaboration, + existingBranch, + branchesEndpoint, + } = el.dataset; + + const store = createStore({ + endpoint, + branchesEndpoint, + branch, + pushCode: parseBoolean(pushCode), + branchCollaboration: parseBoolean(branchCollaboration), + defaultBranch: branch, + modalTitle: title, + existingBranch, + }); + + return new Vue({ + el, + store, + render: (createElement) => + createElement(CommitFormModal, { + props: { + i18n: { ...I18N_CHERRY_PICK_MODAL, ...I18N_MODAL }, + openModal: OPEN_CHERRY_PICK_MODAL, + modalId: CHERRY_PICK_MODAL_ID, + }, + }), + }); +} diff --git a/app/assets/javascripts/projects/commit/init_cherry_pick_commit_trigger.js b/app/assets/javascripts/projects/commit/init_cherry_pick_commit_trigger.js new file mode 100644 index 00000000000..942451dc96a --- /dev/null +++ b/app/assets/javascripts/projects/commit/init_cherry_pick_commit_trigger.js @@ -0,0 +1,20 @@ +import Vue from 'vue'; +import CommitFormTrigger from './components/form_trigger.vue'; +import { OPEN_CHERRY_PICK_MODAL, CHERRY_PICK_LINK_TEST_ID } from './constants'; + +export default function initInviteMembersTrigger() { + const el = document.querySelector('.js-cherry-pick-commit-trigger'); + + if (!el) { + return false; + } + + const { displayText } = el.dataset; + + return new Vue({ + el, + provide: { displayText, testId: CHERRY_PICK_LINK_TEST_ID }, + render: (createElement) => + createElement(CommitFormTrigger, { props: { openModal: OPEN_CHERRY_PICK_MODAL } }), + }); +} diff --git a/app/assets/javascripts/projects/commit/init_revert_commit_modal.js b/app/assets/javascripts/projects/commit/init_revert_commit_modal.js index ec0600cd25a..26695089e90 100644 --- a/app/assets/javascripts/projects/commit/init_revert_commit_modal.js +++ b/app/assets/javascripts/projects/commit/init_revert_commit_modal.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import CommitFormModal from './components/form_modal.vue'; import { parseBoolean } from '~/lib/utils/common_utils'; +import CommitFormModal from './components/form_modal.vue'; import createStore from './store'; import { I18N_MODAL, diff --git a/app/assets/javascripts/projects/commit/init_revert_commit_trigger.js b/app/assets/javascripts/projects/commit/init_revert_commit_trigger.js index 0bb57f22663..dc5168524ca 100644 --- a/app/assets/javascripts/projects/commit/init_revert_commit_trigger.js +++ b/app/assets/javascripts/projects/commit/init_revert_commit_trigger.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import RevertCommitTrigger from './components/form_trigger.vue'; -import { OPEN_REVERT_MODAL } from './constants'; +import CommitFormTrigger from './components/form_trigger.vue'; +import { OPEN_REVERT_MODAL, REVERT_LINK_TEST_ID } from './constants'; export default function initInviteMembersTrigger() { const el = document.querySelector('.js-revert-commit-trigger'); @@ -13,8 +13,8 @@ export default function initInviteMembersTrigger() { return new Vue({ el, - provide: { displayText }, + provide: { displayText, testId: REVERT_LINK_TEST_ID }, render: (createElement) => - createElement(RevertCommitTrigger, { props: { openModal: OPEN_REVERT_MODAL } }), + createElement(CommitFormTrigger, { props: { openModal: OPEN_REVERT_MODAL } }), }); } diff --git a/app/assets/javascripts/projects/commit/store/actions.js b/app/assets/javascripts/projects/commit/store/actions.js index 2ae0370d579..a9d1197e955 100644 --- a/app/assets/javascripts/projects/commit/store/actions.js +++ b/app/assets/javascripts/projects/commit/store/actions.js @@ -1,7 +1,7 @@ -import * as types from './mutation_types'; import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; import { PROJECT_BRANCHES_ERROR } from '../constants'; +import * as types from './mutation_types'; export const clearModal = ({ commit }) => { commit(types.CLEAR_MODAL); diff --git a/app/assets/javascripts/projects/commit_box/info/index.js b/app/assets/javascripts/projects/commit_box/info/index.js index 254d178f013..cfb7dc8476d 100644 --- a/app/assets/javascripts/projects/commit_box/info/index.js +++ b/app/assets/javascripts/projects/commit_box/info/index.js @@ -1,7 +1,7 @@ -import { loadBranches } from './load_branches'; -import { initDetailsButton } from './init_details_button'; import { fetchCommitMergeRequests } from '~/commit_merge_requests'; import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown'; +import { loadBranches } from './load_branches'; +import { initDetailsButton } from './init_details_button'; export const initCommitBoxInfo = (containerSelector = '.js-commit-box-info') => { const containerEl = document.querySelector(containerSelector); diff --git a/app/assets/javascripts/projects/commits/store/actions.js b/app/assets/javascripts/projects/commits/store/actions.js index 359d81f32f7..832f6b904f4 100644 --- a/app/assets/javascripts/projects/commits/store/actions.js +++ b/app/assets/javascripts/projects/commits/store/actions.js @@ -1,9 +1,9 @@ import * as Sentry from '~/sentry/wrapper'; -import * as types from './mutation_types'; import axios from '~/lib/utils/axios_utils'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { __ } from '~/locale'; import { joinPaths } from '~/lib/utils/url_utility'; +import * as types from './mutation_types'; export default { setInitialData({ commit }, data) { diff --git a/app/assets/javascripts/projects/compare/components/app.vue b/app/assets/javascripts/projects/compare/components/app.vue new file mode 100644 index 00000000000..05bd0f1370b --- /dev/null +++ b/app/assets/javascripts/projects/compare/components/app.vue @@ -0,0 +1,89 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import csrf from '~/lib/utils/csrf'; +import RevisionDropdown from './revision_dropdown.vue'; + +export default { + csrf, + components: { + RevisionDropdown, + GlButton, + }, + props: { + projectCompareIndexPath: { + type: String, + required: true, + }, + refsProjectPath: { + type: String, + required: true, + }, + paramsFrom: { + type: String, + required: false, + default: null, + }, + paramsTo: { + type: String, + required: false, + default: null, + }, + projectMergeRequestPath: { + type: String, + required: true, + }, + createMrPath: { + type: String, + required: true, + }, + }, + methods: { + onSubmit() { + this.$refs.form.submit(); + }, + }, +}; +</script> + +<template> + <form + ref="form" + class="form-inline js-requires-input js-signature-container" + method="POST" + :action="projectCompareIndexPath" + > + <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> + <revision-dropdown + :refs-project-path="refsProjectPath" + revision-text="Source" + params-name="to" + :params-branch="paramsTo" + /> + <div class="compare-ellipsis gl-display-inline" data-testid="ellipsis">...</div> + <revision-dropdown + :refs-project-path="refsProjectPath" + revision-text="Target" + params-name="from" + :params-branch="paramsFrom" + /> + <gl-button category="primary" variant="success" class="gl-ml-3" @click="onSubmit"> + {{ s__('CompareRevisions|Compare') }} + </gl-button> + <a + v-if="projectMergeRequestPath" + :href="projectMergeRequestPath" + data-testid="projectMrButton" + class="btn btn-default gl-button gl-ml-3" + > + {{ s__('CompareRevisions|View open merge request') }} + </a> + <a + v-else-if="createMrPath" + :href="createMrPath" + data-testid="createMrButton" + class="btn btn-default gl-button gl-ml-3" + > + {{ s__('CompareRevisions|Create merge request') }} + </a> + </form> +</template> diff --git a/app/assets/javascripts/projects/compare/components/revision_dropdown.vue b/app/assets/javascripts/projects/compare/components/revision_dropdown.vue new file mode 100644 index 00000000000..f657f36322d --- /dev/null +++ b/app/assets/javascripts/projects/compare/components/revision_dropdown.vue @@ -0,0 +1,145 @@ +<script> +import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlDropdownSectionHeader } from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import createFlash from '~/flash'; +import { s__ } from '~/locale'; + +export default { + components: { + GlDropdown, + GlDropdownItem, + GlDropdownSectionHeader, + GlSearchBoxByType, + }, + props: { + refsProjectPath: { + type: String, + required: true, + }, + revisionText: { + type: String, + required: true, + }, + paramsName: { + type: String, + required: true, + }, + paramsBranch: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + branches: [], + tags: [], + loading: true, + searchTerm: '', + selectedRevision: this.getDefaultBranch(), + }; + }, + computed: { + filteredBranches() { + return this.branches.filter((branch) => + branch.toLowerCase().includes(this.searchTerm.toLowerCase()), + ); + }, + hasFilteredBranches() { + return this.filteredBranches.length; + }, + filteredTags() { + return this.tags.filter((tag) => tag.toLowerCase().includes(this.searchTerm.toLowerCase())); + }, + hasFilteredTags() { + return this.filteredTags.length; + }, + }, + mounted() { + this.fetchBranchesAndTags(); + }, + methods: { + fetchBranchesAndTags() { + const endpoint = this.refsProjectPath; + + return axios + .get(endpoint) + .then(({ data }) => { + this.branches = data.Branches; + this.tags = data.Tags; + }) + .catch(() => { + createFlash({ + message: `${s__( + 'CompareRevisions|There was an error while updating the branch/tag list. Please try again.', + )}`, + }); + }) + .finally(() => { + this.loading = false; + }); + }, + getDefaultBranch() { + return this.paramsBranch || s__('CompareRevisions|Select branch/tag'); + }, + onClick(revision) { + this.selectedRevision = revision; + }, + onSearchEnter() { + this.selectedRevision = this.searchTerm; + }, + }, +}; +</script> + +<template> + <div class="form-group compare-form-group" :class="`js-compare-${paramsName}-dropdown`"> + <div class="input-group inline-input-group"> + <span class="input-group-prepend"> + <div class="input-group-text"> + {{ revisionText }} + </div> + </span> + <input type="hidden" :name="paramsName" :value="selectedRevision" /> + <gl-dropdown + class="gl-flex-grow-1 gl-flex-basis-0 gl-min-w-0 gl-font-monospace" + toggle-class="form-control compare-dropdown-toggle js-compare-dropdown gl-min-w-0 gl-rounded-top-left-none! gl-rounded-bottom-left-none!" + :text="selectedRevision" + header-text="Select Git revision" + :loading="loading" + > + <template #header> + <gl-search-box-by-type + v-model.trim="searchTerm" + :placeholder="s__('CompareRevisions|Filter by Git revision')" + @keyup.enter="onSearchEnter" + /> + </template> + <gl-dropdown-section-header v-if="hasFilteredBranches"> + {{ s__('CompareRevisions|Branches') }} + </gl-dropdown-section-header> + <gl-dropdown-item + v-for="(branch, index) in filteredBranches" + :key="`branch${index}`" + is-check-item + :is-checked="selectedRevision === branch" + @click="onClick(branch)" + > + {{ branch }} + </gl-dropdown-item> + <gl-dropdown-section-header v-if="hasFilteredTags"> + {{ s__('CompareRevisions|Tags') }} + </gl-dropdown-section-header> + <gl-dropdown-item + v-for="(tag, index) in filteredTags" + :key="`tag${index}`" + is-check-item + :is-checked="selectedRevision === tag" + @click="onClick(tag)" + > + {{ tag }} + </gl-dropdown-item> + </gl-dropdown> + </div> + </div> +</template> diff --git a/app/assets/javascripts/projects/compare/index.js b/app/assets/javascripts/projects/compare/index.js new file mode 100644 index 00000000000..4337eecb667 --- /dev/null +++ b/app/assets/javascripts/projects/compare/index.js @@ -0,0 +1,33 @@ +import Vue from 'vue'; +import CompareApp from './components/app.vue'; + +export default function init() { + const el = document.getElementById('js-compare-selector'); + const { + refsProjectPath, + paramsFrom, + paramsTo, + projectCompareIndexPath, + projectMergeRequestPath, + createMrPath, + } = el.dataset; + + return new Vue({ + el, + components: { + CompareApp, + }, + render(createElement) { + return createElement(CompareApp, { + props: { + refsProjectPath, + paramsFrom, + paramsTo, + projectCompareIndexPath, + projectMergeRequestPath, + createMrPath, + }, + }); + }, + }); +} diff --git a/app/assets/javascripts/projects/components/project_delete_button.vue b/app/assets/javascripts/projects/components/project_delete_button.vue index 5429d51dae0..81d23a563e2 100644 --- a/app/assets/javascripts/projects/components/project_delete_button.vue +++ b/app/assets/javascripts/projects/components/project_delete_button.vue @@ -22,10 +22,10 @@ export default { strings: { alertTitle: __('You are about to permanently delete this project'), alertBody: __( - 'Once a project is permanently deleted it %{strongStart}cannot be recovered%{strongEnd}. Permanently deleting this project will %{strongStart}immediately delete%{strongEnd} its repositories and %{strongStart}all related resources%{strongEnd} including issues, merge requests etc.', + 'Once a project is permanently deleted, it %{strongStart}cannot be recovered%{strongEnd}. Permanently deleting this project will %{strongStart}immediately delete%{strongEnd} its repositories and %{strongStart}all related resources%{strongEnd}, including issues, merge requests etc.', ), modalBody: __( - "This action cannot be undone. You will lose this project's repository and all content: issues, merge requests, etc.", + "This action cannot be undone. You will lose this project's repository and all related resources, including issues, merge requests, etc.", ), }, }; diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue b/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue index b54f7051806..ec208b97986 100644 --- a/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue +++ b/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue @@ -1,14 +1,14 @@ <script> /* eslint-disable vue/no-v-html */ import { GlBreadcrumb, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; -import WelcomePage from './welcome.vue'; -import LegacyContainer from './legacy_container.vue'; import { __, s__ } from '~/locale'; import blankProjectIllustration from '../illustrations/blank-project.svg'; import createFromTemplateIllustration from '../illustrations/create-from-template.svg'; import importProjectIllustration from '../illustrations/import-project.svg'; import ciCdProjectIllustration from '../illustrations/ci-cd-project.svg'; +import LegacyContainer from './legacy_container.vue'; +import WelcomePage from './welcome.vue'; const BLANK_PANEL = 'blank_project'; const CI_CD_PANEL = 'cicd_for_external_repo'; diff --git a/app/assets/javascripts/projects/members/constants.js b/app/assets/javascripts/projects/members/constants.js new file mode 100644 index 00000000000..a69a64fe882 --- /dev/null +++ b/app/assets/javascripts/projects/members/constants.js @@ -0,0 +1 @@ +export const PROJECT_MEMBER_BASE_PROPERTY_NAME = 'project_member'; diff --git a/app/assets/javascripts/projects/members/utils.js b/app/assets/javascripts/projects/members/utils.js new file mode 100644 index 00000000000..cc27a43afa9 --- /dev/null +++ b/app/assets/javascripts/projects/members/utils.js @@ -0,0 +1,8 @@ +import { baseRequestFormatter } from '~/members/utils'; +import { MEMBER_ACCESS_LEVEL_PROPERTY_NAME } from '~/members/constants'; +import { PROJECT_MEMBER_BASE_PROPERTY_NAME } from './constants'; + +export const projectMemberRequestFormatter = baseRequestFormatter( + PROJECT_MEMBER_BASE_PROPERTY_NAME, + MEMBER_ACCESS_LEVEL_PROPERTY_NAME, +); diff --git a/app/assets/javascripts/projects/pipelines/charts/components/app.vue b/app/assets/javascripts/projects/pipelines/charts/components/app.vue index 7bb62cf4a73..7282ac85c70 100644 --- a/app/assets/javascripts/projects/pipelines/charts/components/app.vue +++ b/app/assets/javascripts/projects/pipelines/charts/components/app.vue @@ -1,44 +1,12 @@ <script> -import { GlAlert, GlTabs, GlTab } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import getPipelineCountByStatus from '../graphql/queries/get_pipeline_count_by_status.query.graphql'; -import getProjectPipelineStatistics from '../graphql/queries/get_project_pipeline_statistics.query.graphql'; +import { GlTabs, GlTab } from '@gitlab/ui'; +import { mergeUrlParams, updateHistory, getParameterValues } from '~/lib/utils/url_utility'; import PipelineCharts from './pipeline_charts.vue'; -import { - DEFAULT, - LOAD_ANALYTICS_FAILURE, - LOAD_PIPELINES_FAILURE, - PARSE_FAILURE, - UNSUPPORTED_DATA, -} from '../constants'; - -const defaultAnalyticsValues = { - weekPipelinesTotals: [], - weekPipelinesLabels: [], - weekPipelinesSuccessful: [], - monthPipelinesLabels: [], - monthPipelinesTotals: [], - monthPipelinesSuccessful: [], - yearPipelinesLabels: [], - yearPipelinesTotals: [], - yearPipelinesSuccessful: [], - pipelineTimesLabels: [], - pipelineTimesValues: [], -}; - -const defaultCountValues = { - totalPipelines: { - count: 0, - }, - successfulPipelines: { - count: 0, - }, -}; +const charts = ['pipelines', 'deployments']; export default { components: { - GlAlert, GlTabs, GlTab, PipelineCharts, @@ -50,171 +18,34 @@ export default { type: Boolean, default: false, }, - projectPath: { - type: String, - default: '', - }, }, data() { + const [chart] = getParameterValues('chart') || charts; + const tab = charts.indexOf(chart); return { - showFailureAlert: false, - failureType: null, - analytics: { ...defaultAnalyticsValues }, - counts: { ...defaultCountValues }, + chart, + selectedTab: tab >= 0 ? tab : 0, }; }, - apollo: { - counts: { - query: getPipelineCountByStatus, - variables() { - return { - projectPath: this.projectPath, - }; - }, - update(data) { - return data?.project; - }, - error() { - this.reportFailure(LOAD_PIPELINES_FAILURE); - }, - }, - analytics: { - query: getProjectPipelineStatistics, - variables() { - return { - projectPath: this.projectPath, - }; - }, - update(data) { - return data?.project?.pipelineAnalytics; - }, - error() { - this.reportFailure(LOAD_ANALYTICS_FAILURE); - }, - }, - }, - computed: { - failure() { - switch (this.failureType) { - case LOAD_ANALYTICS_FAILURE: - return { - text: this.$options.errorTexts[LOAD_ANALYTICS_FAILURE], - variant: 'danger', - }; - case PARSE_FAILURE: - return { - text: this.$options.errorTexts[PARSE_FAILURE], - variant: 'danger', - }; - case UNSUPPORTED_DATA: - return { - text: this.$options.errorTexts[UNSUPPORTED_DATA], - variant: 'info', - }; - default: - return { - text: this.$options.errorTexts[DEFAULT], - variant: 'danger', - }; - } - }, - lastWeekChartData() { - return { - labels: this.analytics.weekPipelinesLabels, - totals: this.analytics.weekPipelinesTotals, - success: this.analytics.weekPipelinesSuccessful, - }; - }, - lastMonthChartData() { - return { - labels: this.analytics.monthPipelinesLabels, - totals: this.analytics.monthPipelinesTotals, - success: this.analytics.monthPipelinesSuccessful, - }; - }, - lastYearChartData() { - return { - labels: this.analytics.yearPipelinesLabels, - totals: this.analytics.yearPipelinesTotals, - success: this.analytics.yearPipelinesSuccessful, - }; - }, - timesChartData() { - return { - labels: this.analytics.pipelineTimesLabels, - values: this.analytics.pipelineTimesValues, - }; - }, - successRatio() { - const { successfulPipelines, failedPipelines } = this.counts; - const successfulCount = successfulPipelines?.count; - const failedCount = failedPipelines?.count; - const ratio = (successfulCount / (successfulCount + failedCount)) * 100; - - return failedCount === 0 ? 100 : ratio; - }, - formattedCounts() { - const { totalPipelines, successfulPipelines, failedPipelines } = this.counts; - - return { - total: totalPipelines?.count, - success: successfulPipelines?.count, - failed: failedPipelines?.count, - successRatio: this.successRatio, - }; - }, - }, methods: { - hideAlert() { - this.showFailureAlert = false; + onTabChange(index) { + this.selectedTab = index; + const path = mergeUrlParams({ chart: charts[index] }, window.location.pathname); + updateHistory({ url: path }); }, - reportFailure(type) { - this.showFailureAlert = true; - this.failureType = type; - }, - }, - errorTexts: { - [LOAD_ANALYTICS_FAILURE]: s__( - 'PipelineCharts|An error has ocurred when retrieving the analytics data', - ), - [LOAD_PIPELINES_FAILURE]: s__( - 'PipelineCharts|An error has ocurred when retrieving the pipelines data', - ), - [PARSE_FAILURE]: s__('PipelineCharts|There was an error parsing the data for the charts.'), - [DEFAULT]: s__('PipelineCharts|An unknown error occurred while processing CI/CD analytics.'), }, }; </script> <template> <div> - <gl-alert v-if="showFailureAlert" :variant="failure.variant" @dismiss="hideAlert">{{ - failure.text - }}</gl-alert> - <gl-tabs v-if="shouldRenderDeploymentFrequencyCharts"> + <gl-tabs v-if="shouldRenderDeploymentFrequencyCharts" :value="selectedTab" @input="onTabChange"> <gl-tab :title="__('Pipelines')"> - <pipeline-charts - :counts="formattedCounts" - :last-week="lastWeekChartData" - :last-month="lastMonthChartData" - :last-year="lastYearChartData" - :times-chart="timesChartData" - :loading="$apollo.queries.counts.loading" - @report-failure="reportFailure" - /> + <pipeline-charts /> </gl-tab> <gl-tab :title="__('Deployments')"> <deployment-frequency-charts /> </gl-tab> </gl-tabs> - <pipeline-charts - v-else - :counts="formattedCounts" - :last-week="lastWeekChartData" - :last-month="lastMonthChartData" - :last-year="lastYearChartData" - :times-chart="timesChartData" - :loading="$apollo.queries.counts.loading" - @report-failure="reportFailure" - /> + <pipeline-charts v-else /> </div> </template> diff --git a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue index bec4ab407f0..86433e1b59f 100644 --- a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue +++ b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue @@ -1,10 +1,13 @@ <script> import dateFormat from 'dateformat'; import { GlColumnChart } from '@gitlab/ui/dist/charts'; -import { GlSkeletonLoader } from '@gitlab/ui'; +import { GlAlert, GlSkeletonLoader } from '@gitlab/ui'; import { __, s__, sprintf } from '~/locale'; import { getDateInPast } from '~/lib/utils/datetime_utility'; +import getPipelineCountByStatus from '../graphql/queries/get_pipeline_count_by_status.query.graphql'; +import getProjectPipelineStatistics from '../graphql/queries/get_project_pipeline_statistics.query.graphql'; import { + DEFAULT, CHART_CONTAINER_HEIGHT, CHART_DATE_FORMAT, INNER_CHART_HEIGHT, @@ -13,51 +16,167 @@ import { X_AXIS_LABEL_ROTATION, X_AXIS_TITLE_OFFSET, PARSE_FAILURE, + LOAD_ANALYTICS_FAILURE, + LOAD_PIPELINES_FAILURE, + UNSUPPORTED_DATA, } from '../constants'; import StatisticsList from './statistics_list.vue'; import CiCdAnalyticsAreaChart from './ci_cd_analytics_area_chart.vue'; +const defaultAnalyticsValues = { + weekPipelinesTotals: [], + weekPipelinesLabels: [], + weekPipelinesSuccessful: [], + monthPipelinesLabels: [], + monthPipelinesTotals: [], + monthPipelinesSuccessful: [], + yearPipelinesLabels: [], + yearPipelinesTotals: [], + yearPipelinesSuccessful: [], + pipelineTimesLabels: [], + pipelineTimesValues: [], +}; + +const defaultCountValues = { + totalPipelines: { + count: 0, + }, + successfulPipelines: { + count: 0, + }, +}; + export default { components: { + GlAlert, GlColumnChart, GlSkeletonLoader, StatisticsList, CiCdAnalyticsAreaChart, }, - props: { + inject: { + projectPath: { + type: String, + default: '', + }, + }, + data() { + return { + showFailureAlert: false, + failureType: null, + analytics: { ...defaultAnalyticsValues }, + counts: { ...defaultCountValues }, + }; + }, + apollo: { counts: { - required: true, - type: Object, + query: getPipelineCountByStatus, + variables() { + return { + projectPath: this.projectPath, + }; + }, + update(data) { + return data?.project; + }, + error() { + this.reportFailure(LOAD_PIPELINES_FAILURE); + }, }, - loading: { - required: false, - default: false, - type: Boolean, + analytics: { + query: getProjectPipelineStatistics, + variables() { + return { + projectPath: this.projectPath, + }; + }, + update(data) { + return data?.project?.pipelineAnalytics; + }, + error() { + this.reportFailure(LOAD_ANALYTICS_FAILURE); + }, }, - lastWeek: { - required: true, - type: Object, + }, + computed: { + loading() { + return this.$apollo.queries.counts.loading; }, - lastMonth: { - required: true, - type: Object, + failure() { + switch (this.failureType) { + case LOAD_ANALYTICS_FAILURE: + return { + text: this.$options.errorTexts[LOAD_ANALYTICS_FAILURE], + variant: 'danger', + }; + case PARSE_FAILURE: + return { + text: this.$options.errorTexts[PARSE_FAILURE], + variant: 'danger', + }; + case UNSUPPORTED_DATA: + return { + text: this.$options.errorTexts[UNSUPPORTED_DATA], + variant: 'info', + }; + default: + return { + text: this.$options.errorTexts[DEFAULT], + variant: 'danger', + }; + } }, - lastYear: { - required: true, - type: Object, + lastWeekChartData() { + return { + labels: this.analytics.weekPipelinesLabels, + totals: this.analytics.weekPipelinesTotals, + success: this.analytics.weekPipelinesSuccessful, + }; }, - timesChart: { - required: true, - type: Object, + lastMonthChartData() { + return { + labels: this.analytics.monthPipelinesLabels, + totals: this.analytics.monthPipelinesTotals, + success: this.analytics.monthPipelinesSuccessful, + }; + }, + lastYearChartData() { + return { + labels: this.analytics.yearPipelinesLabels, + totals: this.analytics.yearPipelinesTotals, + success: this.analytics.yearPipelinesSuccessful, + }; + }, + timesChartData() { + return { + labels: this.analytics.pipelineTimesLabels, + values: this.analytics.pipelineTimesValues, + }; + }, + successRatio() { + const { successfulPipelines, failedPipelines } = this.counts; + const successfulCount = successfulPipelines?.count; + const failedCount = failedPipelines?.count; + const ratio = (successfulCount / (successfulCount + failedCount)) * 100; + + return failedCount === 0 ? 100 : ratio; + }, + formattedCounts() { + const { totalPipelines, successfulPipelines, failedPipelines } = this.counts; + + return { + total: totalPipelines?.count, + success: successfulPipelines?.count, + failed: failedPipelines?.count, + successRatio: this.successRatio, + }; }, - }, - computed: { areaCharts() { const { lastWeek, lastMonth, lastYear } = this.$options.chartTitles; const charts = [ - { title: lastWeek, data: this.lastWeek }, - { title: lastMonth, data: this.lastMonth }, - { title: lastYear, data: this.lastYear }, + { title: lastWeek, data: this.lastWeekChartData }, + { title: lastMonth, data: this.lastMonthChartData }, + { title: lastYear, data: this.lastYearChartData }, ]; let areaChartsData = []; @@ -65,7 +184,7 @@ export default { areaChartsData = charts.map(this.buildAreaChartData); } catch { areaChartsData = []; - this.vm.$emit('report-failure', PARSE_FAILURE); + this.reportFailure(PARSE_FAILURE); } return areaChartsData; @@ -74,12 +193,19 @@ export default { return [ { name: 'full', - data: this.mergeLabelsAndValues(this.timesChart.labels, this.timesChart.values), + data: this.mergeLabelsAndValues(this.timesChartData.labels, this.timesChartData.values), }, ]; }, }, methods: { + hideAlert() { + this.showFailureAlert = false; + }, + reportFailure(type) { + this.showFailureAlert = true; + this.failureType = type; + }, mergeLabelsAndValues(labels, values) { return labels.map((label, index) => [label, values[index]]); }, @@ -118,8 +244,19 @@ export default { }, yAxis: { name: s__('Pipeline|Pipelines'), + minInterval: 1, }, }, + errorTexts: { + [LOAD_ANALYTICS_FAILURE]: s__( + 'PipelineCharts|An error has ocurred when retrieving the analytics data', + ), + [LOAD_PIPELINES_FAILURE]: s__( + 'PipelineCharts|An error has ocurred when retrieving the pipelines data', + ), + [PARSE_FAILURE]: s__('PipelineCharts|There was an error parsing the data for the charts.'), + [DEFAULT]: s__('PipelineCharts|An unknown error occurred while processing CI/CD analytics.'), + }, get chartTitles() { const today = dateFormat(new Date(), CHART_DATE_FORMAT); const pastDate = (timeScale) => @@ -140,6 +277,9 @@ export default { </script> <template> <div> + <gl-alert v-if="showFailureAlert" :variant="failure.variant" @dismiss="hideAlert">{{ + failure.text + }}</gl-alert> <div class="gl-mb-3"> <h3>{{ s__('PipelineCharts|CI / CD Analytics') }}</h3> </div> @@ -147,7 +287,7 @@ export default { <div class="row"> <div class="col-md-6"> <gl-skeleton-loader v-if="loading" :lines="5" /> - <statistics-list v-else :counts="counts" /> + <statistics-list v-else :counts="formattedCounts" /> </div> <div v-if="!loading" class="col-md-6"> <strong>{{ __('Duration for the last 30 commits') }}</strong> diff --git a/app/assets/javascripts/projects/settings/access_dropdown.js b/app/assets/javascripts/projects/settings/access_dropdown.js index a62b5d423de..e5946de3efd 100644 --- a/app/assets/javascripts/projects/settings/access_dropdown.js +++ b/app/assets/javascripts/projects/settings/access_dropdown.js @@ -3,8 +3,8 @@ import { escape, find, countBy } from 'lodash'; import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; import { n__, s__, __, sprintf } from '~/locale'; -import { LEVEL_TYPES, LEVEL_ID_PROP, ACCESS_LEVELS, ACCESS_LEVEL_NONE } from './constants'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; +import { LEVEL_TYPES, LEVEL_ID_PROP, ACCESS_LEVELS, ACCESS_LEVEL_NONE } from './constants'; export default class AccessDropdown { constructor(options) { diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue index 909f1afd9f6..7d570d01f85 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue @@ -1,61 +1,43 @@ <script> import { GlAlert } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; +import axios from '~/lib/utils/axios_utils'; import ServiceDeskSetting from './service_desk_setting.vue'; -import ServiceDeskService from '../services/service_desk_service'; -import eventHub from '../event_hub'; export default { - name: 'ServiceDeskRoot', components: { GlAlert, ServiceDeskSetting, }, - props: { + inject: { initialIsEnabled: { - type: Boolean, - required: true, + default: false, }, endpoint: { - type: String, - required: true, + default: '', }, - incomingEmail: { - type: String, - required: false, + initialIncomingEmail: { default: '', }, customEmail: { - type: String, - required: false, default: '', }, customEmailEnabled: { - type: Boolean, - required: false, + default: false, }, selectedTemplate: { - type: String, - required: false, default: '', }, outgoingName: { - type: String, - required: false, default: '', }, projectKey: { - type: String, - required: false, default: '', }, templates: { - type: Array, - required: false, - default: () => [], + default: [], }, }, - data() { return { isEnabled: this.initialIsEnabled, @@ -63,28 +45,21 @@ export default { isAlertShowing: false, alertVariant: 'danger', alertMessage: '', + incomingEmail: this.initialIncomingEmail, updatedCustomEmail: this.customEmail, }; }, - - created() { - eventHub.$on('serviceDeskEnabledCheckboxToggled', this.onEnableToggled); - eventHub.$on('serviceDeskTemplateSave', this.onSaveTemplate); - this.service = new ServiceDeskService(this.endpoint); - }, - - beforeDestroy() { - eventHub.$off('serviceDeskEnabledCheckboxToggled', this.onEnableToggled); - eventHub.$off('serviceDeskTemplateSave', this.onSaveTemplate); - }, - methods: { onEnableToggled(isChecked) { this.isEnabled = isChecked; this.incomingEmail = ''; - this.service - .toggleServiceDesk(isChecked) + const body = { + service_desk_enabled: isChecked, + }; + + return axios + .put(this.endpoint, body) .then(({ data }) => { const email = data.service_desk_address; if (isChecked && !email) { @@ -104,8 +79,16 @@ export default { onSaveTemplate({ selectedTemplate, outgoingName, projectKey }) { this.isTemplateSaving = true; - this.service - .updateTemplate({ selectedTemplate, outgoingName, projectKey }, this.isEnabled) + + const body = { + issue_template_key: selectedTemplate, + outgoing_name: outgoingName, + project_key: projectKey, + service_desk_enabled: this.isEnabled, + }; + + return axios + .put(this.endpoint, body) .then(({ data }) => { this.updatedCustomEmail = data?.service_desk_address; this.showAlert(__('Changes saved.'), 'success'); @@ -150,6 +133,8 @@ export default { :initial-project-key="projectKey" :templates="templates" :is-template-saving="isTemplateSaving" + @save="onSaveTemplate" + @toggle="onEnableToggled" /> </div> </template> diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue index a850374fc88..39d9a6a4239 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue @@ -1,12 +1,9 @@ <script> import { GlButton, GlFormSelect, GlToggle, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; import { __ } from '~/locale'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import eventHub from '../event_hub'; export default { - name: 'ServiceDeskSetting', components: { ClipboardButton, GlButton, @@ -15,7 +12,6 @@ export default { GlLoadingIcon, GlSprintf, }, - mixins: [glFeatureFlagsMixin()], props: { isEnabled: { type: Boolean, @@ -84,10 +80,10 @@ export default { }, methods: { onCheckboxToggle(isChecked) { - eventHub.$emit('serviceDeskEnabledCheckboxToggled', isChecked); + this.$emit('toggle', isChecked); }, onSaveTemplate() { - eventHub.$emit('serviceDeskTemplateSave', { + this.$emit('save', { selectedTemplate: this.selectedTemplate, outgoingName: this.outgoingName, projectKey: this.projectKey, @@ -111,7 +107,11 @@ export default { </label> <div v-if="isEnabled" class="row mt-3"> <div class="col-md-9 mb-0"> - <strong id="incoming-email-describer" class="d-block mb-1"> + <strong + id="incoming-email-describer" + class="gl-display-block gl-mb-1" + data-testid="incoming-email-describer" + > {{ __('Email address to use for Support Desk') }} </strong> <template v-if="email"> @@ -128,11 +128,7 @@ export default { disabled="true" /> <div class="input-group-append"> - <clipboard-button - :title="__('Copy')" - :text="email" - css-class="input-group-text qa-clipboard-button" - /> + <clipboard-button :title="__('Copy')" :text="email" css-class="input-group-text" /> </div> </div> <span v-if="hasCustomEmail" class="form-text text-muted"> diff --git a/app/assets/javascripts/projects/settings_service_desk/event_hub.js b/app/assets/javascripts/projects/settings_service_desk/event_hub.js deleted file mode 100644 index e31806ad199..00000000000 --- a/app/assets/javascripts/projects/settings_service_desk/event_hub.js +++ /dev/null @@ -1,3 +0,0 @@ -import createEventHub from '~/helpers/event_hub_factory'; - -export default createEventHub(); diff --git a/app/assets/javascripts/projects/settings_service_desk/index.js b/app/assets/javascripts/projects/settings_service_desk/index.js index 8f9828dd73d..f842ffaaa2b 100644 --- a/app/assets/javascripts/projects/settings_service_desk/index.js +++ b/app/assets/javascripts/projects/settings_service_desk/index.js @@ -3,43 +3,37 @@ import { parseBoolean } from '~/lib/utils/common_utils'; import ServiceDeskRoot from './components/service_desk_root.vue'; export default () => { - const serviceDeskRootElement = document.querySelector('.js-service-desk-setting-root'); - if (serviceDeskRootElement) { - // eslint-disable-next-line no-new - new Vue({ - el: serviceDeskRootElement, - components: { - ServiceDeskRoot, - }, - data() { - const { dataset } = serviceDeskRootElement; - return { - initialIsEnabled: parseBoolean(dataset.enabled), - endpoint: dataset.endpoint, - incomingEmail: dataset.incomingEmail, - customEmail: dataset.customEmail, - customEmailEnabled: parseBoolean(dataset.customEmailEnabled), - selectedTemplate: dataset.selectedTemplate, - outgoingName: dataset.outgoingName, - projectKey: dataset.projectKey, - templates: JSON.parse(dataset.templates), - }; - }, - render(createElement) { - return createElement('service-desk-root', { - props: { - initialIsEnabled: this.initialIsEnabled, - endpoint: this.endpoint, - incomingEmail: this.incomingEmail, - customEmail: this.customEmail, - customEmailEnabled: this.customEmailEnabled, - selectedTemplate: this.selectedTemplate, - outgoingName: this.outgoingName, - projectKey: this.projectKey, - templates: this.templates, - }, - }); - }, - }); + const el = document.querySelector('.js-service-desk-setting-root'); + + if (!el) { + return false; } + + const { + customEmail, + customEmailEnabled, + enabled, + endpoint, + incomingEmail, + outgoingName, + projectKey, + selectedTemplate, + templates, + } = el.dataset; + + return new Vue({ + el, + provide: { + customEmail, + customEmailEnabled: parseBoolean(customEmailEnabled), + endpoint, + initialIncomingEmail: incomingEmail, + initialIsEnabled: parseBoolean(enabled), + outgoingName, + projectKey, + selectedTemplate, + templates: JSON.parse(templates), + }, + render: (createElement) => createElement(ServiceDeskRoot), + }); }; diff --git a/app/assets/javascripts/projects/settings_service_desk/services/service_desk_service.js b/app/assets/javascripts/projects/settings_service_desk/services/service_desk_service.js deleted file mode 100644 index b68c5bb876f..00000000000 --- a/app/assets/javascripts/projects/settings_service_desk/services/service_desk_service.js +++ /dev/null @@ -1,23 +0,0 @@ -import axios from '~/lib/utils/axios_utils'; - -class ServiceDeskService { - constructor(endpoint) { - this.endpoint = endpoint; - } - - toggleServiceDesk(enable) { - return axios.put(this.endpoint, { service_desk_enabled: enable }); - } - - updateTemplate({ selectedTemplate, outgoingName, projectKey = '' }, isEnabled) { - const body = { - issue_template_key: selectedTemplate, - outgoing_name: outgoingName, - project_key: projectKey, - service_desk_enabled: isEnabled, - }; - return axios.put(this.endpoint, body); - } -} - -export default ServiceDeskService; diff --git a/app/assets/javascripts/prometheus_metrics/custom_metrics.js b/app/assets/javascripts/prometheus_metrics/custom_metrics.js index e891b8bf3b6..f829b080224 100644 --- a/app/assets/javascripts/prometheus_metrics/custom_metrics.js +++ b/app/assets/javascripts/prometheus_metrics/custom_metrics.js @@ -1,10 +1,10 @@ import $ from 'jquery'; import { escape, sortBy } from 'lodash'; -import PrometheusMetrics from './prometheus_metrics'; -import PANEL_STATE from './constants'; import axios from '~/lib/utils/axios_utils'; import { s__ } from '~/locale'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import PANEL_STATE from './constants'; +import PrometheusMetrics from './prometheus_metrics'; export default class CustomMetrics extends PrometheusMetrics { constructor(wrapperSelector) { diff --git a/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js b/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js index 57f9cec9682..821de0560cd 100644 --- a/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js +++ b/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js @@ -2,8 +2,8 @@ import $ from 'jquery'; import { escape } from 'lodash'; import { s__, n__, sprintf } from '~/locale'; import axios from '../lib/utils/axios_utils'; -import PANEL_STATE from './constants'; import { backOff } from '../lib/utils/common_utils'; +import PANEL_STATE from './constants'; export default class PrometheusMetrics { constructor(wrapperSelector) { diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js index a5c7b18f709..a1f79084292 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_create.js +++ b/app/assets/javascripts/protected_branches/protected_branch_create.js @@ -4,8 +4,8 @@ import axios from '~/lib/utils/axios_utils'; import AccessorUtilities from '~/lib/utils/accessor'; import { deprecatedCreateFlash as Flash } from '~/flash'; import CreateItemDropdown from '~/create_item_dropdown'; -import { ACCESS_LEVELS, LEVEL_TYPES } from './constants'; import { __ } from '~/locale'; +import { ACCESS_LEVELS, LEVEL_TYPES } from './constants'; export default class ProtectedBranchCreate { constructor(options) { diff --git a/app/assets/javascripts/protected_branches/protected_branch_edit.js b/app/assets/javascripts/protected_branches/protected_branch_edit.js index f5f27b67c71..8316b10d49c 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_edit.js +++ b/app/assets/javascripts/protected_branches/protected_branch_edit.js @@ -1,9 +1,9 @@ import { find } from 'lodash'; import AccessDropdown from '~/projects/settings/access_dropdown'; import axios from '~/lib/utils/axios_utils'; -import { ACCESS_LEVELS, LEVEL_TYPES } from './constants'; -import { deprecatedCreateFlash as flash } from '../flash'; import { __ } from '~/locale'; +import { deprecatedCreateFlash as flash } from '../flash'; +import { ACCESS_LEVELS, LEVEL_TYPES } from './constants'; export default class ProtectedBranchEdit { constructor(options) { diff --git a/app/assets/javascripts/protected_tags/protected_tag_create.js b/app/assets/javascripts/protected_tags/protected_tag_create.js index eb44f0c67fd..e3f427b8408 100644 --- a/app/assets/javascripts/protected_tags/protected_tag_create.js +++ b/app/assets/javascripts/protected_tags/protected_tag_create.js @@ -1,7 +1,7 @@ import $ from 'jquery'; -import ProtectedTagAccessDropdown from './protected_tag_access_dropdown'; -import CreateItemDropdown from '../create_item_dropdown'; import { __ } from '~/locale'; +import CreateItemDropdown from '../create_item_dropdown'; +import ProtectedTagAccessDropdown from './protected_tag_access_dropdown'; export default class ProtectedTagCreate { constructor() { diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit.js b/app/assets/javascripts/protected_tags/protected_tag_edit.js index 157ac1c7ebd..59aa634872f 100644 --- a/app/assets/javascripts/protected_tags/protected_tag_edit.js +++ b/app/assets/javascripts/protected_tags/protected_tag_edit.js @@ -1,7 +1,7 @@ +import { __ } from '~/locale'; import { deprecatedCreateFlash as flash } from '../flash'; import axios from '../lib/utils/axios_utils'; import ProtectedTagAccessDropdown from './protected_tag_access_dropdown'; -import { __ } from '~/locale'; export default class ProtectedTagEdit { constructor(options) { diff --git a/app/assets/javascripts/ref/stores/mutations.js b/app/assets/javascripts/ref/stores/mutations.js index 75026a40175..4dc73dabfe2 100644 --- a/app/assets/javascripts/ref/stores/mutations.js +++ b/app/assets/javascripts/ref/stores/mutations.js @@ -1,7 +1,7 @@ -import * as types from './mutation_types'; -import { X_TOTAL_HEADER } from '../constants'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import httpStatusCodes from '~/lib/utils/http_status'; +import { X_TOTAL_HEADER } from '../constants'; +import * as types from './mutation_types'; export default { [types.SET_PROJECT_ID](state, projectId) { diff --git a/app/assets/javascripts/registry/explorer/components/delete_image.vue b/app/assets/javascripts/registry/explorer/components/delete_image.vue new file mode 100644 index 00000000000..62e287429ea --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/delete_image.vue @@ -0,0 +1,77 @@ +<script> +import { produce } from 'immer'; +import deleteContainerRepositoryMutation from '../graphql/mutations/delete_container_repository.mutation.graphql'; +import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container_repository_details.query.graphql'; + +import { GRAPHQL_PAGE_SIZE } from '../constants/index'; + +export default { + props: { + id: { + type: String, + required: false, + default: null, + }, + useUpdateFn: { + type: Boolean, + required: false, + default: false, + }, + }, + methods: { + updateImageStatus(store, { data: { destroyContainerRepository } }) { + const variables = { + id: this.id, + first: GRAPHQL_PAGE_SIZE, + }; + const sourceData = store.readQuery({ + query: getContainerRepositoryDetailsQuery, + variables, + }); + + const data = produce(sourceData, (draftState) => { + // eslint-disable-next-line no-param-reassign + draftState.containerRepository.status = + destroyContainerRepository.containerRepository.status; + }); + + store.writeQuery({ + query: getContainerRepositoryDetailsQuery, + variables, + data, + }); + }, + doDelete() { + this.$emit('start'); + return this.$apollo + .mutate({ + mutation: deleteContainerRepositoryMutation, + variables: { + id: this.id, + }, + update: this.useUpdateFn ? this.updateImageStatus : undefined, + }) + .then(({ data }) => { + if (data?.destroyContainerRepository?.errors[0]) { + this.$emit('error', data?.destroyContainerRepository?.errors); + return; + } + this.$emit('success'); + }) + .catch((e) => { + // note: we are adding an array to follow the same format of the error raised above + this.$emit('error', [e]); + }) + .finally(() => { + this.$emit('end'); + }); + }, + }, + render() { + if (this.$scopedSlots?.default) { + return this.$scopedSlots.default({ doDelete: this.doDelete }); + } + return null; + }, +}; +</script> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/empty_state.vue b/app/assets/javascripts/registry/explorer/components/details_page/empty_state.vue new file mode 100644 index 00000000000..a16d95a6b30 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/details_page/empty_state.vue @@ -0,0 +1,44 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import { + NO_TAGS_TITLE, + NO_TAGS_MESSAGE, + MISSING_OR_DELETED_IMAGE_TITLE, + MISSING_OR_DELETED_IMAGE_MESSAGE, +} from '../../constants/index'; + +export default { + components: { + GlEmptyState, + }, + props: { + noContainersImage: { + type: String, + required: false, + default: '', + }, + isEmptyImage: { + type: Boolean, + default: false, + required: false, + }, + }, + computed: { + title() { + return this.isEmptyImage ? MISSING_OR_DELETED_IMAGE_TITLE : NO_TAGS_TITLE; + }, + description() { + return this.isEmptyImage ? MISSING_OR_DELETED_IMAGE_MESSAGE : NO_TAGS_MESSAGE; + }, + }, +}; +</script> + +<template> + <gl-empty-state + :title="title" + :svg-path="noContainersImage" + :description="description" + class="gl-mx-auto gl-my-0" + /> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/empty_tags_state.vue b/app/assets/javascripts/registry/explorer/components/details_page/empty_tags_state.vue deleted file mode 100644 index 0c684d124d5..00000000000 --- a/app/assets/javascripts/registry/explorer/components/details_page/empty_tags_state.vue +++ /dev/null @@ -1,33 +0,0 @@ -<script> -import { GlEmptyState } from '@gitlab/ui'; -import { - EMPTY_IMAGE_REPOSITORY_TITLE, - EMPTY_IMAGE_REPOSITORY_MESSAGE, -} from '../../constants/index'; - -export default { - components: { - GlEmptyState, - }, - props: { - noContainersImage: { - type: String, - required: false, - default: '', - }, - }, - i18n: { - EMPTY_IMAGE_REPOSITORY_TITLE, - EMPTY_IMAGE_REPOSITORY_MESSAGE, - }, -}; -</script> - -<template> - <gl-empty-state - :title="$options.i18n.EMPTY_IMAGE_REPOSITORY_TITLE" - :svg-path="noContainersImage" - :description="$options.i18n.EMPTY_IMAGE_REPOSITORY_MESSAGE" - class="gl-mx-auto gl-my-0" - /> -</template> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue index 1e0736c4a53..9a4ae41d275 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue +++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue @@ -1,7 +1,7 @@ <script> import { GlButton } from '@gitlab/ui'; -import TagsListRow from './tags_list_row.vue'; import { REMOVE_TAGS_BUTTON_TITLE, TAGS_LIST_TITLE } from '../../constants/index'; +import TagsListRow from './tags_list_row.vue'; export default { name: 'TagsList', diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue index 2e4a489f2cb..ed4fea0c88e 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue +++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue @@ -6,8 +6,8 @@ import { numberToHumanSize } from '~/lib/utils/number_utils'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { formatDate } from '~/lib/utils/datetime_utility'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; -import DeleteButton from '../delete_button.vue'; import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; +import DeleteButton from '../delete_button.vue'; import { REMOVE_TAG_BUTTON_TITLE, DIGEST_LABEL, diff --git a/app/assets/javascripts/registry/explorer/constants/details.js b/app/assets/javascripts/registry/explorer/constants/details.js index b5627352857..bb084e813af 100644 --- a/app/assets/javascripts/registry/explorer/constants/details.js +++ b/app/assets/javascripts/registry/explorer/constants/details.js @@ -1,4 +1,5 @@ import { s__, __ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; // Translations strings export const DETAILS_PAGE_TITLE = s__('ContainerRegistry|%{imageName} tags'); @@ -32,18 +33,30 @@ export const CONFIGURATION_DETAILS_ROW_TEST = s__( export const REMOVE_TAG_BUTTON_TITLE = s__('ContainerRegistry|Remove tag'); export const REMOVE_TAGS_BUTTON_TITLE = s__('ContainerRegistry|Delete selected'); + export const REMOVE_TAG_CONFIRMATION_TEXT = s__( `ContainerRegistry|You are about to remove %{item}. Are you sure?`, ); export const REMOVE_TAGS_CONFIRMATION_TEXT = s__( `ContainerRegistry|You are about to remove %{item} tags. Are you sure?`, ); -export const EMPTY_IMAGE_REPOSITORY_TITLE = s__('ContainerRegistry|This image has no active tags'); -export const EMPTY_IMAGE_REPOSITORY_MESSAGE = s__( +export const NO_TAGS_TITLE = s__('ContainerRegistry|This image has no active tags'); +export const NO_TAGS_MESSAGE = s__( `ContainerRegistry|The last tag related to this image was recently removed. This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process. If you have any questions, contact your administrator.`, ); + +export const MISSING_OR_DELETED_IMAGE_TITLE = s__( + 'ContainerRegistry|The image repository could not be found.', +); +export const MISSING_OR_DELETED_IMAGE_MESSAGE = s__( + 'ContainerRegistry|The requested image repository does not exist or has been deleted. If you think this is an error, try refreshing the page.', +); +export const MISSING_OR_DELETE_IMAGE_BREADCRUMB = s__( + 'ContainerRegistry|Image repository not found', +); + export const ADMIN_GARBAGE_COLLECTION_TIP = s__( 'ContainerRegistry|Remember to run %{docLinkStart}garbage collection%{docLinkEnd} to remove the stale data from storage.', ); @@ -76,6 +89,29 @@ export const CLEANUP_DISABLED_TOOLTIP = s__( 'ContainerRegistry|Cleanup is disabled for this project', ); +export const DETAILS_DELETE_IMAGE_ERROR_MESSAGE = s__( + 'ContainerRegistry|Something went wrong while scheduling the image for deletion.', +); + +export const DELETE_IMAGE_CONFIRMATION_TITLE = s__('ContainerRegistry|Delete image repository?'); +export const DELETE_IMAGE_CONFIRMATION_TEXT = s__( + 'ContainerRegistry|Deleting the image repository will delete all images and tags inside. This action cannot be undone.', +); + +export const SCHEDULED_FOR_DELETION_STATUS_TITLE = s__( + 'ContainerRegistry|Image repository will be deleted', +); +export const SCHEDULED_FOR_DELETION_STATUS_MESSAGE = s__( + 'ContainerRegistry|This image repository will be deleted. %{linkStart}Learn more.%{linkEnd}', +); + +export const FAILED_DELETION_STATUS_TITLE = s__( + 'ContainerRegistry|Image repository deletion failed', +); +export const FAILED_DELETION_STATUS_MESSAGE = s__( + 'ContainerRegistry|This image repository has failed to be deleted', +); + // Parameters export const DEFAULT_PAGE = 1; @@ -85,15 +121,39 @@ export const ALERT_SUCCESS_TAG = 'success_tag'; export const ALERT_DANGER_TAG = 'danger_tag'; export const ALERT_SUCCESS_TAGS = 'success_tags'; export const ALERT_DANGER_TAGS = 'danger_tags'; +export const ALERT_DANGER_IMAGE = 'danger_image'; + +export const DELETE_SCHEDULED = 'DELETE_SCHEDULED'; +export const DELETE_FAILED = 'DELETE_FAILED'; export const ALERT_MESSAGES = { [ALERT_SUCCESS_TAG]: DELETE_TAG_SUCCESS_MESSAGE, [ALERT_DANGER_TAG]: DELETE_TAG_ERROR_MESSAGE, [ALERT_SUCCESS_TAGS]: DELETE_TAGS_SUCCESS_MESSAGE, [ALERT_DANGER_TAGS]: DELETE_TAGS_ERROR_MESSAGE, + [ALERT_DANGER_IMAGE]: DETAILS_DELETE_IMAGE_ERROR_MESSAGE, }; export const UNFINISHED_STATUS = 'UNFINISHED'; export const UNSCHEDULED_STATUS = 'UNSCHEDULED'; export const SCHEDULED_STATUS = 'SCHEDULED'; export const ONGOING_STATUS = 'ONGOING'; + +export const IMAGE_STATUS_TITLES = { + [DELETE_SCHEDULED]: SCHEDULED_FOR_DELETION_STATUS_TITLE, + [DELETE_FAILED]: FAILED_DELETION_STATUS_TITLE, +}; + +export const IMAGE_STATUS_MESSAGES = { + [DELETE_SCHEDULED]: SCHEDULED_FOR_DELETION_STATUS_MESSAGE, + [DELETE_FAILED]: FAILED_DELETION_STATUS_MESSAGE, +}; + +export const IMAGE_STATUS_ALERT_TYPE = { + [DELETE_SCHEDULED]: 'info', + [DELETE_FAILED]: 'warning', +}; + +export const PACKAGE_DELETE_HELP_PAGE_PATH = helpPagePath('user/packages/container_registry', { + anchor: 'delete-images', +}); diff --git a/app/assets/javascripts/registry/explorer/index.js b/app/assets/javascripts/registry/explorer/index.js index a3890ab5c42..0ddbe50441b 100644 --- a/app/assets/javascripts/registry/explorer/index.js +++ b/app/assets/javascripts/registry/explorer/index.js @@ -29,7 +29,14 @@ export default () => { return null; } - const { endpoint, expirationPolicy, isGroupPage, isAdmin, ...config } = el.dataset; + const { + endpoint, + expirationPolicy, + isGroupPage, + isAdmin, + showUnfinishedTagCleanupCallout, + ...config + } = el.dataset; // This is a mini state to help the breadcrumb have the correct name in the details page const breadCrumbState = Vue.observable({ @@ -57,6 +64,7 @@ export default () => { expirationPolicy: expirationPolicy ? JSON.parse(expirationPolicy) : undefined, isGroupPage: parseBoolean(isGroupPage), isAdmin: parseBoolean(isAdmin), + showUnfinishedTagCleanupCallout: parseBoolean(showUnfinishedTagCleanupCallout), }, /* eslint-disable @gitlab/require-i18n-strings */ dockerBuildCommand: `docker build -t ${config.repositoryUrl} .`, diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue index 0894fd6fcfa..0cf83d9c62e 100644 --- a/app/assets/javascripts/registry/explorer/pages/details.vue +++ b/app/assets/javascripts/registry/explorer/pages/details.vue @@ -3,6 +3,7 @@ import { GlKeysetPagination, GlResizeObserverDirective } from '@gitlab/ui'; import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; import createFlash from '~/flash'; import Tracking from '~/tracking'; +import axios from '~/lib/utils/axios_utils'; import { joinPaths } from '~/lib/utils/url_utility'; import DeleteAlert from '../components/details_page/delete_alert.vue'; import PartialCleanupAlert from '../components/details_page/partial_cleanup_alert.vue'; @@ -10,7 +11,7 @@ import DeleteModal from '../components/details_page/delete_modal.vue'; import DetailsHeader from '../components/details_page/details_header.vue'; import TagsList from '../components/details_page/tags_list.vue'; import TagsLoader from '../components/details_page/tags_loader.vue'; -import EmptyTagsState from '../components/details_page/empty_tags_state.vue'; +import EmptyState from '../components/details_page/empty_state.vue'; import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container_repository_details.query.graphql'; import deleteContainerRepositoryTagsMutation from '../graphql/mutations/delete_container_repository_tags.mutation.graphql'; @@ -23,6 +24,7 @@ import { GRAPHQL_PAGE_SIZE, FETCH_IMAGES_LIST_ERROR_MESSAGE, UNFINISHED_STATUS, + MISSING_OR_DELETE_IMAGE_BREADCRUMB, } from '../constants/index'; export default { @@ -35,7 +37,7 @@ export default { DeleteModal, TagsList, TagsLoader, - EmptyTagsState, + EmptyState, }, directives: { GlResizeObserver: GlResizeObserverDirective, @@ -53,7 +55,7 @@ export default { }, result({ data }) { this.tagsPageInfo = data.containerRepository?.tags?.pageInfo; - this.breadCrumbState.updateName(data.containerRepository?.name); + this.updateBreadcrumb(); }, error() { createFlash({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE }); @@ -68,7 +70,7 @@ export default { isMobile: false, mutationLoading: false, deleteAlertType: null, - dismissPartialCleanupWarning: false, + hidePartialCleanupWarning: false, }; }, computed: { @@ -86,8 +88,9 @@ export default { }, showPartialCleanupWarning() { return ( + this.config.showUnfinishedTagCleanupCallout && this.image?.expirationPolicyCleanupStatus === UNFINISHED_STATUS && - !this.dismissPartialCleanupWarning + !this.hidePartialCleanupWarning ); }, tracking() { @@ -99,8 +102,15 @@ export default { showPagination() { return this.tagsPageInfo.hasPreviousPage || this.tagsPageInfo.hasNextPage; }, + hasNoTags() { + return this.tags.length === 0; + }, }, methods: { + updateBreadcrumb() { + const name = this.image?.name || MISSING_OR_DELETE_IMAGE_BREADCRUMB; + this.breadCrumbState.updateName(name); + }, deleteTags(toBeDeleted) { this.itemsToBeDeleted = this.tags.filter((tag) => toBeDeleted[tag.name]); this.track('click_button'); @@ -168,51 +178,60 @@ export default { }); } }, + dismissPartialCleanupWarning() { + this.hidePartialCleanupWarning = true; + axios.post(this.config.userCalloutsPath, { + feature_name: this.config.userCalloutId, + }); + }, }, }; </script> <template> <div v-gl-resize-observer="handleResize" class="gl-my-3"> - <delete-alert - v-model="deleteAlertType" - :garbage-collection-help-page-path="config.garbageCollectionHelpPagePath" - :is-admin="config.isAdmin" - class="gl-my-2" - /> + <template v-if="image"> + <delete-alert + v-model="deleteAlertType" + :garbage-collection-help-page-path="config.garbageCollectionHelpPagePath" + :is-admin="config.isAdmin" + class="gl-my-2" + /> - <partial-cleanup-alert - v-if="showPartialCleanupWarning" - :run-cleanup-policies-help-page-path="config.runCleanupPoliciesHelpPagePath" - :cleanup-policies-help-page-path="config.cleanupPoliciesHelpPagePath" - @dismiss="dismissPartialCleanupWarning = true" - /> + <partial-cleanup-alert + v-if="showPartialCleanupWarning" + :run-cleanup-policies-help-page-path="config.runCleanupPoliciesHelpPagePath" + :cleanup-policies-help-page-path="config.cleanupPoliciesHelpPagePath" + @dismiss="dismissPartialCleanupWarning" + /> - <details-header :image="image" :metadata-loading="isLoading" /> + <details-header :image="image" :metadata-loading="isLoading" /> - <tags-loader v-if="isLoading" /> - <template v-else> - <empty-tags-state v-if="tags.length === 0" :no-containers-image="config.noContainersImage" /> + <tags-loader v-if="isLoading" /> <template v-else> - <tags-list :tags="tags" :is-mobile="isMobile" @delete="deleteTags" /> - <div class="gl-display-flex gl-justify-content-center"> - <gl-keyset-pagination - v-if="showPagination" - :has-next-page="tagsPageInfo.hasNextPage" - :has-previous-page="tagsPageInfo.hasPreviousPage" - class="gl-mt-3" - @prev="fetchPreviousPage" - @next="fetchNextPage" - /> - </div> + <empty-state v-if="hasNoTags" :no-containers-image="config.noContainersImage" /> + <template v-else> + <tags-list :tags="tags" :is-mobile="isMobile" @delete="deleteTags" /> + <div class="gl-display-flex gl-justify-content-center"> + <gl-keyset-pagination + v-if="showPagination" + :has-next-page="tagsPageInfo.hasNextPage" + :has-previous-page="tagsPageInfo.hasPreviousPage" + class="gl-mt-3" + @prev="fetchPreviousPage" + @next="fetchNextPage" + /> + </div> + </template> </template> - </template> - <delete-modal - ref="deleteModal" - :items-to-be-deleted="itemsToBeDeleted" - @confirmDelete="handleDelete" - @cancel="track('cancel_delete')" - /> + <delete-modal + ref="deleteModal" + :items-to-be-deleted="itemsToBeDeleted" + @confirmDelete="handleDelete" + @cancel="track('cancel_delete')" + /> + </template> + <empty-state v-else is-empty-image :no-containers-image="config.noContainersImage" /> </div> </template> diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue index 336a997d629..d362b79789b 100644 --- a/app/assets/javascripts/registry/explorer/pages/list.vue +++ b/app/assets/javascripts/registry/explorer/pages/list.vue @@ -14,9 +14,9 @@ import getContainerRepositoriesQuery from 'shared_queries/container_registry/get import Tracking from '~/tracking'; import createFlash from '~/flash'; import RegistryHeader from '../components/list_page/registry_header.vue'; +import DeleteImage from '../components/delete_image.vue'; import getContainerRepositoriesDetails from '../graphql/queries/get_container_repositories_details.query.graphql'; -import deleteContainerRepositoryMutation from '../graphql/mutations/delete_container_repository.mutation.graphql'; import { DELETE_IMAGE_SUCCESS_MESSAGE, @@ -60,6 +60,7 @@ export default { GlSkeletonLoader, GlSearchBoxByClick, RegistryHeader, + DeleteImage, }, directives: { GlTooltip: GlTooltipDirective, @@ -179,30 +180,6 @@ export default { this.itemToDelete = item; this.$refs.deleteModal.show(); }, - handleDeleteImage() { - this.track('confirm_delete'); - this.mutationLoading = true; - return this.$apollo - .mutate({ - mutation: deleteContainerRepositoryMutation, - variables: { - id: this.itemToDelete.id, - }, - }) - .then(({ data }) => { - if (data?.destroyContainerRepository?.errors[0]) { - this.deleteAlertType = 'danger'; - } else { - this.deleteAlertType = 'success'; - } - }) - .catch(() => { - this.deleteAlertType = 'danger'; - }) - .finally(() => { - this.mutationLoading = false; - }); - }, dismissDeleteAlert() { this.deleteAlertType = null; this.itemToDelete = {}; @@ -250,6 +227,10 @@ export default { }); } }, + startDelete() { + this.track('confirm_delete'); + this.mutationLoading = true; + }, }, }; </script> @@ -358,23 +339,32 @@ export default { </template> </template> - <gl-modal - ref="deleteModal" - modal-id="delete-image-modal" - ok-variant="danger" - @ok="handleDeleteImage" - @cancel="track('cancel_delete')" + <delete-image + :id="itemToDelete.id" + @start="startDelete" + @error="deleteAlertType = 'danger'" + @success="deleteAlertType = 'success'" + @end="mutationLoading = false" > - <template #modal-title>{{ $options.i18n.REMOVE_REPOSITORY_LABEL }}</template> - <p> - <gl-sprintf :message="$options.i18n.REMOVE_REPOSITORY_MODAL_TEXT"> - <template #title> - <b>{{ itemToDelete.path }}</b> - </template> - </gl-sprintf> - </p> - <template #modal-ok>{{ __('Remove') }}</template> - </gl-modal> + <template #default="{ doDelete }"> + <gl-modal + ref="deleteModal" + modal-id="delete-image-modal" + :action-primary="{ text: __('Remove'), attributes: { variant: 'danger' } }" + @primary="doDelete" + @cancel="track('cancel_delete')" + > + <template #modal-title>{{ $options.i18n.REMOVE_REPOSITORY_LABEL }}</template> + <p> + <gl-sprintf :message="$options.i18n.REMOVE_REPOSITORY_MODAL_TEXT"> + <template #title> + <b>{{ itemToDelete.path }}</b> + </template> + </gl-sprintf> + </p> + </gl-modal> + </template> + </delete-image> </template> </div> </template> diff --git a/app/assets/javascripts/related_issues/components/add_issuable_form.vue b/app/assets/javascripts/related_issues/components/add_issuable_form.vue index 6fbae95094a..8d412e14b47 100644 --- a/app/assets/javascripts/related_issues/components/add_issuable_form.vue +++ b/app/assets/javascripts/related_issues/components/add_issuable_form.vue @@ -1,7 +1,6 @@ <script> import { GlFormGroup, GlFormRadioGroup, GlButton } from '@gitlab/ui'; import { __ } from '~/locale'; -import RelatedIssuableInput from './related_issuable_input.vue'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import { @@ -11,6 +10,7 @@ import { addRelatedIssueErrorMap, addRelatedItemErrorMap, } from '../constants'; +import RelatedIssuableInput from './related_issuable_input.vue'; export default { name: 'AddIssuableForm', diff --git a/app/assets/javascripts/related_issues/components/related_issuable_input.vue b/app/assets/javascripts/related_issues/components/related_issuable_input.vue index a124b055e19..2dc56c3110b 100644 --- a/app/assets/javascripts/related_issues/components/related_issuable_input.vue +++ b/app/assets/javascripts/related_issues/components/related_issuable_input.vue @@ -1,13 +1,13 @@ <script> import $ from 'jquery'; import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; -import issueToken from './issue_token.vue'; import { autoCompleteTextMap, inputPlaceholderConfidentialTextMap, inputPlaceholderTextMap, issuableTypesMap, } from '../constants'; +import issueToken from './issue_token.vue'; const SPACE_FACTOR = 1; 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 2591e3e7f48..c042f0eef5f 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_block.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue @@ -1,13 +1,13 @@ <script> import { GlLink, GlIcon, GlButton } from '@gitlab/ui'; -import AddIssuableForm from './add_issuable_form.vue'; -import RelatedIssuesList from './related_issues_list.vue'; import { issuableIconMap, issuableQaClassMap, linkedIssueTypesMap, linkedIssueTypesTextMap, } from '../constants'; +import AddIssuableForm from './add_issuable_form.vue'; +import RelatedIssuesList from './related_issues_list.vue'; export default { name: 'RelatedIssuesBlock', @@ -146,7 +146,7 @@ export default { class="gl-display-flex gl-align-items-center gl-ml-2 gl-text-gray-500" :aria-label="__('Read more about related issues')" > - <gl-icon name="question" :size="12" role="text" /> + <gl-icon name="question" :size="12" /> </gl-link> <div class="gl-display-inline-flex"> 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 a81edcf141c..1ad9d8b7986 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_root.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_root.vue @@ -25,7 +25,6 @@ Your caret can stop touching a `rawReference` can happen in a variety of ways: */ import { deprecatedCreateFlash as Flash } from '~/flash'; import { __ } from '~/locale'; -import RelatedIssuesBlock from './related_issues_block.vue'; import RelatedIssuesStore from '../stores/related_issues_store'; import RelatedIssuesService from '../services/related_issues_service'; import { @@ -35,6 +34,7 @@ import { issuableTypesMap, PathIdSeparator, } from '../constants'; +import RelatedIssuesBlock from './related_issues_block.vue'; export default { name: 'RelatedIssuesRoot', diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue index 8d1bc44cba0..6e159939103 100644 --- a/app/assets/javascripts/releases/components/app_edit_new.vue +++ b/app/assets/javascripts/releases/components/app_edit_new.vue @@ -5,8 +5,8 @@ import { __ } from '~/locale'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import { BACK_URL_PARAM } from '~/releases/constants'; import { getParameterByName } from '~/lib/utils/common_utils'; -import AssetLinksForm from './asset_links_form.vue'; import MilestoneCombobox from '~/milestones/components/milestone_combobox.vue'; +import AssetLinksForm from './asset_links_form.vue'; import TagField from './tag_field.vue'; export default { diff --git a/app/assets/javascripts/releases/components/asset_links_form.vue b/app/assets/javascripts/releases/components/asset_links_form.vue index 331cc8ade6c..b6595ea78be 100644 --- a/app/assets/javascripts/releases/components/asset_links_form.vue +++ b/app/assets/javascripts/releases/components/asset_links_form.vue @@ -10,8 +10,8 @@ import { GlFormInput, GlFormSelect, } from '@gitlab/ui'; -import { DEFAULT_ASSET_LINK_TYPE, ASSET_LINK_TYPE } from '../constants'; import { s__ } from '~/locale'; +import { DEFAULT_ASSET_LINK_TYPE, ASSET_LINK_TYPE } from '../constants'; export default { name: 'AssetLinksForm', diff --git a/app/assets/javascripts/releases/components/release_block_assets.vue b/app/assets/javascripts/releases/components/release_block_assets.vue index 36929f559b5..1761f4360d1 100644 --- a/app/assets/javascripts/releases/components/release_block_assets.vue +++ b/app/assets/javascripts/releases/components/release_block_assets.vue @@ -1,8 +1,8 @@ <script> import { GlTooltipDirective, GlLink, GlButton, GlCollapse, GlIcon, GlBadge } from '@gitlab/ui'; import { difference, get } from 'lodash'; -import { ASSET_LINK_TYPE } from '../constants'; import { __, s__, sprintf } from '~/locale'; +import { ASSET_LINK_TYPE } from '../constants'; export default { name: 'ReleaseBlockAssets', diff --git a/app/assets/javascripts/releases/stores/modules/detail/actions.js b/app/assets/javascripts/releases/stores/modules/detail/actions.js index 127646826a6..54b08ac0dbc 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/actions.js +++ b/app/assets/javascripts/releases/stores/modules/detail/actions.js @@ -1,4 +1,3 @@ -import * as types from './mutation_types'; import api from '~/api'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { s__ } from '~/locale'; @@ -10,6 +9,7 @@ import { convertOneReleaseGraphQLResponse, } from '~/releases/util'; import oneReleaseQuery from '~/releases/queries/one_release.query.graphql'; +import * as types from './mutation_types'; export const initializeRelease = ({ commit, dispatch, getters }) => { if (getters.isExistingRelease) { diff --git a/app/assets/javascripts/releases/stores/modules/detail/mutations.js b/app/assets/javascripts/releases/stores/modules/detail/mutations.js index 8f4bfbc9b86..cf282f9ab2c 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/mutations.js +++ b/app/assets/javascripts/releases/stores/modules/detail/mutations.js @@ -1,6 +1,6 @@ import { uniqueId, cloneDeep } from 'lodash'; -import * as types from './mutation_types'; import { DEFAULT_ASSET_LINK_TYPE } from '../../../constants'; +import * as types from './mutation_types'; const findReleaseLink = (release, id) => { return release.assets.links.find((l) => l.id === id); diff --git a/app/assets/javascripts/releases/stores/modules/list/actions.js b/app/assets/javascripts/releases/stores/modules/list/actions.js index 4c4f6e19a93..9d56323b3c7 100644 --- a/app/assets/javascripts/releases/stores/modules/list/actions.js +++ b/app/assets/javascripts/releases/stores/modules/list/actions.js @@ -1,4 +1,3 @@ -import * as types from './mutation_types'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { __ } from '~/locale'; import api from '~/api'; @@ -10,6 +9,7 @@ import { import allReleasesQuery from '~/releases/queries/all_releases.query.graphql'; import { gqClient, convertAllReleasesGraphQLResponse } from '../../../util'; import { PAGE_SIZE } from '../../../constants'; +import * as types from './mutation_types'; /** * Gets a paginated list of releases from the server diff --git a/app/assets/javascripts/reports/accessibility_report/store/getters.js b/app/assets/javascripts/reports/accessibility_report/store/getters.js index 8f8eec11c7f..20506b1bfd1 100644 --- a/app/assets/javascripts/reports/accessibility_report/store/getters.js +++ b/app/assets/javascripts/reports/accessibility_report/store/getters.js @@ -1,5 +1,5 @@ -import { LOADING, ERROR, SUCCESS, STATUS_FAILED } from '../../constants'; import { s__, n__ } from '~/locale'; +import { LOADING, ERROR, SUCCESS, STATUS_FAILED } from '../../constants'; export const groupedSummaryText = (state) => { if (state.isLoading) { diff --git a/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue b/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue index 5c8f31d7da0..c1de1ab25c1 100644 --- a/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue +++ b/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue @@ -2,6 +2,7 @@ import { mapState, mapActions, mapGetters } from 'vuex'; import { componentNames } from '~/reports/components/issue_body'; import { s__, sprintf } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import ReportSection from '~/reports/components/report_section.vue'; import createStore from './store'; @@ -11,6 +12,7 @@ export default { components: { ReportSection, }, + mixins: [glFeatureFlagsMixin()], props: { headPath: { type: String, @@ -30,6 +32,11 @@ export default { required: false, default: null, }, + codequalityReportsPath: { + type: String, + required: false, + default: '', + }, codequalityHelpPath: { type: String, required: true, @@ -37,7 +44,7 @@ export default { }, componentNames, computed: { - ...mapState(['newIssues', 'resolvedIssues']), + ...mapState(['newIssues', 'resolvedIssues', 'hasError', 'statusReason']), ...mapGetters([ 'hasCodequalityIssues', 'codequalityStatus', @@ -51,10 +58,11 @@ export default { headPath: this.headPath, baseBlobPath: this.baseBlobPath, headBlobPath: this.headBlobPath, + reportsPath: this.codequalityReportsPath, helpPath: this.codequalityHelpPath, }); - this.fetchReports(); + this.fetchReports(this.glFeatures.codequalityBackendComparison); }, methods: { ...mapActions(['fetchReports', 'setPaths']), @@ -80,5 +88,7 @@ export default { :popover-options="codequalityPopover" :show-report-section-status-icon="false" class="js-codequality-widget mr-widget-border-top mr-report" - /> + > + <template v-if="hasError" #sub-heading>{{ statusReason }}</template> + </report-section> </template> diff --git a/app/assets/javascripts/reports/codequality_report/store/actions.js b/app/assets/javascripts/reports/codequality_report/store/actions.js index e5fb5caca2e..ddd1747899f 100644 --- a/app/assets/javascripts/reports/codequality_report/store/actions.js +++ b/app/assets/javascripts/reports/codequality_report/store/actions.js @@ -4,9 +4,20 @@ import { parseCodeclimateMetrics, doCodeClimateComparison } from './utils/codequ export const setPaths = ({ commit }, paths) => commit(types.SET_PATHS, paths); -export const fetchReports = ({ state, dispatch, commit }) => { +export const fetchReports = ({ state, dispatch, commit }, diffFeatureFlagEnabled) => { commit(types.REQUEST_REPORTS); + if (diffFeatureFlagEnabled) { + return axios + .get(state.reportsPath) + .then(({ data }) => { + return dispatch('receiveReportsSuccess', { + newIssues: parseCodeclimateMetrics(data.new_errors, state.headBlobPath), + resolvedIssues: parseCodeclimateMetrics(data.resolved_errors, state.baseBlobPath), + }); + }) + .catch((error) => dispatch('receiveReportsError', error)); + } if (!state.basePath) { return dispatch('receiveReportsError'); } @@ -18,13 +29,13 @@ export const fetchReports = ({ state, dispatch, commit }) => { ), ) .then((data) => dispatch('receiveReportsSuccess', data)) - .catch(() => dispatch('receiveReportsError')); + .catch((error) => dispatch('receiveReportsError', error)); }; export const receiveReportsSuccess = ({ commit }, data) => { commit(types.RECEIVE_REPORTS_SUCCESS, data); }; -export const receiveReportsError = ({ commit }) => { - commit(types.RECEIVE_REPORTS_ERROR); +export const receiveReportsError = ({ commit }, error) => { + commit(types.RECEIVE_REPORTS_ERROR, error); }; diff --git a/app/assets/javascripts/reports/codequality_report/store/getters.js b/app/assets/javascripts/reports/codequality_report/store/getters.js index e017bab976c..f26a41d94c0 100644 --- a/app/assets/javascripts/reports/codequality_report/store/getters.js +++ b/app/assets/javascripts/reports/codequality_report/store/getters.js @@ -1,6 +1,6 @@ -import { LOADING, ERROR, SUCCESS } from '../../constants'; import { sprintf, __, s__, n__ } from '~/locale'; import { spriteIcon } from '~/lib/utils/common_utils'; +import { LOADING, ERROR, SUCCESS } from '../../constants'; export const hasCodequalityIssues = (state) => Boolean(state.newIssues?.length || state.resolvedIssues?.length); diff --git a/app/assets/javascripts/reports/codequality_report/store/mutations.js b/app/assets/javascripts/reports/codequality_report/store/mutations.js index 7ef4f3ce2db..095e6637966 100644 --- a/app/assets/javascripts/reports/codequality_report/store/mutations.js +++ b/app/assets/javascripts/reports/codequality_report/store/mutations.js @@ -6,6 +6,7 @@ export default { state.headPath = paths.headPath; state.baseBlobPath = paths.baseBlobPath; state.headBlobPath = paths.headBlobPath; + state.reportsPath = paths.reportsPath; state.helpPath = paths.helpPath; }, [types.REQUEST_REPORTS](state) { @@ -13,12 +14,14 @@ export default { }, [types.RECEIVE_REPORTS_SUCCESS](state, data) { state.hasError = false; + state.statusReason = ''; state.isLoading = false; state.newIssues = data.newIssues; state.resolvedIssues = data.resolvedIssues; }, - [types.RECEIVE_REPORTS_ERROR](state) { + [types.RECEIVE_REPORTS_ERROR](state, error) { state.isLoading = false; state.hasError = true; + state.statusReason = error?.response?.data?.status_reason; }, }; diff --git a/app/assets/javascripts/reports/codequality_report/store/state.js b/app/assets/javascripts/reports/codequality_report/store/state.js index 38ab53b432e..b39ff4f9d66 100644 --- a/app/assets/javascripts/reports/codequality_report/store/state.js +++ b/app/assets/javascripts/reports/codequality_report/store/state.js @@ -1,12 +1,14 @@ export default () => ({ basePath: null, headPath: null, + reportsPath: null, baseBlobPath: null, headBlobPath: null, isLoading: false, hasError: false, + statusReason: '', newIssues: [], resolvedIssues: [], diff --git a/app/assets/javascripts/reports/codequality_report/store/utils/codequality_comparison.js b/app/assets/javascripts/reports/codequality_report/store/utils/codequality_comparison.js index fd775f52f7d..b252c8c9817 100644 --- a/app/assets/javascripts/reports/codequality_report/store/utils/codequality_comparison.js +++ b/app/assets/javascripts/reports/codequality_report/store/utils/codequality_comparison.js @@ -3,8 +3,10 @@ import CodeQualityComparisonWorker from '../../workers/codequality_comparison_wo export const parseCodeclimateMetrics = (issues = [], path = '') => { return issues.map((issue) => { const parsedIssue = { - ...issue, name: issue.description, + path: issue.file_path, + urlPath: `${path}/${issue.file_path}#L${issue.line}`, + ...issue, }; if (issue?.location?.path) { diff --git a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue index bf1868d427e..dc09c3d175e 100644 --- a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue +++ b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue @@ -3,19 +3,19 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import { once } from 'lodash'; import { GlButton } from '@gitlab/ui'; import { sprintf, s__ } from '~/locale'; -import { componentNames } from './issue_body'; -import ReportSection from './report_section.vue'; -import SummaryRow from './summary_row.vue'; -import IssuesList from './issues_list.vue'; -import Modal from './modal.vue'; -import createStore from '../store'; import Tracking from '~/tracking'; +import createStore from '../store'; import { summaryTextBuilder, reportTextBuilder, statusIcon, recentFailuresTextBuilder, } from '../store/utils'; +import { componentNames } from './issue_body'; +import ReportSection from './report_section.vue'; +import SummaryRow from './summary_row.vue'; +import IssuesList from './issues_list.vue'; +import Modal from './modal.vue'; export default { name: 'GroupedTestReportsApp', diff --git a/app/assets/javascripts/reports/components/issue_body.js b/app/assets/javascripts/reports/components/issue_body.js index 1e6dc4f8b78..a0349506b69 100644 --- a/app/assets/javascripts/reports/components/issue_body.js +++ b/app/assets/javascripts/reports/components/issue_body.js @@ -1,6 +1,6 @@ -import TestIssueBody from './test_issue_body.vue'; import AccessibilityIssueBody from '../accessibility_report/components/accessibility_issue_body.vue'; import CodequalityIssueBody from '../codequality_report/components/codequality_issue_body.vue'; +import TestIssueBody from './test_issue_body.vue'; export const components = { AccessibilityIssueBody, diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue index 0e9975ea81f..9d0631fbc01 100644 --- a/app/assets/javascripts/reports/components/report_section.vue +++ b/app/assets/javascripts/reports/components/report_section.vue @@ -2,8 +2,8 @@ import { __ } from '~/locale'; import StatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue'; import Popover from '~/vue_shared/components/help_popover.vue'; -import IssuesList from './issues_list.vue'; import { status, SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR } from '../constants'; +import IssuesList from './issues_list.vue'; export default { name: 'ReportSection', diff --git a/app/assets/javascripts/reports/store/actions.js b/app/assets/javascripts/reports/store/actions.js index 301fdce7989..a872cfa7e26 100644 --- a/app/assets/javascripts/reports/store/actions.js +++ b/app/assets/javascripts/reports/store/actions.js @@ -1,8 +1,8 @@ import Visibility from 'visibilityjs'; import axios from '../../lib/utils/axios_utils'; import Poll from '../../lib/utils/poll'; -import * as types from './mutation_types'; import httpStatusCodes from '../../lib/utils/http_status'; +import * as types from './mutation_types'; export const setEndpoint = ({ commit }, endpoint) => commit(types.SET_ENDPOINT, endpoint); diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue index 7fe6863d006..336237abd8a 100644 --- a/app/assets/javascripts/repository/components/tree_content.vue +++ b/app/assets/javascripts/repository/components/tree_content.vue @@ -2,11 +2,11 @@ import filesQuery from 'shared_queries/repository/files.query.graphql'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { __ } from '../../locale'; -import FileTable from './table/index.vue'; import getRefMixin from '../mixins/get_ref'; import projectPathQuery from '../queries/project_path.query.graphql'; -import FilePreview from './preview/index.vue'; import { readmeFile } from '../utils/readme'; +import FilePreview from './preview/index.vue'; +import FileTable from './table/index.vue'; const LIMIT = 1000; const PAGE_SIZE = 100; diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index f56b141fe5c..aec3d90a6d7 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -1,17 +1,17 @@ import Vue from 'vue'; +import initWebIdeLink from '~/pages/projects/shared/web_ide_link'; import { escapeFileUrl } from '../lib/utils/url_utility'; +import { parseBoolean } from '../lib/utils/common_utils'; +import { __ } from '../locale'; import createRouter from './router'; import App from './components/app.vue'; import Breadcrumbs from './components/breadcrumbs.vue'; import LastCommit from './components/last_commit.vue'; import TreeActionLink from './components/tree_action_link.vue'; -import initWebIdeLink from '~/pages/projects/shared/web_ide_link'; import DirectoryDownloadLinks from './components/directory_download_links.vue'; import apolloProvider from './graphql'; import { setTitle } from './utils/title'; import { updateFormAction } from './utils/dom'; -import { parseBoolean } from '../lib/utils/common_utils'; -import { __ } from '../locale'; export default function setupVueRepositoryList() { const el = document.getElementById('js-tree-list'); diff --git a/app/assets/javascripts/repository/mixins/preload.js b/app/assets/javascripts/repository/mixins/preload.js index c1607866941..ffc260ec84f 100644 --- a/app/assets/javascripts/repository/mixins/preload.js +++ b/app/assets/javascripts/repository/mixins/preload.js @@ -1,6 +1,6 @@ import filesQuery from 'shared_queries/repository/files.query.graphql'; -import getRefMixin from './get_ref'; import projectPathQuery from '../queries/project_path.query.graphql'; +import getRefMixin from './get_ref'; export default { mixins: [getRefMixin], diff --git a/app/assets/javascripts/repository/pages/index.vue b/app/assets/javascripts/repository/pages/index.vue index 29786bf4ec8..0e53235779c 100644 --- a/app/assets/javascripts/repository/pages/index.vue +++ b/app/assets/javascripts/repository/pages/index.vue @@ -1,6 +1,6 @@ <script> -import TreePage from './tree.vue'; import { updateElementsVisibility } from '../utils/dom'; +import TreePage from './tree.vue'; export default { components: { diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index b9bc799fb0b..6d46decf978 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -2,10 +2,10 @@ import $ from 'jquery'; import Cookies from 'js-cookie'; +import { fixTitle, hide } from '~/tooltips'; import { deprecatedCreateFlash as flash } from './flash'; import axios from './lib/utils/axios_utils'; import { sprintf, s__, __ } from './locale'; -import { fixTitle, hide } from '~/tooltips'; function Sidebar() { this.toggleTodo = this.toggleTodo.bind(this); diff --git a/app/assets/javascripts/search/highlight_blob_search_result.js b/app/assets/javascripts/search/highlight_blob_search_result.js index 3c3ac3582d0..c553d5b14a0 100644 --- a/app/assets/javascripts/search/highlight_blob_search_result.js +++ b/app/assets/javascripts/search/highlight_blob_search_result.js @@ -1,7 +1,7 @@ -export default () => { +export default (search = '') => { const highlightLineClass = 'hll'; const contentBody = document.getElementById('content-body'); - const searchTerm = contentBody.querySelector('.js-search-input').value.toLowerCase(); + const searchTerm = search.toLowerCase(); const blobs = contentBody.querySelectorAll('.blob-result'); blobs.forEach((blob) => { diff --git a/app/assets/javascripts/search/index.js b/app/assets/javascripts/search/index.js index d2bb1ccfc44..3050b628cd5 100644 --- a/app/assets/javascripts/search/index.js +++ b/app/assets/javascripts/search/index.js @@ -1,14 +1,25 @@ +import setHighlightClass from 'ee_else_ce/search/highlight_blob_search_result'; +import Project from '~/pages/projects/project'; +import refreshCounts from '~/pages/search/show/refresh_counts'; import { queryToObject } from '~/lib/utils/url_utility'; import createStore from './store'; import { initTopbar } from './topbar'; import { initSidebar } from './sidebar'; +import { initSearchSort } from './sort'; export const initSearchApp = () => { // Similar to url_utility.decodeUrlParameter // Our query treats + as %20. This replaces the query + symbols with %20. const sanitizedSearch = window.location.search.replace(/\+/g, '%20'); - const store = createStore({ query: queryToObject(sanitizedSearch) }); + const query = queryToObject(sanitizedSearch); + + const store = createStore({ query }); initTopbar(store); initSidebar(store); + initSearchSort(store); + + setHighlightClass(query.search); // Code Highlighting + refreshCounts(); // Other Scope Tab Counts + Project.initRefSwitcher(); // Code Search Branch Picker }; diff --git a/app/assets/javascripts/search/sort/components/app.vue b/app/assets/javascripts/search/sort/components/app.vue new file mode 100644 index 00000000000..6ce76467c1e --- /dev/null +++ b/app/assets/javascripts/search/sort/components/app.vue @@ -0,0 +1,103 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import { + GlButtonGroup, + GlButton, + GlDropdown, + GlDropdownItem, + GlTooltipDirective, +} from '@gitlab/ui'; +import { SORT_DIRECTION_UI } from '../constants'; + +export default { + name: 'GlobalSearchSort', + components: { + GlButtonGroup, + GlButton, + GlDropdown, + GlDropdownItem, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + searchSortOptions: { + type: Array, + required: true, + }, + }, + computed: { + ...mapState(['query']), + selectedSortOption: { + get() { + const { sort } = this.query; + + if (!sort) { + return this.searchSortOptions[0]; + } + + const sortOption = this.searchSortOptions.find((option) => { + if (!option.sortable) { + return option.sortParam === sort; + } + + return Object.values(option.sortParam).indexOf(sort) !== -1; + }); + + // Handle invalid sort param + return sortOption || this.searchSortOptions[0]; + }, + set(value) { + this.setQuery({ key: 'sort', value }); + this.applyQuery(); + }, + }, + sortDirectionData() { + if (!this.selectedSortOption.sortable) { + return SORT_DIRECTION_UI.disabled; + } + + return this.query?.sort?.includes('asc') ? SORT_DIRECTION_UI.asc : SORT_DIRECTION_UI.desc; + }, + }, + methods: { + ...mapActions(['applyQuery', 'setQuery']), + handleSortChange(option) { + if (!option.sortable) { + this.selectedSortOption = option.sortParam; + } else { + // Default new sort options to desc + this.selectedSortOption = option.sortParam.desc; + } + }, + handleSortDirectionChange() { + this.selectedSortOption = + this.sortDirectionData.direction === 'desc' + ? this.selectedSortOption.sortParam.asc + : this.selectedSortOption.sortParam.desc; + }, + }, +}; +</script> + +<template> + <gl-button-group> + <gl-dropdown :text="selectedSortOption.title" :right="true" class="w-100"> + <gl-dropdown-item + v-for="sortOption in searchSortOptions" + :key="sortOption.title" + is-check-item + :is-checked="sortOption.title === selectedSortOption.title" + @click="handleSortChange(sortOption)" + >{{ sortOption.title }}</gl-dropdown-item + > + </gl-dropdown> + <gl-button + v-gl-tooltip + :disabled="!selectedSortOption.sortable" + :title="sortDirectionData.tooltip" + :icon="sortDirectionData.icon" + @click="handleSortDirectionChange" + /> + </gl-button-group> +</template> diff --git a/app/assets/javascripts/search/sort/constants.js b/app/assets/javascripts/search/sort/constants.js new file mode 100644 index 00000000000..575fba5873b --- /dev/null +++ b/app/assets/javascripts/search/sort/constants.js @@ -0,0 +1,19 @@ +import { __ } from '~/locale'; + +export const SORT_DIRECTION_UI = { + disabled: { + direction: null, + tooltip: '', + icon: 'sort-highest', + }, + desc: { + direction: 'desc', + tooltip: __('Sort direction: Descending'), + icon: 'sort-highest', + }, + asc: { + direction: 'asc', + tooltip: __('Sort direction: Ascending'), + icon: 'sort-lowest', + }, +}; diff --git a/app/assets/javascripts/search/sort/index.js b/app/assets/javascripts/search/sort/index.js new file mode 100644 index 00000000000..84bb5175b1d --- /dev/null +++ b/app/assets/javascripts/search/sort/index.js @@ -0,0 +1,27 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import GlobalSearchSort from './components/app.vue'; + +Vue.use(Translate); + +export const initSearchSort = (store) => { + const el = document.getElementById('js-search-sort'); + + if (!el) return false; + + let { searchSortOptions } = el.dataset; + + searchSortOptions = JSON.parse(searchSortOptions); + + return new Vue({ + el, + store, + render(createElement) { + return createElement(GlobalSearchSort, { + props: { + searchSortOptions, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/search/topbar/components/app.vue b/app/assets/javascripts/search/topbar/components/app.vue new file mode 100644 index 00000000000..639cff591c3 --- /dev/null +++ b/app/assets/javascripts/search/topbar/components/app.vue @@ -0,0 +1,73 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import { GlForm, GlSearchBoxByType, GlButton } from '@gitlab/ui'; +import GroupFilter from './group_filter.vue'; +import ProjectFilter from './project_filter.vue'; + +export default { + name: 'GlobalSearchTopbar', + components: { + GlForm, + GlSearchBoxByType, + GroupFilter, + ProjectFilter, + GlButton, + }, + props: { + groupInitialData: { + type: Object, + required: false, + default: () => ({}), + }, + projectInitialData: { + type: Object, + required: false, + default: () => ({}), + }, + }, + computed: { + ...mapState(['query']), + search: { + get() { + return this.query ? this.query.search : ''; + }, + set(value) { + this.setQuery({ key: 'search', value }); + }, + }, + showFilters() { + return !this.query.snippets || this.query.snippets === 'false'; + }, + }, + methods: { + ...mapActions(['applyQuery', 'setQuery']), + }, +}; +</script> + +<template> + <gl-form class="search-page-form" @submit.prevent="applyQuery"> + <section class="gl-lg-display-flex gl-align-items-flex-end"> + <div class="gl-flex-fill-1 gl-mb-4 gl-lg-mb-0 gl-lg-mr-2"> + <label>{{ __('What are you searching for?') }}</label> + <gl-search-box-by-type + id="dashboard_search" + v-model="search" + name="search" + :placeholder="__(`Search for projects, issues, etc.`)" + /> + </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> + <gl-button class="btn-search gl-lg-ml-2" variant="success" type="submit">{{ + __('Search') + }}</gl-button> + </section> + </gl-form> +</template> diff --git a/app/assets/javascripts/search/topbar/components/group_filter.vue b/app/assets/javascripts/search/topbar/components/group_filter.vue index fce9ec17d23..39555ce4fd3 100644 --- a/app/assets/javascripts/search/topbar/components/group_filter.vue +++ b/app/assets/javascripts/search/topbar/components/group_filter.vue @@ -2,8 +2,8 @@ import { mapState, mapActions } from 'vuex'; import { isEmpty } from 'lodash'; import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; -import SearchableDropdown from './searchable_dropdown.vue'; import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '../constants'; +import SearchableDropdown from './searchable_dropdown.vue'; export default { name: 'GroupFilter', @@ -37,6 +37,7 @@ export default { <template> <searchable-dropdown + data-testid="group-filter" :header-text="$options.GROUP_DATA.headerText" :selected-display-value="$options.GROUP_DATA.selectedDisplayValue" :items-display-value="$options.GROUP_DATA.itemsDisplayValue" diff --git a/app/assets/javascripts/search/topbar/components/project_filter.vue b/app/assets/javascripts/search/topbar/components/project_filter.vue index 3f1f3848ac7..b2dd79fcfa3 100644 --- a/app/assets/javascripts/search/topbar/components/project_filter.vue +++ b/app/assets/javascripts/search/topbar/components/project_filter.vue @@ -1,8 +1,8 @@ <script> import { mapState, mapActions } from 'vuex'; import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; -import SearchableDropdown from './searchable_dropdown.vue'; import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '../constants'; +import SearchableDropdown from './searchable_dropdown.vue'; export default { name: 'ProjectFilter', @@ -27,7 +27,7 @@ export default { handleProjectChange(project) { // This determines if we need to update the group filter or not const queryParams = { - ...(project.namespace_id && { [GROUP_DATA.queryParam]: project.namespace_id }), + ...(project.namespace?.id && { [GROUP_DATA.queryParam]: project.namespace.id }), [PROJECT_DATA.queryParam]: project.id, }; @@ -40,6 +40,7 @@ export default { <template> <searchable-dropdown + data-testid="project-filter" :header-text="$options.PROJECT_DATA.headerText" :selected-display-value="$options.PROJECT_DATA.selectedDisplayValue" :items-display-value="$options.PROJECT_DATA.itemsDisplayValue" diff --git a/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue b/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue index 14577fd7d7a..5fb7217db74 100644 --- a/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue +++ b/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue @@ -101,7 +101,7 @@ export default { @keydown.enter.stop="resetDropdown" @click.stop="resetDropdown" > - <gl-icon name="clear" class="gl-text-gray-200! gl-hover-text-blue-800!" /> + <gl-icon name="clear" /> </gl-button> <gl-icon name="chevron-down" /> </template> diff --git a/app/assets/javascripts/search/topbar/index.js b/app/assets/javascripts/search/topbar/index.js index f0308109b32..87316e10e8d 100644 --- a/app/assets/javascripts/search/topbar/index.js +++ b/app/assets/javascripts/search/topbar/index.js @@ -1,44 +1,31 @@ import Vue from 'vue'; import Translate from '~/vue_shared/translate'; -import GroupFilter from './components/group_filter.vue'; -import ProjectFilter from './components/project_filter.vue'; +import GlobalSearchTopbar from './components/app.vue'; Vue.use(Translate); -const mountSearchableDropdown = (store, { id, component }) => { - const el = document.getElementById(id); +export const initTopbar = (store) => { + const el = document.getElementById('js-search-topbar'); if (!el) { return false; } - let { initialData } = el.dataset; + let { groupInitialData, projectInitialData } = el.dataset; - initialData = JSON.parse(initialData); + groupInitialData = JSON.parse(groupInitialData); + projectInitialData = JSON.parse(projectInitialData); return new Vue({ el, store, render(createElement) { - return createElement(component, { + return createElement(GlobalSearchTopbar, { props: { - initialData, + groupInitialData, + projectInitialData, }, }); }, }); }; - -const searchableDropdowns = [ - { - id: 'js-search-group-dropdown', - component: GroupFilter, - }, - { - id: 'js-search-project-dropdown', - component: ProjectFilter, - }, -]; - -export const initTopbar = (store) => - searchableDropdowns.map((dropdown) => mountSearchableDropdown(store, dropdown)); diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js index b8a5836e2d4..7ccec640c20 100644 --- a/app/assets/javascripts/search_autocomplete.js +++ b/app/assets/javascripts/search_autocomplete.js @@ -4,6 +4,8 @@ import $ from 'jquery'; import { escape, throttle } from 'lodash'; import { s__, __, sprintf } from '~/locale'; import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar_helper'; +import Tracking from '~/tracking'; +import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import axios from './lib/utils/axios_utils'; import { isInGroupsPage, @@ -12,8 +14,6 @@ import { getProjectSlug, spriteIcon, } from './lib/utils/common_utils'; -import Tracking from '~/tracking'; -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; /** * Search input in top navigation bar. diff --git a/app/assets/javascripts/search_settings/components/search_settings.vue b/app/assets/javascripts/search_settings/components/search_settings.vue index 820055dc656..de8ec57e255 100644 --- a/app/assets/javascripts/search_settings/components/search_settings.vue +++ b/app/assets/javascripts/search_settings/components/search_settings.vue @@ -118,12 +118,10 @@ export default { }; </script> <template> - <div class="gl-mt-5"> - <gl-search-box-by-type - :value="searchTerm" - :debounce="$options.TYPING_DELAY" - :placeholder="__('Search settings')" - @input="search" - /> - </div> + <gl-search-box-by-type + :value="searchTerm" + :debounce="$options.TYPING_DELAY" + :placeholder="__('Search settings')" + @input="search" + /> </template> diff --git a/app/assets/javascripts/search_settings/index.js b/app/assets/javascripts/search_settings/index.js index 1fb1a378ffb..676c43c5631 100644 --- a/app/assets/javascripts/search_settings/index.js +++ b/app/assets/javascripts/search_settings/index.js @@ -1,23 +1,10 @@ -import Vue from 'vue'; -import $ from 'jquery'; -import { expandSection, closeSection } from '~/settings_panels'; -import SearchSettings from '~/search_settings/components/search_settings.vue'; +const initSearch = async () => { + const el = document.querySelector('.js-search-settings-app'); -const initSearch = ({ el }) => - new Vue({ - el, - render: (h) => - h(SearchSettings, { - ref: 'searchSettings', - props: { - searchRoot: document.querySelector('#content-body'), - sectionSelector: 'section.settings', - }, - on: { - collapse: (section) => closeSection($(section)), - expand: (section) => expandSection($(section)), - }, - }), - }); + if (el) { + const { default: mount } = await import(/* webpackChunkName: 'search_settings' */ './mount'); + mount({ el }); + } +}; export default initSearch; diff --git a/app/assets/javascripts/search_settings/mount.js b/app/assets/javascripts/search_settings/mount.js new file mode 100644 index 00000000000..85ad3b28d59 --- /dev/null +++ b/app/assets/javascripts/search_settings/mount.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import $ from 'jquery'; +import { expandSection, closeSection } from '~/settings_panels'; +import SearchSettings from '~/search_settings/components/search_settings.vue'; + +const mountSearch = ({ el }) => + new Vue({ + el, + render: (h) => + h(SearchSettings, { + ref: 'searchSettings', + props: { + searchRoot: document.querySelector('#content-body'), + sectionSelector: 'section.settings', + }, + on: { + collapse: (section) => closeSection($(section)), + expand: (section) => expandSection($(section)), + }, + }), + }); + +export default mountSearch; diff --git a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue index 6776a9ebb22..f63fb8228d4 100644 --- a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue +++ b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue @@ -5,6 +5,7 @@ import { GlFormGroup, GlButton, GlModal, GlToast, GlToggle } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; import { __, s__, sprintf } from '~/locale'; import { visitUrl, getBaseURL } from '~/lib/utils/url_utility'; +import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; Vue.use(GlToast); @@ -98,11 +99,11 @@ export default { 'resetAlert', ]), hideSelfMonitorModal() { - this.$root.$emit('bv::hide::modal', this.modalId); + this.$root.$emit(BV_HIDE_MODAL, this.modalId); this.setSelfMonitor(true); }, showSelfMonitorModal() { - this.$root.$emit('bv::show::modal', this.modalId); + this.$root.$emit(BV_SHOW_MODAL, this.modalId); }, saveChangesSelfMonitorProject() { if (this.projectCreated && !this.projectEnabled) { diff --git a/app/assets/javascripts/sentry_error_stack_trace/index.js b/app/assets/javascripts/sentry_error_stack_trace/index.js index 80fa0988f0a..8e9ee25e7a8 100644 --- a/app/assets/javascripts/sentry_error_stack_trace/index.js +++ b/app/assets/javascripts/sentry_error_stack_trace/index.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import SentryErrorStackTrace from './components/sentry_error_stack_trace.vue'; import store from '~/error_tracking/store'; +import SentryErrorStackTrace from './components/sentry_error_stack_trace.vue'; export default function initSentryErrorStacktrace() { const sentryErrorStackTraceEl = document.querySelector('#js-sentry-error-stack-trace'); diff --git a/app/assets/javascripts/serverless/components/area.vue b/app/assets/javascripts/serverless/components/area.vue index 056b342cf39..a9584c070fe 100644 --- a/app/assets/javascripts/serverless/components/area.vue +++ b/app/assets/javascripts/serverless/components/area.vue @@ -2,9 +2,9 @@ import { GlAreaChart } from '@gitlab/ui/dist/charts'; import dateFormat from 'dateformat'; import { debounceByAnimationFrame } from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; import { X_INTERVAL } from '../constants'; import { validateGraphData } from '../utils'; -import { __ } from '~/locale'; let debouncedResize; diff --git a/app/assets/javascripts/serverless/components/environment_row.vue b/app/assets/javascripts/serverless/components/environment_row.vue index c46dfb66afe..01030172ea8 100644 --- a/app/assets/javascripts/serverless/components/environment_row.vue +++ b/app/assets/javascripts/serverless/components/environment_row.vue @@ -1,6 +1,6 @@ <script> -import FunctionRow from './function_row.vue'; import ItemCaret from '~/groups/components/item_caret.vue'; +import FunctionRow from './function_row.vue'; export default { components: { diff --git a/app/assets/javascripts/serverless/components/function_row.vue b/app/assets/javascripts/serverless/components/function_row.vue index aac7c46a295..aea38b54062 100644 --- a/app/assets/javascripts/serverless/components/function_row.vue +++ b/app/assets/javascripts/serverless/components/function_row.vue @@ -1,8 +1,8 @@ <script> import { isString } from 'lodash'; import Timeago from '~/vue_shared/components/time_ago_tooltip.vue'; -import Url from './url.vue'; import { visitUrl } from '~/lib/utils/url_utility'; +import Url from './url.vue'; export default { components: { diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue index d662cc7b802..19a3c3decd2 100644 --- a/app/assets/javascripts/serverless/components/functions.vue +++ b/app/assets/javascripts/serverless/components/functions.vue @@ -2,9 +2,9 @@ import { mapState, mapActions, mapGetters } from 'vuex'; import { GlLink, GlLoadingIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { sprintf, s__ } from '~/locale'; +import { CHECKING_INSTALLED } from '../constants'; import EnvironmentRow from './environment_row.vue'; import EmptyState from './empty_state.vue'; -import { CHECKING_INSTALLED } from '../constants'; export default { components: { @@ -69,18 +69,16 @@ export default { <div v-else-if="isInstalled"> <div v-if="hasFunctionData"> - <template> - <div class="groups-list-tree-container js-functions-wrapper"> - <ul class="content-list group-list-tree"> - <environment-row - v-for="(env, index) in getFunctions" - :key="index" - :env="env" - :env-name="index" - /> - </ul> - </div> - </template> + <div class="groups-list-tree-container js-functions-wrapper"> + <ul class="content-list group-list-tree"> + <environment-row + v-for="(env, index) in getFunctions" + :key="index" + :env="env" + :env-name="index" + /> + </ul> + </div> <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3 gl-mb-3 js-functions-loader" /> </div> <div v-else class="empty-state js-empty-state"> diff --git a/app/assets/javascripts/serverless/store/actions.js b/app/assets/javascripts/serverless/store/actions.js index acd7020f70f..27d476dc53e 100644 --- a/app/assets/javascripts/serverless/store/actions.js +++ b/app/assets/javascripts/serverless/store/actions.js @@ -1,10 +1,10 @@ -import * as types from './mutation_types'; import axios from '~/lib/utils/axios_utils'; import statusCodes from '~/lib/utils/http_status'; import { backOff } from '~/lib/utils/common_utils'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { __ } from '~/locale'; import { MAX_REQUESTS, CHECKING_INSTALLED, TIMEOUT } from '../constants'; +import * as types from './mutation_types'; export const requestFunctionsLoading = ({ commit }) => commit(types.REQUEST_FUNCTIONS_LOADING); export const receiveFunctionsSuccess = ({ commit }, data) => 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 c8efbd73b48..a2b2fbddde7 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 @@ -2,14 +2,15 @@ /* eslint-disable vue/no-v-html */ import $ from 'jquery'; import Vue from 'vue'; -import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; import { GlToast, GlModal, GlTooltipDirective, GlIcon, GlFormCheckbox } from '@gitlab/ui'; +import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { __, s__ } from '~/locale'; import { updateUserStatus } from '~/rest_api'; +import * as Emoji from '~/emoji'; +import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; import EmojiMenuInModal from './emoji_menu_in_modal'; import { isUserBusy, isValidAvailibility } from './utils'; -import * as Emoji from '~/emoji'; const emojiMenuClass = 'js-modal-status-emoji-menu'; export const AVAILABILITY_STATUS = { @@ -76,14 +77,14 @@ export default { }, }, mounted() { - this.$root.$emit('bv::show::modal', this.modalId); + this.$root.$emit(BV_SHOW_MODAL, this.modalId); }, beforeDestroy() { this.emojiMenu.destroy(); }, methods: { closeModal() { - this.$root.$emit('bv::hide::modal', this.modalId); + this.$root.$emit(BV_HIDE_MODAL, this.modalId); }, setupEmojiListAndAutocomplete() { const toggleEmojiMenuButtonSelector = '#set-user-status-modal .js-toggle-emoji-menu'; diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue index b9f268629fb..1fd7514b513 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -4,10 +4,10 @@ import eventHub from '~/sidebar/event_hub'; import Store from '~/sidebar/stores/sidebar_store'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { __ } from '~/locale'; import AssigneeTitle from './assignee_title.vue'; import Assignees from './assignees.vue'; import AssigneesRealtime from './assignees_realtime.vue'; -import { __ } from '~/locale'; export default { name: 'SidebarAssignees', diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue index 17e44cf0e1d..057224d5918 100644 --- a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue +++ b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue @@ -1,7 +1,7 @@ <script> import { GlSprintf } from '@gitlab/ui'; -import editFormButtons from './edit_form_buttons.vue'; import { __ } from '../../../locale'; +import editFormButtons from './edit_form_buttons.vue'; export default { components: { 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 26a7c8e4a80..6439536a6b4 100644 --- a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue +++ b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue @@ -2,8 +2,8 @@ import $ from 'jquery'; import { GlButton } from '@gitlab/ui'; import { mapActions } from 'vuex'; -import { __, sprintf } from '../../../locale'; import { deprecatedCreateFlash as Flash } from '~/flash'; +import { __, sprintf } from '../../../locale'; import eventHub from '../../event_hub'; export default { diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue index b1b04564a62..87780888c2f 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue @@ -76,8 +76,8 @@ export default { class="d-inline-block" > <!-- use d-flex so that slot can be appropriately styled --> - <span class="d-flex"> - <reviewer-avatar :user="user" :img-size="32" :issuable-type="issuableType" /> + <span class="gl-display-flex gl-align-items-center"> + <reviewer-avatar :user="user" :img-size="24" :issuable-type="issuableType" /> <slot :user="user"></slot> </span> </gl-link> diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue index cd62fe5be0f..00a2456c6b6 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue @@ -46,6 +46,9 @@ export default { assignSelf() { this.$emit('assign-self'); }, + requestReview(data) { + this.$emit('request-review', data); + }, }, }; </script> @@ -66,6 +69,7 @@ export default { :users="sortedReviewers" :root-path="rootPath" :issuable-type="issuableType" + @request-review="requestReview" /> </div> </div> diff --git a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue index 1a2473e5f6c..4bfae2dfffa 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue @@ -6,9 +6,9 @@ import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests import eventHub from '~/sidebar/event_hub'; import Store from '~/sidebar/stores/sidebar_store'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { __ } from '~/locale'; import ReviewerTitle from './reviewer_title.vue'; import Reviewers from './reviewers.vue'; -import { __ } from '~/locale'; export default { name: 'SidebarReviewers', @@ -83,6 +83,9 @@ export default { return new Flash(__('Error occurred when saving reviewers')); }); }, + requestReview(data) { + this.mediator.requestReview(data); + }, }, }; </script> @@ -101,6 +104,7 @@ export default { :editable="store.editable" :issuable-type="issuableType" class="value" + @request-review="requestReview" /> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue index e82a271d007..be5fd93f77c 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue @@ -1,6 +1,7 @@ <script> // NOTE! For the first iteration, we are simply copying the implementation of Assignees // It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 +import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import ReviewerAvatarLink from './reviewer_avatar_link.vue'; @@ -8,8 +9,13 @@ const DEFAULT_RENDER_COUNT = 5; export default { components: { + GlButton, + GlIcon, ReviewerAvatarLink, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { users: { type: Array, @@ -28,6 +34,8 @@ export default { data() { return { showLess: true, + loading: false, + requestedReviewSuccess: false, }; }, computed: { @@ -61,43 +69,53 @@ export default { toggleShowLess() { this.showLess = !this.showLess; }, + reRequestReview(userId) { + this.loading = true; + this.$emit('request-review', { userId, callback: this.requestReviewComplete }); + }, + requestReviewComplete(success) { + if (success) { + this.requestedReviewSuccess = true; + + setTimeout(() => { + this.requestedReviewSuccess = false; + }, 1500); + } + + this.loading = false; + }, }, }; </script> <template> - <reviewer-avatar-link - v-if="hasOneUser" - #default="{ user }" - tooltip-placement="left" - :tooltip-has-name="false" - :user="firstUser" - :root-path="rootPath" - :issuable-type="issuableType" - > - <div class="gl-ml-3 gl-line-height-normal"> - <div class="author">{{ user.name }}</div> - <div class="username">{{ username }}</div> - </div> - </reviewer-avatar-link> - <div v-else> - <div class="user-list"> - <div v-for="user in uncollapsedUsers" :key="user.id" class="user-item"> - <reviewer-avatar-link :user="user" :root-path="rootPath" :issuable-type="issuableType" /> - </div> - </div> - <div v-if="renderShowMoreSection" class="user-list-more"> - <button - type="button" - class="btn-link" - data-qa-selector="more_reviewers_link" - @click="toggleShowLess" - > - <template v-if="showLess"> - {{ hiddenReviewersLabel }} - </template> - <template v-else>{{ __('- show less') }}</template> - </button> + <div> + <div + v-for="(user, index) in users" + :key="user.id" + :class="{ 'gl-mb-3': index !== users.length - 1 }" + data-testid="reviewer" + > + <reviewer-avatar-link :user="user" :root-path="rootPath" :issuable-type="issuableType"> + <div class="gl-ml-3">@{{ user.username }}</div> + </reviewer-avatar-link> + <gl-icon + v-if="requestedReviewSuccess" + :size="24" + name="check" + class="float-right gl-text-green-500" + /> + <gl-button + v-else-if="user.can_update_merge_request && user.reviewed" + v-gl-tooltip.left + :title="__('Re-request review')" + :loading="loading" + class="float-right gl-text-gray-500!" + size="small" + icon="redo" + variant="link" + @click="reRequestReview(user.id)" + /> </div> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue index 0cf11e83349..6a6300dcde0 100644 --- a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue +++ b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue @@ -7,10 +7,10 @@ import { GlSprintf, GlLink, } from '@gitlab/ui'; +import createFlash 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'; -import createFlash from '~/flash'; export default { i18n: I18N, diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue index 6d21936791c..9b06c20a6f3 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue @@ -1,8 +1,7 @@ <script> -import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlIcon, GlToggle, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; import Tracking from '~/tracking'; -import toggleButton from '~/vue_shared/components/toggle_button.vue'; import eventHub from '../../event_hub'; const ICON_ON = 'notifications'; @@ -16,7 +15,7 @@ export default { }, components: { GlIcon, - toggleButton, + GlToggle, }, mixins: [Tracking.mixin({ label: 'right_sidebar' })], props: { @@ -106,7 +105,7 @@ export default { </script> <template> - <div> + <div class="gl-display-flex gl-justify-content-space-between"> <span ref="tooltip" v-gl-tooltip.viewport.left @@ -116,13 +115,13 @@ export default { > <gl-icon :name="notificationIcon" :size="16" class="sidebar-item-icon is-active" /> </span> - <span class="issuable-header-text hide-collapsed float-left"> {{ notificationText }} </span> - <toggle-button + <span class="hide-collapsed" data-testid="subscription-title"> {{ notificationText }} </span> + <gl-toggle v-if="!projectEmailsDisabled" - ref="toggleButton" :is-loading="showLoadingState" :value="subscribed" - class="float-right hide-collapsed js-issuable-subscribe-button" + class="hide-collapsed" + data-testid="subscription-toggle" @change="toggleSubscription" /> </div> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue index 8bc828091c0..e0f60b9af08 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue @@ -1,7 +1,7 @@ <script> /* eslint-disable vue/no-v-html */ -import { sprintf, s__ } from '../../../locale'; import { joinPaths } from '~/lib/utils/url_utility'; +import { sprintf, s__ } from '../../../locale'; export default { name: 'TimeTrackingHelpState', 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 26e0a0da860..f66c2540a9e 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 @@ -4,11 +4,10 @@ import { intersection } from 'lodash'; import '~/smart_interval'; -import IssuableTimeTracker from './time_tracker.vue'; - import Store from '../../stores/sidebar_store'; import Mediator from '../../sidebar_mediator'; import eventHub from '../../event_hub'; +import IssuableTimeTracker from './time_tracker.vue'; export default { components: { diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue index 26b8e087512..6d36d16965f 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -1,13 +1,12 @@ <script> import { GlIcon } from '@gitlab/ui'; import { s__, __ } from '~/locale'; +import eventHub from '../../event_hub'; import TimeTrackingHelpState from './help_state.vue'; import TimeTrackingCollapsedState from './collapsed_state.vue'; import TimeTrackingSpentOnlyPane from './spent_only_pane.vue'; import TimeTrackingComparisonPane from './comparison_pane.vue'; -import eventHub from '../../event_hub'; - export default { name: 'IssuableTimeTracker', i18n: { @@ -48,11 +47,11 @@ export default { /* In issue list, "time-tracking-collapsed-state" is always rendered even if the sidebar isn't collapsed. The actual hiding is controlled with css classes: - Hide "time-tracking-collapsed-state" + Hide "time-tracking-collapsed-state" if .right-sidebar .right-sidebar-collapsed .sidebar-collapsed-icon Show "time-tracking-collapsed-state" if .right-sidebar .right-sidebar-expanded .sidebar-collapsed-icon - + In Swimlanes sidebar, we do not use collapsed state at all. */ showCollapsed: { @@ -99,10 +98,12 @@ export default { update(data) { const { timeEstimate, timeSpent, humanTimeEstimate, humanTimeSpent } = data; + /* eslint-disable vue/no-mutating-props */ this.timeEstimate = timeEstimate; this.timeSpent = timeSpent; this.humanTimeEstimate = humanTimeEstimate; this.humanTimeSpent = humanTimeSpent; + /* eslint-enable vue/no-mutating-props */ }, }, }; diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue index 1e3e870ec83..f589e7555b3 100644 --- a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue +++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue @@ -3,7 +3,7 @@ import { GlLoadingIcon, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; const MARK_TEXT = __('Mark as done'); -const TODO_TEXT = __('Add a To-Do'); +const TODO_TEXT = __('Add a to do'); export default { components: { @@ -42,7 +42,7 @@ export default { buttonClasses() { return this.collapsed ? 'btn-blank btn-todo sidebar-collapsed-icon dont-change-state' - : 'btn btn-default btn-todo issuable-header-btn float-right'; + : 'gl-button btn btn-default btn-todo issuable-header-btn float-right'; }, buttonLabel() { return this.isTodo ? MARK_TEXT : TODO_TEXT; diff --git a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js index 4d9e99941d1..b11c8f76a6d 100644 --- a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import timeTracker from './components/time_tracking/time_tracker.vue'; import { parseBoolean } from '~/lib/utils/common_utils'; +import timeTracker from './components/time_tracking/time_tracker.vue'; export default class SidebarMilestone { constructor() { diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 2760bf431ea..1af52529be9 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -1,6 +1,16 @@ import $ from 'jquery'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { + isInIssuePage, + isInDesignPage, + isInIncidentPage, + parseBoolean, +} from '~/lib/utils/common_utils'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; +import Translate from '../vue_shared/translate'; import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue'; import SidebarAssignees from './components/assignees/sidebar_assignees.vue'; import SidebarLabels from './components/labels/sidebar_labels.vue'; @@ -11,12 +21,7 @@ import IssuableLockForm from './components/lock/issuable_lock_form.vue'; import sidebarParticipants from './components/participants/sidebar_participants.vue'; import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptions.vue'; import SidebarSeverity from './components/severity/sidebar_severity.vue'; -import Translate from '../vue_shared/translate'; import CopyEmailToClipboard from './components/copy_email_to_clipboard.vue'; -import createDefaultClient from '~/lib/graphql'; -import { isInIssuePage, isInIncidentPage, parseBoolean } from '~/lib/utils/common_utils'; -import createFlash from '~/flash'; -import { __ } from '~/locale'; Vue.use(Translate); Vue.use(VueApollo); @@ -49,7 +54,8 @@ function mountAssigneesComponent(mediator) { projectPath: fullPath, field: el.dataset.field, signedIn: el.hasAttribute('data-signed-in'), - issuableType: isInIssuePage() || isInIncidentPage() ? 'issue' : 'merge_request', + issuableType: + isInIssuePage() || isInIncidentPage() || isInDesignPage() ? 'issue' : 'merge_request', }, }), }); @@ -78,7 +84,7 @@ function mountReviewersComponent(mediator) { issuableIid: String(iid), projectPath: fullPath, field: el.dataset.field, - issuableType: isInIssuePage() ? 'issue' : 'merge_request', + issuableType: isInIssuePage() || isInDesignPage() ? 'issue' : 'merge_request', }, }), }); diff --git a/app/assets/javascripts/sidebar/queries/reviewer_rereview.mutation.graphql b/app/assets/javascripts/sidebar/queries/reviewer_rereview.mutation.graphql new file mode 100644 index 00000000000..73765e7d77b --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/reviewer_rereview.mutation.graphql @@ -0,0 +1,5 @@ +mutation mergeRequestRequestRereview($projectPath: ID!, $iid: String!, $userId: ID!) { + mergeRequestReviewerRereview(input: { projectPath: $projectPath, iid: $iid, userId: $userId }) { + errors + } +} diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js index a61af631661..eef9a22cce5 100644 --- a/app/assets/javascripts/sidebar/services/sidebar_service.js +++ b/app/assets/javascripts/sidebar/services/sidebar_service.js @@ -1,6 +1,8 @@ import sidebarDetailsQuery from 'ee_else_ce/sidebar/queries/sidebarDetails.query.graphql'; import axios from '~/lib/utils/axios_utils'; import createGqClient, { fetchPolicies } from '~/lib/graphql'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import reviewerRereviewMutation from '../queries/reviewer_rereview.mutation.graphql'; export const gqClient = createGqClient( {}, @@ -70,4 +72,15 @@ export default class SidebarService { move_to_project_id: moveToProjectId, }); } + + requestReview(userId) { + return gqClient.mutate({ + mutation: reviewerRereviewMutation, + variables: { + userId: convertToGraphQLId('User', `${userId}`), // eslint-disable-line @gitlab/require-i18n-strings + projectPath: this.fullPath, + iid: this.iid.toString(), + }, + }); + } } diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js index d143283653b..b23788f81fe 100644 --- a/app/assets/javascripts/sidebar/sidebar_mediator.js +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -1,8 +1,9 @@ import Store from 'ee_else_ce/sidebar/stores/sidebar_store'; +import toast from '~/vue_shared/plugins/global_toast'; +import { __ } from '~/locale'; import { visitUrl } from '../lib/utils/url_utility'; import { deprecatedCreateFlash as Flash } from '../flash'; import Service from './services/sidebar_service'; -import { __ } from '~/locale'; export default class SidebarMediator { constructor(options) { @@ -51,6 +52,17 @@ export default class SidebarMediator { return this.service.update(field, data); } + requestReview({ userId, callback }) { + return this.service + .requestReview(userId) + .then(() => { + this.store.updateReviewer(userId); + toast(__('Requested review')); + callback(true); + }) + .catch(() => callback(false)); + } + setMoveToProjectId(projectId) { this.store.setMoveToProjectId(projectId); } diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js index d53393052eb..3c108b06eab 100644 --- a/app/assets/javascripts/sidebar/stores/sidebar_store.js +++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js @@ -96,6 +96,14 @@ export default class SidebarStore { } } + updateReviewer(id) { + const reviewer = this.findReviewer({ id }); + + if (reviewer) { + reviewer.reviewed = false; + } + } + findAssignee(findAssignee) { return this.assignees.find(({ id }) => id === findAssignee.id); } diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js index 192eb0784d4..5b40140a232 100644 --- a/app/assets/javascripts/single_file_diff.js +++ b/app/assets/javascripts/single_file_diff.js @@ -1,13 +1,13 @@ /* eslint-disable consistent-return */ import $ from 'jquery'; +import { spriteIcon } from '~/lib/utils/common_utils'; import { __ } from './locale'; import axios from './lib/utils/axios_utils'; import { deprecatedCreateFlash as createFlash } from './flash'; import FilesCommentButton from './files_comment_button'; import initImageDiffHelper from './image_diff/helpers/init_image_diff'; import syntaxHighlight from './syntax_highlight'; -import { spriteIcon } from '~/lib/utils/common_utils'; const WRAPPER = '<div class="diff-content"></div>'; const LOADING_HTML = '<span class="spinner"></span>'; diff --git a/app/assets/javascripts/snippets/components/show.vue b/app/assets/javascripts/snippets/components/show.vue index a3e5535c5fa..63257785e9d 100644 --- a/app/assets/javascripts/snippets/components/show.vue +++ b/app/assets/javascripts/snippets/components/show.vue @@ -1,9 +1,5 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; -import EmbedDropdown from './embed_dropdown.vue'; -import SnippetHeader from './snippet_header.vue'; -import SnippetTitle from './snippet_title.vue'; -import SnippetBlob from './snippet_blob_view.vue'; import CloneDropdownButton from '~/vue_shared/components/clone_dropdown.vue'; import { SNIPPET_VISIBILITY_PUBLIC } from '~/snippets/constants'; import { @@ -15,6 +11,10 @@ import eventHub from '~/blob/components/eventhub'; import { getSnippetMixin } from '../mixins/snippets'; import { markBlobPerformance } from '../utils/blob'; +import SnippetBlob from './snippet_blob_view.vue'; +import SnippetTitle from './snippet_title.vue'; +import SnippetHeader from './snippet_header.vue'; +import EmbedDropdown from './embed_dropdown.vue'; eventHub.$on(SNIPPET_MEASURE_BLOBS_CONTENT, markBlobPerformance); diff --git a/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue index ff27c90a84d..d221195ddc7 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue @@ -2,9 +2,9 @@ import { GlButton } from '@gitlab/ui'; import { cloneDeep } from 'lodash'; import { s__, sprintf } from '~/locale'; -import SnippetBlobEdit from './snippet_blob_edit.vue'; import { SNIPPET_MAX_BLOBS } from '../constants'; import { createBlob, decorateBlob, diffAll } from '../utils/blob'; +import SnippetBlobEdit from './snippet_blob_edit.vue'; export default { components: { diff --git a/app/assets/javascripts/snippets/components/snippet_blob_view.vue b/app/assets/javascripts/snippets/components/snippet_blob_view.vue index 4326c3c3159..816a7842dda 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_view.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_view.vue @@ -31,8 +31,10 @@ export default { }, result() { if (this.activeViewerType === RICH_BLOB_VIEWER) { + // eslint-disable-next-line vue/no-mutating-props this.blob.richViewer.renderError = null; } else { + // eslint-disable-next-line vue/no-mutating-props this.blob.simpleViewer.renderError = null; } }, diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue index 5ba62908b43..65df2464dc5 100644 --- a/app/assets/javascripts/snippets/components/snippet_header.vue +++ b/app/assets/javascripts/snippets/components/snippet_header.vue @@ -16,9 +16,9 @@ import CanCreateProjectSnippet from 'shared_queries/snippet/project_permissions. import { __ } from '~/locale'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import DeleteSnippetMutation from '../mutations/deleteSnippet.mutation.graphql'; import { joinPaths } from '~/lib/utils/url_utility'; import { fetchPolicies } from '~/lib/graphql'; +import DeleteSnippetMutation from '../mutations/deleteSnippet.mutation.graphql'; export default { components: { diff --git a/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue b/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue index ee5076835ab..18a7d4ad218 100644 --- a/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue @@ -1,7 +1,7 @@ <script> import { GlIcon, GlFormGroup, GlFormRadio, GlFormRadioGroup, GlLink } from '@gitlab/ui'; -import { defaultSnippetVisibilityLevels } from '../utils/blob'; import { SNIPPET_LEVELS_RESTRICTED, SNIPPET_LEVELS_DISABLED } from '~/snippets/constants'; +import { defaultSnippetVisibilityLevels } from '../utils/blob'; export default { components: { diff --git a/app/assets/javascripts/snippets/utils/blob.js b/app/assets/javascripts/snippets/utils/blob.js index a47418323f2..49ebd79845f 100644 --- a/app/assets/javascripts/snippets/utils/blob.js +++ b/app/assets/javascripts/snippets/utils/blob.js @@ -1,4 +1,6 @@ import { uniqueId } from 'lodash'; +import { performanceMarkAndMeasure } from '~/performance/utils'; +import { SNIPPET_MARK_BLOBS_CONTENT, SNIPPET_MEASURE_BLOBS_CONTENT } from '~/performance/constants'; import { SNIPPET_BLOB_ACTION_CREATE, SNIPPET_BLOB_ACTION_UPDATE, @@ -7,8 +9,6 @@ import { SNIPPET_LEVELS_MAP, SNIPPET_VISIBILITY, } from '../constants'; -import { performanceMarkAndMeasure } from '~/performance/utils'; -import { SNIPPET_MARK_BLOBS_CONTENT, SNIPPET_MEASURE_BLOBS_CONTENT } from '~/performance/constants'; const createLocalId = () => uniqueId('blob_local_'); diff --git a/app/assets/javascripts/static_site_editor/components/edit_area.vue b/app/assets/javascripts/static_site_editor/components/edit_area.vue index b47126cdeb3..3eed05a5915 100644 --- a/app/assets/javascripts/static_site_editor/components/edit_area.vue +++ b/app/assets/javascripts/static_site_editor/components/edit_area.vue @@ -1,15 +1,15 @@ <script> import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue'; -import PublishToolbar from './publish_toolbar.vue'; -import EditHeader from './edit_header.vue'; -import EditDrawer from './edit_drawer.vue'; -import UnsavedChangesConfirmDialog from './unsaved_changes_confirm_dialog.vue'; import parseSourceFile from '~/static_site_editor/services/parse_source_file'; import { EDITOR_TYPES } from '~/vue_shared/components/rich_content_editor/constants'; import imageRepository from '../image_repository'; import formatter from '../services/formatter'; import templater from '../services/templater'; import renderImage from '../services/renderers/render_image'; +import UnsavedChangesConfirmDialog from './unsaved_changes_confirm_dialog.vue'; +import EditDrawer from './edit_drawer.vue'; +import EditHeader from './edit_header.vue'; +import PublishToolbar from './publish_toolbar.vue'; export default { components: { diff --git a/app/assets/javascripts/static_site_editor/components/edit_drawer.vue b/app/assets/javascripts/static_site_editor/components/edit_drawer.vue index 0484d38dde0..0685dfdb1d1 100644 --- a/app/assets/javascripts/static_site_editor/components/edit_drawer.vue +++ b/app/assets/javascripts/static_site_editor/components/edit_drawer.vue @@ -22,11 +22,6 @@ export default { <template> <gl-drawer class="gl-pt-8" :open="isOpen" @close="$emit('close')"> <template #header>{{ __('Page settings') }}</template> - <template> - <front-matter-controls - :settings="settings" - @updateSettings="$emit('updateSettings', $event)" - /> - </template> + <front-matter-controls :settings="settings" @updateSettings="$emit('updateSettings', $event)" /> </gl-drawer> </template> diff --git a/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue b/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue index f583d2049af..5ecb242f96a 100644 --- a/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue +++ b/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue @@ -4,9 +4,8 @@ import { __, s__, sprintf } from '~/locale'; import Api from '~/api'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; -import EditMetaControls from './edit_meta_controls.vue'; - import { ISSUABLE_TYPE, MR_META_LOCAL_STORAGE_KEY } from '../constants'; +import EditMetaControls from './edit_meta_controls.vue'; export default { components: { diff --git a/app/assets/javascripts/subscription_select.js b/app/assets/javascripts/subscription_select.js index 354ee00a977..4a688d819b0 100644 --- a/app/assets/javascripts/subscription_select.js +++ b/app/assets/javascripts/subscription_select.js @@ -1,6 +1,6 @@ import $ from 'jquery'; -import { __ } from './locale'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; +import { __ } from './locale'; export default function subscriptionSelect() { $('.js-subscription-event').each((i, element) => { diff --git a/app/assets/javascripts/templates/issuable_template_selector.js b/app/assets/javascripts/templates/issuable_template_selector.js index 22bbd083a5d..af9979b2502 100644 --- a/app/assets/javascripts/templates/issuable_template_selector.js +++ b/app/assets/javascripts/templates/issuable_template_selector.js @@ -1,9 +1,9 @@ /* eslint-disable no-useless-return */ import $ from 'jquery'; +import { __ } from '~/locale'; import Api from '../api'; import TemplateSelector from '../blob/template_selector'; -import { __ } from '~/locale'; export default class IssuableTemplateSelector extends TemplateSelector { constructor(...args) { diff --git a/app/assets/javascripts/terraform/components/states_table.vue b/app/assets/javascripts/terraform/components/states_table.vue index d0d49233334..ab039608ef2 100644 --- a/app/assets/javascripts/terraform/components/states_table.vue +++ b/app/assets/javascripts/terraform/components/states_table.vue @@ -1,15 +1,16 @@ <script> -import { GlBadge, GlIcon, GlLink, GlSprintf, GlTable, GlTooltip } from '@gitlab/ui'; +import { GlAlert, GlBadge, GlIcon, GlLink, GlSprintf, GlTable, GlTooltip } from '@gitlab/ui'; import { s__ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; -import StateActions from './states_table_actions.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; +import StateActions from './states_table_actions.vue'; export default { components: { CiBadge, + GlAlert, GlBadge, GlIcon, GlLink, @@ -105,6 +106,7 @@ export default { :items="states" :fields="fields" data-testid="terraform-states-table" + details-td-class="gl-p-0!" fixed stacked="md" > @@ -189,5 +191,21 @@ export default { <template v-if="terraformAdmin" #cell(actions)="{ item }"> <state-actions :state="item" /> </template> + + <template #row-details="row"> + <gl-alert + data-testid="terraform-states-table-error" + variant="danger" + @dismiss="row.toggleDetails" + > + <span + v-for="errorMessage in row.item.errorMessages" + :key="errorMessage" + class="gl-display-flex gl-justify-content-start" + > + {{ errorMessage }} + </span> + </gl-alert> + </template> </gl-table> </template> diff --git a/app/assets/javascripts/terraform/components/states_table_actions.vue b/app/assets/javascripts/terraform/components/states_table_actions.vue index 44b0713e544..ba064825034 100644 --- a/app/assets/javascripts/terraform/components/states_table_actions.vue +++ b/app/assets/javascripts/terraform/components/states_table_actions.vue @@ -10,6 +10,7 @@ import { GlSprintf, } from '@gitlab/ui'; import { s__ } from '~/locale'; +import addDataToState from '../graphql/mutations/add_data_to_state.mutation.graphql'; import lockState from '../graphql/mutations/lock_state.mutation.graphql'; import unlockState from '../graphql/mutations/unlock_state.mutation.graphql'; import removeState from '../graphql/mutations/remove_state.mutation.graphql'; @@ -33,13 +34,13 @@ export default { }, data() { return { - loading: false, showRemoveModal: false, removeConfirmText: '', }; }, i18n: { downloadJSON: s__('Terraform|Download JSON'), + errorUpdate: s__('Terraform|An error occurred while changing the state file'), lock: s__('Terraform|Lock'), modalBody: s__( 'Terraform|You are about to remove the State file %{name}. This will permanently delete all the State versions and history. The infrastructure provisioned previously will remain intact, only the state file with all its versions are to be removed. This action is non-revertible.', @@ -76,19 +77,37 @@ export default { this.removeConfirmText = ''; }, lock() { - this.stateMutation(lockState); + this.stateActionMutation(lockState); }, unlock() { - this.stateMutation(unlockState); + this.stateActionMutation(unlockState); + }, + updateStateCache(newData) { + this.$apollo.mutate({ + mutation: addDataToState, + variables: { + terraformState: { + ...this.state, + ...newData, + }, + }, + }); }, remove() { if (!this.disableModalSubmit) { this.hideModal(); - this.stateMutation(removeState); + this.stateActionMutation(removeState); } }, - stateMutation(mutation) { - this.loading = true; + stateActionMutation(mutation) { + let errorMessages = []; + + this.updateStateCache({ + _showDetails: false, + errorMessages, + loadingActions: true, + }); + this.$apollo .mutate({ mutation, @@ -99,9 +118,22 @@ export default { awaitRefetchQueries: true, notifyOnNetworkStatusChange: true, }) - .catch(() => {}) + .then(({ data }) => { + errorMessages = + data?.terraformStateDelete?.errors || + data?.terraformStateLock?.errors || + data?.terraformStateUnlock?.errors || + []; + }) + .catch(() => { + errorMessages = [this.$options.i18n.errorUpdate]; + }) .finally(() => { - this.loading = false; + this.updateStateCache({ + _showDetails: Boolean(errorMessages.length), + errorMessages, + loadingActions: false, + }); }); }, }, @@ -114,7 +146,7 @@ export default { icon="ellipsis_v" right :data-testid="`terraform-state-actions-${state.name}`" - :disabled="loading" + :disabled="state.loadingActions" toggle-class="gl-px-3! gl-shadow-none!" > <template #button-content> diff --git a/app/assets/javascripts/terraform/components/terraform_list.vue b/app/assets/javascripts/terraform/components/terraform_list.vue index b71133d8813..ea533967ed9 100644 --- a/app/assets/javascripts/terraform/components/terraform_list.vue +++ b/app/assets/javascripts/terraform/components/terraform_list.vue @@ -1,9 +1,9 @@ <script> import { GlAlert, GlBadge, GlKeysetPagination, GlLoadingIcon, GlTab, GlTabs } from '@gitlab/ui'; import getStatesQuery from '../graphql/queries/get_states.query.graphql'; +import { MAX_LIST_COUNT } from '../constants'; import EmptyState from './empty_state.vue'; import StatesTable from './states_table.vue'; -import { MAX_LIST_COUNT } from '../constants'; export default { apollo: { diff --git a/app/assets/javascripts/terraform/graphql/fragments/state.fragment.graphql b/app/assets/javascripts/terraform/graphql/fragments/state.fragment.graphql index 49f9ae3dd97..f876fea64ac 100644 --- a/app/assets/javascripts/terraform/graphql/fragments/state.fragment.graphql +++ b/app/assets/javascripts/terraform/graphql/fragments/state.fragment.graphql @@ -2,6 +2,10 @@ #import "./state_version.fragment.graphql" fragment State on TerraformState { + _showDetails @client + errorMessages @client + loadingActions @client + id name lockedAt diff --git a/app/assets/javascripts/terraform/graphql/mutations/add_data_to_state.mutation.graphql b/app/assets/javascripts/terraform/graphql/mutations/add_data_to_state.mutation.graphql new file mode 100644 index 00000000000..645b9766e2b --- /dev/null +++ b/app/assets/javascripts/terraform/graphql/mutations/add_data_to_state.mutation.graphql @@ -0,0 +1,3 @@ +mutation addDataToTerraformState($terraformState: State!) { + addDataToTerraformState(terraformState: $terraformState) @client +} diff --git a/app/assets/javascripts/terraform/graphql/resolvers.js b/app/assets/javascripts/terraform/graphql/resolvers.js new file mode 100644 index 00000000000..2845a1e5279 --- /dev/null +++ b/app/assets/javascripts/terraform/graphql/resolvers.js @@ -0,0 +1,41 @@ +import TerraformState from './fragments/state.fragment.graphql'; + +export default { + TerraformState: { + _showDetails: (state) => { + // eslint-disable-next-line no-underscore-dangle + return state._showDetails || false; + }, + errorMessages: (state) => { + return state.errorMessages || []; + }, + loadingActions: (state) => { + return state.loadingActions || false; + }, + }, + Mutation: { + addDataToTerraformState: (_, { terraformState }, { client }) => { + const fragmentData = { + id: terraformState.id, + fragment: TerraformState, + // eslint-disable-next-line @gitlab/require-i18n-strings + fragmentName: 'State', + }; + + const previousTerraformState = client.readFragment(fragmentData); + + if (previousTerraformState) { + client.writeFragment({ + ...fragmentData, + data: { + ...previousTerraformState, + // eslint-disable-next-line no-underscore-dangle + _showDetails: terraformState._showDetails, + errorMessages: terraformState.errorMessages, + loadingActions: terraformState.loadingActions, + }, + }); + } + }, + }, +}; diff --git a/app/assets/javascripts/terraform/index.js b/app/assets/javascripts/terraform/index.js index e27a29433f3..f0a924d8a58 100644 --- a/app/assets/javascripts/terraform/index.js +++ b/app/assets/javascripts/terraform/index.js @@ -1,7 +1,9 @@ +import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import TerraformList from './components/terraform_list.vue'; import createDefaultClient from '~/lib/graphql'; +import TerraformList from './components/terraform_list.vue'; +import resolvers from './graphql/resolvers'; Vue.use(VueApollo); @@ -12,7 +14,13 @@ export default () => { return null; } - const defaultClient = createDefaultClient(); + const defaultClient = createDefaultClient(resolvers, { + cacheConfig: { + dataIdFromObject: (object) => { + return object.id || defaultDataIdFromObject(object); + }, + }, + }); const { emptyStateImage, projectPath } = el.dataset; diff --git a/app/assets/javascripts/ui_development_kit.js b/app/assets/javascripts/ui_development_kit.js index 028b047d9f5..1a3fd6c77ed 100644 --- a/app/assets/javascripts/ui_development_kit.js +++ b/app/assets/javascripts/ui_development_kit.js @@ -1,6 +1,6 @@ import $ from 'jquery'; -import Api from './api'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; +import Api from './api'; export default () => { initDeprecatedJQueryDropdown($('#js-project-dropdown'), { diff --git a/app/assets/javascripts/user_lists/store/show/mutations.js b/app/assets/javascripts/user_lists/store/show/mutations.js index 3cf3b2d8371..bb5f9abe79e 100644 --- a/app/assets/javascripts/user_lists/store/show/mutations.js +++ b/app/assets/javascripts/user_lists/store/show/mutations.js @@ -1,6 +1,6 @@ import { states } from '../../constants/show'; -import * as types from './mutation_types'; import { parseUserIds } from '../utils'; +import * as types from './mutation_types'; export default { [types.REQUEST_USER_LIST](state) { diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js index 79dc20fd498..cab30a85e2a 100644 --- a/app/assets/javascripts/users_select/index.js +++ b/app/assets/javascripts/users_select/index.js @@ -8,14 +8,14 @@ import { AJAX_USERS_SELECT_OPTIONS_MAP, AJAX_USERS_SELECT_PARAMS_MAP, } from 'ee_else_ce/users_select/constants'; +import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; +import { fixTitle, dispose } from '~/tooltips'; import axios from '../lib/utils/axios_utils'; import { s__, __, sprintf } from '../locale'; import ModalStore from '../boards/stores/modal_store'; import { parseBoolean, spriteIcon } from '../lib/utils/common_utils'; -import { getAjaxUsersSelectOptions, getAjaxUsersSelectParams } from './utils'; -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import { fixTitle, dispose } from '~/tooltips'; import { loadCSSFile } from '../lib/utils/css_utils'; +import { getAjaxUsersSelectOptions, getAjaxUsersSelectParams } from './utils'; // TODO: remove eventHub hack after code splitting refactor window.emitSidebarEvent = window.emitSidebarEvent || $.noop; 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 9b822657184..951dc108c51 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 @@ -2,6 +2,7 @@ import { GlButton } from '@gitlab/ui'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { s__ } from '~/locale'; +import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import eventHub from '../../event_hub'; import approvalsMixin from '../../mixins/approvals'; import MrWidgetContainer from '../mr_widget_container.vue'; @@ -124,7 +125,7 @@ export default { methods: { approve() { if (this.requirePasswordToApprove) { - this.$root.$emit('bv::show::modal', this.modalId); + this.$root.$emit(BV_SHOW_MODAL, this.modalId); return; } @@ -158,6 +159,7 @@ export default { .then((data) => { this.mr.setApprovals(data); eventHub.$emit('MRWidgetUpdateRequested'); + eventHub.$emit('ApprovalUpdated'); this.$emit('updated'); }) .catch(errFn) diff --git a/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list_app.vue b/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list_app.vue index 730e67761be..ebf42fa0be0 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list_app.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list_app.vue @@ -1,8 +1,8 @@ <script> import { mapActions, mapState, mapGetters } from 'vuex'; +import createStore from '../stores/artifacts_list'; import ArtifactsList from './artifacts_list.vue'; import MrCollapsibleExtension from './mr_collapsible_extension.vue'; -import createStore from '../stores/artifacts_list'; export default { store: createStore(), diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue index 6628ab7be83..26693a8f51c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue @@ -133,14 +133,12 @@ export default { v-gl-tooltip :title="ideButtonTitle" class="gl-display-none d-md-inline-block gl-mr-3" - :tabindex="!mr.canPushToSourceBranch ? 0 : null" + :tabindex="ideButtonTitle ? 0 : null" > <gl-button :href="webIdePath" :disabled="!mr.canPushToSourceBranch" class="js-web-ide" - tabindex="0" - role="button" data-qa-selector="open_in_web_ide_button" > {{ s__('mrWidget|Open in Web IDE') }} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue index 4c130945487..8084ad59f42 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -139,45 +139,38 @@ export default { <div class="ci-widget media"> <template v-if="hasCIError"> <gl-icon name="status_failed" class="gl-text-red-500" :size="24" /> - <div - class="gl-flex-fill-1 gl-ml-5" - tabindex="0" - role="text" - :aria-label="$options.errorText" - data-testid="ci-error-message" - > + <p class="gl-flex-fill-1 gl-ml-5 gl-mb-0" data-testid="ci-error-message"> <gl-sprintf :message="$options.errorText"> <template #link="{ content }"> <gl-link :href="mrTroubleshootingDocsPath">{{ content }}</gl-link> </template> </gl-sprintf> - </div> + </p> </template> <template v-else-if="!hasPipeline"> <gl-loading-icon size="md" /> - <div class="gl-flex-fill-1 gl-display-flex gl-ml-5" data-testid="monitoring-pipeline-message"> - <span tabindex="0" role="text" :aria-label="$options.monitoringPipelineText"> - <gl-sprintf :message="$options.monitoringPipelineText" /> - </span> + <p + class="gl-flex-fill-1 gl-display-flex gl-ml-5 gl-mb-0" + data-testid="monitoring-pipeline-message" + > + {{ $options.monitoringPipelineText }} <gl-link + v-gl-tooltip :href="ciTroubleshootingDocsPath" target="_blank" + :title="__('About this feature')" class="gl-display-flex gl-align-items-center gl-ml-2" - tabindex="0" > <gl-icon name="question" - :size="12" - tabindex="0" - role="text" :aria-label="__('Link to go to GitLab pipeline documentation')" /> </gl-link> - </div> + </p> </template> <template v-else-if="hasPipeline"> <a :href="status.details_path" class="align-self-start gl-mr-3"> - <ci-icon :status="status" :size="24" :borderless="true" class="add-border" /> + <ci-icon :status="status" :size="24" /> </a> <div class="ci-widget-container d-flex"> <div class="ci-widget-content"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue index 99b55c0f9ee..2bf86c1863a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue @@ -1,10 +1,10 @@ <script> import { isNumber } from 'lodash'; import { sanitize } from '~/lib/dompurify'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import ArtifactsApp from './artifacts_list_app.vue'; import MrWidgetContainer from './mr_widget_container.vue'; import MrWidgetPipeline from './mr_widget_pipeline.vue'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; /** * Renders the pipeline and related deployments from the store. diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue index bc23ca6b1fc..677c50ed930 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue @@ -43,10 +43,9 @@ export default { <gl-button v-if="showDisabledButton" - type="button" category="primary" variant="success" - class="js-disabled-merge-button" + data-testid="disabled-merge-button" :disabled="true" > {{ s__('mrWidget|Merge') }} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue index 7acdd695cc2..d2581f57837 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue @@ -1,6 +1,5 @@ <script> import { GlLink, GlSprintf, GlButton } from '@gitlab/ui'; -import MrWidgetIcon from './mr_widget_icon.vue'; import Tracking from '~/tracking'; import DismissibleContainer from '~/vue_shared/components/dismissible_container.vue'; import { @@ -13,6 +12,7 @@ import { SP_HELP_URL, SP_ICON_NAME, } from '../constants'; +import MrWidgetIcon from './mr_widget_icon.vue'; const trackingMixin = Tracking.mixin(); 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 20ac8f5a467..9c16bf78c93 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 @@ -3,12 +3,13 @@ import { GlLoadingIcon, GlSkeletonLoader } 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 glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { __ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { deprecatedCreateFlash as Flash } from '../../../flash'; import statusIcon from '../mr_widget_status_icon.vue'; import MrWidgetAuthor from '../mr_widget_author.vue'; import eventHub from '../../event_hub'; import { AUTO_MERGE_STRATEGIES } from '../../constants'; -import { __ } from '~/locale'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; export default { @@ -53,7 +54,11 @@ export default { }, computed: { loading() { - return this.glFeatures.mergeRequestWidgetGraphql && this.$apollo.queries.state.loading; + return ( + this.glFeatures.mergeRequestWidgetGraphql && + this.$apollo.queries.state.loading && + Object.keys(this.state).length === 0 + ); }, mergeUser() { if (this.glFeatures.mergeRequestWidgetGraphql) { @@ -78,7 +83,7 @@ export default { canRemoveSourceBranch() { const { currentUserId } = this.mr; const mergeUserId = this.glFeatures.mergeRequestWidgetGraphql - ? this.state.mergeUser?.id + ? getIdFromGraphQLId(this.state.mergeUser?.id) : this.mr.mergeUserId; const canRemoveSourceBranch = this.glFeatures.mergeRequestWidgetGraphql ? this.state.userPermissions.removeSourceBranch @@ -96,7 +101,11 @@ export default { .cancelAutomaticMerge() .then((res) => res.data) .then((data) => { - eventHub.$emit('UpdateWidgetData', data); + if (this.glFeatures.mergeRequestWidgetGraphql) { + eventHub.$emit('MRWidgetUpdateRequested'); + } else { + eventHub.$emit('UpdateWidgetData', data); + } }) .catch(() => { this.isCancellingAutoMerge = false; @@ -119,6 +128,11 @@ export default { eventHub.$emit('MRWidgetUpdateRequested'); } }) + .then(() => { + if (this.glFeatures.mergeRequestWidgetGraphql) { + this.$apollo.queries.state.refetch(); + } + }) .catch(() => { this.isRemovingSourceBranch = false; Flash(__('Something went wrong. Please try again.')); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue index a5ec095b8ec..f411f91245d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue @@ -1,6 +1,6 @@ <script> import { GlButton } from '@gitlab/ui'; -import { n__ } from '~/locale'; +import { sprintf, s__, n__ } from '~/locale'; import { stripHtml } from '~/lib/utils/text_utility'; import statusIcon from '../mr_widget_status_icon.vue'; import eventHub from '../../event_hub'; @@ -31,7 +31,15 @@ export default { computed: { mergeError() { - return this.mr.mergeError ? stripHtml(this.mr.mergeError, ' ').trim() : ''; + const mergeError = this.mr.mergeError ? stripHtml(this.mr.mergeError, ' ').trim() : ''; + + return sprintf( + s__('mrWidget|%{mergeError}.'), + { + mergeError, + }, + false, + ); }, timerText() { return n__( 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 9d646dbfb3e..31302904b2d 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 @@ -4,6 +4,8 @@ import { GlLoadingIcon, GlButton, GlTooltipDirective } from '@gitlab/ui'; import { deprecatedCreateFlash as Flash } from '~/flash'; import { s__, __ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import modalEventHub from '~/projects/commit/event_hub'; +import { OPEN_REVERT_MODAL } from '~/projects/commit/constants'; import MrWidgetAuthorTime from '../mr_widget_author_time.vue'; import statusIcon from '../mr_widget_status_icon.vue'; import eventHub from '../../event_hub'; @@ -77,6 +79,9 @@ export default { return s__('mrWidget|Cherry-pick'); }, }, + mounted() { + document.dispatchEvent(new CustomEvent('merged:UpdateActions')); + }, methods: { removeSourceBranch() { this.isMakingRequest = true; @@ -98,6 +103,9 @@ export default { Flash(__('Something went wrong. Please try again.')); }); }, + openRevertModal() { + modalEventHub.$emit(OPEN_REVERT_MODAL); + }, }, }; </script> @@ -119,9 +127,7 @@ export default { size="small" category="secondary" variant="warning" - href="#modal-revert-commit" - data-toggle="modal" - data-container="body" + @click="openRevertModal" > {{ revertLabel }} </gl-button> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue index 3f68979bc0e..0a87edb0c89 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue @@ -1,8 +1,8 @@ <script> import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { sprintf, s__ } from '~/locale'; -import statusIcon from '../mr_widget_status_icon.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import statusIcon from '../mr_widget_status_icon.vue'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; import missingBranchQuery from '../../queries/states/missing_branch.query.graphql'; 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 bf86e0d8b07..5127ab3d400 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 @@ -7,7 +7,7 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import simplePoll from '../../../lib/utils/simple_poll'; import eventHub from '../../event_hub'; import statusIcon from '../mr_widget_status_icon.vue'; -import rebaseQuery from '../../queries/states/ready_to_merge.query.graphql'; +import rebaseQuery from '../../queries/states/rebase.query.graphql'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; import { deprecatedCreateFlash as Flash } from '../../../flash'; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue index 14a29483d3c..f0259a975db 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue @@ -1,9 +1,13 @@ <script> /* eslint-disable vue/no-v-html */ +import { GlButton } from '@gitlab/ui'; import emptyStateSVG from 'icons/_mr_widget_empty_state.svg'; export default { name: 'MRWidgetNothingToMerge', + components: { + GlButton, + }, props: { mr: { type: Object, @@ -25,11 +29,13 @@ export default { <span v-html="emptyStateSVG"></span> </div> <div class="text col-md-7 order-md-first col-12"> - <span>{{ - s__( - 'mrWidgetNothingToMerge|Merge requests are a place to propose changes you have made to a project and discuss those changes with others.', - ) - }}</span> + <p class="highlight"> + {{ + s__( + 'mrWidgetNothingToMerge|Merge requests are a place to propose changes you have made to a project and discuss those changes with others.', + ) + }} + </p> <p> {{ s__( @@ -45,9 +51,14 @@ export default { }} </p> <div> - <a v-if="mr.newBlobPath" :href="mr.newBlobPath" class="btn btn-inverted btn-success">{{ - __('Create file') - }}</a> + <gl-button + v-if="mr.newBlobPath" + :href="mr.newBlobPath" + category="secondary" + variant="success" + > + {{ __('Create file') }} + </gl-button> </div> </div> </div> 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 5f56157cb89..89c7a27b1bc 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 @@ -1,11 +1,26 @@ <script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; import statusIcon from '../mr_widget_status_icon.vue'; export default { name: 'PipelineFailed', components: { + GlLink, + GlSprintf, statusIcon, }, + computed: { + troubleshootingDocsPath() { + return helpPagePath('ci/troubleshooting', { anchor: 'merge-request-status-messages' }); + }, + }, + i18n: { + failedMessage: s__( + `mrWidget|The pipeline for this merge request did not complete. Push a new commit to fix the failure or check the %{linkStart}troubleshooting documentation%{linkEnd} to see other possible actions.`, + ), + }, }; </script> @@ -14,10 +29,13 @@ export default { <status-icon :show-disabled-button="true" status="warning" /> <div class="media-body space-children"> <span class="bold"> - {{ - s__(`mrWidget|The pipeline for this merge request failed. -Please retry the job or push a new commit to fix the failure`) - }} + <gl-sprintf :message="$options.i18n.failedMessage"> + <template #link="{ content }"> + <gl-link :href="troubleshootingDocsPath" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> </span> </div> </div> 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 a890b176df0..58337ea8f67 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 @@ -15,19 +15,19 @@ import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_ import readyToMergeQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql'; import simplePoll from '~/lib/utils/simple_poll'; import { __ } from '~/locale'; -import MergeRequest from '../../../merge_request'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import MergeRequest from '../../../merge_request'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; import { deprecatedCreateFlash as Flash } from '../../../flash'; import MergeRequestStore from '../../stores/mr_widget_store'; import statusIcon from '../mr_widget_status_icon.vue'; import eventHub from '../../event_hub'; +import { AUTO_MERGE_STRATEGIES, DANGER, INFO, WARNING } from '../../constants'; import SquashBeforeMerge from './squash_before_merge.vue'; import CommitsHeader from './commits_header.vue'; import CommitEdit from './commit_edit.vue'; import CommitMessageDropdown from './commit_message_dropdown.vue'; -import { AUTO_MERGE_STRATEGIES, DANGER, INFO, WARNING } from '../../constants'; const PIPELINE_RUNNING_STATE = 'running'; const PIPELINE_FAILED_STATE = 'failed'; @@ -53,8 +53,8 @@ export default { result({ data }) { this.state = { ...data.project.mergeRequest, - mergeRequestsFfOnlyEnabled: data.mergeRequestsFfOnlyEnabled, - onlyAllowMergeIfPipelineSucceeds: data.onlyAllowMergeIfPipelineSucceeds, + mergeRequestsFfOnlyEnabled: data.project.mergeRequestsFfOnlyEnabled, + onlyAllowMergeIfPipelineSucceeds: data.project.onlyAllowMergeIfPipelineSucceeds, }; this.removeSourceBranch = data.project.mergeRequest.shouldRemoveSourceBranch; this.commitMessage = data.project.mergeRequest.defaultMergeCommitMessage; @@ -277,7 +277,20 @@ export default { return this.mr.mergeRequestDiffsPath; }, }, + mounted() { + if (this.glFeatures.mergeRequestWidgetGraphql) { + eventHub.$on('ApprovalUpdated', this.updateGraphqlState); + } + }, + beforeDestroy() { + if (this.glFeatures.mergeRequestWidgetGraphql) { + eventHub.$off('ApprovalUpdated', this.updateGraphqlState); + } + }, methods: { + updateGraphqlState() { + return this.$apollo.queries.state.refetch(); + }, updateMergeCommitMessage(includeDescription) { const commitMessage = this.glFeatures.mergeRequestWidgetGraphql ? this.state.defaultMergeCommitMessage @@ -326,6 +339,10 @@ export default { } else if (hasError) { eventHub.$emit('FailedToMerge', data.merge_error); } + + if (this.glFeatures.mergeRequestWidgetGraphql) { + this.updateGraphqlState(); + } }) .catch(() => { this.isMakingRequest = false; @@ -532,7 +549,7 @@ export default { </div> <merge-train-helper-text v-if="shouldRenderMergeTrainHelperText" - :pipeline-id="pipeline.id" + :pipeline-id="pipelineId" :pipeline-link="pipeline.path" :merge-train-length="stateData.mergeTrainsCount" :merge-train-when-pipeline-succeeds-docs-path="mr.mergeTrainWhenPipelineSucceedsDocsPath" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue index 78e69b9ff9b..329964d009a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue @@ -1,7 +1,7 @@ <script> import { GlButton } from '@gitlab/ui'; -import statusIcon from '../mr_widget_status_icon.vue'; import notesEventHub from '~/notes/event_hub'; +import statusIcon from '../mr_widget_status_icon.vue'; export default { name: 'UnresolvedDiscussions', 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 index 180db7828a8..ce04fa1cef5 100644 --- 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 @@ -2,8 +2,8 @@ import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlSprintf } from '@gitlab/ui'; import { n__ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; -import MrWidgetExpanableSection from '../mr_widget_expandable_section.vue'; import Poll from '~/lib/utils/poll'; +import MrWidgetExpanableSection from '../mr_widget_expandable_section.vue'; import TerraformPlan from './terraform_plan.vue'; export default { 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 index b74e036d9d9..5f65d1fa49a 100644 --- 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 @@ -15,6 +15,16 @@ export default { 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 report %{name} failed to generate.'), + namedReportGenerated: s__('Terraform|The report %{name} was generated in your pipelines.'), + 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); @@ -30,23 +40,21 @@ export default { }, reportChangeText() { if (this.validPlanValues) { - return s__( - 'Terraform|Reported Resource Changes: %{addNum} to add, %{changeNum} to change, %{deleteNum} to delete', - ); + return this.$options.i18n.changes; } - return s__('Terraform|Generating the report caused an error.'); + return this.$options.i18n.generationErrored; }, reportHeaderText() { if (this.validPlanValues) { return this.plan.job_name - ? s__('Terraform|The Terraform report %{name} was generated in your pipelines.') - : s__('Terraform|A Terraform report was generated in your pipelines.'); + ? this.$options.i18n.namedReportGenerated + : this.$options.i18n.reportGenerated; } return this.plan.job_name - ? s__('Terraform|The Terraform report %{name} failed to generate.') - : s__('Terraform|A Terraform report failed to generate.'); + ? this.$options.i18n.namedReportFailed + : this.$options.i18n.reportFailed; }, validPlanValues() { return this.addNum + this.changeNum + this.deleteNum >= 0; diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js index d512877a20d..c1c491f6fe0 100644 --- a/app/assets/javascripts/vue_merge_request_widget/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/index.js @@ -1,8 +1,12 @@ +// This is a false violation of @gitlab/no-runtime-template-compiler, since it +// creates a new Vue instance by spreading a _valid_ Vue component definition +// into the Vue constructor. +/* eslint-disable @gitlab/no-runtime-template-compiler */ import Vue from 'vue'; -import MrWidgetOptions from 'ee_else_ce/vue_merge_request_widget/mr_widget_options.vue'; import VueApollo from 'vue-apollo'; -import Translate from '../vue_shared/translate'; +import MrWidgetOptions from 'ee_else_ce/vue_merge_request_widget/mr_widget_options.vue'; import createDefaultClient from '~/lib/graphql'; +import Translate from '../vue_shared/translate'; import { registerExtension } from './components/extensions'; import issueExtension from './extensions/issues'; diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js index fe512d68ea2..23215982e6e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js @@ -35,5 +35,8 @@ export default { shouldRenderMergeTrainHelperText() { return false; }, + pipelineId() { + return this.pipeline.id; + }, }, }; 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 519576d9fe6..83370901cc3 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 @@ -1,15 +1,20 @@ <script> import { isEmpty } from 'lodash'; +import { GlSafeHtmlDirective } from '@gitlab/ui'; import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_store'; import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service'; import MrWidgetApprovals from 'ee_else_ce/vue_merge_request_widget/components/approvals/approvals.vue'; import stateMaps from 'ee_else_ce/vue_merge_request_widget/stores/state_maps'; -import { GlSafeHtmlDirective } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; import Project from '~/pages/projects/project'; import SmartInterval from '~/smart_interval'; import { secondsToMilliseconds } from '~/lib/utils/datetime_utility'; +import notify from '~/lib/utils/notify'; import { deprecatedCreateFlash as createFlash } from '../flash'; +import GroupedCodequalityReportsApp from '../reports/codequality_report/grouped_codequality_reports_app.vue'; +import GroupedTestReportsApp from '../reports/components/grouped_test_reports_app.vue'; +import { setFaviconOverlay } from '../lib/utils/favicon'; +import GroupedAccessibilityReportsApp from '../reports/accessibility_report/grouped_accessibility_reports_app.vue'; import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variables'; import Loading from './components/loading.vue'; import WidgetHeader from './components/mr_widget_header.vue'; @@ -38,13 +43,8 @@ import AutoMergeFailed from './components/states/mr_widget_auto_merge_failed.vue import CheckingState from './components/states/mr_widget_checking.vue'; // import ExtensionsContainer from './components/extensions/container'; import eventHub from './event_hub'; -import notify from '~/lib/utils/notify'; import SourceBranchRemovalStatus from './components/source_branch_removal_status.vue'; import TerraformPlan from './components/terraform/mr_widget_terraform_container.vue'; -import GroupedCodequalityReportsApp from '../reports/codequality_report/grouped_codequality_reports_app.vue'; -import GroupedTestReportsApp from '../reports/components/grouped_test_reports_app.vue'; -import { setFaviconOverlay } from '../lib/utils/favicon'; -import GroupedAccessibilityReportsApp from '../reports/accessibility_report/grouped_accessibility_reports_app.vue'; import getStateQuery from './queries/get_state.query.graphql'; export default { @@ -94,7 +94,6 @@ export default { state: { query: getStateQuery, manual: true, - pollInterval: 10 * 1000, skip() { return !this.mr || !window.gon?.features?.mergeRequestWidgetGraphql; }, @@ -181,7 +180,7 @@ export default { ); }, shouldRenderSecurityReport() { - return Boolean(window.gon?.features?.coreSecurityMrWidget && this.mr.pipeline.id); + return Boolean(this.mr.pipeline.id); }, mergeError() { let { mergeError } = this.mr; @@ -191,7 +190,7 @@ export default { } return sprintf( - s__('mrWidget|Merge failed: %{mergeError}. Please try again.'), + s__('mrWidget|%{mergeError}. Try again.'), { mergeError, }, @@ -286,6 +285,10 @@ export default { return new MRWidgetService(this.getServiceEndpoints(store)); }, checkStatus(cb, isRebased) { + if (window.gon?.features?.mergeRequestWidgetGraphql) { + this.$apollo.queries.state.refetch(); + } + return this.service .checkStatus() .then(({ data }) => { @@ -365,6 +368,7 @@ export default { const el = document.createElement('div'); el.innerHTML = res.data; document.body.appendChild(el); + document.dispatchEvent(new CustomEvent('merged:UpdateActions')); Project.initRefSwitcher(); } }) @@ -464,6 +468,7 @@ export default { :head-path="mr.codeclimate.head_path" :head-blob-path="mr.headBlobPath" :base-blob-path="mr.baseBlobPath" + :codequality-reports-path="mr.codequalityReportsPath" :codequality-help-path="mr.codequalityHelpPath" /> 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 44fc1cc7f23..b284bb23969 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 @@ -18,6 +18,7 @@ query getState($projectPath: ID!, $iid: String!) { } shouldBeRebased sourceBranchExists + state targetBranchExists userPermissions { canMerge diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.fragment.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.fragment.graphql index 64cd70fcf42..ad715599eb1 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.fragment.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.fragment.graphql @@ -1,6 +1,7 @@ fragment autoMergeEnabled on MergeRequest { autoMergeStrategy mergeUser { + id name username webUrl diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql index bdcb7a8206b..daf21e75b3b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql @@ -4,7 +4,6 @@ query autoMergeEnabledQuery($projectPath: ID!, $iid: String!) { project(fullPath: $projectPath) { mergeRequest(iid: $iid) { ...autoMergeEnabled - mergeTrainsCount } } } 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 a6bbab47a06..78a17493d31 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -1,9 +1,9 @@ import { format } from 'timeago.js'; import getStateKey from 'ee_else_ce/vue_merge_request_widget/stores/get_state_key'; import mrEventHub from '~/merge_request/eventhub'; -import { stateKey } from './state_maps'; import { formatDate } from '../../lib/utils/datetime_utility'; import { MTWPS_MERGE_STRATEGY, MT_MERGE_STRATEGY, MWPS_MERGE_STRATEGY } from '../constants'; +import { stateKey } from './state_maps'; export default class MergeRequestStore { constructor(data) { @@ -156,9 +156,9 @@ export default class MergeRequestStore { this.setState(); - mrEventHub.$emit('mr.state.updated', { - state: this.mergeRequestState, - }); + if (!window.gon?.features?.mergeRequestWidgetGraphql) { + this.emitUpdatedState(); + } } setGraphqlData(project) { @@ -182,7 +182,9 @@ export default class MergeRequestStore { this.isSHAMismatch = this.sha !== mergeRequest.diffHeadSha; this.shouldBeRebased = mergeRequest.shouldBeRebased; this.workInProgress = mergeRequest.workInProgress; + this.mergeRequestState = mergeRequest.state; + this.emitUpdatedState(); this.setState(); } @@ -208,6 +210,12 @@ export default class MergeRequestStore { } } + emitUpdatedState() { + mrEventHub.$emit('mr.state.updated', { + state: this.mergeRequestState, + }); + } + setPaths(data) { // Paths are set on the first load of the page and not auto-refreshed this.squashBeforeMergeHelpPath = data.squash_before_merge_help_path; @@ -241,10 +249,11 @@ export default class MergeRequestStore { this.isDismissedSuggestPipeline = data.is_dismissed_suggest_pipeline; this.securityReportsDocsPath = data.security_reports_docs_path; - // codeclimate + // code quality const blobPath = data.blob_path || {}; this.headBlobPath = blobPath.head_path || ''; this.baseBlobPath = blobPath.base_path || ''; + this.codequalityReportsPath = data.codequality_reports_path; this.codequalityHelpPath = data.codequality_help_path; this.codeclimate = data.codeclimate; diff --git a/app/assets/javascripts/alert_management/components/alert_details.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue index 895c6e76019..673c6f4a1eb 100644 --- a/app/assets/javascripts/alert_management/components/alert_details.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue @@ -13,22 +13,22 @@ import { } from '@gitlab/ui'; import * as Sentry from '~/sentry/wrapper'; import { s__ } from '~/locale'; -import alertQuery from '../graphql/queries/details.query.graphql'; -import sidebarStatusQuery from '../graphql/queries/sidebar_status.query.graphql'; import { fetchPolicies } from '~/lib/graphql'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user'; import initUserPopovers from '~/user_popovers'; -import { ALERTS_SEVERITY_LABELS, trackAlertsDetailsViewsOptions } from '../constants'; -import createIssueMutation from '../graphql/mutations/create_issue_from_alert.mutation.graphql'; -import toggleSidebarStatusMutation from '../graphql/mutations/toggle_sidebar_status.mutation.graphql'; import { visitUrl, joinPaths } from '~/lib/utils/url_utility'; import Tracking from '~/tracking'; import { toggleContainerClasses } from '~/lib/utils/dom_utils'; +import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; +import alertQuery from '../graphql/queries/alert_details.query.graphql'; +import sidebarStatusQuery from '../graphql/queries/alert_sidebar_status.query.graphql'; +import { SEVERITY_LEVELS } from '../constants'; +import createIssueMutation from '../graphql/mutations/alert_issue_create.mutation.graphql'; +import toggleSidebarStatusMutation from '../graphql/mutations/alert_sidebar_status.mutation.graphql'; import SystemNote from './system_notes/system_note.vue'; import AlertSidebar from './alert_sidebar.vue'; import AlertMetrics from './alert_metrics.vue'; -import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; import AlertSummaryRow from './alert_summary_row.vue'; const containerEl = document.querySelector('.page-with-contextual-sidebar'); @@ -44,7 +44,7 @@ export default { directives: { SafeHtml: GlSafeHtmlDirective, }, - severityLabels: ALERTS_SEVERITY_LABELS, + severityLabels: SEVERITY_LEVELS, tabsConfig: [ { id: 'overview', @@ -89,6 +89,9 @@ export default { projectIssuesPath: { default: '', }, + trackAlertsDetailsViewsOptions: { + default: null, + }, }, apollo: { alert: { @@ -155,7 +158,9 @@ export default { }, }, mounted() { - this.trackPageViews(); + if (this.trackAlertsDetailsViewsOptions) { + this.trackPageViews(); + } toggleContainerClasses(containerEl, { 'issuable-bulk-update-sidebar': true, 'right-sidebar-expanded': true, @@ -217,7 +222,7 @@ export default { return joinPaths(this.projectIssuesPath, issueId); }, trackPageViews() { - const { category, action } = trackAlertsDetailsViewsOptions; + const { category, action } = this.trackAlertsDetailsViewsOptions; Tracking.event(category, action); }, }, diff --git a/app/assets/javascripts/alert_management/components/alert_metrics.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue index dd4faa03c00..dd4faa03c00 100644 --- a/app/assets/javascripts/alert_management/components/alert_metrics.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue diff --git a/app/assets/javascripts/alert_management/components/alert_sidebar.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_sidebar.vue index 41d77716592..12c58b582c5 100644 --- a/app/assets/javascripts/alert_management/components/alert_sidebar.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_sidebar.vue @@ -1,11 +1,10 @@ <script> +import sidebarStatusQuery from '../graphql/queries/alert_sidebar_status.query.graphql'; import SidebarHeader from './sidebar/sidebar_header.vue'; import SidebarTodo from './sidebar/sidebar_todo.vue'; import SidebarStatus from './sidebar/sidebar_status.vue'; import SidebarAssignees from './sidebar/sidebar_assignees.vue'; -import sidebarStatusQuery from '../graphql/queries/sidebar_status.query.graphql'; - export default { components: { SidebarAssignees, diff --git a/app/assets/javascripts/alert_management/components/alert_status.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_status.vue index 2afdeb8b6fd..5520f7ff045 100644 --- a/app/assets/javascripts/alert_management/components/alert_status.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_status.vue @@ -2,8 +2,7 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { s__ } from '~/locale'; import Tracking from '~/tracking'; -import { trackAlertStatusUpdateOptions } from '../constants'; -import updateAlertStatusMutation from '~/graphql_shared/mutations/update_alert_status.mutation.graphql'; +import updateAlertStatusMutation from '~/graphql_shared/mutations/alert_status_update.mutation.graphql'; export default { i18n: { @@ -21,6 +20,11 @@ export default { GlDropdown, GlDropdownItem, }, + inject: { + trackAlertStatusUpdateOptions: { + default: null, + }, + }, props: { projectPath: { type: String, @@ -58,7 +62,9 @@ export default { }, }) .then((resp) => { - this.trackStatusUpdate(status); + if (this.trackAlertStatusUpdateOptions) { + this.trackStatusUpdate(status); + } const errors = resp.data?.updateAlertStatus?.errors || []; if (errors[0]) { @@ -81,7 +87,7 @@ export default { }); }, trackStatusUpdate(status) { - const { category, action, label } = trackAlertStatusUpdateOptions; + const { category, action, label } = this.trackAlertStatusUpdateOptions; Tracking.event(category, action, { label, property: status }); }, }, diff --git a/app/assets/javascripts/alert_management/components/alert_summary_row.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_summary_row.vue index 13835b7e2fa..13835b7e2fa 100644 --- a/app/assets/javascripts/alert_management/components/alert_summary_row.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_summary_row.vue diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignee.vue index c39a72a45b9..c39a72a45b9 100644 --- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignee.vue diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue index 2a999b908f9..2a999b908f9 100644 --- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_header.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_header.vue index 70902a204f8..fd40b5d9f65 100644 --- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_header.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_header.vue @@ -27,7 +27,7 @@ export default { <template> <div class="block gl-display-flex gl-justify-content-space-between"> <span class="issuable-header-text hide-collapsed"> - {{ __('To-Do') }} + {{ __('To Do') }} </span> <sidebar-todo v-if="!sidebarCollapsed" diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_status.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue index 0a2bad5510b..0a2bad5510b 100644 --- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_status.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_todo.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue index 485395bcac2..216b429fcbe 100644 --- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_todo.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue @@ -2,14 +2,14 @@ import produce from 'immer'; import { s__ } from '~/locale'; import Todo from '~/sidebar/components/todo_toggle/todo.vue'; -import createAlertTodoMutation from '../../graphql/mutations/alert_todo_create.mutation.graphql'; import todoMarkDoneMutation from '~/graphql_shared/mutations/todo_mark_done.mutation.graphql'; -import alertQuery from '../../graphql/queries/details.query.graphql'; +import createAlertTodoMutation from '../../graphql/mutations/alert_todo_create.mutation.graphql'; +import alertQuery from '../../graphql/queries/alert_details.query.graphql'; export default { i18n: { UPDATE_ALERT_TODO_ERROR: s__( - 'AlertManagement|There was an error while updating the To-Do of the alert.', + 'AlertManagement|There was an error while updating the to-do item of the alert.', ), }, components: { diff --git a/app/assets/javascripts/alert_management/components/system_notes/system_note.vue b/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue index 3705e36a579..3705e36a579 100644 --- a/app/assets/javascripts/alert_management/components/system_notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue diff --git a/app/assets/javascripts/vue_shared/alert_details/constants.js b/app/assets/javascripts/vue_shared/alert_details/constants.js new file mode 100644 index 00000000000..56f79410064 --- /dev/null +++ b/app/assets/javascripts/vue_shared/alert_details/constants.js @@ -0,0 +1,29 @@ +import { s__ } from '~/locale'; + +export const SEVERITY_LEVELS = { + CRITICAL: s__('severity|Critical'), + HIGH: s__('severity|High'), + MEDIUM: s__('severity|Medium'), + LOW: s__('severity|Low'), + INFO: s__('severity|Info'), + UNKNOWN: s__('severity|Unknown'), +}; + +export const DEFAULT_PAGE = 'OPERATIONS'; + +/* eslint-disable @gitlab/require-i18n-strings */ +export const PAGE_CONFIG = { + OPERATIONS: { + // Tracks snowplow event when user views alert details + TRACK_ALERTS_DETAILS_VIEWS_OPTIONS: { + category: 'Alert Management', + action: 'view_alert_details', + }, + // Tracks snowplow event when alert status is updated + TRACK_ALERT_STATUS_UPDATE_OPTIONS: { + category: 'Alert Management', + action: 'update_alert_status', + label: 'Status', + }, + }, +}; diff --git a/app/assets/javascripts/alert_management/graphql/fragments/detail_item.fragment.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/fragments/alert_detail_item.fragment.graphql index 9a9ae369519..9a9ae369519 100644 --- a/app/assets/javascripts/alert_management/graphql/fragments/detail_item.fragment.graphql +++ b/app/assets/javascripts/vue_shared/alert_details/graphql/fragments/alert_detail_item.fragment.graphql diff --git a/app/assets/javascripts/alert_management/graphql/mutations/create_issue_from_alert.mutation.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql index bc4d91a51d1..bc4d91a51d1 100644 --- a/app/assets/javascripts/alert_management/graphql/mutations/create_issue_from_alert.mutation.graphql +++ b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql diff --git a/app/assets/javascripts/alert_management/graphql/mutations/alert_set_assignees.mutation.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql index 63d952a4857..63d952a4857 100644 --- a/app/assets/javascripts/alert_management/graphql/mutations/alert_set_assignees.mutation.graphql +++ b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql diff --git a/app/assets/javascripts/alert_management/graphql/mutations/toggle_sidebar_status.mutation.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_sidebar_status.mutation.graphql index f666fcd6782..f666fcd6782 100644 --- a/app/assets/javascripts/alert_management/graphql/mutations/toggle_sidebar_status.mutation.graphql +++ b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_sidebar_status.mutation.graphql diff --git a/app/assets/javascripts/alert_management/graphql/mutations/alert_todo_create.mutation.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_todo_create.mutation.graphql index ac9858c104f..dc961b5eb90 100644 --- a/app/assets/javascripts/alert_management/graphql/mutations/alert_todo_create.mutation.graphql +++ b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_todo_create.mutation.graphql @@ -1,4 +1,4 @@ -#import "../fragments/detail_item.fragment.graphql" +#import "../fragments/alert_detail_item.fragment.graphql" mutation alertTodoCreate($projectPath: ID!, $iid: String!) { alertTodoCreate(input: { iid: $iid, projectPath: $projectPath }) { diff --git a/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_details.query.graphql index 8881f49b689..5ee2cf7ca44 100644 --- a/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql +++ b/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_details.query.graphql @@ -1,4 +1,4 @@ -#import "../fragments/detail_item.fragment.graphql" +#import "../fragments/alert_detail_item.fragment.graphql" query alertDetails($fullPath: ID!, $alertId: String) { project(fullPath: $fullPath) { diff --git a/app/assets/javascripts/alert_management/graphql/queries/sidebar_status.query.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_status.query.graphql index 61c570c5cd0..61c570c5cd0 100644 --- a/app/assets/javascripts/alert_management/graphql/queries/sidebar_status.query.graphql +++ b/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_status.query.graphql diff --git a/app/assets/javascripts/alert_management/details.js b/app/assets/javascripts/vue_shared/alert_details/index.js index 4217b702d0a..643d6b3a3fe 100644 --- a/app/assets/javascripts/alert_management/details.js +++ b/app/assets/javascripts/vue_shared/alert_details/index.js @@ -4,14 +4,15 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import AlertDetails from './components/alert_details.vue'; -import sidebarStatusQuery from './graphql/queries/sidebar_status.query.graphql'; +import sidebarStatusQuery from './graphql/queries/alert_sidebar_status.query.graphql'; import createRouter from './router'; +import { DEFAULT_PAGE, PAGE_CONFIG } from './constants'; Vue.use(VueApollo); export default (selector) => { const domEl = document.querySelector(selector); - const { alertId, projectPath, projectIssuesPath, projectId } = domEl.dataset; + const { alertId, projectPath, projectIssuesPath, projectId, page = DEFAULT_PAGE } = domEl.dataset; const router = createRouter(); const resolvers = { @@ -48,18 +49,28 @@ export default (selector) => { }, }); + const provide = { + projectPath, + alertId, + projectIssuesPath, + projectId, + }; + + if (page === DEFAULT_PAGE) { + const { TRACK_ALERTS_DETAILS_VIEWS_OPTIONS, TRACK_ALERT_STATUS_UPDATE_OPTIONS } = PAGE_CONFIG[ + page + ]; + provide.trackAlertsDetailsViewsOptions = TRACK_ALERTS_DETAILS_VIEWS_OPTIONS; + provide.trackAlertStatusUpdateOptions = TRACK_ALERT_STATUS_UPDATE_OPTIONS; + } + // eslint-disable-next-line no-new new Vue({ el: selector, components: { AlertDetails, }, - provide: { - projectPath, - alertId, - projectIssuesPath, - projectId, - }, + provide, apolloProvider, router, render(createElement) { diff --git a/app/assets/javascripts/alert_management/router.js b/app/assets/javascripts/vue_shared/alert_details/router.js index 5687fe4e0f5..5687fe4e0f5 100644 --- a/app/assets/javascripts/alert_management/router.js +++ b/app/assets/javascripts/vue_shared/alert_details/router.js diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue index c1da2b8c305..4fefce47da5 100644 --- a/app/assets/javascripts/vue_shared/components/awards_list.vue +++ b/app/assets/javascripts/vue_shared/components/awards_list.vue @@ -2,8 +2,8 @@ /* eslint-disable vue/no-v-html */ import { groupBy } from 'lodash'; import { GlIcon, GlButton, GlTooltipDirective } from '@gitlab/ui'; -import { glEmojiTag } from '../../emoji'; import { __, sprintf } from '~/locale'; +import { glEmojiTag } from '../../emoji'; // Internal constant, specific to this component, used when no `currentUserId` is given const NO_USER_ID = -1; diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue index d0f5570db6b..98e6fdead3a 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue @@ -1,8 +1,8 @@ <script> import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue'; -import ViewerMixin from './mixins'; import { handleBlobRichViewer } from '~/blob/viewer'; +import ViewerMixin from './mixins'; export default { components: { diff --git a/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue b/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue index 6977692e30c..0ff33e462b4 100644 --- a/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue +++ b/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue @@ -3,12 +3,16 @@ * Renders a color picker input with preset colors to choose from * * @example - * <color-picker :label="__('Background color')" set-color="#FF0000" /> + * <color-picker + :invalid-feedback="__('Please enter a valid hex (#RRGGBB or #RGB) color value')" + :label="__('Background color')" + :value="#FF0000" + state="isValidColor" + /> */ import { GlFormGroup, GlFormInput, GlFormInputGroup, GlLink, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; -const VALID_RGB_HEX_COLOR = /^#([0-9A-F]{3}){1,2}$/i; const PREVIEW_COLOR_DEFAULT_CLASSES = 'gl-relative gl-w-7 gl-bg-gray-10 gl-rounded-top-left-base gl-rounded-bottom-left-base'; @@ -24,21 +28,26 @@ export default { GlTooltip: GlTooltipDirective, }, props: { + invalidFeedback: { + type: String, + required: false, + default: __('Please enter a valid hex (#RRGGBB or #RGB) color value'), + }, label: { type: String, required: false, default: '', }, - setColor: { + value: { type: String, required: false, default: '', }, - }, - data() { - return { - selectedColor: this.setColor.trim() || '', - }; + state: { + type: Boolean, + required: false, + default: null, + }, }, computed: { description() { @@ -50,46 +59,30 @@ export default { return gon.suggested_label_colors; }, previewColor() { - if (this.isValidColor) { - return { backgroundColor: this.selectedColor }; + if (this.state) { + return { backgroundColor: this.value }; } return {}; }, previewColorClasses() { - const borderStyle = this.isInvalidColor - ? 'gl-inset-border-1-red-500' - : 'gl-inset-border-1-gray-400'; + const borderStyle = + this.state === false ? 'gl-inset-border-1-red-500' : 'gl-inset-border-1-gray-400'; return `${PREVIEW_COLOR_DEFAULT_CLASSES} ${borderStyle}`; }, hasSuggestedColors() { return Object.keys(this.suggestedColors).length; }, - isInvalidColor() { - return this.isValidColor === false; - }, - isValidColor() { - if (this.selectedColor === '') { - return null; - } - - return VALID_RGB_HEX_COLOR.test(this.selectedColor); - }, }, methods: { handleColorChange(color) { - this.selectedColor = color.trim(); - - if (this.isValidColor) { - this.$emit('input', this.selectedColor); - } + this.$emit('input', color.trim()); }, }, i18n: { fullDescription: __('Choose any color. Or you can choose one of the suggested colors below'), shortDescription: __('Choose any color'), - invalid: __('Please enter a valid hex (#RRGGBB or #RGB) color value'), }, }; </script> @@ -100,17 +93,17 @@ export default { :label="label" label-for="color-picker" :description="description" - :invalid-feedback="this.$options.i18n.invalid" - :state="isValidColor" + :invalid-feedback="invalidFeedback" + :state="state" :class="{ 'gl-mb-3!': hasSuggestedColors }" > <gl-form-input-group id="color-picker" - :state="isValidColor" max-length="7" type="text" class="gl-align-center gl-rounded-0 gl-rounded-top-right-base gl-rounded-bottom-right-base" - :value="selectedColor" + :value="value" + :state="state" @input="handleColorChange" > <template #prepend> @@ -119,7 +112,7 @@ export default { type="color" class="gl-absolute gl-top-0 gl-left-0 gl-h-full! gl-p-0! gl-m-0! gl-cursor-pointer gl-opacity-0" tabindex="-1" - :value="selectedColor" + :value="value" @input="handleColorChange" /> </div> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue index 00033145603..31dbe6c4c1a 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue @@ -1,9 +1,9 @@ <script> import ImageViewer from '../../content_viewer/viewers/image_viewer.vue'; +import { diffModes, imageViewMode } from '../constants'; import TwoUpViewer from './image_diff/two_up_viewer.vue'; import SwipeViewer from './image_diff/swipe_viewer.vue'; import OnionSkinViewer from './image_diff/onion_skin_viewer.vue'; -import { diffModes, imageViewMode } from '../constants'; export default { components: { diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue index 7e82d8f3f9c..eb8400e81c7 100644 --- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue @@ -36,10 +36,8 @@ export default { aria-expanded="false" > <gl-loading-icon v-show="isLoading" :inline="true" /> - <template> - <slot v-if="$slots.default"></slot> - <span v-else class="dropdown-toggle-text"> {{ toggleText }} </span> - </template> + <slot v-if="$slots.default"></slot> + <span v-else class="dropdown-toggle-text"> {{ toggleText }} </span> <gl-icon v-show="!isLoading" class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" diff --git a/app/assets/javascripts/vue_shared/components/editor_lite.vue b/app/assets/javascripts/vue_shared/components/editor_lite.vue index 7218b84cf8a..b5b5330973c 100644 --- a/app/assets/javascripts/vue_shared/components/editor_lite.vue +++ b/app/assets/javascripts/vue_shared/components/editor_lite.vue @@ -1,7 +1,7 @@ <script> import { debounce } from 'lodash'; import Editor from '~/editor/editor_lite'; -import { CONTENT_UPDATE_DEBOUNCE } from '~/editor/constants'; +import { CONTENT_UPDATE_DEBOUNCE, EDITOR_READY_EVENT } from '~/editor/constants'; function initEditorLite({ el, ...args }) { const editor = new Editor({ @@ -88,6 +88,7 @@ export default { return this.editor; }, }, + readyEvent: EDITOR_READY_EVENT, }; </script> <template> @@ -95,7 +96,7 @@ export default { :id="`editor-lite-${fileGlobalId}`" ref="editor" data-editor-loading - @editor-ready="$emit('editor-ready')" + @[$options.readyEvent]="$emit($options.readyEvent)" > <pre class="editor-loading-content">{{ value }}</pre> </div> diff --git a/app/assets/javascripts/vue_shared/components/file_finder/index.vue b/app/assets/javascripts/vue_shared/components/file_finder/index.vue index 27933f87929..c3cebf079ea 100644 --- a/app/assets/javascripts/vue_shared/components/file_finder/index.vue +++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue @@ -3,8 +3,8 @@ import fuzzaldrinPlus from 'fuzzaldrin-plus'; import Mousetrap from 'mousetrap'; import VirtualList from 'vue-virtual-scroll-list'; import { GlIcon } from '@gitlab/ui'; -import Item from './item.vue'; import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; +import Item from './item.vue'; export const MAX_FILE_FINDER_RESULTS = 40; export const FILE_FINDER_ROW_HEIGHT = 55; diff --git a/app/assets/javascripts/vue_shared/components/file_icon.vue b/app/assets/javascripts/vue_shared/components/file_icon.vue index 6190b07962d..8ac8a3beb7d 100644 --- a/app/assets/javascripts/vue_shared/components/file_icon.vue +++ b/app/assets/javascripts/vue_shared/components/file_icon.vue @@ -1,7 +1,7 @@ <script> import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; -import getIconForFile from './file_icon/file_icon_map'; import { FILE_SYMLINK_MODE } from '../constants'; +import getIconForFile from './file_icon/file_icon_map'; /* This is a re-usable vue component for rendering a svg sprite icon @@ -90,6 +90,12 @@ export default { <svg v-else-if="!folder" :key="spriteHref" :class="[iconSizeClass, cssClasses]"> <use v-bind="{ 'xlink:href': spriteHref }" /> </svg> - <gl-icon v-else :name="folderIconName" :size="size" class="folder-icon" /> + <gl-icon + v-else + :name="folderIconName" + :size="size" + class="folder-icon" + data-qa-selector="folder_icon_content" + /> </span> </template> diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index 96567111bbc..17ebdce232a 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -147,6 +147,7 @@ export default { :style="levelIndentation" class="file-row-name" data-qa-selector="file_name_content" + :data-qa-file-name="file.name" data-testid="file-row-name-container" :class="[fileClasses, { 'str-truncated': !truncateMiddle, 'gl-min-w-0': truncateMiddle }]" > diff --git a/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js index 809932b0f29..44c3fc34ba6 100644 --- a/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js +++ b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js @@ -84,7 +84,7 @@ export const tributeConfig = { value.type === groupType ? last(value.name.split(' / ')) : `${value.name}${value.username}`, menuItemLimit: memberLimit, menuItemTemplate: ({ original }) => { - const commonClasses = 'gl-avatar gl-avatar-s24 gl-flex-shrink-0'; + const commonClasses = 'gl-avatar gl-avatar-s32 gl-flex-shrink-0'; const noAvatarClasses = `${commonClasses} gl-rounded-small gl-display-flex gl-align-items-center gl-justify-content-center`; @@ -111,7 +111,7 @@ export const tributeConfig = { return ` <div class="gl-display-flex gl-align-items-center"> ${avatar} - <div class="gl-font-sm gl-line-height-normal gl-ml-3"> + <div class="gl-line-height-normal gl-ml-4"> <div>${escape(displayName)}${count}</div> <div class="gl-text-gray-700">${escape(parentGroupOrUsername)}</div> </div> diff --git a/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue b/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue index e14f6a04d3c..c9e5f433c3c 100644 --- a/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue +++ b/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue @@ -1,6 +1,7 @@ <script> import { mapState, mapActions } from 'vuex'; import { GlModal } from '@gitlab/ui'; +import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; /** * This component keeps the GlModal's visibility in sync with the given vuex module. @@ -46,11 +47,11 @@ export default { }, }), bsShow() { - this.$root.$emit('bv::show::modal', this.modalId); + this.$root.$emit(BV_SHOW_MODAL, this.modalId); }, bsHide() { // $root.$emit is a workaround because other b-modal approaches don't work yet with gl-modal - this.$root.$emit('bv::hide::modal', this.modalId); + this.$root.$emit(BV_HIDE_MODAL, this.modalId); }, cancel() { this.$emit('cancel'); diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue index 79d9ba6df57..c882e894e7a 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -1,11 +1,11 @@ <script> /* eslint-disable vue/no-v-html */ import { GlTooltipDirective, GlLink, GlButton, GlTooltip } from '@gitlab/ui'; +import { glEmojiTag } from '../../emoji'; +import { __, sprintf } from '../../locale'; import CiIconBadge from './ci_badge_link.vue'; import TimeagoTooltip from './time_ago_tooltip.vue'; import UserAvatarImage from './user_avatar/user_avatar_image.vue'; -import { glEmojiTag } from '../../emoji'; -import { __, sprintf } from '../../locale'; /** * Renders header component for job and pipeline page based on UI mockups @@ -148,7 +148,7 @@ export default { <gl-button v-if="hasSidebarButton" class="d-sm-none js-sidebar-build-toggle gl-ml-auto" - icon="angle-double-left" + icon="chevron-double-lg-left" :aria-label="__('Toggle sidebar')" @click="onClickSidebarButton" /> diff --git a/app/assets/javascripts/vue_shared/components/help_popover.vue b/app/assets/javascripts/vue_shared/components/help_popover.vue index 821ae6cec52..051c65bae70 100644 --- a/app/assets/javascripts/vue_shared/components/help_popover.vue +++ b/app/assets/javascripts/vue_shared/components/help_popover.vue @@ -1,8 +1,5 @@ <script> -import $ from 'jquery'; -import { GlButton } from '@gitlab/ui'; -import { inserted } from '~/feature_highlight/feature_highlight_helper'; -import { mouseenter, debouncedMouseleave, togglePopover } from '~/shared/popover'; +import { GlButton, GlPopover } from '@gitlab/ui'; /** * Render a button with a question mark icon @@ -12,6 +9,7 @@ export default { name: 'HelpPopover', components: { GlButton, + GlPopover, }, props: { options: { @@ -20,28 +18,20 @@ export default { default: () => ({}), }, }, - mounted() { - const $el = $(this.$el); - - $el - .popover({ - html: true, - trigger: 'focus', - container: 'body', - placement: 'top', - template: - '<div class="popover" role="tooltip"><div class="arrow"></div><p class="popover-header"></p><div class="popover-body"></div></div>', - ...this.options, - }) - .on('mouseenter', mouseenter) - .on('mouseleave', debouncedMouseleave(300)) - .on('inserted.bs.popover', inserted) - .on('show.bs.popover', () => { - window.addEventListener('scroll', togglePopover.bind($el, false), { once: true }); - }); - }, }; </script> <template> - <gl-button variant="link" icon="question" tabindex="0" /> + <span> + <gl-button ref="popoverTrigger" variant="link" icon="question" tabindex="0" /> + <gl-popover triggers="hover focus" :target="() => $refs.popoverTrigger.$el" v-bind="options"> + <template #title> + <!-- eslint-disable-next-line vue/no-v-html --> + <span v-html="options.title"></span> + </template> + <template #default> + <!-- eslint-disable-next-line vue/no-v-html --> + <div v-html="options.content"></div> + </template> + </gl-popover> + </span> </template> diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue index 2ff4033a07e..d5a8e10f5c0 100644 --- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue +++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue @@ -3,11 +3,11 @@ import '~/commons/bootstrap'; import { GlIcon, GlTooltip, GlTooltipDirective, GlButton } from '@gitlab/ui'; import { sprintf } from '~/locale'; -import IssueMilestone from './issue_milestone.vue'; -import IssueAssignees from './issue_assignees.vue'; import IssueDueDate from '~/boards/components/issue_due_date.vue'; import relatedIssuableMixin from '../../mixins/related_issuable_mixin'; import CiIcon from '../ci_icon.vue'; +import IssueAssignees from './issue_assignees.vue'; +import IssueMilestone from './issue_milestone.vue'; export default { name: 'IssueItem', diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index b6e167524aa..d75bff04e32 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -8,12 +8,12 @@ import { __, sprintf } from '~/locale'; import { stripHtml } from '~/lib/utils/text_utility'; import { deprecatedCreateFlash as Flash } from '~/flash'; import GLForm from '~/gl_form'; -import MarkdownHeader from './header.vue'; -import MarkdownToolbar from './toolbar.vue'; import GfmAutocomplete from '~/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue'; import Suggestions from '~/vue_shared/components/markdown/suggestions.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import axios from '~/lib/utils/axios_utils'; +import MarkdownToolbar from './toolbar.vue'; +import MarkdownHeader from './header.vue'; export default { components: { @@ -110,11 +110,6 @@ export default { return this.referencedUsers.length >= referencedUsersThreshold; }, lineContent() { - const [firstSuggestion] = this.suggestions; - if (firstSuggestion) { - return firstSuggestion.from_content; - } - if (this.line) { const { rich_text: richText, text } = this.line; diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index 173d192dab0..7ea1a2e5ee8 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -172,6 +172,7 @@ export default { :cursor-offset="4" :tag-content="lineContent" icon="doc-code" + data-qa-selector="suggestion_button" class="js-suggestion-btn" @click="handleSuggestDismissed" /> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue index 93a270b8a97..bcd8c02e968 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue @@ -1,7 +1,7 @@ <script> +import { selectDiffLines } from '../lib/utils/diff_utils'; import SuggestionDiffHeader from './suggestion_diff_header.vue'; import SuggestionDiffRow from './suggestion_diff_row.vue'; -import { selectDiffLines } from '../lib/utils/diff_utils'; export default { components: { 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 63341b433e0..4c6fa71398d 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 @@ -88,7 +88,12 @@ export default { applySuggestion(message) { if (!this.canApply) return; this.isApplyingSingle = true; - this.$emit('apply', this.applySuggestionCallback, message); + + this.$emit( + 'apply', + this.applySuggestionCallback, + gon.features?.suggestionsCustomCommit ? message : undefined, + ); }, applySuggestionCallback() { this.isApplyingSingle = false; @@ -131,6 +136,7 @@ export default { <gl-button v-gl-tooltip.viewport="__('This also resolves all related threads')" class="btn-inverted js-apply-batch-btn btn-grouped" + data-qa-selector="apply_suggestions_batch_button" :disabled="isApplying" variant="success" @click="applySuggestionBatch" @@ -145,6 +151,7 @@ export default { <gl-button v-if="suggestionsCount > 1 && canBeBatched && !isDisableButton" class="btn-inverted js-add-to-batch-btn btn-grouped" + data-qa-selector="add_suggestion_batch_button" :disabled="isDisableButton" @click="addSuggestionToBatch" > diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue index 5ee51764555..c86f16e0254 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue @@ -2,8 +2,8 @@ import Vue from 'vue'; import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { __ } from '~/locale'; -import SuggestionDiff from './suggestion_diff.vue'; import { deprecatedCreateFlash as Flash } from '~/flash'; +import SuggestionDiff from './suggestion_diff.vue'; export default { directives: { @@ -64,6 +64,11 @@ export default { mounted() { this.renderSuggestions(); }, + beforeDestroy() { + if (this.suggestionsWatch) { + this.suggestionsWatch(); + } + }, methods: { renderSuggestions() { // swaps out suggestion(s) markdown with rich diff components @@ -108,6 +113,13 @@ export default { }, }); + // We're using `$watch` as `suggestionsCount` updates do not + // propagate to this component for some unknown reason while + // using a traditional prop watcher. + this.suggestionsWatch = this.$watch('suggestionsCount', () => { + suggestionDiff.suggestionsCount = this.suggestionsCount; + }); + suggestionDiff.$on('apply', ({ suggestionId, callback, message }) => { this.$emit('apply', { suggestionId, callback, flashContainer: this.$el, message }); }); diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue index 15c5b9d6733..387b100a04f 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue @@ -60,9 +60,7 @@ export default { </div> <span v-if="canAttachFile" class="uploading-container"> <span class="uploading-progress-container hide"> - <template> - <gl-icon name="media" /> - </template> + <gl-icon name="media" /> <span class="attaching-file-message"></span> <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings --> <span class="uploading-progress">0%</span> 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 e3a7f144321..7b36d57dfbf 100644 --- a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue +++ b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue @@ -2,6 +2,7 @@ import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import Clipboard from 'clipboard'; import { uniqueId } from 'lodash'; +import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; export default { components: { @@ -76,7 +77,7 @@ export default { }); this.clipboard .on('success', (e) => { - this.$root.$emit('bv::hide::tooltip', this.id); + this.$root.$emit(BV_HIDE_TOOLTIP, this.id); this.$emit('success', e); // Clear the selection and blur the trigger so it loses its border e.clearSelection(); 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 cc1203f83f0..c66b1112bf4 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -30,9 +30,9 @@ import { import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history'; import noteHeader from '~/notes/components/note_header.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import TimelineEntryItem from './timeline_entry_item.vue'; -import { spriteIcon } from '../../../lib/utils/common_utils'; import initMRPopovers from '~/mr_popover/'; +import { spriteIcon } from '../../../lib/utils/common_utils'; +import TimelineEntryItem from './timeline_entry_item.vue'; const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; 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 d03987bbbe0..7b766e2f818 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 @@ -4,10 +4,10 @@ import Api from '~/api'; import Tracking from '~/tracking'; import { __ } from '~/locale'; import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; -import { initialPaginationState, defaultI18n, defaultPageSize } from './constants'; -import { isAny } from './utils'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; +import { initialPaginationState, defaultI18n, defaultPageSize } from './constants'; +import { isAny } from './utils'; export default { defaultI18n, diff --git a/app/assets/javascripts/vue_shared/components/registry/list_item.vue b/app/assets/javascripts/vue_shared/components/registry/list_item.vue index 8965dba3e83..9db5d6953d7 100644 --- a/app/assets/javascripts/vue_shared/components/registry/list_item.vue +++ b/app/assets/javascripts/vue_shared/components/registry/list_item.vue @@ -54,19 +54,17 @@ export default { class="gl-display-flex gl-flex-direction-column gl-border-b-solid gl-border-t-solid gl-border-t-1 gl-border-b-1" :class="optionalClasses" > - <div class="gl-display-flex gl-align-items-center gl-py-5"> + <div class="gl-display-flex gl-align-items-center gl-py-3"> <div v-if="$slots['left-action']" - class="gl-w-7 gl-display-none gl-display-sm-flex gl-justify-content-start gl-pl-2" + class="gl-w-7 gl-display-none gl-sm-display-flex gl-justify-content-start gl-pl-2" > <slot name="left-action"></slot> </div> <div class="gl-display-flex gl-xs-flex-direction-column gl-justify-content-space-between gl-align-items-stretch gl-flex-fill-1" > - <div - class="gl-display-flex gl-flex-direction-column gl-justify-content-space-between gl-xs-mb-3 gl-min-w-0 gl-flex-grow-1" - > + <div class="gl-display-flex gl-flex-direction-column gl-xs-mb-3 gl-min-w-0 gl-flex-grow-1"> <div v-if="$slots['left-primary']" class="gl-display-flex gl-align-items-center gl-text-body gl-font-weight-bold gl-min-h-6 gl-min-w-0" @@ -77,13 +75,13 @@ export default { :selected="isDetailsShown" icon="ellipsis_h" size="small" - class="gl-ml-2 gl-display-none gl-display-sm-block" + class="gl-ml-2 gl-display-none gl-sm-display-block" @click="toggleDetails" /> </div> <div v-if="$slots['left-secondary']" - class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1 gl-min-h-6 gl-min-w-0 gl-flex-fill-1" + class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-min-h-6 gl-min-w-0 gl-flex-fill-1" > <slot name="left-secondary"></slot> </div> @@ -99,7 +97,7 @@ export default { </div> <div v-if="$slots['right-secondary']" - class="gl-display-flex gl-align-items-center gl-mt-1 gl-min-h-6" + class="gl-display-flex gl-align-items-center gl-min-h-6" > <slot name="right-secondary"></slot> </div> @@ -107,7 +105,7 @@ export default { </div> <div v-if="$slots['right-action']" - class="gl-w-9 gl-display-none gl-display-sm-flex gl-justify-content-end gl-pr-1" + class="gl-w-9 gl-display-none gl-sm-display-flex gl-justify-content-end gl-pr-1" > <slot name="right-action"></slot> </div> diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js index be78651d38d..f7bd49c7def 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js @@ -1,9 +1,9 @@ import Vue from 'vue'; import { defaults } from 'lodash'; import ToolbarItem from '../toolbar_item.vue'; +import { TOOLBAR_ITEM_CONFIGS, VIDEO_ATTRIBUTES } from '../constants'; import buildHtmlToMarkdownRenderer from './build_html_to_markdown_renderer'; import buildCustomHTMLRenderer from './build_custom_renderer'; -import { TOOLBAR_ITEM_CONFIGS, VIDEO_ATTRIBUTES } from '../constants'; import sanitizeHTML from './sanitize_html'; const buildWrapper = (propsData) => { diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js index 30012c1123f..710b807275b 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js @@ -1,6 +1,6 @@ -import { buildUneditableHtmlAsTextTokens } from './build_uneditable_token'; -import { ALLOWED_VIDEO_ORIGINS } from '../../constants'; import { getURLOrigin } from '~/lib/utils/url_utility'; +import { ALLOWED_VIDEO_ORIGINS } from '../../constants'; +import { buildUneditableHtmlAsTextTokens } from './build_uneditable_token'; const isVideoFrame = (html) => { const parser = new DOMParser(); diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/sanitize_html.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/sanitize_html.js index cb0f1d51cb1..486d88466b7 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/sanitize_html.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/sanitize_html.js @@ -1,6 +1,6 @@ import createSanitizer from 'dompurify'; -import { ALLOWED_VIDEO_ORIGINS } from '../constants'; import { getURLOrigin } from '~/lib/utils/url_utility'; +import { ALLOWED_VIDEO_ORIGINS } from '../constants'; const sanitizer = createSanitizer(window); const ADD_TAGS = ['iframe']; diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js b/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js new file mode 100644 index 00000000000..facace0d809 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js @@ -0,0 +1,18 @@ +import { s__ } from '~/locale'; + +export const PLATFORMS_WITHOUT_ARCHITECTURES = ['docker', 'kubernetes']; + +export const INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES = { + docker: { + instructions: s__( + 'Runners|To install Runner in a container follow the instructions described in the GitLab documentation', + ), + link: 'https://docs.gitlab.com/runner/install/docker.html', + }, + kubernetes: { + instructions: s__( + 'Runners|To install Runner in Kubernetes follow the instructions described in the GitLab documentation.', + ), + link: 'https://docs.gitlab.com/runner/install/kubernetes.html', + }, +}; diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql new file mode 100644 index 00000000000..ff0626167a9 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql @@ -0,0 +1,20 @@ +query getRunnerPlatforms($projectPath: ID!, $groupPath: ID!) { + runnerPlatforms { + nodes { + name + humanReadableName + architectures { + nodes { + name + downloadLocation + } + } + } + } + project(fullPath: $projectPath) { + id + } + group(fullPath: $groupPath) { + id + } +} diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql new file mode 100644 index 00000000000..643c1991807 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql @@ -0,0 +1,16 @@ +query runnerSetupInstructions( + $platform: String! + $architecture: String! + $projectId: ID! + $groupId: ID! +) { + runnerSetup( + platform: $platform + architecture: $architecture + projectId: $projectId + groupId: $groupId + ) { + installInstructions + registerInstructions + } +} diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue new file mode 100644 index 00000000000..1d6db576942 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue @@ -0,0 +1,261 @@ +<script> +import { + GlAlert, + GlButton, + GlModal, + GlModalDirective, + GlButtonGroup, + GlDropdown, + GlDropdownItem, + GlIcon, +} from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; +import { + PLATFORMS_WITHOUT_ARCHITECTURES, + INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES, +} from './constants'; +import getRunnerPlatforms from './graphql/queries/get_runner_platforms.query.graphql'; +import getRunnerSetupInstructions from './graphql/queries/get_runner_setup.query.graphql'; + +export default { + components: { + GlAlert, + GlButton, + GlButtonGroup, + GlDropdown, + GlDropdownItem, + GlModal, + GlIcon, + ModalCopyButton, + }, + directives: { + GlModalDirective, + }, + inject: { + projectPath: { + default: '', + }, + groupPath: { + default: '', + }, + }, + apollo: { + runnerPlatforms: { + query: getRunnerPlatforms, + variables() { + return { + projectPath: this.projectPath, + groupPath: this.groupPath, + }; + }, + error() { + this.showAlert = true; + }, + result({ data }) { + this.project = data?.project; + this.group = data?.group; + + this.selectPlatform(this.platforms[0].name); + }, + }, + }, + data() { + return { + showAlert: false, + selectedPlatformArchitectures: [], + selectedPlatform: { + name: '', + }, + selectedArchitecture: {}, + runnerPlatforms: {}, + instructions: {}, + project: {}, + group: {}, + }; + }, + computed: { + isPlatformSelected() { + return Object.keys(this.selectedPlatform).length > 0; + }, + instructionsEmpty() { + return Object.keys(this.instructions).length === 0; + }, + groupId() { + return this.group?.id ?? ''; + }, + projectId() { + return this.project?.id ?? ''; + }, + platforms() { + return this.runnerPlatforms?.nodes; + }, + hasArchitecureList() { + return !PLATFORMS_WITHOUT_ARCHITECTURES.includes(this.selectedPlatform?.name); + }, + instructionsWithoutArchitecture() { + return INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES[this.selectedPlatform.name]?.instructions; + }, + runnerInstallationLink() { + return INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES[this.selectedPlatform.name]?.link; + }, + }, + methods: { + selectPlatform(name) { + this.selectedPlatform = this.platforms.find((platform) => platform.name === name); + if (this.hasArchitecureList) { + this.selectedPlatformArchitectures = this.selectedPlatform?.architectures?.nodes; + [this.selectedArchitecture] = this.selectedPlatformArchitectures; + this.selectArchitecture(this.selectedArchitecture); + } + }, + selectArchitecture(architecture) { + this.selectedArchitecture = architecture; + + this.$apollo.addSmartQuery('instructions', { + variables() { + return { + platform: this.selectedPlatform.name, + architecture: this.selectedArchitecture.name, + projectId: this.projectId, + groupId: this.groupId, + }; + }, + query: getRunnerSetupInstructions, + update(data) { + return data?.runnerSetup; + }, + error() { + this.showAlert = true; + }, + }); + }, + toggleAlert(state) { + this.showAlert = state; + }, + }, + modalId: 'installation-instructions-modal', + i18n: { + installARunner: s__('Runners|Install a Runner'), + architecture: s__('Runners|Architecture'), + downloadInstallBinary: s__('Runners|Download and Install Binary'), + downloadLatestBinary: s__('Runners|Download Latest Binary'), + registerRunner: s__('Runners|Register Runner'), + method: __('Method'), + fetchError: s__('Runners|An error has occurred fetching instructions'), + instructions: s__('Runners|Show Runner installation instructions'), + copyInstructions: s__('Runners|Copy instructions'), + }, + closeButton: { + text: __('Close'), + attributes: [{ variant: 'default' }], + }, +}; +</script> +<template> + <div> + <gl-button + v-gl-modal-directive="$options.modalId" + class="gl-mt-4" + data-testid="show-modal-button" + > + {{ $options.i18n.instructions }} + </gl-button> + <gl-modal + :modal-id="$options.modalId" + :title="$options.i18n.installARunner" + :action-secondary="$options.closeButton" + > + <gl-alert v-if="showAlert" variant="danger" @dismiss="toggleAlert(false)"> + {{ $options.i18n.fetchError }} + </gl-alert> + <h5>{{ __('Environment') }}</h5> + <gl-button-group class="gl-mb-5"> + <gl-button + v-for="platform in platforms" + :key="platform.name" + data-testid="platform-button" + @click="selectPlatform(platform.name)" + > + {{ platform.humanReadableName }} + </gl-button> + </gl-button-group> + <template v-if="hasArchitecureList"> + <template v-if="isPlatformSelected"> + <h5> + {{ $options.i18n.architecture }} + </h5> + <gl-dropdown class="gl-mb-5" :text="selectedArchitecture.name"> + <gl-dropdown-item + v-for="architecture in selectedPlatformArchitectures" + :key="architecture.name" + data-testid="architecture-dropdown-item" + @click="selectArchitecture(architecture)" + > + {{ architecture.name }} + </gl-dropdown-item> + </gl-dropdown> + <div class="gl-display-flex gl-align-items-center gl-mb-5"> + <h5>{{ $options.i18n.downloadInstallBinary }}</h5> + <gl-button + class="gl-ml-auto" + :href="selectedArchitecture.downloadLocation" + download + data-testid="binary-download-button" + > + {{ $options.i18n.downloadLatestBinary }} + </gl-button> + </div> + </template> + <template v-if="!instructionsEmpty"> + <div class="gl-display-flex"> + <pre + class="gl-bg-gray gl-flex-fill-1 gl-white-space-pre-line" + data-testid="binary-instructions" + > + + {{ instructions.installInstructions }} + </pre + > + <modal-copy-button + :title="$options.i18n.copyInstructions" + :text="instructions.installInstructions" + :modal-id="$options.modalId" + css-classes="gl-align-self-start gl-ml-2 gl-mt-2" + category="tertiary" + /> + </div> + + <hr /> + <h5 class="gl-mb-5">{{ $options.i18n.registerRunner }}</h5> + <h5 class="gl-mb-5">{{ $options.i18n.method }}</h5> + <div class="gl-display-flex"> + <pre + class="gl-bg-gray gl-flex-fill-1 gl-white-space-pre-line" + data-testid="runner-instructions" + > + {{ instructions.registerInstructions }} + </pre + > + <modal-copy-button + :title="$options.i18n.copyInstructions" + :text="instructions.registerInstructions" + :modal-id="$options.modalId" + css-classes="gl-align-self-start gl-ml-2 gl-mt-2" + category="tertiary" + /> + </div> + </template> + </template> + <template v-else> + <div> + <p>{{ instructionsWithoutArchitecture }}</p> + <gl-button :href="runnerInstallationLink"> + <gl-icon name="external-link" /> + {{ s__('Runners|View installation instructions') }} + </gl-button> + </div> + </template> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/settings/settings_block.vue b/app/assets/javascripts/vue_shared/components/settings/settings_block.vue new file mode 100644 index 00000000000..31094b985a2 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/settings/settings_block.vue @@ -0,0 +1,45 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { GlButton }, + props: { + defaultExpanded: { + type: Boolean, + default: false, + required: false, + }, + }, + data() { + return { + sectionExpanded: false, + }; + }, + computed: { + expanded() { + return this.defaultExpanded || this.sectionExpanded; + }, + toggleText() { + return this.expanded ? __('Collapse') : __('Expand'); + }, + }, +}; +</script> + +<template> + <section class="settings no-animate" :class="{ expanded }"> + <div class="settings-header"> + <h4><slot name="title"></slot></h4> + <gl-button @click="sectionExpanded = !sectionExpanded"> + {{ toggleText }} + </gl-button> + <p> + <slot name="description"></slot> + </p> + </div> + <div class="settings-content"> + <slot></slot> + </div> + </section> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue b/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue index 6caf8bc92c2..30daabc1e24 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue @@ -1,10 +1,10 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; import datePicker from '../pikaday.vue'; +import { dateInWords } from '../../../lib/utils/datetime_utility'; import toggleSidebar from './toggle_sidebar.vue'; import collapsedCalendarIcon from './collapsed_calendar_icon.vue'; -import { dateInWords } from '../../../lib/utils/datetime_utility'; -import { __ } from '~/locale'; export default { name: 'SidebarDatePicker', diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue index 22d86ee25d1..d014139504f 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue @@ -5,6 +5,7 @@ import { __ } from '~/locale'; import LabelsSelect from '~/labels_select'; import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue'; +import { DropdownVariant } from '../labels_select_vue/constants'; import DropdownTitle from './dropdown_title.vue'; import DropdownValue from './dropdown_value.vue'; import DropdownValueCollapsed from './dropdown_value_collapsed.vue'; @@ -14,8 +15,6 @@ import DropdownSearchInput from './dropdown_search_input.vue'; import DropdownFooter from './dropdown_footer.vue'; import DropdownCreateLabel from './dropdown_create_label.vue'; -import { DropdownVariant } from '../labels_select_vue/constants'; - export default { DropdownVariant, components: { 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 683889b8611..9efc17908a2 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 @@ -35,11 +35,13 @@ export default { }, allowLabelEdit: { type: Boolean, - required: true, + required: false, + default: false, }, allowLabelCreate: { type: Boolean, - required: true, + required: false, + default: false, }, allowMultiselect: { type: Boolean, @@ -48,7 +50,8 @@ export default { }, allowScopedLabels: { type: Boolean, - required: true, + required: false, + default: false, }, variant: { type: String, 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 6de436ffd13..55716e1105e 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 @@ -1,5 +1,5 @@ -import * as types from './mutation_types'; import { DropdownVariant } from '../constants'; +import * as types from './mutation_types'; export default { [types.SET_INITIAL_STATE](state, props) { diff --git a/app/assets/javascripts/vue_shared/components/todo_button.vue b/app/assets/javascripts/vue_shared/components/todo_button.vue index a9d4f8403fa..935d222a1a9 100644 --- a/app/assets/javascripts/vue_shared/components/todo_button.vue +++ b/app/assets/javascripts/vue_shared/components/todo_button.vue @@ -15,7 +15,7 @@ export default { }, computed: { buttonLabel() { - return this.isTodo ? __('Mark as done') : __('Add a To Do'); + return this.isTodo ? __('Mark as done') : __('Add a to do'); }, }, }; diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue index b48dfa8b452..8aa6e29adf1 100644 --- a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue +++ b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue @@ -1,7 +1,7 @@ <script> import { isFunction } from 'lodash'; -import tooltip from '../directives/tooltip'; import { hasHorizontalOverflow } from '~/lib/utils/dom_utils'; +import tooltip from '../directives/tooltip'; export default { directives: { diff --git a/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js b/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js index be04ff158e7..85a1ab6092b 100644 --- a/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js +++ b/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js @@ -4,8 +4,8 @@ * * Components need to have `scope`, `page` and `requestData` */ -import { historyPushState, buildUrlWithCurrentLocation } from '../../lib/utils/common_utils'; import { validateParams } from '~/pipelines/utils'; +import { historyPushState, buildUrlWithCurrentLocation } from '../../lib/utils/common_utils'; export default { methods: { 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 a6c7b59aa71..b6b32167ed6 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,13 +1,10 @@ <script> import { mapActions, mapGetters } from 'vuex'; -import { GlLink, GlSprintf } from '@gitlab/ui'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import ReportSection from '~/reports/components/report_section.vue'; -import { LOADING, ERROR, SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR } from '~/reports/constants'; +import { ERROR, SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR } from '~/reports/constants'; import { s__ } from '~/locale'; -import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils'; import createFlash from '~/flash'; -import Api from '~/api'; import HelpIcon from './components/help_icon.vue'; import SecurityReportDownloadDropdown from './components/security_report_download_dropdown.vue'; import SecuritySummary from './components/security_summary.vue'; @@ -24,8 +21,6 @@ import { extractSecurityReportArtifacts } from './utils'; export default { store, components: { - GlLink, - GlSprintf, ReportSection, HelpIcon, SecurityReportDownloadDropdown, @@ -101,9 +96,6 @@ export default { ), }; }, - skip() { - return !this.canShowDownloads; - }, update(data) { return extractSecurityReportArtifacts(this.$options.reportTypes, data); }, @@ -124,9 +116,6 @@ export default { }, computed: { ...mapGetters(['groupedSummaryText', 'summaryStatus']), - canShowDownloads() { - return this.glFeatures.coreSecurityMrWidgetDownloads; - }, hasSecurityReports() { return this.availableSecurityReports.length > 0; }, @@ -139,23 +128,6 @@ export default { isLoadingReportArtifacts() { return this.$apollo.queries.reportArtifacts.loading; }, - shouldShowDownloadGuidance() { - return !this.canShowDownloads && this.summaryStatus !== LOADING; - }, - scansHaveRunMessage() { - return this.canShowDownloads - ? this.$options.i18n.scansHaveRun - : this.$options.i18n.scansHaveRunWithDownloadGuidance; - }, - }, - created() { - if (!this.canShowDownloads) { - this.checkAvailableSecurityReports(this.$options.reportTypes) - .then((availableSecurityReports) => { - this.onCheckingAvailableSecurityReports(Array.from(availableSecurityReports)); - }) - .catch(this.showError); - } }, methods: { ...mapActions(MODULE_SAST, { @@ -166,36 +138,6 @@ export default { setSecretDetectionDiffEndpoint: 'setDiffEndpoint', fetchSecretDetectionDiff: 'fetchDiff', }), - async checkAvailableSecurityReports(reportTypes) { - const reportTypesSet = new Set(reportTypes); - const availableReportTypes = new Set(); - - let page = 1; - while (page) { - // eslint-disable-next-line no-await-in-loop - const { data: jobs, headers } = await Api.pipelineJobs(this.projectId, this.pipelineId, { - per_page: 100, - page, - }); - - jobs.forEach(({ artifacts = [] }) => { - artifacts.forEach(({ file_type }) => { - if (reportTypesSet.has(file_type)) { - availableReportTypes.add(file_type); - } - }); - }); - - // If we've found artifacts for all the report types, stop looking! - if (availableReportTypes.size === reportTypesSet.size) { - return availableReportTypes; - } - - page = parseIntPagination(normalizeHeaders(headers)).nextPage; - } - - return availableReportTypes; - }, fetchCounts() { if (!this.glFeatures.coreSecurityMrWidgetCounts) { return; @@ -213,11 +155,6 @@ export default { this.canShowCounts = true; } }, - activatePipelinesTab() { - if (window.mrTabs) { - window.mrTabs.tabShown('pipelines'); - } - }, onCheckingAvailableSecurityReports(availableSecurityReports) { this.availableSecurityReports = availableSecurityReports; this.fetchCounts(); @@ -236,12 +173,6 @@ export default { 'SecurityReports|Failed to get security report information. Please reload the page or try again later.', ), scansHaveRun: s__('SecurityReports|Security scans have run'), - scansHaveRunWithDownloadGuidance: s__( - 'SecurityReports|Security scans have run. Go to the %{linkStart}pipelines tab%{linkEnd} to download the security reports', - ), - downloadFromPipelineTab: s__( - 'SecurityReports|Go to the %{linkStart}pipelines tab%{linkEnd} to download the security reports', - ), }, summarySlots: [SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR], }; @@ -265,22 +196,7 @@ export default { </span> </template> - <template v-if="shouldShowDownloadGuidance" #sub-heading> - <span class="gl-font-sm"> - <gl-sprintf :message="$options.i18n.downloadFromPipelineTab"> - <template #link="{ content }"> - <gl-link - class="gl-font-sm" - data-testid="show-pipelines" - @click="activatePipelinesTab" - >{{ content }}</gl-link - > - </template> - </gl-sprintf> - </span> - </template> - - <template v-if="canShowDownloads" #action-buttons> + <template #action-buttons> <security-report-download-dropdown :artifacts="reportArtifacts" :loading="isLoadingReportArtifacts" @@ -298,13 +214,7 @@ export default { data-testid="security-mr-widget" > <template #error> - <gl-sprintf :message="scansHaveRunMessage"> - <template #link="{ content }"> - <gl-link data-testid="show-pipelines" @click="activatePipelinesTab">{{ - content - }}</gl-link> - </template> - </gl-sprintf> + {{ $options.i18n.scansHaveRun }} <help-icon :help-path="securityReportsDocsPath" @@ -312,7 +222,7 @@ export default { /> </template> - <template v-if="canShowDownloads" #action-buttons> + <template #action-buttons> <security-report-download-dropdown :artifacts="reportArtifacts" :loading="isLoadingReportArtifacts" diff --git a/app/assets/javascripts/vue_shared/security_reports/store/getters.js b/app/assets/javascripts/vue_shared/security_reports/store/getters.js index 443255b0e6a..819b0a4af31 100644 --- a/app/assets/javascripts/vue_shared/security_reports/store/getters.js +++ b/app/assets/javascripts/vue_shared/security_reports/store/getters.js @@ -1,6 +1,6 @@ import { s__, sprintf } from '~/locale'; -import { countVulnerabilities, groupedTextBuilder } from './utils'; import { LOADING, ERROR, SUCCESS } from '~/reports/constants'; +import { countVulnerabilities, groupedTextBuilder } from './utils'; import { TRANSLATION_IS_LOADING } from './messages'; export const summaryCounts = (state) => diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js index 0f26e3c30ef..4f92e181f9f 100644 --- a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js +++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js @@ -1,5 +1,5 @@ -import * as types from './mutation_types'; import { fetchDiffData } from '../../utils'; +import * as types from './mutation_types'; export const setDiffEndpoint = ({ commit }, path) => commit(types.SET_DIFF_ENDPOINT, path); diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutations.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutations.js index 5f6153ca3b1..11aa71d2b6b 100644 --- a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutations.js +++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutations.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import * as types from './mutation_types'; import { parseDiff } from '../../utils'; +import * as types from './mutation_types'; export default { [types.SET_DIFF_ENDPOINT](state, path) { diff --git a/app/assets/javascripts/webpack_non_compiled_placeholder.js b/app/assets/javascripts/webpack_non_compiled_placeholder.js new file mode 100644 index 00000000000..8cd1d2eb2ca --- /dev/null +++ b/app/assets/javascripts/webpack_non_compiled_placeholder.js @@ -0,0 +1,24 @@ +const div = document.createElement('div'); + +Object.assign(div.style, { + width: '100vw', + height: '100vh', + position: 'fixed', + top: 0, + left: 0, + 'z-index': 100000, + background: 'rgba(0,0,0,0.9)', + 'font-size': '25px', + 'font-family': 'monospace', + color: 'white', + padding: '2.5em', + 'text-align': 'center', +}); + +div.innerHTML = ` +<h1 style="color:white">🧙 Webpack is doing its magic 🧙</h1> +<p>If you use Hot Module reloading, the page will reload in a few seconds.</p> +<p>If you do not use Hot Module reloading, please <a href="">reload the page manually in a few seconds</a></p> +`; + +document.body.append(div); diff --git a/app/assets/javascripts/whats_new/components/app.vue b/app/assets/javascripts/whats_new/components/app.vue index 0a81f172fe9..a5c8aad01be 100644 --- a/app/assets/javascripts/whats_new/components/app.vue +++ b/app/assets/javascripts/whats_new/components/app.vue @@ -9,10 +9,10 @@ import { GlBadge, GlLoadingIcon, } from '@gitlab/ui'; -import SkeletonLoader from './skeleton_loader.vue'; -import Feature from './feature.vue'; import Tracking from '~/tracking'; import { getDrawerBodyHeight } from '../utils/get_drawer_body_height'; +import SkeletonLoader from './skeleton_loader.vue'; +import Feature from './feature.vue'; const trackingMixin = Tracking.mixin(); diff --git a/app/assets/javascripts/whats_new/store/actions.js b/app/assets/javascripts/whats_new/store/actions.js index 0e5eeda742a..4b3cfa55977 100644 --- a/app/assets/javascripts/whats_new/store/actions.js +++ b/app/assets/javascripts/whats_new/store/actions.js @@ -1,6 +1,6 @@ -import * as types from './mutation_types'; import axios from '~/lib/utils/axios_utils'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; +import * as types from './mutation_types'; export default { closeDrawer({ commit }) { diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss index 42d15635566..20ff78d32d3 100644 --- a/app/assets/stylesheets/_page_specific_files.scss +++ b/app/assets/stylesheets/_page_specific_files.scss @@ -1,4 +1,3 @@ -@import './pages/admin'; @import './pages/branches'; @import './pages/ci_projects'; @import './pages/clusters'; diff --git a/app/assets/stylesheets/components/batch_comments/review_bar.scss b/app/assets/stylesheets/components/batch_comments/review_bar.scss index 76bf7ac81e8..d769ea73101 100644 --- a/app/assets/stylesheets/components/batch_comments/review_bar.scss +++ b/app/assets/stylesheets/components/batch_comments/review_bar.scss @@ -4,7 +4,7 @@ left: 0; width: 100%; background: $white; - z-index: 300; + z-index: $zindex-dropdown-menu; padding: 7px 0 6px; // to keep aligned with "collapse sidebar" button on the left sidebar border-top: 1px solid $border-color; padding-left: $contextual-sidebar-width; diff --git a/app/assets/stylesheets/components/design_management/design_list_item.scss b/app/assets/stylesheets/components/design_management/design_list_item.scss index b7f6b2026fe..09af4da37e9 100644 --- a/app/assets/stylesheets/components/design_management/design_list_item.scss +++ b/app/assets/stylesheets/components/design_management/design_list_item.scss @@ -8,11 +8,6 @@ top: 10px; } - .design-event { - top: $gl-padding; - right: $gl-padding; - } - .card-body { height: 230px; } diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index e40b95cdce6..7931f4deea0 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -15,7 +15,6 @@ @import 'framework/badges'; @import 'framework/calendar'; @import 'framework/callout'; -@import 'framework/carousel'; @import 'framework/common'; @import 'framework/dropdowns'; @import 'framework/files'; diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss index d9ad4992458..a7623b65539 100644 --- a/app/assets/stylesheets/framework/awards.scss +++ b/app/assets/stylesheets/framework/awards.scss @@ -14,7 +14,7 @@ top: 0; margin-top: 3px; padding: $gl-padding; - z-index: 300; + z-index: $zindex-dropdown-menu; width: $award-emoji-width; font-size: 14px; background-color: $white; diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index 182c58c3931..050c9039b2e 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -437,19 +437,6 @@ } } -.btn-missing { - color: $gl-text-color-secondary; - border: 1px dashed $border-gray-normal-dashed; - border-radius: $border-radius-default; - - &:hover, - &:active, - &:focus { - color: $gl-text-color-secondary; - background-color: $white-normal; - } -} - // The .btn-svg class is available for legacy icon buttons to // preserve a 34px height and have 16x16 icons at the same time. // Once a button is migrated (to the current 32px height) diff --git a/app/assets/stylesheets/framework/carousel.scss b/app/assets/stylesheets/framework/carousel.scss deleted file mode 100644 index d51a9f9c173..00000000000 --- a/app/assets/stylesheets/framework/carousel.scss +++ /dev/null @@ -1,202 +0,0 @@ -// Notes on the classes: -// -// 1. .carousel.pointer-event should ideally be pan-y (to allow for users to scroll vertically) -// even when their scroll action started on a carousel, but for compatibility (with Firefox) -// we're preventing all actions instead -// 2. The .carousel-item-left and .carousel-item-right is used to indicate where -// the active slide is heading. -// 3. .active.carousel-item is the current slide. -// 4. .active.carousel-item-left and .active.carousel-item-right is the current -// slide in its in-transition state. Only one of these occurs at a time. -// 5. .carousel-item-next.carousel-item-left and .carousel-item-prev.carousel-item-right -// is the upcoming slide in transition. - -.carousel { - position: relative; - - &.pointer-event { - touch-action: pan-y; - } -} - - -.carousel-inner { - position: relative; - width: 100%; - overflow: hidden; - @include clearfix(); -} - -.carousel-item { - position: relative; - display: none; - float: left; - width: 100%; - margin-right: -100%; - backface-visibility: hidden; - @include transition($carousel-transition); -} - -.carousel-item.active, -.carousel-item-next, -.carousel-item-prev { - display: block; -} - -.carousel-item-next:not(.carousel-item-left), -.active.carousel-item-right { - transform: translateX(100%); -} - -.carousel-item-prev:not(.carousel-item-right), -.active.carousel-item-left { - transform: translateX(-100%); -} - - -// -// Alternate transitions -// - -.carousel-fade { - .carousel-item { - opacity: 0; - transition-property: opacity; - transform: none; - } - - .carousel-item.active, - .carousel-item-next.carousel-item-left, - .carousel-item-prev.carousel-item-right { - z-index: 1; - opacity: 1; - } - - .active.carousel-item-left, - .active.carousel-item-right { - z-index: 0; - opacity: 0; - @include transition(0s $carousel-transition-duration opacity); - } -} - - -// -// Left/right controls for nav -// - -.carousel-control-prev, -.carousel-control-next { - position: absolute; - top: 0; - bottom: 0; - z-index: 1; - // Use flex for alignment (1-3) - display: flex; // 1. allow flex styles - align-items: center; // 2. vertically center contents - justify-content: center; // 3. horizontally center contents - width: $carousel-control-width; - color: $carousel-control-color; - text-align: center; - opacity: $carousel-control-opacity; - @include transition($carousel-control-transition); - - // Hover/focus state - @include hover-focus { - color: $carousel-control-color; - text-decoration: none; - outline: 0; - opacity: $carousel-control-hover-opacity; - } -} - -.carousel-control-prev { - left: 0; - @if $enable-gradients { - background: linear-gradient(90deg, rgba($black, 0.25), rgba($black, 0.001)); - } -} - -.carousel-control-next { - right: 0; - @if $enable-gradients { - background: linear-gradient(270deg, rgba($black, 0.25), rgba($black, 0.001)); - } -} - -// Icons for within -.carousel-control-prev-icon, -.carousel-control-next-icon { - display: inline-block; - width: $carousel-control-icon-width; - height: $carousel-control-icon-width; - background: no-repeat 50% / 100% 100%; -} - -.carousel-control-prev-icon { - background-image: $carousel-control-prev-icon-bg; -} - -.carousel-control-next-icon { - background-image: $carousel-control-next-icon-bg; -} - - -// Optional indicator pips -// -// Add an ordered list with the following class and add a list item for each -// slide your carousel holds. - -.carousel-indicators { - position: absolute; - right: 0; - bottom: 0; - left: 0; - z-index: 15; - display: flex; - justify-content: center; - padding-left: 0; // override <ol> default - // Use the .carousel-control's width as margin so we don't overlay those - margin-right: $carousel-control-width; - margin-left: $carousel-control-width; - list-style: none; - - li { - box-sizing: content-box; - flex: 0 1 auto; - width: $carousel-indicator-width; - height: $carousel-indicator-height; - margin-right: $carousel-indicator-spacer; - margin-left: $carousel-indicator-spacer; - text-indent: -999px; - cursor: pointer; - background-color: $carousel-indicator-active-bg; - background-clip: padding-box; - // Use transparent borders to increase the hit area by 10px on top and bottom. - border-top: $carousel-indicator-hit-area-height solid transparent; - border-bottom: $carousel-indicator-hit-area-height solid transparent; - opacity: 0.5; - @include transition($carousel-indicator-transition); - } - - .active { - opacity: 1; - } -} - - -// Optional captions -// -// - -.carousel-caption { - position: absolute; - right: (100% - $carousel-caption-width) / 2; - bottom: 20px; - left: (100% - $carousel-caption-width) / 2; - z-index: 10; - padding-top: 20px; - padding-bottom: 20px; - color: $carousel-caption-color; - text-align: center; -} diff --git a/app/assets/stylesheets/framework/ci_variable_list.scss b/app/assets/stylesheets/framework/ci_variable_list.scss index 2204b037f69..95025459cc9 100644 --- a/app/assets/stylesheets/framework/ci_variable_list.scss +++ b/app/assets/stylesheets/framework/ci_variable_list.scss @@ -98,13 +98,3 @@ color: $gl-text-color-disabled; } } - -.group-variable-list { - color: $gray-500; - - .table-section:not(:first-child) { - @include media-breakpoint-down(sm) { - border-top: hidden; - } - } -} diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 3b59c028437..5d182373fb1 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -110,7 +110,7 @@ pre { } hr { - margin: 24px 0; + margin: 1.5rem 0; border-top: 1px solid $gray-darker; } diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss index 745d469e3e8..c5467c304ec 100644 --- a/app/assets/stylesheets/framework/contextual_sidebar.scss +++ b/app/assets/stylesheets/framework/contextual_sidebar.scss @@ -471,7 +471,7 @@ background-color: $black-transparent; height: 100%; width: 100%; - z-index: 300; + z-index: $zindex-dropdown-menu; } } } diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss index 499b9c00116..e30caeb1dfb 100644 --- a/app/assets/stylesheets/framework/diffs.scss +++ b/app/assets/stylesheets/framework/diffs.scss @@ -1136,10 +1136,6 @@ table.code { display: block; } } - - .note-edit-form { - margin-left: $note-icon-gutter-width; - } } .discussion-body .image .frame { diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 41fc4d3dd4e..dd140bd8f5c 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -216,7 +216,7 @@ position: absolute; width: auto; top: 100%; - z-index: 300; + z-index: $zindex-dropdown-menu; min-width: 240px; max-width: 500px; margin-top: $dropdown-vertical-offset; @@ -896,7 +896,7 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu { position: absolute; top: 13px; right: 25px; - color: $gray-50; + color: $gray-300; } } diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index fe8c27ae9b6..103d59382b4 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -2,6 +2,14 @@ * File content holder * */ +.container-fluid.container-limited.limit-container-width { + .file-holder.readme-holder.limited-width-container .file-content { + max-width: $limited-layout-width; + margin-left: auto; + margin-right: auto; + } +} + .file-holder { border: 1px solid $border-color; border-top: 0; @@ -17,12 +25,6 @@ &.readme-holder { margin: $gl-padding 0; - - &.limited-width-container .file-content { - max-width: $limited-layout-width; - margin-left: auto; - margin-right: auto; - } } .file-title { @@ -442,12 +444,6 @@ span.idiff { .user-avatar-link.new-comment { position: absolute; margin: 40px $gl-padding 0 116px; - - ~ .note-edit-form form.edit-note { - @include media-breakpoint-up(sm) { - margin-left: $note-icon-gutter-width; - } - } } } diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 5f56fa3be86..07d59847829 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -475,6 +475,15 @@ } } + .sort-dropdown-container { + // This property is set to have borders + // around sort dropdown match with filter + // input field. + .gl-button { + box-shadow: inset 0 0 0 1px $gray-400; + } + } + @include media-breakpoint-up(md) { .sort-dropdown-container { margin-left: 10px; diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss index 5623d38d66e..222e10f51ad 100644 --- a/app/assets/stylesheets/framework/icons.scss +++ b/app/assets/stylesheets/framework/icons.scss @@ -3,10 +3,6 @@ svg { fill: $green-500; } - - &.add-border { - @include borderless-status-icon($green-500); - } } .ci-status-icon-error, @@ -14,10 +10,6 @@ svg { fill: $red-500; } - - &.add-border { - @include borderless-status-icon($red-500); - } } .ci-status-icon-pending, @@ -27,59 +19,29 @@ svg { fill: $orange-500; } - - &.add-border { - @include borderless-status-icon($orange-500); - } -} - -.ci-status-icon-preparing { - svg { - fill: $gray-300; - } - - &.add-border { - @include borderless-status-icon($gray-300); - } } .ci-status-icon-running { svg { fill: $blue-400; } - - &.add-border { - @include borderless-status-icon($blue-400); - } } .ci-status-icon-canceled, -.ci-status-icon-disabled { +.ci-status-icon-disabled, +.ci-status-icon-scheduled, +.ci-status-icon-manual { svg { fill: $gl-text-color; } - - &.add-border { - @include borderless-status-icon($gl-text-color); - } } +.ci-status-icon-preparing, .ci-status-icon-created, .ci-status-icon-skipped, .ci-status-icon-notfound { svg { - fill: $gray-darkest; - } - - &.add-border { - @include borderless-status-icon($gray-darkest); - } -} - -.ci-status-icon-scheduled, -.ci-status-icon-manual { - svg { - fill: $gl-text-color; + fill: var(--gray-400, $gray-400); } } diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index e3d02d01496..e29e204b14f 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -354,13 +354,6 @@ } } -@mixin borderless-status-icon($color) { - svg { - border: 1px solid $color; - border-radius: 50%; - } -} - @mixin emoji-menu-toggle-button { line-height: 1; padding: 0; diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index bef33bd2ef0..241aaad015e 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -75,7 +75,7 @@ .right-sidebar-expanded { padding-right: 0; - z-index: 300; + z-index: $zindex-dropdown-menu; @include media-breakpoint-only(sm) { &:not(.wiki-sidebar):not(.build-sidebar):not(.issuable-bulk-update-sidebar) .content-wrapper { diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 1a568bb41a5..496e2aba421 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -458,7 +458,7 @@ h6 { a.anchor { float: left; - margin-left: -16px; + margin-left: -20px; text-decoration: none; outline: none; @@ -471,6 +471,11 @@ &:hover > a.anchor::after { visibility: visible; } + + > a.anchor:focus::after { + visibility: visible; + outline: auto; + } } .big { diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 674ba1a307b..4bf9236407f 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -433,6 +433,7 @@ $browser-scrollbar-size: 10px; */ $header-height: 40px; $header-zindex: 1000; +$zindex-dropdown-menu: 300; $suggestion-header-height: 46px; $ide-statusbar-height: 25px; $fixed-layout-width: 1280px; @@ -626,7 +627,6 @@ $search-input-xl-width: 320px; $note-disabled-comment-color: #b2b2b2; $note-targe3-outside: #fffff0; $note-targe3-inside: #ffffd3; -$note-icon-gutter-width: 55px; /* * Identicon @@ -871,6 +871,27 @@ $add-to-slack-well-max-width: 750px; $add-to-slack-logo-size: 100px; /* +Security & Compliance Carousel +*/ +$security-and-compliance-carousel-image-carousel-width: 1000px; +$security-and-compliance-carousel-image-discover-button-width: 45%; +$security-and-compliance-carousel-image-discover-buttons-max-width: 280px; +$security-and-compliance-carousel-image-discover-footer-max-width: 500px; +$security-and-compliance-carousel-image-discover-feedback-width: 30%; +$security-and-compliance-carousel-image-discover-text-carousel-max-width: 650px; +$security-and-compliance-carousel-image-discover-text-carousel-caption-height: 100%; +$security-and-compliance-carousel-image-discover-text-carousel-caption-max-width: 500px; +$security-and-compliance-carousel-control-icon-width: 10px; +$security-and-compliance-carousel-control-position: -5%; +$security-and-compliance-carousel-inner-width: 90%; +$security-and-compliance-carousel-indicators-bottom: -20px; +$security-and-compliance-carousel-indicators-bottom-lg: -15px; +$security-and-compliance-carousel-indicators-dimension: 6px; +$security-and-compliance-carousel-indicators-border-radius: 100%; +$security-and-compliance-carousel-prev-icon-background: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23666666' viewBox='0 0 8 8'%3E%3Cpath d='M5.25 0l-4 4 4 4 1.5-1.5-2.5-2.5 2.5-2.5-1.5-1.5z'/%3E%3C/svg%3E"); +$security-and-compliance-carousel-next-icon-background: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23666666' viewBox='0 0 8 8'%3E%3Cpath d='M2.75 0l-1.5 1.5 2.5 2.5-2.5 2.5 1.5 1.5 4-4-4-4z'/%3E%3C/svg%3E"); + +/* Popup */ $popup-triangle-size: 15px; diff --git a/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss b/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss index 72e2a45565e..0d6f360112b 100644 --- a/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss +++ b/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss @@ -98,7 +98,6 @@ @include mini-pipeline-graph-color(var(--white, $white), $orange-50, $orange-100, $orange-500, $orange-600, $orange-700); } - &.ci-status-icon-preparing, &.ci-status-icon-running { @include mini-pipeline-graph-color(var(--white, $white), $blue-100, $blue-200, $blue-500, $blue-600, $blue-700); } @@ -106,14 +105,15 @@ &.ci-status-icon-canceled, &.ci-status-icon-scheduled, &.ci-status-icon-disabled, - &.ci-status-icon-not-found, &.ci-status-icon-manual { @include mini-pipeline-graph-color(var(--white, $white), $gray-500, $gray-700, $gray-900, $gray-950, $black); } + &.ci-status-icon-preparing, &.ci-status-icon-created, + &.ci-status-icon-not-found, &.ci-status-icon-skipped { - @include mini-pipeline-graph-color(var(--white, $white), $gray-100, $gray-200, $gray-300, $gray-400, $gray-500); + @include mini-pipeline-graph-color(var(--white, $white), var(--gray-100, $gray-100), var(--gray-200, $gray-200), var(--gray-400, $gray-400), var(--gray-500, $gray-500), var(--gray-600, $gray-600)); } } diff --git a/app/assets/stylesheets/page_bundles/admin/application_settings_metrics_and_profiling.scss b/app/assets/stylesheets/page_bundles/admin/application_settings_metrics_and_profiling.scss new file mode 100644 index 00000000000..41bb6d107f1 --- /dev/null +++ b/app/assets/stylesheets/page_bundles/admin/application_settings_metrics_and_profiling.scss @@ -0,0 +1,3 @@ +.usage-data { + max-height: 400px; +} diff --git a/app/assets/stylesheets/page_bundles/admin/jobs_index.scss b/app/assets/stylesheets/page_bundles/admin/jobs_index.scss new file mode 100644 index 00000000000..7844cae5f87 --- /dev/null +++ b/app/assets/stylesheets/page_bundles/admin/jobs_index.scss @@ -0,0 +1,5 @@ +.admin-builds-table { + td:last-child { + min-width: 120px; + } +} diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index f6b9473d235..b5b34c0a64e 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -605,6 +605,17 @@ $ide-commit-header-height: 48px; left: -1px; } } + + .ide-commit-badge { + background-color: var(--ide-highlight-accent, $almost-black) !important; + color: var(--ide-highlight-background, $white) !important; + position: absolute; + left: 38px; + top: $gl-padding-8; + font-size: $gl-font-size-12; + padding: 2px $gl-padding-4; + font-weight: $gl-font-weight-bold !important; + } } .ide-activity-bar { diff --git a/app/assets/stylesheets/page_bundles/jira_connect.scss b/app/assets/stylesheets/page_bundles/jira_connect.scss index 231723ca4e3..25401a161da 100644 --- a/app/assets/stylesheets/page_bundles/jira_connect.scss +++ b/app/assets/stylesheets/page_bundles/jira_connect.scss @@ -79,12 +79,6 @@ $header-height: 40px; margin-top: 16px; } -.heading-with-border { - border-bottom: 1px solid $gray-100; - display: inline-block; - padding-bottom: 16px; -} - svg { fill: currentColor; diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss index be74503c21f..3263a5067ea 100644 --- a/app/assets/stylesheets/page_bundles/merge_requests.scss +++ b/app/assets/stylesheets/page_bundles/merge_requests.scss @@ -7,7 +7,6 @@ .diff-files-holder { flex: 1; min-width: 0; - z-index: 201; } .diff-tree-list { diff --git a/app/assets/stylesheets/page_bundles/oncall_schedules.scss b/app/assets/stylesheets/page_bundles/oncall_schedules.scss index 1b190024457..a6668f00147 100644 --- a/app/assets/stylesheets/page_bundles/oncall_schedules.scss +++ b/app/assets/stylesheets/page_bundles/oncall_schedules.scss @@ -37,12 +37,6 @@ &.gl-modal .modal-md { max-width: 640px; } - - // TODO: move to gitlab/ui utilities - // https://gitlab.com/gitlab-org/gitlab/-/issues/297502 - .gl-w-fit-content { - width: fit-content; - } } //// Copied from roadmaps.scss - adapted for on-call schedules @@ -91,9 +85,6 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi } .timeline-header-item { - // container size minus left panel width divided by 2 week timeframes - width: calc((100% - #{$details-cell-width}) / 2); - &:last-of-type .item-label { @include gl-border-r-0; } @@ -174,9 +165,6 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi .timeline-cell { @include gl-relative; - // width: $timeline-cell-width; - // container size minus left panel width divided by 2 week timeframes - width: calc((100% - #{$details-cell-width}) / 2); @include gl-bg-transparent; border-right: $border-style; diff --git a/app/assets/stylesheets/page_bundles/pipelines.scss b/app/assets/stylesheets/page_bundles/pipelines.scss index dbde7933a8b..ae36f7e3ac1 100644 --- a/app/assets/stylesheets/page_bundles/pipelines.scss +++ b/app/assets/stylesheets/page_bundles/pipelines.scss @@ -67,7 +67,8 @@ // Mini Pipelines .stage-cell { - .mini-pipeline-graph-dropdown-toggle { + .mini-pipeline-graph-dropdown-toggle, + .mini-pipeline-graph-gl-dropdown-toggle { svg { height: $ci-action-icon-size; width: $ci-action-icon-size; @@ -138,7 +139,13 @@ } // Dropdown button in mini pipeline graph -button.mini-pipeline-graph-dropdown-toggle { +button.mini-pipeline-graph-dropdown-toggle, +// As the `mini-pipeline-item` mixin specificity is lower +// than the toggle of dropdown with 'variant="link"' we add +// classes ".gl-button.btn-link" to make it more specific. +// Once FF ci_mini_pipeline_gl_dropdown is removed, the `mini-pipeline-item` +// itself could increase its specificity to simplify this selector +button.gl-button.btn-link.mini-pipeline-graph-gl-dropdown-toggle { @include mini-pipeline-item(); } diff --git a/app/assets/stylesheets/page_bundles/signup.scss b/app/assets/stylesheets/page_bundles/signup.scss index 9ed48b693b9..a207c10b04f 100644 --- a/app/assets/stylesheets/page_bundles/signup.scss +++ b/app/assets/stylesheets/page_bundles/signup.scss @@ -73,3 +73,7 @@ text-decoration: none; } } + +.edit-profile { + max-width: 460px; +} diff --git a/app/assets/stylesheets/pages/admin.scss b/app/assets/stylesheets/pages/admin.scss deleted file mode 100644 index af1eefd7587..00000000000 --- a/app/assets/stylesheets/pages/admin.scss +++ /dev/null @@ -1,18 +0,0 @@ -.info-well { - .admin-well-statistics, - .admin-well-features { - padding-bottom: 46px; - } -} - -.usage-data { - max-height: 400px; -} - -[data-page='admin:jobs:index'] { - .admin-builds-table { - td:last-child { - min-width: 120px; - } - } -} diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss index aeda91c1714..87307fd682e 100644 --- a/app/assets/stylesheets/pages/groups.scss +++ b/app/assets/stylesheets/pages/groups.scss @@ -260,7 +260,6 @@ } .pipeline-quota { - border-top: 1px solid $table-border-color; border-bottom: 1px solid $table-border-color; margin: 0 0 $gl-padding; diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index 1caf62067a6..1c1e9d93909 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -203,15 +203,9 @@ ul.related-merge-requests > li { } } -.discussion-reply-holder { - .avatar-note-form-holder .note-edit-form { - display: block; - margin-left: $note-icon-gutter-width; - - @include media-breakpoint-down(xs) { - margin-left: 0; - } - } +.discussion-reply-holder, +.note-edit-form { + display: block; } .issue-sort-dropdown { diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index 4d93702f1c2..b7d05fc411a 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -120,7 +120,7 @@ } .labels-container { - background-color: $gray-light; + background-color: $gray-100; border-radius: $border-radius-default; padding: $gl-padding $gl-padding-8; } diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss index 81a70470c65..019d827798c 100644 --- a/app/assets/stylesheets/pages/login.scss +++ b/app/assets/stylesheets/pages/login.scss @@ -42,8 +42,7 @@ .login-box, .omniauth-container { box-shadow: 0 0 0 1px $border-color; - border-bottom-right-radius: $border-radius; - border-bottom-left-radius: $border-radius; + border-radius: $border-radius; padding: 15px; .login-heading h3 { diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index b99e619cc98..58f8cf09780 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -221,7 +221,7 @@ $mr-widget-min-height: 69px; .mr-widget-pipeline-graph { .dropdown-menu { - z-index: 300; + z-index: $zindex-dropdown-menu; } } @@ -375,13 +375,14 @@ $mr-widget-min-height: 69px; } .text { - span { - font-weight: $gl-font-weight-bold; - } - p { margin-top: $gl-padding; } + + .highlight { + margin: 0 0 $gl-padding; + font-weight: $gl-font-weight-bold; + } } } diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 254ad96bb57..ffbfa47f9bd 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -212,8 +212,12 @@ table { } } -.note-edit-form { +// Snippets are the only non-vue form left +.snippets.note-edit-form { display: none; +} + +.note-edit-form { font-size: 14px; .md-area { diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 4216091e8a9..121ee1e635c 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -1,6 +1,5 @@ $system-note-icon-size: 32px; $system-note-svg-size: 16px; -$note-form-margin-left: 72px; @mixin vertical-line($left) { &::before { @@ -54,16 +53,6 @@ $note-form-margin-left: 72px; &.note-form { margin-left: 0; - @include notes-media('min', map-get($grid-breakpoints, md)) { - margin-left: $note-form-margin-left; - } - - .timeline-icon { - @include notes-media('min', map-get($grid-breakpoints, sm)) { - margin-left: -$note-icon-gutter-width; - } - } - .timeline-content { margin-left: 0; } @@ -84,36 +73,17 @@ $note-form-margin-left: 72px; .replies-toggle { background-color: $gray-light; padding: $gl-padding-8 $gl-padding; - border-top: 1px solid $gray-50; - border-bottom: 1px solid $gray-50; + border-top: 1px solid $gray-100; + border-bottom: 1px solid $gray-100; .collapse-replies-btn:hover { color: $blue-600; } - &.expanded { - span { - cursor: pointer; - } - - svg { - position: relative; - top: 3px; - } - } - &.collapsed { color: $gl-text-color-secondary; border-radius: 0 0 $border-radius-default $border-radius-default; - svg { - float: left; - position: relative; - top: $gl-padding-4; - margin-right: $gl-padding-8; - cursor: pointer; - } - img { margin: -2px 4px 0 0; } @@ -178,7 +148,6 @@ $note-form-margin-left: 72px; > li { display: block; position: relative; - border-bottom: 0; &.being-posted { pointer-events: none; @@ -549,21 +518,6 @@ $note-form-margin-left: 72px; .code-commit .notes-content, .diff-viewer > .image ~ .note-container { background-color: $white; - - .avatar-note-form-holder { - .user-avatar-link img { - margin: 13px $gl-padding $gl-padding; - } - - form, - ~ .discussion-form-container { - padding: $gl-padding; - - @include media-breakpoint-up(sm) { - margin-left: $note-icon-gutter-width; - } - } - } } .diff-viewer > .image ~ .note-container form.new-note { diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 3605283245f..6a2fa2ee7a1 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -138,6 +138,13 @@ } } +.social-provider-btn-image { + > img { + width: 16px; + vertical-align: inherit; + } +} + .provider-btn-image { display: inline-block; padding: 5px 10px; @@ -378,19 +385,6 @@ table.u2f-registrations, display: inline; margin-right: $gl-padding / 4; } - - .badge-verification-status { - border-width: 1px; - border-style: solid; - - &.verified { - @include green-status-color; - } - - &.unverified { - @include status-color($gray-dark, color('gray'), $common-gray-dark); - } - } } .edit-user { diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 7fafd28be56..8251cdb9bbb 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -571,10 +571,6 @@ top: 0; } } - - .btn-missing { - @extend .btn-missing; - } } } @@ -996,6 +992,20 @@ pre.light-well { width: auto; } } + + // Remove once gitlab/ui solution is implemented: + // https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1157 + // https://gitlab.com/gitlab-org/gitlab/-/issues/300405 + .gl-search-box-by-type-input { + width: 100%; + } + + // Remove once gitlab/ui solution is implemented + // https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1158 + // https://gitlab.com/gitlab-org/gitlab/-/issues/300405 + .gl-new-dropdown-button-text { + @include str-truncated; + } } .clearable-input { diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss index 352050f7b01..33bd40a488f 100644 --- a/app/assets/stylesheets/pages/tree.scss +++ b/app/assets/stylesheets/pages/tree.scss @@ -49,8 +49,6 @@ position: relative; .dropdown-menu { - min-width: 100%; - width: 100%; left: inherit; right: 0; } diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss index af43c532b7c..608740078b6 100644 --- a/app/assets/stylesheets/startup/startup-signin.scss +++ b/app/assets/stylesheets/startup/startup-signin.scss @@ -2093,8 +2093,7 @@ table.code { .login-page .login-box, .login-page .omniauth-container { box-shadow: 0 0 0 1px #dbdbdb; - border-bottom-right-radius: 0.25rem; - border-bottom-left-radius: 0.25rem; + border-radius: 0.25rem; padding: 15px; } .login-page .login-box .nav .active a, diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index ab330ed69c6..7178e7f0c78 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -110,7 +110,7 @@ // This utility is used to force the z-index to match that of dropdown menu's .gl-z-dropdown-menu\! { - z-index: 300 !important; + z-index: $zindex-dropdown-menu !important; } .gl-flex-basis-quarter { diff --git a/app/controllers/admin/cohorts_controller.rb b/app/controllers/admin/cohorts_controller.rb index a26dc554506..9bb73c822b0 100644 --- a/app/controllers/admin/cohorts_controller.rb +++ b/app/controllers/admin/cohorts_controller.rb @@ -1,19 +1,11 @@ # frozen_string_literal: true class Admin::CohortsController < Admin::ApplicationController - include Analytics::UniqueVisitsHelper - - track_unique_visits :index, target_id: 'i_analytics_cohorts' - feature_category :devops_reports + # Backwards compatibility. Remove it and routing in 14.0 + # @see https://gitlab.com/gitlab-org/gitlab/-/issues/299303 def index - if Gitlab::CurrentSettings.usage_ping_enabled - cohorts_results = Rails.cache.fetch('cohorts', expires_in: 1.day) do - CohortsService.new.execute - end - - @cohorts = CohortsSerializer.new.represent(cohorts_results) - end + redirect_to admin_users_path(tab: 'cohorts') end end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 3fe972d1917..d0761083c8b 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -2,6 +2,7 @@ class Admin::UsersController < Admin::ApplicationController include RoutableActions + include Analytics::UniqueVisitsHelper before_action :user, except: [:index, :new, :create] before_action :check_impersonation_availability, only: :impersonate @@ -15,6 +16,10 @@ class Admin::UsersController < Admin::ApplicationController @users = @users.includes(:authorized_projects) # rubocop: disable CodeReuse/ActiveRecord @users = @users.sort_by_attribute(@sort = params[:sort]) @users = @users.page(params[:page]) + + @cohorts = load_cohorts + + track_cohorts_visit if params[:tab] == 'cohorts' end def show @@ -307,6 +312,22 @@ class Admin::UsersController < Admin::ApplicationController def log_impersonation_event Gitlab::AppLogger.info(_("User %{current_user_username} has started impersonating %{username}") % { current_user_username: current_user.username, username: user.username }) end + + def load_cohorts + if Gitlab::CurrentSettings.usage_ping_enabled + cohorts_results = Rails.cache.fetch('cohorts', expires_in: 1.day) do + CohortsService.new.execute + end + + CohortsSerializer.new.represent(cohorts_results) + end + end + + def track_cohorts_visit + if request.format.html? && request.headers['DNT'] != '1' + track_visit('i_analytics_cohorts') + end + end end Admin::UsersController.prepend_if_ee('EE::Admin::UsersController') diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 3cb7373a970..5f14d95ffed 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -556,4 +556,4 @@ class ApplicationController < ActionController::Base end end -ApplicationController.prepend_if_ee('EE::ApplicationController') +ApplicationController.prepend_ee_mod diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index 9ee69c7c07f..79e45bcf929 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -18,7 +18,7 @@ class AutocompleteController < ApplicationController .new(params: params, current_user: current_user, project: project, group: group) .execute - render json: UserSerializer.new(params).represent(users, project: project) + render json: UserSerializer.new(params.merge({ current_user: current_user })).represent(users, project: project) end def user diff --git a/app/controllers/chaos_controller.rb b/app/controllers/chaos_controller.rb index e0d1f313fc7..0ec6a2cb38a 100644 --- a/app/controllers/chaos_controller.rb +++ b/app/controllers/chaos_controller.rb @@ -23,6 +23,15 @@ class ChaosController < ActionController::Base do_chaos :kill, Chaos::KillWorker end + def gc + gc_stat = Gitlab::Chaos.run_gc + + render json: { + worker_id: Prometheus::PidProvider.worker_id, + gc_stat: gc_stat + } + end + private def do_chaos(method, worker, *args) diff --git a/app/controllers/concerns/comment_and_close_flag.rb b/app/controllers/concerns/comment_and_close_flag.rb new file mode 100644 index 00000000000..e2f3272abbc --- /dev/null +++ b/app/controllers/concerns/comment_and_close_flag.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module CommentAndCloseFlag + extend ActiveSupport::Concern + + included do + before_action do + push_frontend_feature_flag(:remove_comment_close_reopen, @group) + end + end +end diff --git a/app/controllers/concerns/integrations_actions.rb b/app/controllers/concerns/integrations_actions.rb index baebedb8e5d..a3ea39d9c3d 100644 --- a/app/controllers/concerns/integrations_actions.rb +++ b/app/controllers/concerns/integrations_actions.rb @@ -34,10 +34,6 @@ module IntegrationsActions end end - def custom_integration_projects - Project.with_custom_integration_compared_to(integration).page(params[:page]).per(20) - end - def test render json: {}, status: :ok end diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb index 816a93f14c6..9e3625d1b36 100644 --- a/app/controllers/concerns/membership_actions.rb +++ b/app/controllers/concerns/membership_actions.rb @@ -18,18 +18,26 @@ module MembershipActions def update update_params = params.require(root_params_key).permit(:access_level, :expires_at) member = membershipable.members_and_requesters.find(params[:id]) - member = Members::UpdateService + result = Members::UpdateService .new(current_user, update_params) .execute(member) - if member.expires? - render json: { - expires_in: helpers.distance_of_time_in_words_to_now(member.expires_at), - expires_soon: member.expires_soon?, - expires_at_formatted: member.expires_at.to_time.in_time_zone.to_s(:medium) - } + member = result[:member] + + member_data = if member.expires? + { + expires_in: helpers.distance_of_time_in_words_to_now(member.expires_at), + expires_soon: member.expires_soon?, + expires_at_formatted: member.expires_at.to_time.in_time_zone.to_s(:medium) + } + else + {} + end + + if result[:status] == :success + render json: member_data else - render json: {} + render json: { message: result[:message] }, status: :unprocessable_entity end end diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index bfa7a30bc65..2cef43f19ab 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -31,9 +31,9 @@ module NotesActions # We know there's more data, so tell the frontend to poll again after 1ms set_polling_interval_header(interval: 1) if meta[:more] - # Only present an ETag for the empty response to ensure pagination works - # as expected - ::Gitlab::EtagCaching::Middleware.skip!(response) if notes.present? + # We might still want to investigate further adjusting ETag caching with paginated notes, but + # let's avoid ETag caching for now until we confirm the viability of paginated notes. + ::Gitlab::EtagCaching::Middleware.skip!(response) render json: meta.merge(notes: notes) end diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb index c295290a123..3cab198c1f9 100644 --- a/app/controllers/concerns/service_params.rb +++ b/app/controllers/concerns/service_params.rb @@ -12,6 +12,7 @@ module ServiceParams :api_version, :bamboo_url, :branches_to_be_notified, + :labels_to_be_notified, :build_key, :build_type, :ca_pem, diff --git a/app/controllers/concerns/spammable_actions.rb b/app/controllers/concerns/spammable_actions.rb index 4ec561014a8..b285faee9bc 100644 --- a/app/controllers/concerns/spammable_actions.rb +++ b/app/controllers/concerns/spammable_actions.rb @@ -3,9 +3,6 @@ module SpammableActions extend ActiveSupport::Concern - include Recaptcha::Verify - include Gitlab::Utils::StrongMemoize - included do before_action :authorize_submit_spammable!, only: :mark_as_spam end @@ -20,17 +17,11 @@ module SpammableActions private - def ensure_spam_config_loaded! - strong_memoize(:spam_config_loaded) do - Gitlab::Recaptcha.load_configurations! - end - end - def recaptcha_check_with_fallback(should_redirect = true, &fallback) if should_redirect && spammable.valid? redirect_to spammable_path - elsif render_recaptcha? - ensure_spam_config_loaded! + elsif spammable.render_recaptcha? + Gitlab::Recaptcha.load_configurations! respond_to do |format| format.html do @@ -50,33 +41,30 @@ module SpammableActions end def spammable_params - default_params = { request: request } - - recaptcha_check = recaptcha_response && - ensure_spam_config_loaded! && - verify_recaptcha(response: recaptcha_response) - - return default_params unless recaptcha_check - - { recaptcha_verified: true, - spam_log_id: params[:spam_log_id] }.merge(default_params) - end - - def recaptcha_response - # NOTE: This field name comes from `Recaptcha::ClientHelper#recaptcha_tags` in the recaptcha - # gem, which is called from the HAML `_recaptcha_form.html.haml` form. + # NOTE: For the legacy reCAPTCHA implementation based on the HTML/HAML form, the + # 'g-recaptcha-response' field name comes from `Recaptcha::ClientHelper#recaptcha_tags` in the + # recaptcha gem, which is called from the HAML `_recaptcha_form.html.haml` form. # - # It is used in the `Recaptcha::Verify#verify_recaptcha` if the `response` option is not - # passed explicitly. + # It is used in the `Recaptcha::Verify#verify_recaptcha` to extract the value from `params`, + # if the `response` option is not passed explicitly. # # Instead of relying on this behavior, we are extracting and passing it explicitly. This will # make it consistent with the newer, modern reCAPTCHA verification process as it will be # implemented via the GraphQL API and in Vue components via the native reCAPTCHA Javascript API, # which requires that the recaptcha response param be obtained and passed explicitly. # - # After this newer GraphQL/JS API process is fully supported by the backend, we can remove this - # (and other) HAML-specific support. - params['g-recaptcha-response'] + # It can also be expanded to multiple fields when we move to future alternative captcha + # implementations such as FriendlyCaptcha. See https://gitlab.com/gitlab-org/gitlab/-/issues/273480 + + # After this newer GraphQL/JS API process is fully supported by the backend, we can remove the + # check for the 'g-recaptcha-response' field and other HTML/HAML form-specific support. + captcha_response = params['g-recaptcha-response'] + + { + request: request, + spam_log_id: params[:spam_log_id], + captcha_response: captcha_response + } end def spammable @@ -90,11 +78,4 @@ module SpammableActions def authorize_submit_spammable! access_denied! unless current_user.admin? end - - def render_recaptcha? - return false if spammable.errors.count > 1 # re-render "new" template in case there are other errors - return false unless Gitlab::Recaptcha.enabled? - - spammable.needs_recaptcha? - end end diff --git a/app/controllers/groups/dependency_proxy_for_containers_controller.rb b/app/controllers/groups/dependency_proxy_for_containers_controller.rb index 0f640397320..ff3c24a91a1 100644 --- a/app/controllers/groups/dependency_proxy_for_containers_controller.rb +++ b/app/controllers/groups/dependency_proxy_for_containers_controller.rb @@ -16,7 +16,12 @@ class Groups::DependencyProxyForContainersController < Groups::ApplicationContro result = DependencyProxy::FindOrCreateManifestService.new(group, image, tag, token).execute if result[:status] == :success - send_upload(result[:manifest].file) + response.headers['Docker-Content-Digest'] = result[:manifest].digest + response.headers['Content-Length'] = result[:manifest].size + response.headers['Docker-Distribution-Api-Version'] = DependencyProxy::DISTRIBUTION_API_VERSION + response.headers['Etag'] = "\"#{result[:manifest].digest}\"" + + send_upload(result[:manifest].file, send_params: { type: result[:manifest].content_type }) else render status: result[:http_status], json: result[:message] end diff --git a/app/controllers/groups/email_campaigns_controller.rb b/app/controllers/groups/email_campaigns_controller.rb new file mode 100644 index 00000000000..cbb0176ea7b --- /dev/null +++ b/app/controllers/groups/email_campaigns_controller.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +class Groups::EmailCampaignsController < Groups::ApplicationController + include InProductMarketingHelper + include Gitlab::Tracking::ControllerConcern + + EMAIL_CAMPAIGNS_SCHEMA_URL = 'iglu:com.gitlab/email_campaigns/jsonschema/1-0-0' + + feature_category :navigation + + before_action :check_params + + def index + track_click + redirect_to redirect_link + end + + private + + def track_click + data = { + namespace_id: group.id, + track: @track, + series: @series, + subject_line: subject_line(@track, @series) + } + + track_self_describing_event(EMAIL_CAMPAIGNS_SCHEMA_URL, data: data) + end + + def redirect_link + case @track + when :create + create_track_url + when :verify + project_pipelines_url(group.projects.first) + when :trial + 'https://about.gitlab.com/free-trial/' + when :team + group_group_members_url(group) + end + end + + def create_track_url + [ + new_project_url, + new_project_url(anchor: 'import_project'), + help_page_url('user/project/repository/repository_mirroring') + ][@series] + end + + def check_params + @track = params[:track]&.to_sym + @series = params[:series]&.to_i + + track_valid = @track.in?(Namespaces::InProductMarketingEmailsService::TRACKS.keys) + series_valid = @series.in?(0..Namespaces::InProductMarketingEmailsService::INTERVAL_DAYS.size - 1) + + render_404 unless track_valid && series_valid + end +end diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 068815f7f07..9d7aebe4505 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -30,6 +30,7 @@ class GroupsController < Groups::ApplicationController before_action do push_frontend_feature_flag(:vue_issuables_list, @group) + push_frontend_feature_flag(:vue_notification_dropdown, @group, default_enabled: :yaml) end before_action do @@ -319,9 +320,7 @@ class GroupsController < Groups::ApplicationController private - def successful_creation_hooks - track_experiment_event(:onboarding_issues, 'created_namespace') - end + def successful_creation_hooks; end def groups if @group.supports_events? diff --git a/app/controllers/import/bulk_imports_controller.rb b/app/controllers/import/bulk_imports_controller.rb index 7394e8bf615..61eb9a27560 100644 --- a/app/controllers/import/bulk_imports_controller.rb +++ b/app/controllers/import/bulk_imports_controller.rb @@ -22,7 +22,13 @@ class Import::BulkImportsController < ApplicationController def status respond_to do |format| format.json do - render json: { importable_data: serialized_importable_data } + data = importable_data + + pagination_headers.each do |header| + response.set_header(header, data.headers[header]) + end + + render json: { importable_data: serialized_data(data.parsed_response) } end format.html do @source_url = session[url_key] @@ -44,8 +50,12 @@ class Import::BulkImportsController < ApplicationController private - def serialized_importable_data - serializer.represent(importable_data, {}, Import::BulkImportEntity) + def pagination_headers + %w[x-next-page x-page x-per-page x-prev-page x-total x-total-pages] + end + + def serialized_data(data) + serializer.represent(data, {}, Import::BulkImportEntity) end def serializer @@ -53,7 +63,7 @@ class Import::BulkImportsController < ApplicationController end def importable_data - client.get('groups', query_params).parsed_response + client.get('groups', query_params) end # Default query string params used to fetch groups from GitLab source instance @@ -74,7 +84,9 @@ class Import::BulkImportsController < ApplicationController def client @client ||= BulkImports::Clients::Http.new( uri: session[url_key], - token: session[access_token_key] + token: session[access_token_key], + per_page: params[:per_page], + page: params[:page] ) end diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index ad92645c23e..cce635a8b80 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -6,6 +6,7 @@ class InvitesController < ApplicationController before_action :member before_action :ensure_member_exists before_action :invite_details + before_action :set_invite_type, only: :show skip_before_action :authenticate_user!, only: :decline helper_method :member?, :current_user_matches_invite? @@ -15,11 +16,16 @@ class InvitesController < ApplicationController feature_category :authentication_and_authorization def show + experiment('members/invite_email', actor: member).track(:opened) if initial_invite_email? + accept if skip_invitation_prompt? end def accept if member.accept_invite!(current_user) + experiment('members/invite_email', actor: member).track(:accepted) if initial_invite_email? + session.delete(:invite_type) + redirect_to invite_details[:path], notice: _("You have been granted %{member_human_access} access to %{title} %{name}.") % { member_human_access: member.human_access, title: invite_details[:title], name: invite_details[:name] } else @@ -47,6 +53,14 @@ class InvitesController < ApplicationController private + def set_invite_type + session[:invite_type] = params[:invite_type] if params[:invite_type].in?([Members::InviteEmailExperiment::INVITE_TYPE]) + end + + def initial_invite_email? + session[:invite_type] == Members::InviteEmailExperiment::INVITE_TYPE + end + def skip_invitation_prompt? !member? && current_user_matches_invite? end diff --git a/app/controllers/projects/badges_controller.rb b/app/controllers/projects/badges_controller.rb index 855965ca6e1..f75ab5cdbf2 100644 --- a/app/controllers/projects/badges_controller.rb +++ b/app/controllers/projects/badges_controller.rb @@ -9,7 +9,7 @@ class Projects::BadgesController < Projects::ApplicationController feature_category :continuous_integration def pipeline - pipeline_status = Gitlab::Badge::Pipeline::Status + pipeline_status = Gitlab::Ci::Badge::Pipeline::Status .new(project, params[:ref], opts: { ignore_skipped: params[:ignore_skipped], key_text: params[:key_text], @@ -20,7 +20,7 @@ class Projects::BadgesController < Projects::ApplicationController end def coverage - coverage_report = Gitlab::Badge::Coverage::Report + coverage_report = Gitlab::Ci::Badge::Coverage::Report .new(project, params[:ref], opts: { job: params[:job], key_text: params[:key_text], diff --git a/app/controllers/projects/ci/prometheus_metrics/histograms_controller.rb b/app/controllers/projects/ci/prometheus_metrics/histograms_controller.rb new file mode 100644 index 00000000000..003441d4b91 --- /dev/null +++ b/app/controllers/projects/ci/prometheus_metrics/histograms_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Projects + module Ci + module PrometheusMetrics + class HistogramsController < Projects::ApplicationController + feature_category :pipeline_authoring + + respond_to :json, only: [:create] + + def create + result = ::Ci::PrometheusMetrics::ObserveHistogramsService.new(project, permitted_params).execute + + render json: result.payload, status: result.http_status + end + + private + + def permitted_params + params.permit(histograms: [:name, :value]) + end + end + end + end +end diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index 2e48f2f0e45..b694efbc1eb 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -18,6 +18,9 @@ class Projects::CommitController < Projects::ApplicationController before_action :define_commit_vars, only: [:show, :diff_for_path, :diff_files, :pipelines, :merge_requests] before_action :define_note_vars, only: [:show, :diff_for_path, :diff_files] before_action :authorize_edit_tree!, only: [:revert, :cherry_pick] + before_action only: [:pipelines] do + push_frontend_feature_flag(:ci_mini_pipeline_gl_dropdown, @project, type: :development, default_enabled: :yaml) + end BRANCH_SEARCH_LIMIT = 1000 diff --git a/app/controllers/projects/discussions_controller.rb b/app/controllers/projects/discussions_controller.rb index b9ab1076999..708b7a6c7ba 100644 --- a/app/controllers/projects/discussions_controller.rb +++ b/app/controllers/projects/discussions_controller.rb @@ -18,7 +18,7 @@ class Projects::DiscussionsController < Projects::ApplicationController end def unresolve - discussion.unresolve! + Discussions::UnresolveService.new(discussion, current_user).execute render_discussion end diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb index 1c2930f6e9b..5576d5766c7 100644 --- a/app/controllers/projects/forks_controller.rb +++ b/app/controllers/projects/forks_controller.rb @@ -86,7 +86,7 @@ class Projects::ForksController < Projects::ApplicationController def fork_service strong_memoize(:fork_service) do - ::Projects::ForkService.new(project, current_user, namespace: fork_namespace) + ::Projects::ForkService.new(project, current_user, fork_params) end end @@ -96,6 +96,12 @@ class Projects::ForksController < Projects::ApplicationController end end + def fork_params + params.permit(:path, :name, :description, :visibility).tap do |param| + param[:namespace] = fork_namespace + end + end + def authorize_fork_namespace! access_denied! unless fork_namespace && fork_service.valid_fork_target? end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 3a0e40f9745..45402964d11 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -9,6 +9,7 @@ class Projects::IssuesController < Projects::ApplicationController include IssuesCalendar include SpammableActions include RecordUserLastActivity + include CommentAndCloseFlag ISSUES_EXCEPT_ACTIONS = %i[index calendar new create bulk_update import_csv export_csv service_desk].freeze SET_ISSUEABLES_INDEX_ONLY_ACTIONS = %i[index calendar service_desk].freeze @@ -41,7 +42,6 @@ class Projects::IssuesController < Projects::ApplicationController before_action :create_rate_limit, only: [:create] before_action do - push_frontend_feature_flag(:vue_issuable_sidebar, project.group) push_frontend_feature_flag(:tribute_autocomplete, @project) push_frontend_feature_flag(:vue_issuables_list, project) push_frontend_feature_flag(:usage_data_design_action, project, default_enabled: true) @@ -60,8 +60,7 @@ class Projects::IssuesController < Projects::ApplicationController around_action :allow_gitaly_ref_name_caching, only: [:discussions] before_action :run_null_hypothesis_experiment, - only: [:index, :new, :create], - if: -> { Feature.enabled?(:gitlab_experiments) } + only: [:index, :new, :create] respond_to :html @@ -106,7 +105,7 @@ class Projects::IssuesController < Projects::ApplicationController build_params = issue_create_params.merge( merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of], discussion_to_resolve: params[:discussion_to_resolve], - confidential: !!Gitlab::Utils.to_boolean(params[:issue][:confidential]) + confidential: !!Gitlab::Utils.to_boolean(issue_create_params[:confidential]) ) service = ::Issues::BuildService.new(project, current_user, build_params) diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb index 9cac9f37eb7..e74717a44ab 100644 --- a/app/controllers/projects/merge_requests/application_controller.rb +++ b/app/controllers/projects/merge_requests/application_controller.rb @@ -20,7 +20,7 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont end def preloadable_mr_relations - [:metrics, :assignees, { author: :status }] + [:metrics, { assignees: :status }, { author: :status }] end def merge_request_params diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index 9180b3f6b62..98ef9d918ae 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -122,10 +122,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic end end - if render_merge_ref_head_diff? - return CompareService.new(@project, @merge_request.merge_ref_head.sha) - .execute(@project, @merge_request.target_branch) - end + return @merge_request.merge_head_diff if render_merge_ref_head_diff? if @start_sha @merge_request_diff.compare_with(@start_sha) diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index d452a5e02e2..b8467670e4b 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -11,6 +11,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo include RecordUserLastActivity include SourcegraphDecorator include DiffHelper + include CommentAndCloseFlag skip_before_action :merge_request, only: [:index, :bulk_update, :export_csv] before_action :apply_diff_view_cookie!, only: [:show] @@ -22,7 +23,8 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo :coverage_reports, :terraform_reports, :accessibility_reports, - :codequality_reports + :codequality_reports, + :codequality_mr_diff_reports ] before_action :set_issuables_index, only: [:index] before_action :authenticate_user!, only: [:assign_related_issues] @@ -36,20 +38,20 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:drag_comment_selection, @project, default_enabled: true) push_frontend_feature_flag(:unified_diff_components, @project, default_enabled: true) push_frontend_feature_flag(:default_merge_ref_for_diffs, @project) - push_frontend_feature_flag(:core_security_mr_widget, @project, default_enabled: true) push_frontend_feature_flag(:core_security_mr_widget_counts, @project) - push_frontend_feature_flag(:core_security_mr_widget_downloads, @project, default_enabled: true) push_frontend_feature_flag(:remove_resolve_note, @project, default_enabled: true) push_frontend_feature_flag(:diffs_gradual_load, @project, default_enabled: true) - push_frontend_feature_flag(:codequality_mr_diff, @project) + push_frontend_feature_flag(:codequality_backend_comparison, @project, default_enabled: :yaml) push_frontend_feature_flag(:suggestions_custom_commit, @project) + push_frontend_feature_flag(:local_file_reviews, default_enabled: :yaml) + push_frontend_feature_flag(:paginated_notes, @project, default_enabled: :yaml) + push_frontend_feature_flag(:ci_mini_pipeline_gl_dropdown, @project, type: :development, default_enabled: :yaml) record_experiment_user(:invite_members_version_a) record_experiment_user(:invite_members_version_b) end before_action do - push_frontend_feature_flag(:vue_issuable_sidebar, @project.group) push_frontend_feature_flag(:merge_request_reviewers, @project, default_enabled: true) push_frontend_feature_flag(:mr_collapsed_approval_rules, @project) push_frontend_feature_flag(:reviewer_approval_rules, @project, default_enabled: :yaml) @@ -68,7 +70,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo :toggle_award_emoji, :toggle_subscription, :update ] - feature_category :code_testing, [:test_reports, :coverage_reports] + feature_category :code_testing, [:test_reports, :coverage_reports, :codequality_mr_diff_reports] feature_category :accessibility_testing, [:accessibility_reports] feature_category :infrastructure_as_code, [:terraform_reports] @@ -197,6 +199,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end end + def codequality_mr_diff_reports + reports_response(@merge_request.find_codequality_mr_diff_reports) + end + def codequality_reports reports_response(@merge_request.compare_codequality_reports) end diff --git a/app/controllers/projects/pipelines/tests_controller.rb b/app/controllers/projects/pipelines/tests_controller.rb index 924d52898ea..1702783b10f 100644 --- a/app/controllers/projects/pipelines/tests_controller.rb +++ b/app/controllers/projects/pipelines/tests_controller.rb @@ -42,9 +42,13 @@ module Projects end def test_suite - builds.map do |build| + suite = builds.map do |build| build.collect_test_reports!(Gitlab::Ci::Reports::TestReports.new) end.sum + + Gitlab::Ci::Reports::TestFailureHistory.new(suite.failed.values, project).load! + + suite end # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index e44c00e501e..8edc2e732e0 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -12,12 +12,11 @@ class Projects::PipelinesController < Projects::ApplicationController before_action :authorize_create_pipeline!, only: [:new, :create, :config_variables] before_action :authorize_update_pipeline!, only: [:retry, :cancel] before_action do - push_frontend_feature_flag(:dag_pipeline_tab, project, default_enabled: true) push_frontend_feature_flag(:pipelines_security_report_summary, project) push_frontend_feature_flag(:new_pipeline_form, project, default_enabled: true) - push_frontend_feature_flag(:graphql_pipeline_header, project, type: :development, default_enabled: false) - push_frontend_feature_flag(:graphql_pipeline_details, project, type: :development, default_enabled: false) - push_frontend_feature_flag(:new_pipeline_form_prefilled_vars, project, type: :development, default_enabled: true) + push_frontend_feature_flag(:graphql_pipeline_details, project, type: :development, default_enabled: :yaml) + push_frontend_feature_flag(:graphql_pipeline_details_users, current_user, type: :development, default_enabled: :yaml) + push_frontend_feature_flag(:ci_mini_pipeline_gl_dropdown, project, type: :development, default_enabled: :yaml) end before_action :ensure_pipeline, only: [:show] before_action :push_experiment_to_gon, only: :index, if: :html_request? diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index 5972b29a298..463b989c493 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -8,6 +8,10 @@ class Projects::ProjectMembersController < Projects::ApplicationController # Authorize before_action :authorize_admin_project_member!, except: [:index, :leave, :request_access] + before_action do + push_frontend_feature_flag(:vue_project_members_list, @project) + end + feature_category :authentication_and_authorization def index diff --git a/app/controllers/projects/security/configuration_controller.rb b/app/controllers/projects/security/configuration_controller.rb new file mode 100644 index 00000000000..9366ca7b0ed --- /dev/null +++ b/app/controllers/projects/security/configuration_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Projects + module Security + class ConfigurationController < Projects::ApplicationController + feature_category :static_application_security_testing + + def show + return render_404 unless feature_enabled? + + render_403 unless can?(current_user, :read_security_configuration, project) + end + + private + + def feature_enabled? + ::Feature.enabled?(:secure_security_and_compliance_configuration_page_on_ce, @project, default_enabled: :yaml) + end + end + end +end + +Projects::Security::ConfigurationController.prepend_if_ee('EE::Projects::Security::ConfigurationController') diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index 31533dfeea0..34b11c456b9 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -144,8 +144,8 @@ module Projects def define_badges_variables @ref = params[:ref] || @project.default_branch || 'master' - @badges = [Gitlab::Badge::Pipeline::Status, - Gitlab::Badge::Coverage::Report] + @badges = [Gitlab::Ci::Badge::Pipeline::Status, + Gitlab::Ci::Badge::Coverage::Report] @badges.map! do |badge| badge.new(@project, @ref).metadata diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 0c40478d877..64cb1c1ee52 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -14,7 +14,7 @@ class ProjectsController < Projects::ApplicationController around_action :allow_gitaly_ref_name_caching, only: [:index, :show] - before_action :whitelist_query_limiting, only: [:create] + before_action :whitelist_query_limiting, only: [:show, :create] before_action :authenticate_user!, except: [:index, :show, :activity, :refs, :resolve, :unfoldered_environment_names] before_action :redirect_git_extension, only: [:show] before_action :project, except: [:index, :new, :create, :resolve] @@ -31,6 +31,10 @@ class ProjectsController < Projects::ApplicationController # Project Export Rate Limit before_action :export_rate_limit, only: [:export, :download_export, :generate_new_export] + before_action do + push_frontend_feature_flag(:vue_notification_dropdown, @project, default_enabled: :yaml) + end + before_action only: [:edit] do push_frontend_feature_flag(:approval_suggestions, @project, default_enabled: true) push_frontend_feature_flag(:allow_editing_commit_messages, @project) @@ -503,7 +507,7 @@ class ProjectsController < Projects::ApplicationController end def whitelist_query_limiting - Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42440') + Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab/-/issues/20826') end def present_project diff --git a/app/controllers/registrations/experience_levels_controller.rb b/app/controllers/registrations/experience_levels_controller.rb index 23126983eb5..ddc5d128766 100644 --- a/app/controllers/registrations/experience_levels_controller.rb +++ b/app/controllers/registrations/experience_levels_controller.rb @@ -14,9 +14,8 @@ module Registrations if current_user.save hide_advanced_issues - record_experiment_user(:default_to_issues_board) - if experiment_enabled?(:default_to_issues_board) && learn_gitlab.available? + if learn_gitlab.available? redirect_to namespace_project_board_path(params[:namespace_path], learn_gitlab.project, learn_gitlab.board) else redirect_to group_path(params[:namespace_path]) diff --git a/app/controllers/registrations/welcome_controller.rb b/app/controllers/registrations/welcome_controller.rb index 4a6fef56ef5..89e547c3a5c 100644 --- a/app/controllers/registrations/welcome_controller.rb +++ b/app/controllers/registrations/welcome_controller.rb @@ -39,9 +39,6 @@ module Registrations def process_gitlab_com_tracking return false unless ::Gitlab.com? return false unless show_onboarding_issues_experiment? - - track_experiment_event(:onboarding_issues, 'signed_up') - record_experiment_user(:onboarding_issues) end def update_params diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index e7872eeac27..44c08863dd6 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -31,6 +31,8 @@ class RegistrationsController < Devise::RegistrationsController NotificationService.new.new_instance_access_request(new_user) end + after_request_hook(new_user) + yield new_user if block_given? end @@ -85,6 +87,10 @@ class RegistrationsController < Devise::RegistrationsController super end + def after_request_hook(user) + # overridden by EE module + end + def after_sign_up_path_for(user) Gitlab::AppLogger.info(user_created_message(confirmed: user.confirmed?)) diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 196b1887ca7..40e6590d85c 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -9,7 +9,7 @@ class SearchController < ApplicationController around_action :allow_gitaly_ref_name_caching - before_action :block_anonymous_global_searches + before_action :block_anonymous_global_searches, except: :opensearch skip_before_action :authenticate_user! requires_cross_project_access if: -> do search_term_present = params[:search].present? || params[:term].present? @@ -67,6 +67,9 @@ class SearchController < ApplicationController end # rubocop: enable CodeReuse/ActiveRecord + def opensearch + end + private # overridden in EE diff --git a/app/controllers/snippets/notes_controller.rb b/app/controllers/snippets/notes_controller.rb index 8532257cb8d..8a4e8edbf3c 100644 --- a/app/controllers/snippets/notes_controller.rb +++ b/app/controllers/snippets/notes_controller.rb @@ -23,7 +23,7 @@ class Snippets::NotesController < ApplicationController # rubocop: disable CodeReuse/ActiveRecord def snippet - PersonalSnippet.find_by(id: params[:snippet_id]) + @snippet ||= PersonalSnippet.find_by(id: params[:snippet_id]) end # rubocop: enable CodeReuse/ActiveRecord alias_method :noteable, :snippet diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 62208d838c1..c9152128638 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -17,7 +17,7 @@ class UsersController < ApplicationController skip_before_action :authenticate_user! prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) } - before_action :user, except: [:exists, :suggests] + before_action :user, except: [:exists, :suggests, :ssh_keys] before_action :authorize_read_user_profile!, only: [:calendar, :calendar_activities, :groups, :projects, :contributed, :starred, :snippets] @@ -41,7 +41,12 @@ class UsersController < ApplicationController # Get all keys of a user(params[:username]) in a text format # Helpful for sysadmins to put in respective servers + # + # Uses `UserFinder` rather than `find_routable!` because this endpoint should + # be publicly available regardless of instance visibility settings. def ssh_keys + user = UserFinder.new(params[:username]).find_by_username + render plain: user.all_ssh_keys.join("\n") end diff --git a/app/controllers/whats_new_controller.rb b/app/controllers/whats_new_controller.rb index cba86c65848..12a52f30bd0 100644 --- a/app/controllers/whats_new_controller.rb +++ b/app/controllers/whats_new_controller.rb @@ -5,7 +5,6 @@ class WhatsNewController < ApplicationController skip_before_action :authenticate_user! - before_action :check_feature_flag before_action :check_valid_page_param, :set_pagination_headers, unless: -> { has_version_param? } feature_category :navigation @@ -13,17 +12,13 @@ class WhatsNewController < ApplicationController def index respond_to do |format| format.js do - render json: highlight_items + render json: highlights.items end end end private - def check_feature_flag - render_404 unless Feature.enabled?(:whats_new_drawer, current_user) - end - def check_valid_page_param render_404 if current_page < 1 end @@ -42,10 +37,6 @@ class WhatsNewController < ApplicationController end end - def highlight_items - highlights.map {|item| Gitlab::WhatsNew::ItemPresenter.present(item) } - end - def set_pagination_headers response.set_header('X-Next-Page', highlights.next_page) end diff --git a/app/experiments/application_experiment.rb b/app/experiments/application_experiment.rb index 7a8851d11ce..1c9a3c5f37f 100644 --- a/app/experiments/application_experiment.rb +++ b/app/experiments/application_experiment.rb @@ -1,13 +1,20 @@ # frozen_string_literal: true -class ApplicationExperiment < Gitlab::Experiment +class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/NamespacedClass + def enabled? + return false if Feature::Definition.get(feature_flag_name).nil? # there has to be a feature flag yaml file + return false unless Gitlab.dev_env_or_com? # we're in an environment that allows experiments + + Feature.get(feature_flag_name).state != :off # rubocop:disable Gitlab/AvoidFeatureGet + end + def publish(_result) track(:assignment) # track that we've assigned a variant for this context Gon.global.push({ experiment: { name => signature } }, true) # push to client end def track(action, **event_args) - return if excluded? # no events for opted out actors or excluded subjects + return unless should_track? # no events for opted out actors or excluded subjects Gitlab::Tracking.event(name, action.to_s, **event_args.merge( context: (event_args[:context] || []) << SnowplowTracker::SelfDescribingJson.new( @@ -19,11 +26,15 @@ class ApplicationExperiment < Gitlab::Experiment private def resolve_variant_name - return variant_names.first if Feature.enabled?(name, self, type: :experiment) + return variant_names.first if Feature.enabled?(feature_flag_name, self, type: :experiment, default_enabled: :yaml) nil # Returning nil vs. :control is important for not caching and rollouts. end + def feature_flag_name + name.tr('/', '_') + end + # Cache is an implementation on top of Gitlab::Redis::SharedState that also # adheres to the ActiveSupport::Cache::Store interface and uses the redis # hash data type. @@ -41,7 +52,7 @@ class ApplicationExperiment < Gitlab::Experiment # default cache key strategy. So running `cache.fetch("foo:bar", "value")` # would create/update a hash with the key of "foo", with a field named # "bar" that has "value" assigned to it. - class Cache < ActiveSupport::Cache::Store + class Cache < ActiveSupport::Cache::Store # rubocop:disable Gitlab/NamespacedClass # Clears the entire cache for a given experiment. Be careful with this # since it would reset all resolved variants for the entire experiment. def clear(key:) diff --git a/app/experiments/members/invite_email_experiment.rb b/app/experiments/members/invite_email_experiment.rb new file mode 100644 index 00000000000..58703fd505d --- /dev/null +++ b/app/experiments/members/invite_email_experiment.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Members + class InviteEmailExperiment < ApplicationExperiment + exclude { context.actor.created_by.blank? } + exclude { context.actor.created_by.avatar_url.nil? } + + INVITE_TYPE = 'initial_email' + + private + + def resolve_variant_name + # we are overriding here so that when we add another experiment + # we can merely add that variant and check of feature flag here + if Feature.enabled?(feature_flag_name, self, type: :experiment, default_enabled: :yaml) + :avatar + else + nil # :control + end + end + end +end diff --git a/app/finders/autocomplete/users_finder.rb b/app/finders/autocomplete/users_finder.rb index 8dc3c2320ed..ff5d9ea7d19 100644 --- a/app/finders/autocomplete/users_finder.rb +++ b/app/finders/autocomplete/users_finder.rb @@ -38,7 +38,9 @@ module Autocomplete end end - items.uniq + items.uniq.tap do |unique_items| + preload_associations(unique_items) + end end private @@ -91,6 +93,12 @@ module Autocomplete User.none end end + + # rubocop: disable CodeReuse/ActiveRecord + def preload_associations(items) + ActiveRecord::Associations::Preloader.new.preload(items, :status) + end + # rubocop: enable CodeReuse/ActiveRecord end end diff --git a/app/finders/ci/jobs_finder.rb b/app/finders/ci/jobs_finder.rb index 78791d737da..4ade3e6f031 100644 --- a/app/finders/ci/jobs_finder.rb +++ b/app/finders/ci/jobs_finder.rb @@ -49,7 +49,7 @@ module Ci end def filter_by_scope(builds) - return filter_by_statuses!(params[:scope], builds) if params[:scope].is_a?(Array) + return filter_by_statuses!(builds) if params[:scope].is_a?(Array) case params[:scope] when 'pending' @@ -63,7 +63,7 @@ module Ci end end - def filter_by_statuses!(statuses, builds) + def filter_by_statuses!(builds) unknown_statuses = params[:scope] - ::CommitStatus::AVAILABLE_STATUSES raise ArgumentError, 'Scope contains invalid value(s)' unless unknown_statuses.empty? diff --git a/app/finders/labels_finder.rb b/app/finders/labels_finder.rb index 4358cf249f7..f14ae37fcfe 100644 --- a/app/finders/labels_finder.rb +++ b/app/finders/labels_finder.rb @@ -100,6 +100,10 @@ class LabelsFinder < UnionFinder strong_memoize(:group_ids) do groups = groups_to_include(group) + # Because we are sure that all groups are in the same hierarchy tree + # we can preset root group for all of them to optimize permission checks + Group.preset_root_ancestor_for(groups) if Feature.enabled?(:preset_root_ancestor_for_labels, group) + groups_user_can_read_labels(groups).map(&:id) end end diff --git a/app/finders/merge_request/metrics_finder.rb b/app/finders/merge_request/metrics_finder.rb new file mode 100644 index 00000000000..d93e53d1636 --- /dev/null +++ b/app/finders/merge_request/metrics_finder.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +class MergeRequest::MetricsFinder + include Gitlab::Allowable + + def initialize(current_user, params = {}) + @current_user = current_user + @params = params + end + + def execute + return klass.none if target_project.blank? || user_not_authorized? + + items = init_collection + items = by_target_project(items) + items = by_merged_after(items) + items = by_merged_before(items) + + items + end + + private + + attr_reader :current_user, :params + + def by_target_project(items) + items.by_target_project(target_project) + end + + def by_merged_after(items) + return items unless merged_after + + items.merged_after(merged_after) + end + + def by_merged_before(items) + return items unless merged_before + + items.merged_before(merged_before) + end + + def user_not_authorized? + !can?(current_user, :read_merge_request, target_project) + end + + def init_collection + klass.all + end + + def klass + MergeRequest::Metrics + end + + def target_project + params[:target_project] + end + + def merged_after + params[:merged_after] + end + + def merged_before + params[:merged_before] + end +end diff --git a/app/finders/merge_requests/oldest_per_commit_finder.rb b/app/finders/merge_requests/oldest_per_commit_finder.rb new file mode 100644 index 00000000000..f50db43d7d2 --- /dev/null +++ b/app/finders/merge_requests/oldest_per_commit_finder.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module MergeRequests + # OldestPerCommitFinder is used to retrieve the oldest merge requests for + # every given commit, grouped per commit SHA. + # + # This finder is useful when you need to efficiently retrieve the first/oldest + # merge requests for multiple commits, and you want to do so in batches; + # instead of running a query for every commit. + class OldestPerCommitFinder + def initialize(project) + @project = project + end + + # Returns a Hash that maps a commit ID to the oldest merge request that + # introduced that commit. + def execute(commits) + id_rows = MergeRequestDiffCommit + .oldest_merge_request_id_per_commit(@project.id, commits.map(&:id)) + + mrs = MergeRequest + .preload_target_project + .id_in(id_rows.map { |r| r[:merge_request_id] }) + .index_by(&:id) + + id_rows.each_with_object({}) do |row, hash| + if (mr = mrs[row[:merge_request_id]]) + hash[row[:sha]] = mr + end + end + end + end +end diff --git a/app/finders/repositories/commits_with_trailer_finder.rb b/app/finders/repositories/commits_with_trailer_finder.rb new file mode 100644 index 00000000000..4bd643c345b --- /dev/null +++ b/app/finders/repositories/commits_with_trailer_finder.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module Repositories + # Finder for obtaining commits between two refs, with a Git trailer set. + class CommitsWithTrailerFinder + # The maximum number of commits to retrieve per page. + # + # This value is arbitrarily chosen. Lowering it means more Gitaly calls, but + # less data being loaded into memory at once. Increasing it has the opposite + # effect. + # + # This amount is based around the number of commits that usually go in a + # GitLab release. Some examples for GitLab's own releases: + # + # * 13.6.0: 4636 commits + # * 13.5.0: 5912 commits + # * 13.4.0: 5541 commits + # + # Using this limit should result in most (very large) projects only needing + # 5-10 Gitaly calls, while keeping memory usage at a reasonable amount. + COMMITS_PER_PAGE = 1024 + + # The `project` argument specifies the project for which to obtain the + # commits. + # + # The `from` and `to` arguments specify the range of commits to include. The + # commit specified in `from` won't be included itself. The commit specified + # in `to` _is_ included. + # + # The `per_page` argument specifies how many commits are retrieved in a single + # Gitaly API call. + def initialize(project:, from:, to:, per_page: COMMITS_PER_PAGE) + @project = project + @from = from + @to = to + @per_page = per_page + end + + # Fetches all commits that have the given trailer set. + # + # The commits are yielded to the supplied block in batches. This allows + # other code to process these commits in batches too, instead of first + # having to load all commits into memory. + # + # Example: + # + # CommitsWithTrailerFinder.new(...).each_page('Signed-off-by') do |commits| + # commits.each do |commit| + # ... + # end + # end + def each_page(trailer) + return to_enum(__method__, trailer) unless block_given? + + offset = 0 + response = fetch_commits + + while response.any? + commits = [] + + response.each do |commit| + commits.push(commit) if commit.trailers.key?(trailer) + end + + yield commits + + offset += response.length + response = fetch_commits(offset) + end + end + + private + + def fetch_commits(offset = 0) + range = "#{@from}..#{@to}" + + @project + .repository + .commits(range, limit: @per_page, offset: offset, trailers: true) + end + end +end diff --git a/app/finders/terraform/states_finder.rb b/app/finders/terraform/states_finder.rb new file mode 100644 index 00000000000..bbe90fead2b --- /dev/null +++ b/app/finders/terraform/states_finder.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Terraform + class StatesFinder + def initialize(project, current_user, params: {}) + @project = project + @current_user = current_user + @params = params + end + + def execute + return ::Terraform::State.none unless can_read_terraform_states? + + states = project.terraform_states + states = states.with_name(params[:name]) if params[:name].present? + + states.ordered_by_name + end + + private + + attr_reader :project, :current_user, :params + + def can_read_terraform_states? + current_user.can?(:read_terraform_state, project) + end + end +end diff --git a/app/finders/user_recent_events_finder.rb b/app/finders/user_recent_events_finder.rb index f376b26ab9c..c9a1c918365 100644 --- a/app/finders/user_recent_events_finder.rb +++ b/app/finders/user_recent_events_finder.rb @@ -26,42 +26,23 @@ class UserRecentEventsFinder @params = params end - # rubocop: disable CodeReuse/ActiveRecord def execute return Event.none unless can?(current_user, :read_user_profile, target_user) - recent_events(params[:offset] || 0) - .joins(:project) + target_events .with_associations .limit_recent(limit, params[:offset]) + .order_created_desc end - # rubocop: enable CodeReuse/ActiveRecord private # rubocop: disable CodeReuse/ActiveRecord - def recent_events(offset) - sql = <<~SQL - (#{projects}) AS projects_for_join - JOIN (#{target_events.to_sql}) AS #{Event.table_name} - ON #{Event.table_name}.project_id = projects_for_join.id - SQL - - # Workaround for https://github.com/rails/rails/issues/24193 - Event.from([Arel.sql(sql)]) - end - # rubocop: enable CodeReuse/ActiveRecord - - # rubocop: disable CodeReuse/ActiveRecord def target_events Event.where(author: target_user) end # rubocop: enable CodeReuse/ActiveRecord - def projects - target_user.project_interactions.to_sql - end - def limit return DEFAULT_LIMIT unless params[:limit].present? diff --git a/app/graphql/mutations/alert_management/base.rb b/app/graphql/mutations/alert_management/base.rb index 3a57cb9670d..86908c1449c 100644 --- a/app/graphql/mutations/alert_management/base.rb +++ b/app/graphql/mutations/alert_management/base.rb @@ -21,7 +21,7 @@ module Mutations field :todo, Types::TodoType, null: true, - description: "The todo after mutation." + description: "The to-do item after mutation." field :issue, Types::IssueType, diff --git a/app/graphql/mutations/alert_management/http_integration/create.rb b/app/graphql/mutations/alert_management/http_integration/create.rb index ff165d7f302..2d7bffb4333 100644 --- a/app/graphql/mutations/alert_management/http_integration/create.rb +++ b/app/graphql/mutations/alert_management/http_integration/create.rb @@ -4,7 +4,7 @@ module Mutations module AlertManagement module HttpIntegration class Create < HttpIntegrationBase - include ResolvesProject + include FindsProject graphql_name 'HttpIntegrationCreate' @@ -21,27 +21,14 @@ module Mutations description: 'Whether the integration is receiving alerts.' def resolve(args) - @project = authorized_find!(full_path: args[:project_path]) + project = authorized_find!(args[:project_path]) response ::AlertManagement::HttpIntegrations::CreateService.new( project, current_user, - http_integration_params(args) + http_integration_params(project, args) ).execute end - - private - - attr_reader :project - - def find_object(full_path:) - resolve_project(full_path: full_path) - end - - # overriden in EE - def http_integration_params(args) - args.slice(:name, :active) - end end end end diff --git a/app/graphql/mutations/alert_management/http_integration/http_integration_base.rb b/app/graphql/mutations/alert_management/http_integration/http_integration_base.rb index 147df982bec..e33b7bb399a 100644 --- a/app/graphql/mutations/alert_management/http_integration/http_integration_base.rb +++ b/app/graphql/mutations/alert_management/http_integration/http_integration_base.rb @@ -23,7 +23,14 @@ module Mutations errors: result.errors } end + + # overriden in EE + def http_integration_params(_project, args) + args.slice(:name, :active) + end end end end end + +Mutations::AlertManagement::HttpIntegration::HttpIntegrationBase.prepend_if_ee('::EE::Mutations::AlertManagement::HttpIntegration::HttpIntegrationBase') diff --git a/app/graphql/mutations/alert_management/http_integration/update.rb b/app/graphql/mutations/alert_management/http_integration/update.rb index 431fccaa5e5..b1e4ce841ee 100644 --- a/app/graphql/mutations/alert_management/http_integration/update.rb +++ b/app/graphql/mutations/alert_management/http_integration/update.rb @@ -24,10 +24,12 @@ module Mutations response ::AlertManagement::HttpIntegrations::UpdateService.new( integration, current_user, - args.slice(:name, :active) + http_integration_params(integration.project, args) ).execute end end end end end + +Mutations::AlertManagement::HttpIntegration::Update.prepend_if_ee('::EE::Mutations::AlertManagement::HttpIntegration::Update') diff --git a/app/graphql/mutations/alert_management/prometheus_integration/create.rb b/app/graphql/mutations/alert_management/prometheus_integration/create.rb index c676cde90b4..87e6bc46937 100644 --- a/app/graphql/mutations/alert_management/prometheus_integration/create.rb +++ b/app/graphql/mutations/alert_management/prometheus_integration/create.rb @@ -4,7 +4,7 @@ module Mutations module AlertManagement module PrometheusIntegration class Create < PrometheusIntegrationBase - include ResolvesProject + include FindsProject graphql_name 'PrometheusIntegrationCreate' @@ -21,7 +21,7 @@ module Mutations description: 'Endpoint at which prometheus can be queried.' def resolve(args) - project = authorized_find!(full_path: args[:project_path]) + project = authorized_find!(args[:project_path]) return integration_exists if project.prometheus_service @@ -37,10 +37,6 @@ module Mutations private - def find_object(full_path:) - resolve_project(full_path: full_path) - end - def integration_exists response(nil, message: _('Multiple Prometheus integrations are not supported')) end diff --git a/app/graphql/mutations/branches/create.rb b/app/graphql/mutations/branches/create.rb index 9fe9bef5403..6354976f1ea 100644 --- a/app/graphql/mutations/branches/create.rb +++ b/app/graphql/mutations/branches/create.rb @@ -3,7 +3,7 @@ module Mutations module Branches class Create < BaseMutation - include ResolvesProject + include FindsProject graphql_name 'CreateBranch' @@ -28,7 +28,7 @@ module Mutations authorize :push_code def resolve(project_path:, name:, ref:) - project = authorized_find!(full_path: project_path) + project = authorized_find!(project_path) context.scoped_set!(:branch_project, project) @@ -40,12 +40,6 @@ module Mutations errors: Array.wrap(result[:message]) } end - - private - - def find_object(full_path:) - resolve_project(full_path: full_path) - end end end end diff --git a/app/graphql/mutations/commits/create.rb b/app/graphql/mutations/commits/create.rb index ae14401558b..84933fee5d2 100644 --- a/app/graphql/mutations/commits/create.rb +++ b/app/graphql/mutations/commits/create.rb @@ -3,7 +3,7 @@ module Mutations module Commits class Create < BaseMutation - include ResolvesProject + include FindsProject graphql_name 'CommitCreate' @@ -37,7 +37,7 @@ module Mutations authorize :push_code def resolve(project_path:, branch:, message:, actions:, **args) - project = authorized_find!(full_path: project_path) + project = authorized_find!(project_path) attributes = { commit_message: message, @@ -53,12 +53,6 @@ module Mutations errors: Array.wrap(result[:message]) } end - - private - - def find_object(full_path:) - resolve_project(full_path: full_path) - end end end end diff --git a/app/graphql/mutations/concerns/mutations/can_mutate_spammable.rb b/app/graphql/mutations/concerns/mutations/can_mutate_spammable.rb new file mode 100644 index 00000000000..2d4983f0d6e --- /dev/null +++ b/app/graphql/mutations/concerns/mutations/can_mutate_spammable.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module Mutations + # This concern can be mixed into a mutation to provide support for spam checking, + # and optionally support the workflow to allow clients to display and solve CAPTCHAs. + module CanMutateSpammable + extend ActiveSupport::Concern + + # NOTE: The arguments and fields are intentionally named with 'captcha' instead of 'recaptcha', + # so that they can be applied to future alternative CAPTCHA implementations other than + # reCAPTCHA (e.g. FriendlyCaptcha) without having to change the names and descriptions in the API. + included do + argument :captcha_response, GraphQL::STRING_TYPE, + required: false, + description: 'A valid CAPTCHA response value obtained by using the provided captchaSiteKey with a CAPTCHA API to present a challenge to be solved on the client. Required to resubmit if the previous operation returned "NeedsCaptchaResponse: true".' + + argument :spam_log_id, GraphQL::INT_TYPE, + required: false, + description: 'The spam log ID which must be passed along with a valid CAPTCHA response for the operation to be completed. Required to resubmit if the previous operation returned "NeedsCaptchaResponse: true".' + + field :spam, + GraphQL::BOOLEAN_TYPE, + null: true, + description: 'Indicates whether the operation was detected as definite spam. There is no option to resubmit the request with a CAPTCHA response.' + + field :needs_captcha_response, + GraphQL::BOOLEAN_TYPE, + null: true, + description: 'Indicates whether the operation was detected as possible spam and not completed. If CAPTCHA is enabled, the request must be resubmitted with a valid CAPTCHA response and spam_log_id included for the operation to be completed. Included only when an operation was not completed because "NeedsCaptchaResponse" is true.' + + field :spam_log_id, + GraphQL::INT_TYPE, + null: true, + description: 'The spam log ID which must be passed along with a valid CAPTCHA response for an operation to be completed. Included only when an operation was not completed because "NeedsCaptchaResponse" is true.' + + field :captcha_site_key, + GraphQL::STRING_TYPE, + null: true, + description: 'The CAPTCHA site key which must be used to render a challenge for the user to solve to obtain a valid captchaResponse value. Included only when an operation was not completed because "NeedsCaptchaResponse" is true.' + end + + private + + # additional_spam_params -> hash + # + # Used from a spammable mutation's #resolve method to generate + # the required additional spam/recaptcha params which must be merged into the params + # passed to the constructor of a service, where they can then be used in the service + # to perform spam checking via SpamActionService. + # + # Also accesses the #context of the mutation's Resolver superclass to obtain the request. + # + # Example: + # + # existing_args.merge!(additional_spam_params) + def additional_spam_params + { + api: true, + request: context[:request] + } + end + + # with_spam_action_fields(spammable) { {other_fields: true} } -> hash + # + # Takes a Spammable and a block as arguments. + # + # The block passed should be a hash, which the spam action fields will be merged into. + def with_spam_action_fields(spammable) + spam_action_fields = { + spam: spammable.spam?, + # NOTE: These fields are intentionally named with 'captcha' instead of 'recaptcha', so + # that they can be applied to future alternative CAPTCHA implementations other than + # reCAPTCHA (such as FriendlyCaptcha) without having to change the response field name + # in the API. + needs_captcha_response: spammable.render_recaptcha?, + spam_log_id: spammable.spam_log&.id, + captcha_site_key: Gitlab::CurrentSettings.recaptcha_site_key + } + + yield.merge(spam_action_fields) + end + end +end diff --git a/app/graphql/mutations/concerns/mutations/resolves_resource_parent.rb b/app/graphql/mutations/concerns/mutations/resolves_resource_parent.rb index e2b3f4b046f..b8ef675c3d4 100644 --- a/app/graphql/mutations/concerns/mutations/resolves_resource_parent.rb +++ b/app/graphql/mutations/concerns/mutations/resolves_resource_parent.rb @@ -9,11 +9,11 @@ module Mutations included do argument :project_path, GraphQL::ID_TYPE, required: false, - description: 'The project full path the resource is associated with.' + description: 'Full path of the project with which the resource is associated.' argument :group_path, GraphQL::ID_TYPE, required: false, - description: 'The group full path the resource is associated with.' + description: 'Full path of the group with which the resource is associated.' end def ready?(**args) diff --git a/app/graphql/mutations/concerns/mutations/spammable_mutation_fields.rb b/app/graphql/mutations/concerns/mutations/spammable_mutation_fields.rb deleted file mode 100644 index e5df8565618..00000000000 --- a/app/graphql/mutations/concerns/mutations/spammable_mutation_fields.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -module Mutations - module SpammableMutationFields - extend ActiveSupport::Concern - - included do - field :spam, - GraphQL::BOOLEAN_TYPE, - null: true, - description: 'Indicates whether the operation returns a record detected as spam.' - end - - def with_spam_params(&block) - request = Feature.enabled?(:snippet_spam) ? context[:request] : nil - - yield.merge({ api: true, request: request }) - end - - def with_spam_fields(spammable, &block) - { spam: spammable.spam? }.merge!(yield) - end - end -end diff --git a/app/graphql/mutations/container_expiration_policies/update.rb b/app/graphql/mutations/container_expiration_policies/update.rb index 37cf2fa6bf3..f61d852bb6c 100644 --- a/app/graphql/mutations/container_expiration_policies/update.rb +++ b/app/graphql/mutations/container_expiration_policies/update.rb @@ -3,7 +3,7 @@ module Mutations module ContainerExpirationPolicies class Update < Mutations::BaseMutation - include ResolvesProject + include FindsProject graphql_name 'UpdateContainerExpirationPolicy' @@ -50,7 +50,7 @@ module Mutations description: 'The container expiration policy after mutation.' def resolve(project_path:, **args) - project = authorized_find!(full_path: project_path) + project = authorized_find!(project_path) result = ::ContainerExpirationPolicies::UpdateService .new(container: project, current_user: current_user, params: args) @@ -61,12 +61,6 @@ module Mutations errors: result.errors } end - - private - - def find_object(full_path:) - resolve_project(full_path: full_path) - end end end end diff --git a/app/graphql/mutations/discussions/toggle_resolve.rb b/app/graphql/mutations/discussions/toggle_resolve.rb index c9834c946b2..6639252ec67 100644 --- a/app/graphql/mutations/discussions/toggle_resolve.rb +++ b/app/graphql/mutations/discussions/toggle_resolve.rb @@ -69,7 +69,7 @@ module Mutations end def unresolve!(discussion) - discussion.unresolve! + ::Discussions::UnresolveService.new(discussion, current_user).execute end end end diff --git a/app/graphql/mutations/issues/create.rb b/app/graphql/mutations/issues/create.rb index 18b80ff1736..37fddd92832 100644 --- a/app/graphql/mutations/issues/create.rb +++ b/app/graphql/mutations/issues/create.rb @@ -3,7 +3,7 @@ module Mutations module Issues class Create < BaseMutation - include ResolvesProject + include FindsProject graphql_name 'CreateIssue' authorize :create_issue @@ -70,7 +70,7 @@ module Mutations end def resolve(project_path:, **attributes) - project = authorized_find!(full_path: project_path) + project = authorized_find!(project_path) params = build_create_issue_params(attributes.merge(author_id: current_user.id)) issue = ::Issues::CreateService.new(project, current_user, params).execute @@ -98,10 +98,6 @@ module Mutations def mutually_exclusive_label_args [:labels, :label_ids] end - - def find_object(full_path:) - resolve_project(full_path: full_path) - end end end end diff --git a/app/graphql/mutations/jira_import/import_users.rb b/app/graphql/mutations/jira_import/import_users.rb index 616ef390657..af2bb18161f 100644 --- a/app/graphql/mutations/jira_import/import_users.rb +++ b/app/graphql/mutations/jira_import/import_users.rb @@ -3,10 +3,12 @@ module Mutations module JiraImport class ImportUsers < BaseMutation - include ResolvesProject + include FindsProject graphql_name 'JiraImportUsers' + authorize :admin_project + field :jira_users, [Types::JiraUserType], null: true, @@ -20,7 +22,7 @@ module Mutations description: 'The index of the record the import should started at, default 0 (50 records returned).' def resolve(project_path:, start_at: 0) - project = authorized_find!(full_path: project_path) + project = authorized_find!(project_path) service_response = ::JiraImport::UsersImporter.new(context[:current_user], project, start_at.to_i).execute @@ -29,16 +31,6 @@ module Mutations errors: service_response.errors } end - - private - - def find_object(full_path:) - resolve_project(full_path: full_path) - end - - def authorized_resource?(project) - Ability.allowed?(context[:current_user], :admin_project, project) - end end end end diff --git a/app/graphql/mutations/jira_import/start.rb b/app/graphql/mutations/jira_import/start.rb index 3d50ebde13a..e31aaf53a09 100644 --- a/app/graphql/mutations/jira_import/start.rb +++ b/app/graphql/mutations/jira_import/start.rb @@ -3,10 +3,12 @@ module Mutations module JiraImport class Start < BaseMutation - include ResolvesProject + include FindsProject graphql_name 'JiraImportStart' + authorize :admin_project + field :jira_import, Types::JiraImportType, null: true, @@ -27,7 +29,7 @@ module Mutations description: 'The mapping of Jira to GitLab users.' def resolve(project_path:, jira_project_key:, users_mapping:) - project = authorized_find!(full_path: project_path) + project = authorized_find!(project_path) mapping = users_mapping.to_ary.map { |map| map.to_hash } service_response = ::JiraImport::StartImportService @@ -40,16 +42,6 @@ module Mutations errors: service_response.errors } end - - private - - def find_object(full_path:) - resolve_project(full_path: full_path) - end - - def authorized_resource?(project) - Ability.allowed?(context[:current_user], :admin_project, project) - end end end end diff --git a/app/graphql/mutations/merge_requests/create.rb b/app/graphql/mutations/merge_requests/create.rb index 64fa8417e50..9ac8f70be95 100644 --- a/app/graphql/mutations/merge_requests/create.rb +++ b/app/graphql/mutations/merge_requests/create.rb @@ -3,7 +3,7 @@ module Mutations module MergeRequests class Create < BaseMutation - include ResolvesProject + include FindsProject graphql_name 'MergeRequestCreate' @@ -39,7 +39,7 @@ module Mutations authorize :create_merge_request_from def resolve(project_path:, **attributes) - project = authorized_find!(full_path: project_path) + project = authorized_find!(project_path) params = attributes.merge(author_id: current_user.id) merge_request = ::MergeRequests::CreateService.new(project, current_user, params).execute @@ -49,12 +49,6 @@ module Mutations errors: errors_on_object(merge_request) } end - - private - - def find_object(full_path:) - resolve_project(full_path: full_path) - end end end end diff --git a/app/graphql/mutations/merge_requests/reviewer_rereview.rb b/app/graphql/mutations/merge_requests/reviewer_rereview.rb new file mode 100644 index 00000000000..f6f4881654e --- /dev/null +++ b/app/graphql/mutations/merge_requests/reviewer_rereview.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Mutations + module MergeRequests + class ReviewerRereview < Base + graphql_name 'MergeRequestReviewerRereview' + + argument :user_id, ::Types::GlobalIDType[::User], + loads: Types::UserType, + required: true, + description: <<~DESC + The user ID for the user that has been requested for a new review. + DESC + + def resolve(project_path:, iid:, user:) + merge_request = authorized_find!(project_path: project_path, iid: iid) + + result = ::MergeRequests::RequestReviewService.new(merge_request.project, current_user).execute(merge_request, user) + + { + merge_request: merge_request, + errors: Array(result[:message]) + } + end + end + end +end diff --git a/app/graphql/mutations/releases/base.rb b/app/graphql/mutations/releases/base.rb index dd1724fe320..610e9cd9cde 100644 --- a/app/graphql/mutations/releases/base.rb +++ b/app/graphql/mutations/releases/base.rb @@ -3,17 +3,11 @@ module Mutations module Releases class Base < BaseMutation - include ResolvesProject + include FindsProject argument :project_path, GraphQL::ID_TYPE, required: true, description: 'Full path of the project the release is associated with.' - - private - - def find_object(full_path:) - resolve_project(full_path: full_path) - end end end end diff --git a/app/graphql/mutations/releases/create.rb b/app/graphql/mutations/releases/create.rb index 91ac256033e..914c1302094 100644 --- a/app/graphql/mutations/releases/create.rb +++ b/app/graphql/mutations/releases/create.rb @@ -41,7 +41,7 @@ module Mutations authorize :create_release def resolve(project_path:, assets: nil, **scalars) - project = authorized_find!(full_path: project_path) + project = authorized_find!(project_path) params = { **scalars, diff --git a/app/graphql/mutations/releases/delete.rb b/app/graphql/mutations/releases/delete.rb index e887b702cce..020c9133b58 100644 --- a/app/graphql/mutations/releases/delete.rb +++ b/app/graphql/mutations/releases/delete.rb @@ -17,7 +17,7 @@ module Mutations authorize :destroy_release def resolve(project_path:, tag:) - project = authorized_find!(full_path: project_path) + project = authorized_find!(project_path) params = { tag: tag }.with_indifferent_access diff --git a/app/graphql/mutations/releases/update.rb b/app/graphql/mutations/releases/update.rb index dff743254bd..35f2a7b3d4b 100644 --- a/app/graphql/mutations/releases/update.rb +++ b/app/graphql/mutations/releases/update.rb @@ -47,7 +47,7 @@ module Mutations end def resolve(project_path:, **scalars) - project = authorized_find!(full_path: project_path) + project = authorized_find!(project_path) params = scalars.with_indifferent_access diff --git a/app/graphql/mutations/security/ci_configuration/configure_sast.rb b/app/graphql/mutations/security/ci_configuration/configure_sast.rb new file mode 100644 index 00000000000..e4a3f815396 --- /dev/null +++ b/app/graphql/mutations/security/ci_configuration/configure_sast.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Mutations + module Security + module CiConfiguration + class ConfigureSast < BaseMutation + include FindsProject + + graphql_name 'ConfigureSast' + + argument :project_path, GraphQL::ID_TYPE, + required: true, + description: 'Full path of the project.' + + argument :configuration, ::Types::CiConfiguration::Sast::InputType, + required: true, + description: 'SAST CI configuration for the project.' + + field :status, GraphQL::STRING_TYPE, null: false, + description: 'Status of creating the commit for the supplied SAST CI configuration.' + + field :success_path, GraphQL::STRING_TYPE, null: true, + description: 'Redirect path to use when the response is successful.' + + authorize :push_code + + def resolve(project_path:, configuration:) + project = authorized_find!(project_path) + + result = ::Security::CiConfiguration::SastCreateService.new(project, current_user, configuration).execute + prepare_response(result) + end + + private + + def prepare_response(result) + { + status: result[:status], + success_path: result[:success_path], + errors: Array(result[:errors]) + } + end + end + end + end +end diff --git a/app/graphql/mutations/snippets/create.rb b/app/graphql/mutations/snippets/create.rb index b4485e28c5a..73eac9f0f3b 100644 --- a/app/graphql/mutations/snippets/create.rb +++ b/app/graphql/mutations/snippets/create.rb @@ -3,7 +3,8 @@ module Mutations module Snippets class Create < BaseMutation - include SpammableMutationFields + include ServiceCompatibility + include CanMutateSpammable authorize :create_snippet @@ -45,18 +46,17 @@ module Mutations authorize!(:global) end - service_response = ::Snippets::CreateService.new(project, - current_user, - create_params(args)).execute + process_args_for_params!(args) - snippet = service_response.payload[:snippet] + service_response = ::Snippets::CreateService.new(project, current_user, args).execute # Only when the user is not an api user and the operation was successful if !api_user? && service_response.success? ::Gitlab::UsageDataCounters::EditorUniqueCounter.track_snippet_editor_edit_action(author: current_user) end - with_spam_fields(snippet) do + snippet = service_response.payload[:snippet] + with_spam_action_fields(snippet) do { snippet: service_response.success? ? snippet : nil, errors: errors_on_object(snippet) @@ -70,18 +70,25 @@ module Mutations Project.find_by_full_path(full_path) end - def create_params(args) - with_spam_params do - args.tap do |create_args| - # We need to rename `blob_actions` into `snippet_actions` because - # it's the expected key param - create_args[:snippet_actions] = create_args.delete(:blob_actions)&.map(&:to_h) - - # We need to rename `uploaded_files` into `files` because - # it's the expected key param - create_args[:files] = create_args.delete(:uploaded_files) - end + # process_args_for_params!(args) -> nil + # + # Modifies/adds/deletes mutation resolve args as necessary to be passed as params to service layer. + def process_args_for_params!(args) + convert_blob_actions_to_snippet_actions!(args) + + # We need to rename `uploaded_files` into `files` because + # it's the expected key param + args[:files] = args.delete(:uploaded_files) + + if Feature.enabled?(:snippet_spam) + args.merge!(additional_spam_params) + else + args[:disable_spam_action_service] = true end + + # Return nil to make it explicit that this method is mutating the args parameter, and that + # the return value is not relevant and is not to be used. + nil end end end diff --git a/app/graphql/mutations/snippets/service_compatibility.rb b/app/graphql/mutations/snippets/service_compatibility.rb new file mode 100644 index 00000000000..0e7ee5d78bf --- /dev/null +++ b/app/graphql/mutations/snippets/service_compatibility.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Mutations + module Snippets + # Translates graphql mutation field params to be compatible with those expected by the service layer + module ServiceCompatibility + extend ActiveSupport::Concern + + # convert_blob_actions_to_snippet_actions!(args) -> nil + # + # Converts the blob_actions mutation argument into the + # snippet_actions hash which the service layer expects + def convert_blob_actions_to_snippet_actions!(args) + # We need to rename `blob_actions` into `snippet_actions` because + # it's the expected key param + args[:snippet_actions] = args.delete(:blob_actions)&.map(&:to_h) + + # Return nil to make it explicit that this method is mutating the args parameter + nil + end + end + end +end diff --git a/app/graphql/mutations/snippets/update.rb b/app/graphql/mutations/snippets/update.rb index 930440fbd35..af8e6f384b7 100644 --- a/app/graphql/mutations/snippets/update.rb +++ b/app/graphql/mutations/snippets/update.rb @@ -3,7 +3,8 @@ module Mutations module Snippets class Update < Base - include SpammableMutationFields + include ServiceCompatibility + include CanMutateSpammable graphql_name 'UpdateSnippet' @@ -30,19 +31,23 @@ module Mutations def resolve(id:, **args) snippet = authorized_find!(id: id) - result = ::Snippets::UpdateService.new(snippet.project, - current_user, - update_params(args)).execute(snippet) - snippet = result.payload[:snippet] + process_args_for_params!(args) + + service_response = ::Snippets::UpdateService.new(snippet.project, current_user, args).execute(snippet) + + # TODO: DRY this up - From here down, this is all duplicated with Mutations::Snippets::Create#resolve, except for + # `snippet.reset`, which is required in order to return the object in its non-dirty, unmodified, database state + # See issue here: https://gitlab.com/gitlab-org/gitlab/-/issues/300250 # Only when the user is not an api user and the operation was successful - if !api_user? && result.success? + if !api_user? && service_response.success? ::Gitlab::UsageDataCounters::EditorUniqueCounter.track_snippet_editor_edit_action(author: current_user) end - with_spam_fields(snippet) do + snippet = service_response.payload[:snippet] + with_spam_action_fields(snippet) do { - snippet: result.success? ? snippet : snippet.reset, + snippet: service_response.success? ? snippet : snippet.reset, errors: errors_on_object(snippet) } end @@ -50,18 +55,25 @@ module Mutations private - def ability_name - 'update' - end + # process_args_for_params!(args) -> nil + # + # Modifies/adds/deletes mutation resolve args as necessary to be passed as params to service layer. + def process_args_for_params!(args) + convert_blob_actions_to_snippet_actions!(args) - def update_params(args) - with_spam_params do - args.tap do |update_args| - # We need to rename `blob_actions` into `snippet_actions` because - # it's the expected key param - update_args[:snippet_actions] = update_args.delete(:blob_actions)&.map(&:to_h) - end + if Feature.enabled?(:snippet_spam) + args.merge!(additional_spam_params) + else + args[:disable_spam_action_service] = true end + + # Return nil to make it explicit that this method is mutating the args parameter, and that + # the return value is not relevant and is not to be used. + nil + end + + def ability_name + 'update' end end end diff --git a/app/graphql/mutations/todos/create.rb b/app/graphql/mutations/todos/create.rb index 814f7ec4fc4..b6250b0228c 100644 --- a/app/graphql/mutations/todos/create.rb +++ b/app/graphql/mutations/todos/create.rb @@ -14,7 +14,7 @@ module Mutations field :todo, Types::TodoType, null: true, - description: 'The to-do created.' + description: 'The to-do item created.' def resolve(target_id:) id = ::Types::GlobalIDType[Todoable].coerce_isolated_input(target_id) diff --git a/app/graphql/mutations/todos/mark_all_done.rb b/app/graphql/mutations/todos/mark_all_done.rb index c8359953567..22a5893d4ec 100644 --- a/app/graphql/mutations/todos/mark_all_done.rb +++ b/app/graphql/mutations/todos/mark_all_done.rb @@ -10,12 +10,12 @@ module Mutations field :updated_ids, [::Types::GlobalIDType[::Todo]], null: false, - deprecated: { reason: 'Use todos', milestone: '13.2' }, - description: 'Ids of the updated todos.' + deprecated: { reason: 'Use to-do items', milestone: '13.2' }, + description: 'IDs of the updated to-do items.' field :todos, [::Types::TodoType], null: false, - description: 'Updated todos.' + description: 'Updated to-do items.' def resolve authorize!(current_user) diff --git a/app/graphql/mutations/todos/mark_done.rb b/app/graphql/mutations/todos/mark_done.rb index 95144abb040..a78cc91da68 100644 --- a/app/graphql/mutations/todos/mark_done.rb +++ b/app/graphql/mutations/todos/mark_done.rb @@ -10,11 +10,11 @@ module Mutations argument :id, ::Types::GlobalIDType[::Todo], required: true, - description: 'The global ID of the todo to mark as done.' + description: 'The global ID of the to-do item to mark as done.' field :todo, Types::TodoType, null: false, - description: 'The requested todo.' + description: 'The requested to-do item.' def resolve(id:) todo = authorized_find!(id: id) diff --git a/app/graphql/mutations/todos/restore.rb b/app/graphql/mutations/todos/restore.rb index e496627aec2..70c33c439c4 100644 --- a/app/graphql/mutations/todos/restore.rb +++ b/app/graphql/mutations/todos/restore.rb @@ -10,11 +10,11 @@ module Mutations argument :id, ::Types::GlobalIDType[::Todo], required: true, - description: 'The global ID of the todo to restore.' + description: 'The global ID of the to-do item to restore.' field :todo, Types::TodoType, null: false, - description: 'The requested todo.' + description: 'The requested to-do item.' def resolve(id:) todo = authorized_find!(id: id) diff --git a/app/graphql/mutations/todos/restore_many.rb b/app/graphql/mutations/todos/restore_many.rb index 9263c1d9afe..dc02ffadada 100644 --- a/app/graphql/mutations/todos/restore_many.rb +++ b/app/graphql/mutations/todos/restore_many.rb @@ -10,16 +10,16 @@ module Mutations argument :ids, [::Types::GlobalIDType[::Todo]], required: true, - description: 'The global IDs of the todos to restore (a maximum of 50 is supported at once).' + description: 'The global IDs of the to-do items to restore (a maximum of 50 is supported at once).' field :updated_ids, [::Types::GlobalIDType[Todo]], null: false, - description: 'The IDs of the updated todo items.', - deprecated: { reason: 'Use todos', milestone: '13.2' } + description: 'The IDs of the updated to-do items.', + deprecated: { reason: 'Use to-do items', milestone: '13.2' } field :todos, [::Types::TodoType], null: false, - description: 'Updated todos.' + description: 'Updated to-do items.' def resolve(ids:) check_update_amount_limit!(ids) @@ -46,7 +46,7 @@ module Mutations end def raise_too_many_todos_requested_error - raise Gitlab::Graphql::Errors::ArgumentError, 'Too many todos requested.' + raise Gitlab::Graphql::Errors::ArgumentError, 'Too many to-do items requested.' end def check_update_amount_limit!(ids) diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb index 539e37db1c2..5db618254cb 100644 --- a/app/graphql/resolvers/base_resolver.rb +++ b/app/graphql/resolvers/base_resolver.rb @@ -118,7 +118,7 @@ module Resolvers end def offset_pagination(relation) - ::Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection.new(relation) + ::Gitlab::Graphql::Pagination::OffsetPaginatedRelation.new(relation) end override :object diff --git a/app/graphql/resolvers/package_details_resolver.rb b/app/graphql/resolvers/package_details_resolver.rb index dcf4430e55f..e688e34599a 100644 --- a/app/graphql/resolvers/package_details_resolver.rb +++ b/app/graphql/resolvers/package_details_resolver.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true module Resolvers - # No return types defined because they can be different. - # rubocop: disable Graphql/ResolverType class PackageDetailsResolver < BaseResolver + type ::Types::Packages::PackageType, null: true + argument :id, ::Types::GlobalIDType[::Packages::Package], required: true, description: 'The global ID of the package.' diff --git a/app/graphql/resolvers/packages_resolver.rb b/app/graphql/resolvers/packages_resolver.rb index d19099e94d4..3eeed48ff7e 100644 --- a/app/graphql/resolvers/packages_resolver.rb +++ b/app/graphql/resolvers/packages_resolver.rb @@ -2,7 +2,7 @@ module Resolvers class PackagesResolver < BaseResolver - type Types::Packages::PackageType, null: true + type Types::Packages::PackageType.connection_type, null: true def resolve(**args) return unless packages_available? diff --git a/app/graphql/resolvers/project_merge_requests_resolver.rb b/app/graphql/resolvers/project_merge_requests_resolver.rb index 830649d5e52..21d9afc31ab 100644 --- a/app/graphql/resolvers/project_merge_requests_resolver.rb +++ b/app/graphql/resolvers/project_merge_requests_resolver.rb @@ -6,5 +6,38 @@ module Resolvers accept_assignee accept_author accept_reviewer + + def resolve(**args) + scope = super + + if only_count_is_selected_with_merged_at_filter?(args) && Feature.enabled?(:optimized_merge_request_count_with_merged_at_filter) + MergeRequest::MetricsFinder + .new(current_user, args.merge(target_project: project)) + .execute + else + scope + end + end + + def only_count_is_selected_with_merged_at_filter?(args) + return unless lookahead + + argument_names = args.except(:lookahead, :sort, :merged_before, :merged_after).keys + + # no extra filtering arguments are provided + return unless argument_names.empty? + return unless args[:merged_after] || args[:merged_before] + + # Detecting a specific query pattern: + # mergeRequests(mergedAfter: "X", mergedBefore: "Y") { + # count + # totalTimeToMerge + # } + allowed_selected_fields = [:count, :total_time_to_merge] + selected_fields = lookahead.selections.map(&:field).map(&:original_name) + + # only the allowed_selected_fields are present + (selected_fields - allowed_selected_fields).empty? + end end end diff --git a/app/graphql/resolvers/terraform/states_resolver.rb b/app/graphql/resolvers/terraform/states_resolver.rb index 38b26a948b1..f543eb182e8 100644 --- a/app/graphql/resolvers/terraform/states_resolver.rb +++ b/app/graphql/resolvers/terraform/states_resolver.rb @@ -3,20 +3,20 @@ module Resolvers module Terraform class StatesResolver < BaseResolver - type Types::Terraform::StateType, null: true + type Types::Terraform::StateType.connection_type, null: true alias_method :project, :object - def resolve(**args) - return ::Terraform::State.none unless can_read_terraform_states? - - project.terraform_states.ordered_by_name + when_single do + argument :name, GraphQL::STRING_TYPE, + required: true, + description: 'Name of the Terraform state.' end - private - - def can_read_terraform_states? - current_user.can?(:read_terraform_state, project) + def resolve(**args) + ::Terraform::StatesFinder + .new(project, current_user, params: args) + .execute end end end diff --git a/app/graphql/types/access_level_type.rb b/app/graphql/types/access_level_type.rb index c7f915f5038..21c3669979c 100644 --- a/app/graphql/types/access_level_type.rb +++ b/app/graphql/types/access_level_type.rb @@ -7,11 +7,11 @@ module Types description 'Represents the access level of a relationship between a User and object that it is related to' field :integer_value, GraphQL::INT_TYPE, null: true, - description: 'Integer representation of access level', + description: 'Integer representation of access level.', method: :to_i field :string_value, Types::AccessLevelEnum, null: true, - description: 'String representation of access level', + description: 'String representation of access level.', method: :to_i end end diff --git a/app/graphql/types/admin/analytics/instance_statistics/measurement_type.rb b/app/graphql/types/admin/analytics/instance_statistics/measurement_type.rb index eab42c2b78d..17a5af8d36b 100644 --- a/app/graphql/types/admin/analytics/instance_statistics/measurement_type.rb +++ b/app/graphql/types/admin/analytics/instance_statistics/measurement_type.rb @@ -12,13 +12,13 @@ module Types authorize :read_instance_statistics_measurements field :recorded_at, Types::TimeType, null: true, - description: 'The time the measurement was recorded' + description: 'The time the measurement was recorded.' field :count, GraphQL::INT_TYPE, null: false, - description: 'Object count' + description: 'Object count.' field :identifier, Types::Admin::Analytics::InstanceStatistics::MeasurementIdentifierEnum, null: false, - description: 'The type of objects being measured' + description: 'The type of objects being measured.' end end end diff --git a/app/graphql/types/admin/sidekiq_queues/delete_jobs_response_type.rb b/app/graphql/types/admin/sidekiq_queues/delete_jobs_response_type.rb index 93dd49b3c38..996300edad3 100644 --- a/app/graphql/types/admin/sidekiq_queues/delete_jobs_response_type.rb +++ b/app/graphql/types/admin/sidekiq_queues/delete_jobs_response_type.rb @@ -12,17 +12,17 @@ module Types field :completed, GraphQL::BOOLEAN_TYPE, null: true, - description: 'Whether or not the entire queue was processed in time; if not, retrying the same request is safe' + description: 'Whether or not the entire queue was processed in time; if not, retrying the same request is safe.' field :deleted_jobs, GraphQL::INT_TYPE, null: true, - description: 'The number of matching jobs deleted' + description: 'The number of matching jobs deleted.' field :queue_size, GraphQL::INT_TYPE, null: true, - description: 'The queue size after processing' + description: 'The queue size after processing.' end end end diff --git a/app/graphql/types/alert_management/alert_status_counts_type.rb b/app/graphql/types/alert_management/alert_status_counts_type.rb index a84be705445..14a81735fa5 100644 --- a/app/graphql/types/alert_management/alert_status_counts_type.rb +++ b/app/graphql/types/alert_management/alert_status_counts_type.rb @@ -19,12 +19,12 @@ module Types field :open, GraphQL::INT_TYPE, null: true, - description: 'Number of alerts with status TRIGGERED or ACKNOWLEDGED for the project' + description: 'Number of alerts with status TRIGGERED or ACKNOWLEDGED for the project.' field :all, GraphQL::INT_TYPE, null: true, - description: 'Total number of alerts for the project' + description: 'Total number of alerts for the project.' end end end diff --git a/app/graphql/types/alert_management/alert_type.rb b/app/graphql/types/alert_management/alert_type.rb index 623762de208..6b7e7030c1f 100644 --- a/app/graphql/types/alert_management/alert_type.rb +++ b/app/graphql/types/alert_management/alert_type.rb @@ -15,115 +15,115 @@ module Types field :iid, GraphQL::ID_TYPE, null: false, - description: 'Internal ID of the alert' + description: 'Internal ID of the alert.' field :issue_iid, GraphQL::ID_TYPE, null: true, - description: 'Internal ID of the GitLab issue attached to the alert' + description: 'Internal ID of the GitLab issue attached to the alert.' field :title, GraphQL::STRING_TYPE, null: true, - description: 'Title of the alert' + description: 'Title of the alert.' field :description, GraphQL::STRING_TYPE, null: true, - description: 'Description of the alert' + description: 'Description of the alert.' field :severity, AlertManagement::SeverityEnum, null: true, - description: 'Severity of the alert' + description: 'Severity of the alert.' field :status, AlertManagement::StatusEnum, null: true, - description: 'Status of the alert', + description: 'Status of the alert.', method: :status_name field :service, GraphQL::STRING_TYPE, null: true, - description: 'Service the alert came from' + description: 'Service the alert came from.' field :monitoring_tool, GraphQL::STRING_TYPE, null: true, - description: 'Monitoring tool the alert came from' + description: 'Monitoring tool the alert came from.' field :hosts, [GraphQL::STRING_TYPE], null: true, - description: 'List of hosts the alert came from' + description: 'List of hosts the alert came from.' field :started_at, Types::TimeType, null: true, - description: 'Timestamp the alert was raised' + description: 'Timestamp the alert was raised.' field :ended_at, Types::TimeType, null: true, - description: 'Timestamp the alert ended' + description: 'Timestamp the alert ended.' field :environment, Types::EnvironmentType, null: true, - description: 'Environment for the alert' + description: 'Environment for the alert.' field :event_count, GraphQL::INT_TYPE, null: true, - description: 'Number of events of this alert', + description: 'Number of events of this alert.', method: :events field :details, # rubocop:disable Graphql/JSONType GraphQL::Types::JSON, null: true, - description: 'Alert details' + description: 'Alert details.' field :created_at, Types::TimeType, null: true, - description: 'Timestamp the alert was created' + description: 'Timestamp the alert was created.' field :updated_at, Types::TimeType, null: true, - description: 'Timestamp the alert was last updated' + description: 'Timestamp the alert was last updated.' field :assignees, Types::UserType.connection_type, null: true, - description: 'Assignees of the alert' + description: 'Assignees of the alert.' field :metrics_dashboard_url, GraphQL::STRING_TYPE, null: true, - description: 'URL for metrics embed for the alert' + description: 'URL for metrics embed for the alert.' field :runbook, GraphQL::STRING_TYPE, null: true, - description: 'Runbook for the alert as defined in alert details' + description: 'Runbook for the alert as defined in alert details.' field :todos, Types::TodoType.connection_type, null: true, - description: 'Todos of the current user for the alert', + description: 'To-do items of the current user for the alert.', resolver: Resolvers::TodoResolver field :details_url, GraphQL::STRING_TYPE, null: false, - description: 'The URL of the alert detail page' + description: 'The URL of the alert detail page.' field :prometheus_alert, Types::PrometheusAlertType, null: true, - description: 'The alert condition for Prometheus' + description: 'The alert condition for Prometheus.' def notes object.ordered_notes diff --git a/app/graphql/types/alert_management/integration_type.rb b/app/graphql/types/alert_management/integration_type.rb index bf599885584..d26d7348765 100644 --- a/app/graphql/types/alert_management/integration_type.rb +++ b/app/graphql/types/alert_management/integration_type.rb @@ -9,37 +9,37 @@ module Types field :id, GraphQL::ID_TYPE, null: false, - description: 'ID of the integration' + description: 'ID of the integration.' field :type, AlertManagement::IntegrationTypeEnum, null: false, - description: 'Type of integration' + description: 'Type of integration.' field :name, GraphQL::STRING_TYPE, null: true, - description: 'Name of the integration' + description: 'Name of the integration.' field :active, GraphQL::BOOLEAN_TYPE, null: true, - description: 'Whether the endpoint is currently accepting alerts' + description: 'Whether the endpoint is currently accepting alerts.' field :token, GraphQL::STRING_TYPE, null: true, - description: 'Token used to authenticate alert notification requests' + description: 'Token used to authenticate alert notification requests.' field :url, GraphQL::STRING_TYPE, null: true, - description: 'Endpoint which accepts alert notifications' + description: 'Endpoint which accepts alert notifications.' field :api_url, GraphQL::STRING_TYPE, null: true, - description: 'URL at which Prometheus metrics can be queried to populate the metrics dashboard' + description: 'URL at which Prometheus metrics can be queried to populate the metrics dashboard.' definition_methods do def resolve_type(object, context) diff --git a/app/graphql/types/award_emojis/award_emoji_type.rb b/app/graphql/types/award_emojis/award_emoji_type.rb index cd7a2f34ba6..9409304e28f 100644 --- a/app/graphql/types/award_emojis/award_emoji_type.rb +++ b/app/graphql/types/award_emojis/award_emoji_type.rb @@ -13,32 +13,32 @@ module Types field :name, GraphQL::STRING_TYPE, null: false, - description: 'The emoji name' + description: 'The emoji name.' field :description, GraphQL::STRING_TYPE, null: false, - description: 'The emoji description' + description: 'The emoji description.' field :unicode, GraphQL::STRING_TYPE, null: false, - description: 'The emoji in unicode' + description: 'The emoji in Unicode.' field :emoji, GraphQL::STRING_TYPE, null: false, - description: 'The emoji as an icon' + description: 'The emoji as an icon.' field :unicode_version, GraphQL::STRING_TYPE, null: false, - description: 'The unicode version for this emoji' + description: 'The Unicode version for this emoji.' field :user, Types::UserType, null: false, - description: 'The user who awarded the emoji' + description: 'The user who awarded the emoji.' def user Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.user_id).find diff --git a/app/graphql/types/base_enum.rb b/app/graphql/types/base_enum.rb index cbd45b46dd6..4d470aceca4 100644 --- a/app/graphql/types/base_enum.rb +++ b/app/graphql/types/base_enum.rb @@ -21,7 +21,7 @@ module Types graphql_name(enum_mod.name) if use_name description(enum_mod.description) if use_description - enum_mod.definition.each { |key, content| value(key.to_s.upcase, content) } + enum_mod.definition.each { |key, content| value(key.to_s.upcase, **content) } end def value(*args, **kwargs, &block) diff --git a/app/graphql/types/board_list_type.rb b/app/graphql/types/board_list_type.rb index 7999e77eb30..46b49c5d8a4 100644 --- a/app/graphql/types/board_list_type.rb +++ b/app/graphql/types/board_list_type.rb @@ -9,22 +9,22 @@ module Types description 'Represents a list for an issue board' field :id, GraphQL::ID_TYPE, null: false, - description: 'ID (global ID) of the list' + description: 'ID (global ID) of the list.' field :title, GraphQL::STRING_TYPE, null: false, - description: 'Title of the list' + description: 'Title of the list.' field :list_type, GraphQL::STRING_TYPE, null: false, - description: 'Type of the list' + description: 'Type of the list.' field :position, GraphQL::INT_TYPE, null: true, - description: 'Position of list within the board' + description: 'Position of list within the board.' field :label, Types::LabelType, null: true, - description: 'Label of the list' + description: 'Label of the list.' field :collapsed, GraphQL::BOOLEAN_TYPE, null: true, - description: 'Indicates if list is collapsed for this user' + description: 'Indicates if list is collapsed for this user.' field :issues_count, GraphQL::INT_TYPE, null: true, - description: 'Count of issues in the list' + description: 'Count of issues in the list.' field :issues, ::Types::IssueType.connection_type, null: true, - description: 'Board issues', + description: 'Board issues.', resolver: ::Resolvers::BoardListIssuesResolver def issues_count diff --git a/app/graphql/types/board_type.rb b/app/graphql/types/board_type.rb index f576fd83840..f8bd31d5fa0 100644 --- a/app/graphql/types/board_type.rb +++ b/app/graphql/types/board_type.rb @@ -10,20 +10,20 @@ module Types present_using BoardPresenter field :id, type: GraphQL::ID_TYPE, null: false, - description: 'ID (global ID) of the board' + description: 'ID (global ID) of the board.' field :name, type: GraphQL::STRING_TYPE, null: true, - description: 'Name of the board' + description: 'Name of the board.' field :hide_backlog_list, type: GraphQL::BOOLEAN_TYPE, null: true, - description: 'Whether or not backlog list is hidden' + description: 'Whether or not backlog list is hidden.' field :hide_closed_list, type: GraphQL::BOOLEAN_TYPE, null: true, - description: 'Whether or not closed list is hidden' + description: 'Whether or not closed list is hidden.' field :lists, Types::BoardListType.connection_type, null: true, - description: 'Lists of the board', + description: 'Lists of the board.', resolver: Resolvers::BoardListsResolver, extras: [:lookahead] diff --git a/app/graphql/types/boards/board_issue_input_base_type.rb b/app/graphql/types/boards/board_issue_input_base_type.rb index 1187b3352cd..dab1414760b 100644 --- a/app/graphql/types/boards/board_issue_input_base_type.rb +++ b/app/graphql/types/boards/board_issue_input_base_type.rb @@ -6,27 +6,27 @@ module Types class BoardIssueInputBaseType < BaseInputObject argument :label_name, GraphQL::STRING_TYPE.to_list_type, required: false, - description: 'Filter by label name' + description: 'Filter by label name.' argument :milestone_title, GraphQL::STRING_TYPE, required: false, - description: 'Filter by milestone title' + description: 'Filter by milestone title.' argument :assignee_username, GraphQL::STRING_TYPE.to_list_type, required: false, - description: 'Filter by assignee username' + description: 'Filter by assignee username.' argument :author_username, GraphQL::STRING_TYPE, required: false, - description: 'Filter by author username' + description: 'Filter by author username.' argument :release_tag, GraphQL::STRING_TYPE, required: false, - description: 'Filter by release tag' + description: 'Filter by release tag.' argument :my_reaction_emoji, GraphQL::STRING_TYPE, required: false, - description: 'Filter by reaction emoji' + description: 'Filter by reaction emoji.' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/boards/board_issue_input_type.rb b/app/graphql/types/boards/board_issue_input_type.rb index 40d065d8ea9..62a402ee724 100644 --- a/app/graphql/types/boards/board_issue_input_type.rb +++ b/app/graphql/types/boards/board_issue_input_type.rb @@ -11,11 +11,11 @@ module Types argument :not, NegatedBoardIssueInputType, required: false, - description: 'List of negated params. Warning: this argument is experimental and a subject to change in future' + description: 'List of negated params. Warning: this argument is experimental and a subject to change in future.' argument :search, GraphQL::STRING_TYPE, required: false, - description: 'Search query for issue title or description' + description: 'Search query for issue title or description.' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/branch_type.rb b/app/graphql/types/branch_type.rb index b15038a46de..b788ba79769 100644 --- a/app/graphql/types/branch_type.rb +++ b/app/graphql/types/branch_type.rb @@ -8,11 +8,11 @@ module Types field :name, GraphQL::STRING_TYPE, null: false, - description: 'Name of the branch' + description: 'Name of the branch.' field :commit, Types::CommitType, null: true, resolver: Resolvers::BranchCommitResolver, - description: 'Commit for the branch' + description: 'Commit for the branch.' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/ci/analytics_type.rb b/app/graphql/types/ci/analytics_type.rb index c8b12c6a9b8..ba987b133bd 100644 --- a/app/graphql/types/ci/analytics_type.rb +++ b/app/graphql/types/ci/analytics_type.rb @@ -7,27 +7,27 @@ module Types graphql_name 'PipelineAnalytics' field :week_pipelines_totals, [GraphQL::INT_TYPE], null: true, - description: 'Total weekly pipeline count' + description: 'Total weekly pipeline count.' field :week_pipelines_successful, [GraphQL::INT_TYPE], null: true, - description: 'Total weekly successful pipeline count' + description: 'Total weekly successful pipeline count.' field :week_pipelines_labels, [GraphQL::STRING_TYPE], null: true, - description: 'Labels for the weekly pipeline count' + description: 'Labels for the weekly pipeline count.' field :month_pipelines_totals, [GraphQL::INT_TYPE], null: true, - description: 'Total monthly pipeline count' + description: 'Total monthly pipeline count.' field :month_pipelines_successful, [GraphQL::INT_TYPE], null: true, - description: 'Total monthly successful pipeline count' + description: 'Total monthly successful pipeline count.' field :month_pipelines_labels, [GraphQL::STRING_TYPE], null: true, - description: 'Labels for the monthly pipeline count' + description: 'Labels for the monthly pipeline count.' field :year_pipelines_totals, [GraphQL::INT_TYPE], null: true, - description: 'Total yearly pipeline count' + description: 'Total yearly pipeline count.' field :year_pipelines_successful, [GraphQL::INT_TYPE], null: true, - description: 'Total yearly successful pipeline count' + description: 'Total yearly successful pipeline count.' field :year_pipelines_labels, [GraphQL::STRING_TYPE], null: true, - description: 'Labels for the yearly pipeline count' + description: 'Labels for the yearly pipeline count.' field :pipeline_times_values, [GraphQL::INT_TYPE], null: true, - description: 'Pipeline times' + description: 'Pipeline times.' field :pipeline_times_labels, [GraphQL::STRING_TYPE], null: true, - description: 'Pipeline times labels' + description: 'Pipeline times labels.' end end end diff --git a/app/graphql/types/ci/config/config_type.rb b/app/graphql/types/ci/config/config_type.rb index 29093c6d3c9..88caf21c376 100644 --- a/app/graphql/types/ci/config/config_type.rb +++ b/app/graphql/types/ci/config/config_type.rb @@ -8,13 +8,13 @@ module Types graphql_name 'CiConfig' field :errors, [GraphQL::STRING_TYPE], null: true, - description: 'Linting errors' + description: 'Linting errors.' field :merged_yaml, GraphQL::STRING_TYPE, null: true, - description: 'Merged CI config YAML' + description: 'Merged CI configuration YAML.' field :stages, Types::Ci::Config::StageType.connection_type, null: true, - description: 'Stages of the pipeline' + description: 'Stages of the pipeline.' field :status, Types::Ci::Config::StatusEnum, null: true, - description: 'Status of linting, can be either valid or invalid' + description: 'Status of linting, can be either valid or invalid.' end end end diff --git a/app/graphql/types/ci/config/group_type.rb b/app/graphql/types/ci/config/group_type.rb index 8e133bbcba8..11be701e73f 100644 --- a/app/graphql/types/ci/config/group_type.rb +++ b/app/graphql/types/ci/config/group_type.rb @@ -8,11 +8,11 @@ module Types graphql_name 'CiConfigGroup' field :name, GraphQL::STRING_TYPE, null: true, - description: 'Name of the job group' + description: 'Name of the job group.' field :jobs, Types::Ci::Config::JobType.connection_type, null: true, - description: 'Jobs in group' + description: 'Jobs in group.' field :size, GraphQL::INT_TYPE, null: true, - description: 'Size of the job group' + description: 'Size of the job group.' end end end diff --git a/app/graphql/types/ci/config/need_type.rb b/app/graphql/types/ci/config/need_type.rb index a442450b9ae..01f73100409 100644 --- a/app/graphql/types/ci/config/need_type.rb +++ b/app/graphql/types/ci/config/need_type.rb @@ -8,7 +8,7 @@ module Types graphql_name 'CiConfigNeed' field :name, GraphQL::STRING_TYPE, null: true, - description: 'Name of the need' + description: 'Name of the need.' end end end diff --git a/app/graphql/types/ci/config/stage_type.rb b/app/graphql/types/ci/config/stage_type.rb index 2008c553629..060efb7d45c 100644 --- a/app/graphql/types/ci/config/stage_type.rb +++ b/app/graphql/types/ci/config/stage_type.rb @@ -8,9 +8,9 @@ module Types graphql_name 'CiConfigStage' field :name, GraphQL::STRING_TYPE, null: true, - description: 'Name of the stage' + description: 'Name of the stage.' field :groups, Types::Ci::Config::GroupType.connection_type, null: true, - description: 'Groups of jobs for the stage' + description: 'Groups of jobs for the stage.' end end end diff --git a/app/graphql/types/ci/detailed_status_type.rb b/app/graphql/types/ci/detailed_status_type.rb index 80d73e9b174..0b643a6b676 100644 --- a/app/graphql/types/ci/detailed_status_type.rb +++ b/app/graphql/types/ci/detailed_status_type.rb @@ -7,26 +7,27 @@ module Types graphql_name 'DetailedStatus' field :group, GraphQL::STRING_TYPE, null: true, - description: 'Group of the status' + description: 'Group of the status.' field :icon, GraphQL::STRING_TYPE, null: true, - description: 'Icon of the status' + description: 'Icon of the status.' field :favicon, GraphQL::STRING_TYPE, null: true, - description: 'Favicon of the status' + description: 'Favicon of the status.' field :details_path, GraphQL::STRING_TYPE, null: true, - description: 'Path of the details for the status' + description: 'Path of the details for the status.' field :has_details, GraphQL::BOOLEAN_TYPE, null: true, - description: 'Indicates if the status has further details', + description: 'Indicates if the status has further details.', method: :has_details? field :label, GraphQL::STRING_TYPE, null: true, - description: 'Label of the status' + calls_gitaly: true, + description: 'Label of the status.' field :text, GraphQL::STRING_TYPE, null: true, - description: 'Text of the status' + description: 'Text of the status.' field :tooltip, GraphQL::STRING_TYPE, null: true, - description: 'Tooltip associated with the status', + description: 'Tooltip associated with the status.', method: :status_tooltip field :action, Types::Ci::StatusActionType, null: true, - calls_gitaly: true, - description: 'Action information for the status. This includes method, button title, icon, path, and title' + calls_gitaly: true, + description: 'Action information for the status. This includes method, button title, icon, path, and title.' def action if object.has_action? diff --git a/app/graphql/types/ci/group_type.rb b/app/graphql/types/ci/group_type.rb index 03fd50d5dbb..d6d4252e8d7 100644 --- a/app/graphql/types/ci/group_type.rb +++ b/app/graphql/types/ci/group_type.rb @@ -7,13 +7,13 @@ module Types graphql_name 'CiGroup' field :name, GraphQL::STRING_TYPE, null: true, - description: 'Name of the job group' + description: 'Name of the job group.' field :size, GraphQL::INT_TYPE, null: true, - description: 'Size of the group' + description: 'Size of the group.' field :jobs, Ci::JobType.connection_type, null: true, - description: 'Jobs in group' + description: 'Jobs in group.' field :detailed_status, Types::Ci::DetailedStatusType, null: true, - description: 'Detailed status of the group' + description: 'Detailed status of the group.' def detailed_status object.detailed_status(context[:current_user]) diff --git a/app/graphql/types/ci/job_artifact_type.rb b/app/graphql/types/ci/job_artifact_type.rb index c34a12dcc61..7dc93041b53 100644 --- a/app/graphql/types/ci/job_artifact_type.rb +++ b/app/graphql/types/ci/job_artifact_type.rb @@ -7,10 +7,10 @@ module Types graphql_name 'CiJobArtifact' field :download_path, GraphQL::STRING_TYPE, null: true, - description: "URL for downloading the artifact's file" + description: "URL for downloading the artifact's file." field :file_type, ::Types::Ci::JobArtifactFileTypeEnum, null: true, - description: 'File type of the artifact' + description: 'File type of the artifact.' def download_path ::Gitlab::Routing.url_helpers.download_project_job_artifacts_path( diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb index f8bf1732e63..ba463cdd9c1 100644 --- a/app/graphql/types/ci/job_type.rb +++ b/app/graphql/types/ci/job_type.rb @@ -7,17 +7,17 @@ module Types authorize :read_commit_status field :pipeline, Types::Ci::PipelineType, null: true, - description: 'Pipeline the job belongs to' + description: 'Pipeline the job belongs to.' field :name, GraphQL::STRING_TYPE, null: true, - description: 'Name of the job' + description: 'Name of the job.' field :needs, BuildNeedType.connection_type, null: true, - description: 'References to builds that must complete before the jobs run' + description: 'References to builds that must complete before the jobs run.' field :detailed_status, Types::Ci::DetailedStatusType, null: true, - description: 'Detailed status of the job' + description: 'Detailed status of the job.' field :scheduled_at, Types::TimeType, null: true, - description: 'Schedule for the build' + description: 'Schedule for the build.' field :artifacts, Types::Ci::JobArtifactType.connection_type, null: true, - description: 'Artifacts generated by the job' + description: 'Artifacts generated by the job.' def pipeline Gitlab::Graphql::Loaders::BatchModelLoader.new(::Ci::Pipeline, object.pipeline_id).find diff --git a/app/graphql/types/ci/pipeline_type.rb b/app/graphql/types/ci/pipeline_type.rb index 4709d5e8dd6..af7e0fa224f 100644 --- a/app/graphql/types/ci/pipeline_type.rb +++ b/app/graphql/types/ci/pipeline_type.rb @@ -8,94 +8,95 @@ module Types connection_type_class(Types::CountableConnectionType) authorize :read_pipeline + present_using ::Ci::PipelinePresenter expose_permissions Types::PermissionTypes::Ci::Pipeline field :id, GraphQL::ID_TYPE, null: false, - description: 'ID of the pipeline' + description: 'ID of the pipeline.' field :iid, GraphQL::STRING_TYPE, null: false, - description: 'Internal ID of the pipeline' + description: 'Internal ID of the pipeline.' field :sha, GraphQL::STRING_TYPE, null: false, - description: "SHA of the pipeline's commit" + description: "SHA of the pipeline's commit." field :before_sha, GraphQL::STRING_TYPE, null: true, - description: 'Base SHA of the source branch' + description: 'Base SHA of the source branch.' field :status, PipelineStatusEnum, null: false, description: "Status of the pipeline (#{::Ci::Pipeline.all_state_names.compact.join(', ').upcase})" field :detailed_status, Types::Ci::DetailedStatusType, null: false, - description: 'Detailed status of the pipeline' + description: 'Detailed status of the pipeline.' field :config_source, PipelineConfigSourceEnum, null: true, - description: "Config source of the pipeline (#{::Enums::Ci::Pipeline.config_sources.keys.join(', ').upcase})" + description: "Configuration source of the pipeline (#{::Enums::Ci::Pipeline.config_sources.keys.join(', ').upcase})" field :duration, GraphQL::INT_TYPE, null: true, - description: 'Duration of the pipeline in seconds' + description: 'Duration of the pipeline in seconds.' field :coverage, GraphQL::FLOAT_TYPE, null: true, - description: 'Coverage percentage' + description: 'Coverage percentage.' field :created_at, Types::TimeType, null: false, - description: "Timestamp of the pipeline's creation" + description: "Timestamp of the pipeline's creation." field :updated_at, Types::TimeType, null: false, - description: "Timestamp of the pipeline's last activity" + description: "Timestamp of the pipeline's last activity." field :started_at, Types::TimeType, null: true, - description: 'Timestamp when the pipeline was started' + description: 'Timestamp when the pipeline was started.' field :finished_at, Types::TimeType, null: true, - description: "Timestamp of the pipeline's completion" + description: "Timestamp of the pipeline's completion." field :committed_at, Types::TimeType, null: true, - description: "Timestamp of the pipeline's commit" + description: "Timestamp of the pipeline's commit." field :stages, Types::Ci::StageType.connection_type, null: true, - description: 'Stages of the pipeline', + description: 'Stages of the pipeline.', extras: [:lookahead], resolver: Resolvers::Ci::PipelineStagesResolver field :user, Types::UserType, null: true, - description: 'Pipeline user' + description: 'Pipeline user.' field :retryable, GraphQL::BOOLEAN_TYPE, - description: 'Specifies if a pipeline can be retried', + description: 'Specifies if a pipeline can be retried.', method: :retryable?, null: false field :cancelable, GraphQL::BOOLEAN_TYPE, - description: 'Specifies if a pipeline can be canceled', + description: 'Specifies if a pipeline can be canceled.', method: :cancelable?, null: false field :jobs, ::Types::Ci::JobType.connection_type, null: true, - description: 'Jobs belonging to the pipeline', + description: 'Jobs belonging to the pipeline.', resolver: ::Resolvers::Ci::JobsResolver field :source_job, Types::Ci::JobType, null: true, - description: 'Job where pipeline was triggered from' + description: 'Job where pipeline was triggered from.' field :downstream, Types::Ci::PipelineType.connection_type, null: true, - description: 'Pipelines this pipeline will trigger', + description: 'Pipelines this pipeline will trigger.', method: :triggered_pipelines_with_preloads field :upstream, Types::Ci::PipelineType, null: true, - description: 'Pipeline that triggered the pipeline', + description: 'Pipeline that triggered the pipeline.', method: :triggered_by_pipeline field :path, GraphQL::STRING_TYPE, null: true, - description: "Relative path to the pipeline's page" + description: "Relative path to the pipeline's page." field :project, Types::ProjectType, null: true, - description: 'Project the pipeline belongs to' + description: 'Project the pipeline belongs to.' field :active, GraphQL::BOOLEAN_TYPE, null: false, method: :active?, - description: 'Indicates if the pipeline is active' + description: 'Indicates if the pipeline is active.' def detailed_status object.detailed_status(context[:current_user]) diff --git a/app/graphql/types/ci/runner_architecture_type.rb b/app/graphql/types/ci/runner_architecture_type.rb index 526348abd9d..229974d4d13 100644 --- a/app/graphql/types/ci/runner_architecture_type.rb +++ b/app/graphql/types/ci/runner_architecture_type.rb @@ -7,9 +7,9 @@ module Types graphql_name 'RunnerArchitecture' field :name, GraphQL::STRING_TYPE, null: false, - description: 'Name of the runner platform architecture' + description: 'Name of the runner platform architecture.' field :download_location, GraphQL::STRING_TYPE, null: false, - description: 'Download location for the runner for the platform architecture' + description: 'Download location for the runner for the platform architecture.' end end end diff --git a/app/graphql/types/ci/runner_platform_type.rb b/app/graphql/types/ci/runner_platform_type.rb index 64719bc4908..5636f88835e 100644 --- a/app/graphql/types/ci/runner_platform_type.rb +++ b/app/graphql/types/ci/runner_platform_type.rb @@ -7,11 +7,11 @@ module Types graphql_name 'RunnerPlatform' field :name, GraphQL::STRING_TYPE, null: false, - description: 'Name slug of the runner platform' + description: 'Name slug of the runner platform.' field :human_readable_name, GraphQL::STRING_TYPE, null: false, - description: 'Human readable name of the runner platform' + description: 'Human readable name of the runner platform.' field :architectures, Types::Ci::RunnerArchitectureType.connection_type, null: true, - description: 'Runner architectures supported for the platform' + description: 'Runner architectures supported for the platform.' end end end diff --git a/app/graphql/types/ci/runner_setup_type.rb b/app/graphql/types/ci/runner_setup_type.rb index 66abcf65adf..61a2ea2a411 100644 --- a/app/graphql/types/ci/runner_setup_type.rb +++ b/app/graphql/types/ci/runner_setup_type.rb @@ -7,9 +7,9 @@ module Types graphql_name 'RunnerSetup' field :install_instructions, GraphQL::STRING_TYPE, null: false, - description: 'Instructions for installing the runner on the specified architecture' + description: 'Instructions for installing the runner on the specified architecture.' field :register_instructions, GraphQL::STRING_TYPE, null: true, - description: 'Instructions for registering the runner' + description: 'Instructions for registering the runner.' end end end diff --git a/app/graphql/types/ci/stage_type.rb b/app/graphql/types/ci/stage_type.rb index 695e7c61bd9..836f2430890 100644 --- a/app/graphql/types/ci/stage_type.rb +++ b/app/graphql/types/ci/stage_type.rb @@ -7,12 +7,12 @@ module Types graphql_name 'CiStage' field :name, GraphQL::STRING_TYPE, null: true, - description: 'Name of the stage' + description: 'Name of the stage.' field :groups, Ci::GroupType.connection_type, null: true, extras: [:lookahead], - description: 'Group of jobs for the stage' + description: 'Group of jobs for the stage.' field :detailed_status, Types::Ci::DetailedStatusType, null: true, - description: 'Detailed status of the stage' + description: 'Detailed status of the stage.' def detailed_status object.detailed_status(context[:current_user]) diff --git a/app/graphql/types/ci/status_action_type.rb b/app/graphql/types/ci/status_action_type.rb index 08cbb6d3b59..9f7299c0270 100644 --- a/app/graphql/types/ci/status_action_type.rb +++ b/app/graphql/types/ci/status_action_type.rb @@ -6,16 +6,16 @@ module Types graphql_name 'StatusAction' field :button_title, GraphQL::STRING_TYPE, null: true, - description: 'Title for the button, for example: Retry this job' + description: 'Title for the button, for example: Retry this job.' field :icon, GraphQL::STRING_TYPE, null: true, - description: 'Icon used in the action button' + description: 'Icon used in the action button.' field :method, GraphQL::STRING_TYPE, null: true, - description: 'Method for the action, for example: :post', + description: 'Method for the action, for example: :post.', resolver_method: :action_method field :path, GraphQL::STRING_TYPE, null: true, - description: 'Path for the action' + description: 'Path for the action.' field :title, GraphQL::STRING_TYPE, null: true, - description: 'Title for the action, for example: Retry' + description: 'Title for the action, for example: Retry.' def action_method object[:method] diff --git a/app/graphql/types/ci_configuration/sast/analyzers_entity_input_type.rb b/app/graphql/types/ci_configuration/sast/analyzers_entity_input_type.rb new file mode 100644 index 00000000000..9835a7ef208 --- /dev/null +++ b/app/graphql/types/ci_configuration/sast/analyzers_entity_input_type.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Types + module CiConfiguration + module Sast + # rubocop: disable Graphql/AuthorizeTypes + class AnalyzersEntityInputType < BaseInputObject + graphql_name 'SastCiConfigurationAnalyzersEntityInput' + description 'Represents the analyzers entity in SAST CI configuration' + + argument :name, GraphQL::STRING_TYPE, required: true, + description: 'Name of analyzer.' + + argument :enabled, GraphQL::BOOLEAN_TYPE, required: true, + description: 'State of the analyzer.' + + argument :variables, [::Types::CiConfiguration::Sast::EntityInputType], + description: 'List of variables for the analyzer.', + required: false + end + end + end +end diff --git a/app/graphql/types/ci_configuration/sast/analyzers_entity_type.rb b/app/graphql/types/ci_configuration/sast/analyzers_entity_type.rb new file mode 100644 index 00000000000..3c6202ca7e0 --- /dev/null +++ b/app/graphql/types/ci_configuration/sast/analyzers_entity_type.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Types + module CiConfiguration + module Sast + # rubocop: disable Graphql/AuthorizeTypes + class AnalyzersEntityType < BaseObject + graphql_name 'SastCiConfigurationAnalyzersEntity' + description 'Represents an analyzer entity in SAST CI configuration' + + field :name, GraphQL::STRING_TYPE, null: true, + description: 'Name of the analyzer.' + + field :label, GraphQL::STRING_TYPE, null: true, + description: 'Analyzer label used in the config UI.' + + field :enabled, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates whether an analyzer is enabled.' + + field :description, GraphQL::STRING_TYPE, null: true, + description: 'Analyzer description that is displayed on the form.' + + field :variables, ::Types::CiConfiguration::Sast::EntityType.connection_type, null: true, + description: 'List of supported variables.' + end + end + end +end diff --git a/app/graphql/types/ci_configuration/sast/entity_input_type.rb b/app/graphql/types/ci_configuration/sast/entity_input_type.rb new file mode 100644 index 00000000000..39b3efb3db8 --- /dev/null +++ b/app/graphql/types/ci_configuration/sast/entity_input_type.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Types + module CiConfiguration + module Sast + # rubocop: disable Graphql/AuthorizeTypes + class EntityInputType < BaseInputObject + graphql_name 'SastCiConfigurationEntityInput' + description 'Represents an entity in SAST CI configuration' + + argument :field, GraphQL::STRING_TYPE, required: true, + description: 'CI keyword of entity.' + + argument :default_value, GraphQL::STRING_TYPE, required: true, + description: 'Default value that is used if value is empty.' + + argument :value, GraphQL::STRING_TYPE, required: true, + description: 'Current value of the entity.' + end + end + end +end diff --git a/app/graphql/types/ci_configuration/sast/entity_type.rb b/app/graphql/types/ci_configuration/sast/entity_type.rb new file mode 100644 index 00000000000..eeb9025391f --- /dev/null +++ b/app/graphql/types/ci_configuration/sast/entity_type.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Types + module CiConfiguration + module Sast + # rubocop: disable Graphql/AuthorizeTypes + class EntityType < BaseObject + graphql_name 'SastCiConfigurationEntity' + description 'Represents an entity in SAST CI configuration' + + field :field, GraphQL::STRING_TYPE, null: true, + description: 'CI keyword of entity.' + + field :label, GraphQL::STRING_TYPE, null: true, + description: 'Label for entity used in the form.' + + field :type, GraphQL::STRING_TYPE, null: true, + description: 'Type of the field value.' + + field :options, ::Types::CiConfiguration::Sast::OptionsEntityType.connection_type, null: true, + description: 'Different possible values of the field.' + + field :default_value, GraphQL::STRING_TYPE, null: true, + description: 'Default value that is used if value is empty.' + + field :description, GraphQL::STRING_TYPE, null: true, + description: 'Entity description that is displayed on the form.' + + field :value, GraphQL::STRING_TYPE, null: true, + description: 'Current value of the entity.' + + field :size, ::Types::CiConfiguration::Sast::UiComponentSizeEnum, null: true, + description: 'Size of the UI component.' + end + end + end +end diff --git a/app/graphql/types/ci_configuration/sast/input_type.rb b/app/graphql/types/ci_configuration/sast/input_type.rb new file mode 100644 index 00000000000..615436683f6 --- /dev/null +++ b/app/graphql/types/ci_configuration/sast/input_type.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Types + module CiConfiguration + module Sast + class InputType < BaseInputObject # rubocop:disable Graphql/AuthorizeTypes + graphql_name 'SastCiConfigurationInput' + description 'Represents a CI configuration of SAST' + + argument :global, [::Types::CiConfiguration::Sast::EntityInputType], + description: 'List of global entities related to SAST configuration.', + required: false + + argument :pipeline, [::Types::CiConfiguration::Sast::EntityInputType], + description: 'List of pipeline entities related to SAST configuration.', + required: false + + argument :analyzers, [::Types::CiConfiguration::Sast::AnalyzersEntityInputType], + description: 'List of analyzers and related variables for the SAST configuration.', + required: false + end + end + end +end diff --git a/app/graphql/types/ci_configuration/sast/options_entity_type.rb b/app/graphql/types/ci_configuration/sast/options_entity_type.rb new file mode 100644 index 00000000000..86d104a7fda --- /dev/null +++ b/app/graphql/types/ci_configuration/sast/options_entity_type.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + module CiConfiguration + module Sast + # rubocop: disable Graphql/AuthorizeTypes + class OptionsEntityType < BaseObject + graphql_name 'SastCiConfigurationOptionsEntity' + description 'Represents an entity for options in SAST CI configuration' + + field :label, GraphQL::STRING_TYPE, null: true, + description: 'Label of option entity.' + + field :value, GraphQL::STRING_TYPE, null: true, + description: 'Value of option entity.' + end + end + end +end diff --git a/app/graphql/types/ci_configuration/sast/type.rb b/app/graphql/types/ci_configuration/sast/type.rb new file mode 100644 index 00000000000..35d11584ac7 --- /dev/null +++ b/app/graphql/types/ci_configuration/sast/type.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Types + module CiConfiguration + module Sast + # rubocop: disable Graphql/AuthorizeTypes + class Type < BaseObject + graphql_name 'SastCiConfiguration' + description 'Represents a CI configuration of SAST' + + field :global, ::Types::CiConfiguration::Sast::EntityType.connection_type, null: true, + description: 'List of global entities related to SAST configuration.' + + field :pipeline, ::Types::CiConfiguration::Sast::EntityType.connection_type, null: true, + description: 'List of pipeline entities related to SAST configuration.' + + field :analyzers, ::Types::CiConfiguration::Sast::AnalyzersEntityType.connection_type, null: true, + description: 'List of analyzers entities attached to SAST configuration.' + end + end + end +end diff --git a/app/graphql/types/ci_configuration/sast/ui_component_size_enum.rb b/app/graphql/types/ci_configuration/sast/ui_component_size_enum.rb new file mode 100644 index 00000000000..3a208f9d3e4 --- /dev/null +++ b/app/graphql/types/ci_configuration/sast/ui_component_size_enum.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + module CiConfiguration + module Sast + class UiComponentSizeEnum < BaseEnum + graphql_name 'SastUiComponentSize' + description 'Size of UI component in SAST configuration page' + + value 'SMALL' + value 'MEDIUM' + value 'LARGE' + end + end + end +end diff --git a/app/graphql/types/commit_action_type.rb b/app/graphql/types/commit_action_type.rb index 7674abb11eb..e14e7157752 100644 --- a/app/graphql/types/commit_action_type.rb +++ b/app/graphql/types/commit_action_type.rb @@ -4,19 +4,19 @@ module Types # rubocop: disable Graphql/AuthorizeTypes class CommitActionType < BaseInputObject argument :action, type: Types::CommitActionModeEnum, required: true, - description: 'The action to perform, create, delete, move, update, chmod' + description: 'The action to perform, create, delete, move, update, chmod.' argument :file_path, type: GraphQL::STRING_TYPE, required: true, - description: 'Full path to the file' + description: 'Full path to the file.' argument :content, type: GraphQL::STRING_TYPE, required: false, - description: 'Content of the file' + description: 'Content of the file.' argument :previous_path, type: GraphQL::STRING_TYPE, required: false, - description: 'Original full path to the file being moved' + description: 'Original full path to the file being moved.' argument :last_commit_id, type: GraphQL::STRING_TYPE, required: false, - description: 'Last known file commit ID' + description: 'Last known file commit ID.' argument :execute_filemode, type: GraphQL::BOOLEAN_TYPE, required: false, - description: 'Enables/disables the execute flag on the file' + description: 'Enables/disables the execute flag on the file.' argument :encoding, type: Types::CommitEncodingEnum, required: false, - description: 'Encoding of the file. Default is text' + description: 'Encoding of the file. Default is text.' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/commit_type.rb b/app/graphql/types/commit_type.rb index 37d19b4148b..d137901380b 100644 --- a/app/graphql/types/commit_type.rb +++ b/app/graphql/types/commit_type.rb @@ -9,39 +9,39 @@ module Types present_using CommitPresenter field :id, type: GraphQL::ID_TYPE, null: false, - description: 'ID (global ID) of the commit' + description: 'ID (global ID) of the commit.' field :sha, type: GraphQL::STRING_TYPE, null: false, - description: 'SHA1 ID of the commit' + description: 'SHA1 ID of the commit.' field :short_id, type: GraphQL::STRING_TYPE, null: false, - description: 'Short SHA1 ID of the commit' + description: 'Short SHA1 ID of the commit.' field :title, type: GraphQL::STRING_TYPE, null: true, calls_gitaly: true, - description: 'Title of the commit message' + description: 'Title of the commit message.' markdown_field :title_html, null: true field :description, type: GraphQL::STRING_TYPE, null: true, - description: 'Description of the commit message' + description: 'Description of the commit message.' markdown_field :description_html, null: true field :message, type: GraphQL::STRING_TYPE, null: true, - description: 'Raw commit message' + description: 'Raw commit message.' field :authored_date, type: Types::TimeType, null: true, - description: 'Timestamp of when the commit was authored' + description: 'Timestamp of when the commit was authored.' field :web_url, type: GraphQL::STRING_TYPE, null: false, - description: 'Web URL of the commit' + description: 'Web URL of the commit.' field :web_path, type: GraphQL::STRING_TYPE, null: false, - description: 'Web path of the commit' + description: 'Web path of the commit.' field :signature_html, type: GraphQL::STRING_TYPE, null: true, calls_gitaly: true, - description: 'Rendered HTML of the commit signature' + description: 'Rendered HTML of the commit signature.' field :author_name, type: GraphQL::STRING_TYPE, null: true, - description: 'Commit authors name' + description: 'Commit authors name.' field :author_gravatar, type: GraphQL::STRING_TYPE, null: true, - description: 'Commit authors gravatar' + description: 'Commit authors gravatar.' # models/commit lazy loads the author by email field :author, type: Types::UserType, null: true, - description: 'Author of the commit' + description: 'Author of the commit.' field :pipelines, null: true, - description: 'Pipelines of the commit ordered latest first', + description: 'Pipelines of the commit ordered latest first.', resolver: Resolvers::CommitPipelinesResolver def author_gravatar diff --git a/app/graphql/types/container_expiration_policy_type.rb b/app/graphql/types/container_expiration_policy_type.rb index f19aa964377..2b01474617a 100644 --- a/app/graphql/types/container_expiration_policy_type.rb +++ b/app/graphql/types/container_expiration_policy_type.rb @@ -8,14 +8,14 @@ module Types authorize :destroy_container_image - field :created_at, Types::TimeType, null: false, description: 'Timestamp of when the container expiration policy was created' - field :updated_at, Types::TimeType, null: false, description: 'Timestamp of when the container expiration policy was updated' - field :enabled, GraphQL::BOOLEAN_TYPE, null: false, description: 'Indicates whether this container expiration policy is enabled' - field :older_than, Types::ContainerExpirationPolicyOlderThanEnum, null: true, description: 'Tags older that this will expire' - field :cadence, Types::ContainerExpirationPolicyCadenceEnum, null: false, description: 'This container expiration policy schedule' - field :keep_n, Types::ContainerExpirationPolicyKeepEnum, null: true, description: 'Number of tags to retain' - field :name_regex, Types::UntrustedRegexp, null: true, description: 'Tags with names matching this regex pattern will expire' - field :name_regex_keep, Types::UntrustedRegexp, null: true, description: 'Tags with names matching this regex pattern will be preserved' - field :next_run_at, Types::TimeType, null: true, description: 'Next time that this container expiration policy will get executed' + field :created_at, Types::TimeType, null: false, description: 'Timestamp of when the container expiration policy was created.' + field :updated_at, Types::TimeType, null: false, description: 'Timestamp of when the container expiration policy was updated.' + field :enabled, GraphQL::BOOLEAN_TYPE, null: false, description: 'Indicates whether this container expiration policy is enabled.' + field :older_than, Types::ContainerExpirationPolicyOlderThanEnum, null: true, description: 'Tags older that this will expire.' + field :cadence, Types::ContainerExpirationPolicyCadenceEnum, null: false, description: 'This container expiration policy schedule.' + field :keep_n, Types::ContainerExpirationPolicyKeepEnum, null: true, description: 'Number of tags to retain.' + field :name_regex, Types::UntrustedRegexp, null: true, description: 'Tags with names matching this regex pattern will expire.' + field :name_regex_keep, Types::UntrustedRegexp, null: true, description: 'Tags with names matching this regex pattern will be preserved.' + field :next_run_at, Types::TimeType, null: true, description: 'Next time that this container expiration policy will get executed.' end end diff --git a/app/graphql/types/container_repository_details_type.rb b/app/graphql/types/container_repository_details_type.rb index 34523f3ea4a..1a9f57e701f 100644 --- a/app/graphql/types/container_repository_details_type.rb +++ b/app/graphql/types/container_repository_details_type.rb @@ -11,7 +11,7 @@ module Types field :tags, Types::ContainerRepositoryTagType.connection_type, null: true, - description: 'Tags of the container repository', + description: 'Tags of the container repository.', max_page_size: 20 def can_delete diff --git a/app/graphql/types/container_repository_type.rb b/app/graphql/types/container_repository_type.rb index 8735f8a173d..48c2b9f460f 100644 --- a/app/graphql/types/container_repository_type.rb +++ b/app/graphql/types/container_repository_type.rb @@ -19,7 +19,7 @@ module Types field :status, Types::ContainerRepositoryStatusEnum, null: true, description: 'Status of the container repository.' field :tags_count, GraphQL::INT_TYPE, null: false, description: 'Number of tags associated with this image.' field :can_delete, GraphQL::BOOLEAN_TYPE, null: false, description: 'Can the current user delete the container repository.' - field :project, Types::ProjectType, null: false, description: 'Project of the container registry' + field :project, Types::ProjectType, null: false, description: 'Project of the container registry.' def can_delete Ability.allowed?(current_user, :update_container_image, object) diff --git a/app/graphql/types/countable_connection_type.rb b/app/graphql/types/countable_connection_type.rb index f67194d99b3..0a9699a4570 100644 --- a/app/graphql/types/countable_connection_type.rb +++ b/app/graphql/types/countable_connection_type.rb @@ -4,7 +4,7 @@ module Types # rubocop: disable Graphql/AuthorizeTypes class CountableConnectionType < GraphQL::Types::Relay::BaseConnection field :count, GraphQL::INT_TYPE, null: false, - description: 'Total count of collection' + description: 'Total count of collection.' def count # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/graphql/types/current_user_todos.rb b/app/graphql/types/current_user_todos.rb index e610286c1a9..79a430af1d7 100644 --- a/app/graphql/types/current_user_todos.rb +++ b/app/graphql/types/current_user_todos.rb @@ -8,10 +8,10 @@ module Types field_class Types::BaseField field :current_user_todos, Types::TodoType.connection_type, - description: 'Todos for the current user', + description: 'To-do items for the current user.', null: false do argument :state, Types::TodoStateEnum, - description: 'State of the todos', + description: 'State of the to-do items.', required: false end diff --git a/app/graphql/types/custom_emoji_type.rb b/app/graphql/types/custom_emoji_type.rb index f7d1a7800bc..246b60ce184 100644 --- a/app/graphql/types/custom_emoji_type.rb +++ b/app/graphql/types/custom_emoji_type.rb @@ -9,19 +9,19 @@ module Types field :id, ::Types::GlobalIDType[::CustomEmoji], null: false, - description: 'The ID of the emoji' + description: 'The ID of the emoji.' field :name, GraphQL::STRING_TYPE, null: false, - description: 'The name of the emoji' + description: 'The name of the emoji.' field :url, GraphQL::STRING_TYPE, null: false, method: :file, - description: 'The link to file of the emoji' + description: 'The link to file of the emoji.' field :external, GraphQL::BOOLEAN_TYPE, null: false, - description: 'Whether the emoji is an external link' + description: 'Whether the emoji is an external link.' end end diff --git a/app/graphql/types/design_management/design_at_version_type.rb b/app/graphql/types/design_management/design_at_version_type.rb index e10a0de1715..4240b8f3aae 100644 --- a/app/graphql/types/design_management/design_at_version_type.rb +++ b/app/graphql/types/design_management/design_at_version_type.rb @@ -18,12 +18,12 @@ module Types field :version, Types::DesignManagement::VersionType, null: false, - description: 'The version this design-at-versions is pinned to' + description: 'The version this design-at-versions is pinned to.' field :design, Types::DesignManagement::DesignType, null: false, - description: 'The underlying design' + description: 'The underlying design.' def cached_stateful_version(_parent) version diff --git a/app/graphql/types/design_management/design_collection_type.rb b/app/graphql/types/design_management/design_collection_type.rb index 26fbac15b30..570eac907f3 100644 --- a/app/graphql/types/design_management/design_collection_type.rb +++ b/app/graphql/types/design_management/design_collection_type.rb @@ -9,40 +9,40 @@ module Types authorize :read_design field :project, Types::ProjectType, null: false, - description: 'Project associated with the design collection' + description: 'Project associated with the design collection.' field :issue, Types::IssueType, null: false, - description: 'Issue associated with the design collection' + description: 'Issue associated with the design collection.' field :designs, Types::DesignManagement::DesignType.connection_type, null: false, resolver: Resolvers::DesignManagement::DesignsResolver, - description: 'All designs for the design collection', + description: 'All designs for the design collection.', complexity: 5 field :versions, Types::DesignManagement::VersionType.connection_type, resolver: Resolvers::DesignManagement::VersionsResolver, - description: 'All versions related to all designs, ordered newest first' + description: 'All versions related to all designs, ordered newest first.' field :version, Types::DesignManagement::VersionType, resolver: Resolvers::DesignManagement::VersionsResolver.single, - description: 'A specific version' + description: 'A specific version.' field :design_at_version, ::Types::DesignManagement::DesignAtVersionType, null: true, resolver: ::Resolvers::DesignManagement::DesignAtVersionResolver, - description: 'Find a design as of a version' + description: 'Find a design as of a version.' field :design, ::Types::DesignManagement::DesignType, null: true, resolver: ::Resolvers::DesignManagement::DesignResolver, - description: 'Find a specific design' + description: 'Find a specific design.' field :copy_state, ::Types::DesignManagement::DesignCollectionCopyStateEnum, null: true, - description: 'Copy state of the design collection' + description: 'Copy state of the design collection.' end end end diff --git a/app/graphql/types/design_management/design_fields.rb b/app/graphql/types/design_management/design_fields.rb index b03b3927392..b770e30f5be 100644 --- a/app/graphql/types/design_management/design_fields.rb +++ b/app/graphql/types/design_management/design_fields.rb @@ -7,12 +7,12 @@ module Types field_class Types::BaseField - field :id, GraphQL::ID_TYPE, description: 'The ID of this design', null: false - field :project, Types::ProjectType, null: false, description: 'The project the design belongs to' - field :issue, Types::IssueType, null: false, description: 'The issue the design belongs to' - field :filename, GraphQL::STRING_TYPE, null: false, description: 'The filename of the design' - field :full_path, GraphQL::STRING_TYPE, null: false, description: 'The full path to the design file' - field :image, GraphQL::STRING_TYPE, null: false, extras: [:parent], description: 'The URL of the full-sized image' + field :id, GraphQL::ID_TYPE, description: 'The ID of this design.', null: false + field :project, Types::ProjectType, null: false, description: 'The project the design belongs to.' + field :issue, Types::IssueType, null: false, description: 'The issue the design belongs to.' + field :filename, GraphQL::STRING_TYPE, null: false, description: 'The filename of the design.' + field :full_path, GraphQL::STRING_TYPE, null: false, description: 'The full path to the design file.' + field :image, GraphQL::STRING_TYPE, null: false, extras: [:parent], description: 'The URL of the full-sized image.' field :image_v432x230, GraphQL::STRING_TYPE, null: true, extras: [:parent], description: 'The URL of the design resized to fit within the bounds of 432x230. ' \ 'This will be `null` if the image has not been generated' @@ -20,16 +20,16 @@ module Types null: false, calls_gitaly: true, extras: [:parent], - description: 'The diff refs for this design' + description: 'The diff refs for this design.' field :event, Types::DesignManagement::DesignVersionEventEnum, null: false, extras: [:parent], - description: 'How this design was changed in the current version' + description: 'How this design was changed in the current version.' field :notes_count, GraphQL::INT_TYPE, null: false, method: :user_notes_count, - description: 'The total count of user-created notes for this design' + description: 'The total count of user-created notes for this design.' def diff_refs(parent:) version = cached_stateful_version(parent) diff --git a/app/graphql/types/design_management/design_type.rb b/app/graphql/types/design_management/design_type.rb index bab22015dc4..44e87905f92 100644 --- a/app/graphql/types/design_management/design_type.rb +++ b/app/graphql/types/design_management/design_type.rb @@ -17,7 +17,7 @@ module Types field :versions, Types::DesignManagement::VersionType.connection_type, resolver: Resolvers::DesignManagement::VersionsResolver, - description: "All versions related to this design ordered newest first", + description: "All versions related to this design ordered newest first.", extras: [:parent] # Returns a `DesignManagement::Version` for this query based on the diff --git a/app/graphql/types/design_management/version_type.rb b/app/graphql/types/design_management/version_type.rb index c774f5d1bdf..4bc71aef0f4 100644 --- a/app/graphql/types/design_management/version_type.rb +++ b/app/graphql/types/design_management/version_type.rb @@ -12,25 +12,25 @@ module Types authorize :read_design field :id, GraphQL::ID_TYPE, null: false, - description: 'ID of the design version' + description: 'ID of the design version.' field :sha, GraphQL::ID_TYPE, null: false, - description: 'SHA of the design version' + description: 'SHA of the design version.' field :designs, ::Types::DesignManagement::DesignType.connection_type, null: false, - description: 'All designs that were changed in the version' + description: 'All designs that were changed in the version.' field :designs_at_version, ::Types::DesignManagement::DesignAtVersionType.connection_type, null: false, - description: 'All designs that are visible at this version, as of this version', + description: 'All designs that are visible at this version, as of this version.', resolver: ::Resolvers::DesignManagement::Version::DesignsAtVersionResolver field :design_at_version, ::Types::DesignManagement::DesignAtVersionType, null: false, - description: 'A particular design as of this version, provided it is visible at this version', + description: 'A particular design as of this version, provided it is visible at this version.', resolver: ::Resolvers::DesignManagement::Version::DesignsAtVersionResolver.single end end diff --git a/app/graphql/types/design_management_type.rb b/app/graphql/types/design_management_type.rb index ec85b8a0c1f..be0fb8253ca 100644 --- a/app/graphql/types/design_management_type.rb +++ b/app/graphql/types/design_management_type.rb @@ -8,11 +8,11 @@ module Types field :version, ::Types::DesignManagement::VersionType, null: true, resolver: ::Resolvers::DesignManagement::VersionResolver, - description: 'Find a version' + description: 'Find a version.' field :design_at_version, ::Types::DesignManagement::DesignAtVersionType, null: true, resolver: ::Resolvers::DesignManagement::DesignAtVersionResolver, - description: 'Find a design as of a version' + description: 'Find a design as of a version.' end end diff --git a/app/graphql/types/diff_paths_input_type.rb b/app/graphql/types/diff_paths_input_type.rb index 43feddd9827..864cec1ab07 100644 --- a/app/graphql/types/diff_paths_input_type.rb +++ b/app/graphql/types/diff_paths_input_type.rb @@ -4,9 +4,9 @@ module Types # rubocop: disable Graphql/AuthorizeTypes class DiffPathsInputType < BaseInputObject argument :old_path, GraphQL::STRING_TYPE, required: false, - description: 'The path of the file on the start sha' + description: 'The path of the file on the start sha.' argument :new_path, GraphQL::STRING_TYPE, required: false, - description: 'The path of the file on the head sha' + description: 'The path of the file on the head sha.' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/diff_refs_type.rb b/app/graphql/types/diff_refs_type.rb index 4049a204f66..3c8f934f1eb 100644 --- a/app/graphql/types/diff_refs_type.rb +++ b/app/graphql/types/diff_refs_type.rb @@ -7,11 +7,11 @@ module Types graphql_name 'DiffRefs' field :head_sha, GraphQL::STRING_TYPE, null: false, - description: 'SHA of the HEAD at the time the comment was made' + description: 'SHA of the HEAD at the time the comment was made.' field :base_sha, GraphQL::STRING_TYPE, null: true, - description: 'Merge base of the branch the comment was made on' + description: 'Merge base of the branch the comment was made on.' field :start_sha, GraphQL::STRING_TYPE, null: false, - description: 'SHA of the branch being compared against' + description: 'SHA of the branch being compared against.' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/diff_stats_summary_type.rb b/app/graphql/types/diff_stats_summary_type.rb index 956400fd21b..78c0e2f2b4c 100644 --- a/app/graphql/types/diff_stats_summary_type.rb +++ b/app/graphql/types/diff_stats_summary_type.rb @@ -9,13 +9,13 @@ module Types description 'Aggregated summary of changes' field :additions, GraphQL::INT_TYPE, null: false, - description: 'Number of lines added' + description: 'Number of lines added.' field :deletions, GraphQL::INT_TYPE, null: false, - description: 'Number of lines deleted' + description: 'Number of lines deleted.' field :changes, GraphQL::INT_TYPE, null: false, - description: 'Number of lines changed' + description: 'Number of lines changed.' field :file_count, GraphQL::INT_TYPE, null: false, - description: 'Number of files changed' + description: 'Number of files changed.' def changes object[:additions] + object[:deletions] diff --git a/app/graphql/types/diff_stats_type.rb b/app/graphql/types/diff_stats_type.rb index 6c79a4c389d..8a6840e5a94 100644 --- a/app/graphql/types/diff_stats_type.rb +++ b/app/graphql/types/diff_stats_type.rb @@ -9,11 +9,11 @@ module Types description 'Changes to a single file' field :path, GraphQL::STRING_TYPE, null: false, - description: 'File path, relative to repository root' + description: 'File path, relative to repository root.' field :additions, GraphQL::INT_TYPE, null: false, - description: 'Number of lines added to this file' + description: 'Number of lines added to this file.' field :deletions, GraphQL::INT_TYPE, null: false, - description: 'Number of lines deleted from this file' + description: 'Number of lines deleted from this file.' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/environment_type.rb b/app/graphql/types/environment_type.rb index e3885668643..2e6417f08ea 100644 --- a/app/graphql/types/environment_type.rb +++ b/app/graphql/types/environment_type.rb @@ -10,24 +10,24 @@ module Types authorize :read_environment field :name, GraphQL::STRING_TYPE, null: false, - description: 'Human-readable name of the environment' + description: 'Human-readable name of the environment.' field :id, GraphQL::ID_TYPE, null: false, - description: 'ID of the environment' + description: 'ID of the environment.' field :state, GraphQL::STRING_TYPE, null: false, - description: 'State of the environment, for example: available/stopped' + description: 'State of the environment, for example: available/stopped.' field :path, GraphQL::STRING_TYPE, null: false, description: 'The path to the environment.' field :metrics_dashboard, Types::Metrics::DashboardType, null: true, - description: 'Metrics dashboard schema for the environment', + description: 'Metrics dashboard schema for the environment.', resolver: Resolvers::Metrics::DashboardResolver field :latest_opened_most_severe_alert, Types::AlertManagement::AlertType, null: true, - description: 'The most severe open alert for the environment. If multiple alerts have equal severity, the most recent is returned' + description: 'The most severe open alert for the environment. If multiple alerts have equal severity, the most recent is returned.' end end diff --git a/app/graphql/types/error_tracking/sentry_detailed_error_type.rb b/app/graphql/types/error_tracking/sentry_detailed_error_type.rb index cfde9fa0d6a..59bd97e3448 100644 --- a/app/graphql/types/error_tracking/sentry_detailed_error_type.rb +++ b/app/graphql/types/error_tracking/sentry_detailed_error_type.rb @@ -12,93 +12,93 @@ module Types field :id, GraphQL::ID_TYPE, null: false, - description: 'ID (global ID) of the error' + description: 'ID (global ID) of the error.' field :sentry_id, GraphQL::STRING_TYPE, method: :id, null: false, - description: 'ID (Sentry ID) of the error' + description: 'ID (Sentry ID) of the error.' field :title, GraphQL::STRING_TYPE, null: false, - description: 'Title of the error' + description: 'Title of the error.' field :type, GraphQL::STRING_TYPE, null: false, - description: 'Type of the error' + description: 'Type of the error.' field :user_count, GraphQL::INT_TYPE, null: false, - description: 'Count of users affected by the error' + description: 'Count of users affected by the error.' field :count, GraphQL::INT_TYPE, null: false, - description: 'Count of occurrences' + description: 'Count of occurrences.' field :first_seen, Types::TimeType, null: false, - description: 'Timestamp when the error was first seen' + description: 'Timestamp when the error was first seen.' field :last_seen, Types::TimeType, null: false, - description: 'Timestamp when the error was last seen' + description: 'Timestamp when the error was last seen.' field :message, GraphQL::STRING_TYPE, null: true, - description: 'Sentry metadata message of the error' + description: 'Sentry metadata message of the error.' field :culprit, GraphQL::STRING_TYPE, null: false, - description: 'Culprit of the error' + description: 'Culprit of the error.' field :external_base_url, GraphQL::STRING_TYPE, null: false, - description: 'External Base URL of the Sentry Instance' + description: 'External Base URL of the Sentry Instance.' field :external_url, GraphQL::STRING_TYPE, null: false, - description: 'External URL of the error' + description: 'External URL of the error.' field :sentry_project_id, GraphQL::ID_TYPE, method: :project_id, null: false, - description: 'ID of the project (Sentry project)' + description: 'ID of the project (Sentry project).' field :sentry_project_name, GraphQL::STRING_TYPE, method: :project_name, null: false, - description: 'Name of the project affected by the error' + description: 'Name of the project affected by the error.' field :sentry_project_slug, GraphQL::STRING_TYPE, method: :project_slug, null: false, - description: 'Slug of the project affected by the error' + description: 'Slug of the project affected by the error.' field :short_id, GraphQL::STRING_TYPE, null: false, - description: 'Short ID (Sentry ID) of the error' + description: 'Short ID (Sentry ID) of the error.' field :status, Types::ErrorTracking::SentryErrorStatusEnum, null: false, - description: 'Status of the error' + description: 'Status of the error.' field :frequency, [Types::ErrorTracking::SentryErrorFrequencyType], null: false, - description: 'Last 24hr stats of the error' + description: 'Last 24hr stats of the error.' field :first_release_last_commit, GraphQL::STRING_TYPE, null: true, - description: 'Commit the error was first seen' + description: 'Commit the error was first seen.' field :last_release_last_commit, GraphQL::STRING_TYPE, null: true, - description: 'Commit the error was last seen' + description: 'Commit the error was last seen.' field :first_release_short_version, GraphQL::STRING_TYPE, null: true, - description: 'Release short version the error was first seen' + description: 'Release short version the error was first seen.' field :last_release_short_version, GraphQL::STRING_TYPE, null: true, - description: 'Release short version the error was last seen' + description: 'Release short version the error was last seen.' field :first_release_version, GraphQL::STRING_TYPE, null: true, - description: 'Release version the error was first seen' + description: 'Release version the error was first seen.' field :last_release_version, GraphQL::STRING_TYPE, null: true, - description: 'Release version the error was last seen' + description: 'Release version the error was last seen.' field :gitlab_commit, GraphQL::STRING_TYPE, null: true, - description: 'GitLab commit SHA attributed to the Error based on the release version' + description: 'GitLab commit SHA attributed to the Error based on the release version.' field :gitlab_commit_path, GraphQL::STRING_TYPE, null: true, - description: 'Path to the GitLab page for the GitLab commit attributed to the error' + description: 'Path to the GitLab page for the GitLab commit attributed to the error.' field :gitlab_issue_path, GraphQL::STRING_TYPE, method: :gitlab_issue, null: true, - description: 'URL of GitLab Issue' + description: 'URL of GitLab Issue.' field :tags, Types::ErrorTracking::SentryErrorTagsType, null: false, - description: 'Tags associated with the Sentry Error' + description: 'Tags associated with the Sentry Error.' end end end diff --git a/app/graphql/types/error_tracking/sentry_error_collection_type.rb b/app/graphql/types/error_tracking/sentry_error_collection_type.rb index 49d5d62c860..d3941b7c410 100644 --- a/app/graphql/types/error_tracking/sentry_error_collection_type.rb +++ b/app/graphql/types/error_tracking/sentry_error_collection_type.rb @@ -9,18 +9,18 @@ module Types authorize :read_sentry_issue field :errors, - description: "Collection of Sentry Errors", + description: "Collection of Sentry Errors.", resolver: Resolvers::ErrorTracking::SentryErrorsResolver field :detailed_error, - description: 'Detailed version of a Sentry error on the project', + description: 'Detailed version of a Sentry error on the project.', resolver: Resolvers::ErrorTracking::SentryDetailedErrorResolver field :error_stack_trace, - description: 'Stack Trace of Sentry Error', + description: 'Stack Trace of Sentry Error.', resolver: Resolvers::ErrorTracking::SentryErrorStackTraceResolver field :external_url, GraphQL::STRING_TYPE, null: true, - description: "External URL for Sentry" + description: "External URL for Sentry." end end end diff --git a/app/graphql/types/error_tracking/sentry_error_frequency_type.rb b/app/graphql/types/error_tracking/sentry_error_frequency_type.rb index a44ca0684b6..05af1391af3 100644 --- a/app/graphql/types/error_tracking/sentry_error_frequency_type.rb +++ b/app/graphql/types/error_tracking/sentry_error_frequency_type.rb @@ -8,10 +8,10 @@ module Types field :time, Types::TimeType, null: false, - description: "Time the error frequency stats were recorded" + description: "Time the error frequency stats were recorded." field :count, GraphQL::INT_TYPE, null: false, - description: "Count of errors received since the previously recorded time" + description: "Count of errors received since the previously recorded time." end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/error_tracking/sentry_error_stack_trace_context_type.rb b/app/graphql/types/error_tracking/sentry_error_stack_trace_context_type.rb index e6d02c948d5..0b3c4cf55b9 100644 --- a/app/graphql/types/error_tracking/sentry_error_stack_trace_context_type.rb +++ b/app/graphql/types/error_tracking/sentry_error_stack_trace_context_type.rb @@ -10,11 +10,11 @@ module Types field :line, GraphQL::INT_TYPE, null: false, - description: 'Line number of the context' + description: 'Line number of the context.' field :code, GraphQL::STRING_TYPE, null: false, - description: 'Code number of the context' + description: 'Code number of the context.' def line object[0] diff --git a/app/graphql/types/error_tracking/sentry_error_stack_trace_entry_type.rb b/app/graphql/types/error_tracking/sentry_error_stack_trace_entry_type.rb index 2e6c40b233b..c9915d052f9 100644 --- a/app/graphql/types/error_tracking/sentry_error_stack_trace_entry_type.rb +++ b/app/graphql/types/error_tracking/sentry_error_stack_trace_entry_type.rb @@ -9,19 +9,19 @@ module Types field :function, GraphQL::STRING_TYPE, null: true, - description: 'Function in which the Sentry error occurred' + description: 'Function in which the Sentry error occurred.' field :col, GraphQL::STRING_TYPE, null: true, - description: 'Function in which the Sentry error occurred' + description: 'Function in which the Sentry error occurred.' field :line, GraphQL::STRING_TYPE, null: true, - description: 'Function in which the Sentry error occurred' + description: 'Function in which the Sentry error occurred.' field :file_name, GraphQL::STRING_TYPE, null: true, - description: 'File in which the Sentry error occurred' + description: 'File in which the Sentry error occurred.' field :trace_context, [Types::ErrorTracking::SentryErrorStackTraceContextType], null: true, - description: 'Context of the Sentry error' + description: 'Context of the Sentry error.' def function object['function'] diff --git a/app/graphql/types/error_tracking/sentry_error_stack_trace_type.rb b/app/graphql/types/error_tracking/sentry_error_stack_trace_type.rb index 1bbe7e0c77b..52959a9329b 100644 --- a/app/graphql/types/error_tracking/sentry_error_stack_trace_type.rb +++ b/app/graphql/types/error_tracking/sentry_error_stack_trace_type.rb @@ -10,13 +10,13 @@ module Types field :issue_id, GraphQL::STRING_TYPE, null: false, - description: 'ID of the Sentry error' + description: 'ID of the Sentry error.' field :date_received, GraphQL::STRING_TYPE, null: false, - description: 'Time the stack trace was received by Sentry' + description: 'Time the stack trace was received by Sentry.' field :stack_trace_entries, [Types::ErrorTracking::SentryErrorStackTraceEntryType], null: false, - description: 'Stack trace entries for the Sentry error' + description: 'Stack trace entries for the Sentry error.' end end end diff --git a/app/graphql/types/error_tracking/sentry_error_tags_type.rb b/app/graphql/types/error_tracking/sentry_error_tags_type.rb index e6d96571561..e2b051998c5 100644 --- a/app/graphql/types/error_tracking/sentry_error_tags_type.rb +++ b/app/graphql/types/error_tracking/sentry_error_tags_type.rb @@ -9,10 +9,10 @@ module Types field :level, GraphQL::STRING_TYPE, null: true, - description: "Severity level of the Sentry Error" + description: "Severity level of the Sentry Error." field :logger, GraphQL::STRING_TYPE, null: true, - description: "Logger of the Sentry Error" + description: "Logger of the Sentry Error." end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/error_tracking/sentry_error_type.rb b/app/graphql/types/error_tracking/sentry_error_type.rb index 693ab0c4f8f..c0e09fb8c65 100644 --- a/app/graphql/types/error_tracking/sentry_error_type.rb +++ b/app/graphql/types/error_tracking/sentry_error_type.rb @@ -11,59 +11,59 @@ module Types field :id, GraphQL::ID_TYPE, null: false, - description: 'ID (global ID) of the error' + description: 'ID (global ID) of the error.' field :sentry_id, GraphQL::STRING_TYPE, method: :id, null: false, - description: 'ID (Sentry ID) of the error' + description: 'ID (Sentry ID) of the error.' field :first_seen, Types::TimeType, null: false, - description: 'Timestamp when the error was first seen' + description: 'Timestamp when the error was first seen.' field :last_seen, Types::TimeType, null: false, - description: 'Timestamp when the error was last seen' + description: 'Timestamp when the error was last seen.' field :title, GraphQL::STRING_TYPE, null: false, - description: 'Title of the error' + description: 'Title of the error.' field :type, GraphQL::STRING_TYPE, null: false, - description: 'Type of the error' + description: 'Type of the error.' field :user_count, GraphQL::INT_TYPE, null: false, - description: 'Count of users affected by the error' + description: 'Count of users affected by the error.' field :count, GraphQL::INT_TYPE, null: false, - description: 'Count of occurrences' + description: 'Count of occurrences.' field :message, GraphQL::STRING_TYPE, null: true, - description: 'Sentry metadata message of the error' + description: 'Sentry metadata message of the error.' field :culprit, GraphQL::STRING_TYPE, null: false, - description: 'Culprit of the error' + description: 'Culprit of the error.' field :external_url, GraphQL::STRING_TYPE, null: false, - description: 'External URL of the error' + description: 'External URL of the error.' field :short_id, GraphQL::STRING_TYPE, null: false, - description: 'Short ID (Sentry ID) of the error' + description: 'Short ID (Sentry ID) of the error.' field :status, Types::ErrorTracking::SentryErrorStatusEnum, null: false, - description: 'Status of the error' + description: 'Status of the error.' field :frequency, [Types::ErrorTracking::SentryErrorFrequencyType], null: false, - description: 'Last 24hr stats of the error' + description: 'Last 24hr stats of the error.' field :sentry_project_id, GraphQL::ID_TYPE, method: :project_id, null: false, - description: 'ID of the project (Sentry project)' + description: 'ID of the project (Sentry project).' field :sentry_project_name, GraphQL::STRING_TYPE, method: :project_name, null: false, - description: 'Name of the project affected by the error' + description: 'Name of the project affected by the error.' field :sentry_project_slug, GraphQL::STRING_TYPE, method: :project_slug, null: false, - description: 'Slug of the project affected by the error' + description: 'Slug of the project affected by the error.' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/evidence_type.rb b/app/graphql/types/evidence_type.rb index a2fc9953c67..6e56ad7d407 100644 --- a/app/graphql/types/evidence_type.rb +++ b/app/graphql/types/evidence_type.rb @@ -10,12 +10,12 @@ module Types present_using Releases::EvidencePresenter field :id, GraphQL::ID_TYPE, null: false, - description: 'ID of the evidence' + description: 'ID of the evidence.' field :sha, GraphQL::STRING_TYPE, null: true, - description: 'SHA1 ID of the evidence hash' + description: 'SHA1 ID of the evidence hash.' field :filepath, GraphQL::STRING_TYPE, null: true, - description: 'URL from where the evidence can be downloaded' + description: 'URL from where the evidence can be downloaded.' field :collected_at, Types::TimeType, null: true, - description: 'Timestamp when the evidence was collected' + description: 'Timestamp when the evidence was collected.' end end diff --git a/app/graphql/types/global_id_type.rb b/app/graphql/types/global_id_type.rb index 4c51d4248dd..ed28c3ffd7e 100644 --- a/app/graphql/types/global_id_type.rb +++ b/app/graphql/types/global_id_type.rb @@ -46,7 +46,7 @@ module Types @id_types[model_class] ||= Class.new(self) do graphql_name "#{model_class.name.gsub(/::/, '')}ID" - description "Identifier of #{model_class.name}" + description "Identifier of #{model_class.name}." self.define_singleton_method(:to_s) do graphql_name diff --git a/app/graphql/types/grafana_integration_type.rb b/app/graphql/types/grafana_integration_type.rb index 6625af36f82..630d3a10e36 100644 --- a/app/graphql/types/grafana_integration_type.rb +++ b/app/graphql/types/grafana_integration_type.rb @@ -7,14 +7,14 @@ module Types authorize :admin_operations field :id, GraphQL::ID_TYPE, null: false, - description: 'Internal ID of the Grafana integration' + description: 'Internal ID of the Grafana integration.' field :grafana_url, GraphQL::STRING_TYPE, null: false, - description: 'URL for the Grafana host for the Grafana integration' + description: 'URL for the Grafana host for the Grafana integration.' field :enabled, GraphQL::BOOLEAN_TYPE, null: false, - description: 'Indicates whether Grafana integration is enabled' + description: 'Indicates whether Grafana integration is enabled.' field :created_at, Types::TimeType, null: false, - description: 'Timestamp of the issue\'s creation' + description: 'Timestamp of the issue\'s creation.' field :updated_at, Types::TimeType, null: false, - description: 'Timestamp of the issue\'s last activity' + description: 'Timestamp of the issue\'s last activity.' end end diff --git a/app/graphql/types/group_invitation_type.rb b/app/graphql/types/group_invitation_type.rb index efb0c8a41c8..06a997bbc14 100644 --- a/app/graphql/types/group_invitation_type.rb +++ b/app/graphql/types/group_invitation_type.rb @@ -11,7 +11,7 @@ module Types description 'Represents a Group Invitation' field :group, Types::GroupType, null: true, - description: 'Group that a User is invited to' + description: 'Group that a User is invited to.' def group Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, object.source_id).find diff --git a/app/graphql/types/group_member_type.rb b/app/graphql/types/group_member_type.rb index 204da5a302a..8b8e69d795d 100644 --- a/app/graphql/types/group_member_type.rb +++ b/app/graphql/types/group_member_type.rb @@ -11,7 +11,7 @@ module Types description 'Represents a Group Membership' field :group, Types::GroupType, null: true, - description: 'Group that a User is a member of' + description: 'Group that a User is a member of.' def group Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, object.source_id).find diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb index 0ee8a19c1a3..42391ec1d98 100644 --- a/app/graphql/types/group_type.rb +++ b/app/graphql/types/group_type.rb @@ -9,91 +9,91 @@ module Types expose_permissions Types::PermissionTypes::Group field :web_url, GraphQL::STRING_TYPE, null: false, - description: 'Web URL of the group' + description: 'Web URL of the group.' field :avatar_url, GraphQL::STRING_TYPE, null: true, - description: 'Avatar URL of the group' + description: 'Avatar URL of the group.' field :custom_emoji, Types::CustomEmojiType.connection_type, null: true, - description: 'Custom emoji within this namespace', + description: 'Custom emoji within this namespace.', feature_flag: :custom_emoji field :share_with_group_lock, GraphQL::BOOLEAN_TYPE, null: true, - description: 'Indicates if sharing a project with another group within this group is prevented' + description: 'Indicates if sharing a project with another group within this group is prevented.' field :project_creation_level, GraphQL::STRING_TYPE, null: true, method: :project_creation_level_str, - description: 'The permission level required to create projects in the group' + description: 'The permission level required to create projects in the group.' field :subgroup_creation_level, GraphQL::STRING_TYPE, null: true, method: :subgroup_creation_level_str, - description: 'The permission level required to create subgroups within the group' + description: 'The permission level required to create subgroups within the group.' field :require_two_factor_authentication, GraphQL::BOOLEAN_TYPE, null: true, - description: 'Indicates if all users in this group are required to set up two-factor authentication' + description: 'Indicates if all users in this group are required to set up two-factor authentication.' field :two_factor_grace_period, GraphQL::INT_TYPE, null: true, - description: 'Time before two-factor authentication is enforced' + description: 'Time before two-factor authentication is enforced.' field :auto_devops_enabled, GraphQL::BOOLEAN_TYPE, null: true, - description: 'Indicates whether Auto DevOps is enabled for all projects within this group' + description: 'Indicates whether Auto DevOps is enabled for all projects within this group.' field :emails_disabled, GraphQL::BOOLEAN_TYPE, null: true, - description: 'Indicates if a group has email notifications disabled' + description: 'Indicates if a group has email notifications disabled.' field :mentions_disabled, GraphQL::BOOLEAN_TYPE, null: true, - description: 'Indicates if a group is disabled from getting mentioned' + description: 'Indicates if a group is disabled from getting mentioned.' field :parent, GroupType, null: true, - description: 'Parent group' + description: 'Parent group.' field :issues, Types::IssueType.connection_type, null: true, - description: 'Issues for projects in this group', + description: 'Issues for projects in this group.', resolver: Resolvers::GroupIssuesResolver field :merge_requests, Types::MergeRequestType.connection_type, null: true, - description: 'Merge requests for projects in this group', + description: 'Merge requests for projects in this group.', resolver: Resolvers::GroupMergeRequestsResolver field :milestones, Types::MilestoneType.connection_type, null: true, - description: 'Milestones of the group', + description: 'Milestones of the group.', resolver: Resolvers::GroupMilestonesResolver field :boards, Types::BoardType.connection_type, null: true, - description: 'Boards of the group', + description: 'Boards of the group.', max_page_size: 2000, resolver: Resolvers::BoardsResolver field :board, Types::BoardType, null: true, - description: 'A single board of the group', + description: 'A single board of the group.', resolver: Resolvers::BoardResolver field :label, Types::LabelType, null: true, - description: 'A label available on this group' do + description: 'A label available on this group.' do argument :title, GraphQL::STRING_TYPE, required: true, - description: 'Title of the label' + description: 'Title of the label.' end field :group_members, - description: 'A membership of a user within this group', + description: 'A membership of a user within this group.', resolver: Resolvers::GroupMembersResolver field :container_repositories, Types::ContainerRepositoryType.connection_type, null: true, - description: 'Container repositories of the group', + description: 'Container repositories of the group.', resolver: Resolvers::ContainerRepositoriesResolver, authorize: :read_container_image field :container_repositories_count, GraphQL::INT_TYPE, null: false, - description: 'Number of container repositories in the group' + description: 'Number of container repositories in the group.' def label(title:) BatchLoader::GraphQL.for(title).batch(key: group) do |titles, loader, args| @@ -107,10 +107,10 @@ module Types field :labels, Types::LabelType.connection_type, null: true, - description: 'Labels available on this group' do + description: 'Labels available on this group.' do argument :search_term, GraphQL::STRING_TYPE, required: false, - description: 'A search term to find labels with' + description: 'A search term to find labels with.' end def labels(search_term: nil) diff --git a/app/graphql/types/merge_request_state_enum.rb b/app/graphql/types/merge_request_state_enum.rb index 92f52726ab3..c14b9f80a53 100644 --- a/app/graphql/types/merge_request_state_enum.rb +++ b/app/graphql/types/merge_request_state_enum.rb @@ -5,6 +5,6 @@ module Types graphql_name 'MergeRequestState' description 'State of a GitLab merge request' - value 'merged' + value 'merged', description: "Merge Request has been merged" end end diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index ee7d5780f7a..62b3e174a9f 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -152,7 +152,7 @@ module Types end field :task_completion_status, Types::TaskCompletionStatus, null: false, description: Types::TaskCompletionStatus.description - field :commit_count, GraphQL::INT_TYPE, null: true, + field :commit_count, GraphQL::INT_TYPE, null: true, method: :commits_count, description: 'Number of commits in the merge request' field :conflicts, GraphQL::BOOLEAN_TYPE, null: false, method: :cannot_be_merged?, description: 'Indicates if the merge request has conflicts' @@ -218,10 +218,6 @@ module Types BatchLoaders::MergeRequestDiffSummaryBatchLoader.load_for(object) end - def commit_count - object&.metrics&.commits_count - end - def source_branch_protected object.source_project.present? && ProtectedBranch.protected?(object.source_project, object.source_branch) end diff --git a/app/graphql/types/milestone_state_enum.rb b/app/graphql/types/milestone_state_enum.rb index 032571ac88f..e3b60395c9b 100644 --- a/app/graphql/types/milestone_state_enum.rb +++ b/app/graphql/types/milestone_state_enum.rb @@ -2,7 +2,10 @@ module Types class MilestoneStateEnum < BaseEnum - value 'active' - value 'closed' + graphql_name 'MilestoneStateEnum' + description 'Current state of milestone' + + value 'active', description: 'Milestone is currently active' + value 'closed', description: 'Milestone is closed' end end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index f9dd11cbe37..166f5617da2 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -15,6 +15,7 @@ module Types mount_mutation Mutations::AlertManagement::HttpIntegration::Update mount_mutation Mutations::AlertManagement::HttpIntegration::ResetToken mount_mutation Mutations::AlertManagement::HttpIntegration::Destroy + mount_mutation Mutations::Security::CiConfiguration::ConfigureSast mount_mutation Mutations::AlertManagement::PrometheusIntegration::Create mount_mutation Mutations::AlertManagement::PrometheusIntegration::Update mount_mutation Mutations::AlertManagement::PrometheusIntegration::ResetToken @@ -50,6 +51,7 @@ module Types mount_mutation Mutations::MergeRequests::SetSubscription mount_mutation Mutations::MergeRequests::SetWip, calls_gitaly: true mount_mutation Mutations::MergeRequests::SetAssignees + mount_mutation Mutations::MergeRequests::ReviewerRereview mount_mutation Mutations::Metrics::Dashboard::Annotations::Create mount_mutation Mutations::Metrics::Dashboard::Annotations::Delete mount_mutation Mutations::Notes::Create::Note, calls_gitaly: true @@ -97,5 +99,4 @@ module Types end ::Types::MutationType.prepend(::Types::DeprecatedMutations) -::Types::MutationType.prepend_if_ee('EE::Types::DeprecatedMutations') ::Types::MutationType.prepend_if_ee('::EE::Types::MutationType') diff --git a/app/graphql/types/notes/discussion_type.rb b/app/graphql/types/notes/discussion_type.rb index a51d253097d..9b863990849 100644 --- a/app/graphql/types/notes/discussion_type.rb +++ b/app/graphql/types/notes/discussion_type.rb @@ -3,24 +3,26 @@ module Types module Notes class DiscussionType < BaseObject + DiscussionID = ::Types::GlobalIDType[::Discussion] + graphql_name 'Discussion' authorize :read_note implements(Types::ResolvableInterface) - field :id, GraphQL::ID_TYPE, null: false, + field :id, DiscussionID, null: false, description: "ID of this discussion" - field :reply_id, GraphQL::ID_TYPE, null: false, + field :reply_id, DiscussionID, null: false, description: 'ID used to reply to this discussion' field :created_at, Types::TimeType, null: false, description: "Timestamp of the discussion's creation" field :notes, Types::Notes::NoteType.connection_type, null: false, description: 'All notes in the discussion' - # The gem we use to generate Global IDs is hard-coded to work with - # `id` properties. To generate a GID for the `reply_id` property, - # we must use the ::Gitlab::GlobalId module. + # DiscussionID.coerce_result is suitable here, but will always mark this + # as being a 'Discussion'. Using `GlobalId.build` guarantees that we get + # the correct class, and that it matches `id`. def reply_id ::Gitlab::GlobalId.build(object, id: object.reply_id) end diff --git a/app/graphql/types/notes/note_type.rb b/app/graphql/types/notes/note_type.rb index 84b61340e93..dea55fe7f9e 100644 --- a/app/graphql/types/notes/note_type.rb +++ b/app/graphql/types/notes/note_type.rb @@ -11,7 +11,7 @@ module Types implements(Types::ResolvableInterface) - field :id, GraphQL::ID_TYPE, null: false, + field :id, ::Types::GlobalIDType[::Note], null: false, description: 'ID of the note' field :project, Types::ProjectType, diff --git a/app/graphql/types/packages/composer/details_type.rb b/app/graphql/types/packages/composer/details_type.rb deleted file mode 100644 index 8c6845a6fb3..00000000000 --- a/app/graphql/types/packages/composer/details_type.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -module Types - module Packages - module Composer - class DetailsType < Types::Packages::PackageType - graphql_name 'PackageComposerDetails' - description 'Details of a Composer package' - - authorize :read_package - - field :composer_metadatum, Types::Packages::Composer::MetadatumType, null: false, description: 'The Composer metadatum.' - end - end - end -end diff --git a/app/graphql/types/packages/composer/metadatum_type.rb b/app/graphql/types/packages/composer/metadatum_type.rb index a97818b1fb8..9d4ce3cebd4 100644 --- a/app/graphql/types/packages/composer/metadatum_type.rb +++ b/app/graphql/types/packages/composer/metadatum_type.rb @@ -4,8 +4,8 @@ module Types module Packages module Composer class MetadatumType < BaseObject - graphql_name 'PackageComposerMetadatumType' - description 'Composer metadatum' + graphql_name 'ComposerMetadata' + description 'Composer metadata' authorize :read_package diff --git a/app/graphql/types/packages/metadata_type.rb b/app/graphql/types/packages/metadata_type.rb new file mode 100644 index 00000000000..26c43b51a69 --- /dev/null +++ b/app/graphql/types/packages/metadata_type.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Types + module Packages + class MetadataType < BaseUnion + graphql_name 'PackageMetadata' + description 'Represents metadata associated with a Package' + + possible_types ::Types::Packages::Composer::MetadatumType + + def self.resolve_type(object, context) + case object + when ::Packages::Composer::Metadatum + ::Types::Packages::Composer::MetadatumType + else + # NOTE: This method must be kept in sync with `PackageWithoutVersionsType#metadata`, + # which must never produce data that this discriminator cannot handle. + raise 'Unsupported metadata type' + end + end + end + end +end diff --git a/app/graphql/types/packages/package_type.rb b/app/graphql/types/packages/package_type.rb index b13d16e91c6..331898a1e84 100644 --- a/app/graphql/types/packages/package_type.rb +++ b/app/graphql/types/packages/package_type.rb @@ -2,26 +2,13 @@ module Types module Packages - class PackageType < BaseObject + class PackageType < PackageWithoutVersionsType graphql_name 'Package' description 'Represents a package in the Package Registry' - authorize :read_package - field :id, GraphQL::ID_TYPE, null: false, description: 'The ID of the package.' - field :name, GraphQL::STRING_TYPE, null: false, description: 'The name of the package.' - field :created_at, Types::TimeType, null: false, description: 'The created date.' - field :updated_at, Types::TimeType, null: false, description: 'The updated date.' - field :version, GraphQL::STRING_TYPE, null: true, description: 'The version of the package.' - field :package_type, Types::Packages::PackageTypeEnum, null: false, description: 'The type of the package.' - field :tags, Types::Packages::PackageTagType.connection_type, null: true, description: 'The package tags.' - field :project, Types::ProjectType, null: false, description: 'Project where the package is stored.' - field :pipelines, Types::Ci::PipelineType.connection_type, null: true, description: 'Pipelines that built the package.' - field :versions, Types::Packages::PackageType.connection_type, null: true, description: 'The other versions of the package.' - - def project - Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.project_id).find - end + field :versions, ::Types::Packages::PackageWithoutVersionsType.connection_type, null: true, + description: 'The other versions of the package.' end end end diff --git a/app/graphql/types/packages/package_without_versions_type.rb b/app/graphql/types/packages/package_without_versions_type.rb new file mode 100644 index 00000000000..9c6bb37e6cc --- /dev/null +++ b/app/graphql/types/packages/package_without_versions_type.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Types + module Packages + class PackageWithoutVersionsType < ::Types::BaseObject + graphql_name 'PackageWithoutVersions' + description 'Represents a version of a package in the Package Registry' + + authorize :read_package + + field :id, ::Types::GlobalIDType[::Packages::Package], null: false, + description: 'ID of the package.' + + field :name, GraphQL::STRING_TYPE, null: false, description: 'Name of the package.' + field :created_at, Types::TimeType, null: false, description: 'Date of creation.' + field :updated_at, Types::TimeType, null: false, description: 'Date of most recent update.' + field :version, GraphQL::STRING_TYPE, null: true, description: 'Version string.' + field :package_type, Types::Packages::PackageTypeEnum, null: false, description: 'Package type.' + field :tags, Types::Packages::PackageTagType.connection_type, null: true, description: 'Package tags.' + field :project, Types::ProjectType, null: false, description: 'Project where the package is stored.' + field :pipelines, Types::Ci::PipelineType.connection_type, null: true, + description: 'Pipelines that built the package.' + field :metadata, Types::Packages::MetadataType, null: true, + description: 'Package metadata.' + + def project + Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.project_id).find + end + + # NOTE: This method must be kept in sync with the union + # type: `Types::Packages::MetadataType`. + # + # `Types::Packages::MetadataType.resolve_type(metadata, ctx)` must never raise. + def metadata + case object.package_type + when 'composer' + object.composer_metadatum + else + nil + end + end + end + end +end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index f66d8926a9f..20dbbe0987b 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -16,6 +16,10 @@ module Types field :path, GraphQL::STRING_TYPE, null: false, description: 'Path of the project' + field :sast_ci_configuration, Types::CiConfiguration::Sast::Type, null: true, + calls_gitaly: true, + description: 'SAST CI configuration for the project' + field :name_with_namespace, GraphQL::STRING_TYPE, null: false, description: 'Full name of the project with its namespace' field :name, GraphQL::STRING_TYPE, null: false, @@ -108,7 +112,7 @@ module Types field :suggestion_commit_message, GraphQL::STRING_TYPE, null: true, description: 'The commit message used to apply merge request suggestions' field :squash_read_only, GraphQL::BOOLEAN_TYPE, null: false, method: :squash_readonly?, - description: 'Indicates if squash readonly is enabled' + description: 'Indicates if `squashReadOnly` is enabled' field :namespace, Types::NamespaceType, null: true, description: 'Namespace of the project' @@ -175,7 +179,7 @@ module Types description: 'A single issue of the project', resolver: Resolvers::IssuesResolver.single - field :packages, Types::Packages::PackageType.connection_type, null: true, + field :packages, description: 'Packages of the project', resolver: Resolvers::PackagesResolver @@ -305,10 +309,16 @@ module Types description: 'Title of the label' end + field :terraform_state, + Types::Terraform::StateType, + null: true, + description: 'Find a single Terraform state by name.', + resolver: Resolvers::Terraform::StatesResolver.single + field :terraform_states, Types::Terraform::StateType.connection_type, null: true, - description: 'Terraform states associated with the project', + description: 'Terraform states associated with the project.', resolver: Resolvers::Terraform::StatesResolver field :pipeline_analytics, Types::Ci::AnalyticsType, null: true, @@ -359,6 +369,12 @@ module Types project.container_repositories.size end + def sast_ci_configuration + return unless Ability.allowed?(current_user, :download_code, object) + + ::Security::CiConfiguration::SastParserService.new(object).configuration + end + private def project diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 0e0c060f374..69991b6413a 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -58,9 +58,8 @@ module Types argument :id, ::Types::GlobalIDType[::ContainerRepository], required: true, description: 'The global ID of the container repository' end - field :package_composer_details, Types::Packages::Composer::DetailsType, - null: true, - description: 'Find a composer package', + field :package, + description: 'Find a package', resolver: Resolvers::PackageDetailsResolver field :user, Types::UserType, diff --git a/app/graphql/types/snippets/blob_viewer_type.rb b/app/graphql/types/snippets/blob_viewer_type.rb index a2ffa144066..5827e3eeae9 100644 --- a/app/graphql/types/snippets/blob_viewer_type.rb +++ b/app/graphql/types/snippets/blob_viewer_type.rb @@ -11,7 +11,7 @@ module Types null: false field :load_async, GraphQL::BOOLEAN_TYPE, - description: 'Shows whether the blob content is loaded async', + description: 'Shows whether the blob content is loaded asynchronously', null: false field :collapsed, GraphQL::BOOLEAN_TYPE, diff --git a/app/graphql/types/todo_type.rb b/app/graphql/types/todo_type.rb index 3694980ef93..4cf2dbcab9e 100644 --- a/app/graphql/types/todo_type.rb +++ b/app/graphql/types/todo_type.rb @@ -3,49 +3,49 @@ module Types class TodoType < BaseObject graphql_name 'Todo' - description 'Representing a todo entry' + description 'Representing a to-do entry' present_using TodoPresenter authorize :read_todo field :id, GraphQL::ID_TYPE, - description: 'ID of the todo', + description: 'ID of the to-do item', null: false field :project, Types::ProjectType, - description: 'The project this todo is associated with', + description: 'The project this to-do item is associated with', null: true, authorize: :read_project field :group, Types::GroupType, - description: 'Group this todo is associated with', + description: 'Group this to-do item is associated with', null: true, authorize: :read_group field :author, Types::UserType, - description: 'The author of this todo', + description: 'The author of this to-do item', null: false field :action, Types::TodoActionEnum, - description: 'Action of the todo', + description: 'Action of the to-do item', null: false field :target_type, Types::TodoTargetEnum, - description: 'Target type of the todo', + description: 'Target type of the to-do item', null: false field :body, GraphQL::STRING_TYPE, - description: 'Body of the todo', + description: 'Body of the to-do item', null: false, calls_gitaly: true # TODO This is only true when `target_type` is `Commit`. See https://gitlab.com/gitlab-org/gitlab/issues/34757#note_234752665 field :state, Types::TodoStateEnum, - description: 'State of the todo', + description: 'State of the to-do item', null: false field :created_at, Types::TimeType, - description: 'Timestamp this todo was created', + description: 'Timestamp this to-do item was created', null: false def project diff --git a/app/graphql/types/tree/entry_type.rb b/app/graphql/types/tree/entry_type.rb index b40e38ec9d1..5e4cace2e98 100644 --- a/app/graphql/types/tree/entry_type.rb +++ b/app/graphql/types/tree/entry_type.rb @@ -7,7 +7,7 @@ module Types field :id, GraphQL::ID_TYPE, null: false, description: 'ID of the entry' field :sha, GraphQL::STRING_TYPE, null: false, - description: 'Last commit sha for the entry', method: :id + description: 'Last commit SHA for the entry', method: :id field :name, GraphQL::STRING_TYPE, null: false, description: 'Name of the entry' field :type, Tree::TypeEnum, null: false, diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb index 93503268319..c179c84ba84 100644 --- a/app/graphql/types/user_type.rb +++ b/app/graphql/types/user_type.rb @@ -31,7 +31,7 @@ module Types description: 'Web path of the user' field :todos, Types::TodoType.connection_type, null: false, resolver: Resolvers::TodoResolver, - description: 'Todos of the user' + description: 'To-do items of the user' field :group_memberships, Types::GroupMemberType.connection_type, null: true, description: 'Group memberships of the user' field :group_count, GraphQL::INT_TYPE, null: true, diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index ed30adfabf0..6c830ef080e 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -181,7 +181,7 @@ module ApplicationSettingsHelper :asset_proxy_enabled, :asset_proxy_secret_key, :asset_proxy_url, - :asset_proxy_whitelist, + :asset_proxy_allowlist, :static_objects_external_storage_auth_token, :static_objects_external_storage_url, :authorized_keys_enabled, @@ -337,7 +337,9 @@ module ApplicationSettingsHelper :group_download_export_limit, :wiki_page_max_content_bytes, :container_registry_delete_tags_service_timeout, - :rate_limiting_response_text + :rate_limiting_response_text, + :container_registry_expiration_policies_worker_capacity, + :container_registry_cleanup_tags_service_max_list_size ] end @@ -353,9 +355,11 @@ module ApplicationSettingsHelper ] end + # ok to remove in REST API v5 def deprecated_attributes [ - :admin_notification_email # ok to remove in REST API v5 + :admin_notification_email, + :asset_proxy_whitelist ] end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index bca53dfb88a..15ed241f7e4 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -64,7 +64,7 @@ module BlobHelper def edit_blob_button(project = @project, ref = @ref, path = @path, options = {}) return unless blob = readable_blob(options, path, project, ref) - common_classes = "btn btn-primary js-edit-blob gl-mr-3 #{options[:extra_class]}" + common_classes = "btn gl-button btn-confirm js-edit-blob gl-mr-3 #{options[:extra_class]}" data = { track_event: 'click_edit', track_label: 'Edit' } if Feature.enabled?(:web_ide_primary_edit, project.group) @@ -84,7 +84,7 @@ module BlobHelper def ide_edit_button(project = @project, ref = @ref, path = @path, blob:) return unless blob - common_classes = 'btn btn-primary ide-edit-button gl-mr-3' + common_classes = 'btn gl-button btn-confirm ide-edit-button gl-mr-3' data = { track_event: 'click_edit_ide', track_label: 'Web IDE' } unless Feature.enabled?(:web_ide_primary_edit, project.group) @@ -105,7 +105,7 @@ module BlobHelper return unless current_user return unless blob - common_classes = "btn btn-#{btn_class}" + common_classes = "btn gl-button btn-default btn-#{btn_class}" base_button = button_tag(label, class: "#{common_classes} disabled", disabled: true) if !on_top_of_branch?(project, ref) @@ -247,7 +247,7 @@ module BlobHelper def copy_blob_source_button(blob) return unless blob.rendered_as_text?(ignore_errors: false) - clipboard_button(target: ".blob-content[data-blob-id='#{blob.id}'] > pre", class: "btn btn-sm js-copy-blob-source-btn", title: _("Copy file contents")) + clipboard_button(target: ".blob-content[data-blob-id='#{blob.id}'] > pre", class: "btn gl-button btn-default btn-sm js-copy-blob-source-btn", title: _("Copy file contents")) end def open_raw_blob_button(blob) @@ -257,7 +257,7 @@ module BlobHelper title = _('Open raw') link_to sprite_icon('doc-code'), external_storage_url_or_path(blob_raw_path), - class: 'btn btn-sm has-tooltip', + class: 'btn gl-button btn-default btn-sm has-tooltip', target: '_blank', rel: 'noopener noreferrer', aria: { label: title }, @@ -272,7 +272,7 @@ module BlobHelper link_to sprite_icon('download'), external_storage_url_or_path(blob_raw_path(inline: false)), download: @path, - class: 'btn btn-sm has-tooltip', + class: 'btn gl-button btn-default btn-sm has-tooltip', target: '_blank', rel: 'noopener noreferrer', aria: { label: title }, diff --git a/app/helpers/ci/jobs_helper.rb b/app/helpers/ci/jobs_helper.rb index d47f6195c61..ec17eccf693 100644 --- a/app/helpers/ci/jobs_helper.rb +++ b/app/helpers/ci/jobs_helper.rb @@ -8,7 +8,6 @@ module Ci "project_path" => @project.full_path, "artifact_help_url" => help_page_path('user/gitlab_com/index.html', anchor: 'gitlab-cicd'), "deployment_help_url" => help_page_path('user/project/clusters/index.html', anchor: 'troubleshooting'), - "runner_help_url" => help_page_path('ci/runners/README.html', anchor: 'set-maximum-job-timeout-for-a-runner'), "runner_settings_url" => project_runners_path(@build.project, anchor: 'js-runners-settings'), "variables_settings_url" => project_variables_path(@build.project, anchor: 'js-cicd-variables-settings'), "page_path" => project_job_path(@project, @build), diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index e6e2b5b128b..6e5a4dbb08a 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -105,25 +105,21 @@ module CommitsHelper tooltip = _("Browse Directory") end - link_to url, class: "btn btn-default has-tooltip", title: tooltip, data: { container: "body" } do + link_to url, class: "btn gl-button btn-default has-tooltip", title: tooltip, data: { container: "body" } do sprite_icon('folder-open') end end - def revert_commit_link(commit, continue_to_path, btn_class: nil, pajamas: false) + def revert_commit_link return unless current_user - action = 'revert' - - if pajamas && can_collaborate_with_project?(@project) - tag(:div, data: { display_text: action.capitalize }, class: "js-revert-commit-trigger") - else - commit_action_link(action, commit, continue_to_path, btn_class: btn_class, has_tooltip: false) - end + tag(:div, data: { display_text: 'Revert' }, class: "js-revert-commit-trigger") end - def cherry_pick_commit_link(commit, continue_to_path, btn_class: nil, has_tooltip: true) - commit_action_link('cherry-pick', commit, continue_to_path, btn_class: btn_class, has_tooltip: has_tooltip) + def cherry_pick_commit_link + return unless current_user + + tag(:div, data: { display_text: 'Cherry-pick' }, class: "js-cherry-pick-commit-trigger") end def commit_signature_badge_classes(additional_classes) @@ -143,7 +139,7 @@ module CommitsHelper def commit_person_link(commit, options = {}) user = commit.public_send(options[:source]) # rubocop:disable GitlabSecurity/PublicSend - source_name = clean(commit.public_send(:"#{options[:source]}_name")) # rubocop:disable GitlabSecurity/PublicSend + source_name = clean(commit.public_send(:"#{options[:source]}_name")) # rubocop:disable GitlabSecurity/PublicSend source_email = clean(commit.public_send(:"#{options[:source]}_email")) # rubocop:disable GitlabSecurity/PublicSend person_name = user.try(:name) || source_name @@ -166,28 +162,6 @@ module CommitsHelper end end - def commit_action_link(action, commit, continue_to_path, btn_class: nil, has_tooltip: true) - return unless current_user - - tooltip = "#{action.capitalize} this #{commit.change_type_title(current_user)} in a new merge request" if has_tooltip - btn_class = "btn btn-#{btn_class}" unless btn_class.nil? - - if can_collaborate_with_project?(@project) - link_to action.capitalize, "#modal-#{action}-commit", 'data-toggle' => 'modal', 'data-container' => 'body', title: (tooltip if has_tooltip), class: "#{btn_class} #{'has-tooltip' if has_tooltip}" - elsif can?(current_user, :fork_project, @project) - continue_params = { - to: continue_to_path, - notice: "#{edit_in_new_fork_notice} Try to #{action} this commit again.", - notice_now: edit_in_new_fork_notice_now - } - fork_path = project_forks_path(@project, - namespace_key: current_user.namespace.id, - continue: continue_params) - - link_to action.capitalize, fork_path, class: btn_class, method: :post, 'data-toggle' => 'tooltip', 'data-container' => 'body', title: (tooltip if has_tooltip) - end - end - def view_file_button(commit_sha, diff_new_path, project, replaced: false) path = project_blob_path(project, tree_join(commit_sha, diff_new_path)) title = replaced ? _('View replaced file @ ') : _('View file @ ') @@ -203,7 +177,7 @@ module CommitsHelper external_url = environment.external_url_for(diff_new_path, commit_sha) return unless external_url - link_to(external_url, class: 'btn btn-file-option has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: "View on #{environment.formatted_external_url}", data: { container: 'body' }) do + link_to(external_url, class: 'btn gl-button btn-default btn-file-option has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: "View on #{environment.formatted_external_url}", data: { container: 'body' }) do sprite_icon('external-link') end end diff --git a/app/helpers/container_registry_helper.rb b/app/helpers/container_registry_helper.rb index 0efc8c50d58..1b77b639ce1 100644 --- a/app/helpers/container_registry_helper.rb +++ b/app/helpers/container_registry_helper.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module ContainerRegistryHelper - def limit_delete_tags_service? + def container_registry_expiration_policies_throttling? Feature.enabled?(:container_registry_expiration_policies_throttling) && ContainerRegistry::Client.supports_tag_delete? end diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index 69a2efebb1f..49c49bb350d 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -203,6 +203,17 @@ module DiffHelper set_secure_cookie(:diff_view, params.delete(:view), type: CookiesHelper::COOKIE_TYPE_PERMANENT) if params[:view].present? end + def collapsed_diff_url(diff_file) + url_for( + safe_params.merge( + action: :diff_for_path, + old_path: diff_file.old_path, + new_path: diff_file.new_path, + file_identifier: diff_file.file_identifier + ) + ) + end + private def diff_btn(title, name, selected) @@ -254,7 +265,7 @@ module DiffHelper end def code_navigation_path(diffs) - Gitlab::CodeNavigationPath.new(merge_request.project, diffs.diff_refs&.head_sha) + Gitlab::CodeNavigationPath.new(merge_request.project, merge_request.diff_head_sha) end def conflicts diff --git a/app/helpers/enable_search_settings_helper.rb b/app/helpers/enable_search_settings_helper.rb new file mode 100644 index 00000000000..aa92a1b0b1e --- /dev/null +++ b/app/helpers/enable_search_settings_helper.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module EnableSearchSettingsHelper + def enable_search_settings(locals: {}) + content_for :before_content do + render "shared/search_settings", locals + end + end +end diff --git a/app/helpers/external_link_helper.rb b/app/helpers/external_link_helper.rb index bf47087543f..058302d1ed8 100644 --- a/app/helpers/external_link_helper.rb +++ b/app/helpers/external_link_helper.rb @@ -3,7 +3,7 @@ module ExternalLinkHelper def external_link(body, url, options = {}) link_to url, { target: '_blank', rel: 'noopener noreferrer' }.merge(options) do - "#{body} #{sprite_icon('external-link')}".html_safe + "#{body}#{sprite_icon('external-link', css_class: 'gl-ml-1')}".html_safe end end end diff --git a/app/helpers/groups/group_members_helper.rb b/app/helpers/groups/group_members_helper.rb index a4159ed6b19..3e7d6febabf 100644 --- a/app/helpers/groups/group_members_helper.rb +++ b/app/helpers/groups/group_members_helper.rb @@ -13,12 +13,12 @@ module Groups::GroupMembersHelper render 'shared/members/invite_member', submit_url: group_group_members_path(group), access_levels: group.access_level_roles, default_access_level: default_access_level end - def linked_groups_data_json(group_links) - GroupGroupLinkSerializer.new.represent(group_links, { current_user: current_user }).to_json + def group_group_links_data_json(group_links) + GroupLink::GroupGroupLinkSerializer.new.represent(group_links, { current_user: current_user }).to_json end def members_data_json(group, members) - MemberSerializer.new.represent(members, { current_user: current_user, group: group }).to_json + MemberSerializer.new.represent(members, { current_user: current_user, group: group, source: group }).to_json end # Overridden in `ee/app/helpers/ee/groups/group_members_helper.rb` @@ -26,16 +26,16 @@ module Groups::GroupMembersHelper { members: members_data_json(group, members), member_path: group_group_member_path(group, ':id'), - group_id: group.id, + source_id: group.id, can_manage_members: can?(current_user, :admin_group_member, group).to_s } end - def linked_groups_list_data_attributes(group) + def group_group_links_list_data_attributes(group) { - members: linked_groups_data_json(group.shared_with_group_links), + members: group_group_links_data_json(group.shared_with_group_links), member_path: group_group_link_path(group, ':id'), - group_id: group.id + source_id: group.id } end end diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 133d9d21a14..eeeffb7b3ae 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -62,6 +62,14 @@ module GroupsHelper can?(current_user, :set_emails_disabled, group) && !group.parent&.emails_disabled? end + def group_open_issues_count(group) + if Feature.enabled?(:cached_sidebar_open_issues_count, group) + cached_open_group_issues_count(group) + else + number_with_delimiter(group_issues_count(state: 'opened')) + end + end + def group_issues_count(state:) IssuesFinder .new(current_user, group_id: @group.id, state: state, non_archived: true, include_subgroups: true) @@ -69,6 +77,21 @@ module GroupsHelper .count end + def cached_open_group_issues_count(group) + count_service = Groups::OpenIssuesCountService + issues_count = count_service.new(group, current_user).count + + if issues_count > count_service::CACHED_COUNT_THRESHOLD + ActiveSupport::NumberHelper + .number_to_human( + issues_count, + units: { thousand: 'k', million: 'm' }, precision: 1, significant: false, format: '%n%u' + ) + else + number_with_delimiter(issues_count) + end + end + def group_merge_requests_count(state:) MergeRequestsFinder .new(current_user, group_id: @group.id, state: state, non_archived: true, include_subgroups: true) diff --git a/app/helpers/in_product_marketing_helper.rb b/app/helpers/in_product_marketing_helper.rb new file mode 100644 index 00000000000..92809c6d232 --- /dev/null +++ b/app/helpers/in_product_marketing_helper.rb @@ -0,0 +1,350 @@ +# frozen_string_literal: true + +module InProductMarketingHelper + def subject_line(track, series) + { + create: [ + s_('InProductMarketing|Create a project in GitLab in 5 minutes'), + s_('InProductMarketing|Import your project and code from GitHub, Bitbucket and others'), + s_('InProductMarketing|Understand repository mirroring') + ], + verify: [ + s_('InProductMarketing|Feel the need for speed?'), + s_('InProductMarketing|3 ways to dive into GitLab CI/CD'), + s_('InProductMarketing|Explore the power of GitLab CI/CD') + ], + trial: [ + s_('InProductMarketing|Go farther with GitLab'), + s_('InProductMarketing|Automated security scans directly within GitLab'), + s_('InProductMarketing|Take your source code management to the next level') + ], + team: [ + s_('InProductMarketing|Working in GitLab = more efficient'), + s_("InProductMarketing|Multiple owners, confusing workstreams? We've got you covered"), + s_('InProductMarketing|Your teams can be more efficient') + ] + }[track][series] + end + + def in_product_marketing_logo(track, series) + inline_image_link('mailers/in_product_marketing', "#{track}-#{series}.png", width: '150') + end + + def about_link(folder, image, width) + link_to inline_image_link(folder, image, { width: width, alt: s_('InProductMarketing|go to about.gitlab.com') }), 'https://about.gitlab.com/' + end + + def in_product_marketing_tagline(track, series) + { + create: [ + s_('InProductMarketing|Get started today'), + s_('InProductMarketing|Get our import guides'), + s_('InProductMarketing|Need an alternative to importing?') + ], + verify: [ + s_('InProductMarketing|Use GitLab CI/CD'), + s_('InProductMarketing|Test, create, deploy'), + s_('InProductMarketing|Are your runners ready?') + ], + trial: [ + s_('InProductMarketing|Start a free trial of GitLab Gold – no CC required'), + s_('InProductMarketing|Improve app security with a 30-day trial'), + s_('InProductMarketing|Start with a GitLab Gold free trial') + ], + team: [ + s_('InProductMarketing|Invite your colleagues to join in less than one minute'), + s_('InProductMarketing|Get your team set up on GitLab'), + nil + ] + }[track][series] + end + + def in_product_marketing_title(track, series) + { + create: [ + s_('InProductMarketing|Take your first steps with GitLab'), + s_('InProductMarketing|Start by importing your projects'), + s_('InProductMarketing|How (and why) mirroring makes sense') + ], + verify: [ + s_('InProductMarketing|Rapid development, simplified'), + s_('InProductMarketing|Get started with GitLab CI/CD'), + s_('InProductMarketing|Launch GitLab CI/CD in 20 minutes or less') + ], + trial: [ + s_('InProductMarketing|Give us one minute...'), + s_("InProductMarketing|Security that's integrated into your development lifecycle"), + s_('InProductMarketing|Improve code quality and streamline reviews') + ], + team: [ + s_('InProductMarketing|Team work makes the dream work'), + s_('InProductMarketing|*GitLab*, noun: a synonym for efficient teams'), + s_('InProductMarketing|Find out how your teams are really doing') + ] + }[track][series] + end + + def in_product_marketing_subtitle(track, series) + { + create: [ + s_('InProductMarketing|Dig in and create a project and a repo'), + s_("InProductMarketing|Here's what you need to know"), + s_('InProductMarketing|Try it out') + ], + verify: [ + s_('InProductMarketing|How to build and test faster'), + s_('InProductMarketing|Explore the options'), + s_('InProductMarketing|Follow our steps') + ], + trial: [ + s_('InProductMarketing|...and you can get a free trial of GitLab Gold'), + s_('InProductMarketing|Try GitLab Gold for free'), + s_('InProductMarketing|Better code in less time') + ], + team: [ + s_('InProductMarketing|Actually, GitLab makes the team work (better)'), + s_('InProductMarketing|Our tool brings all the things together'), + s_("InProductMarketing|It's all in the stats") + ] + }[track][series] + end + + def in_product_marketing_body_line1(track, series, format: nil) + { + create: [ + s_("InProductMarketing|To understand and get the most out of GitLab, start at the beginning and %{project_link}. In GitLab, repositories are part of a project, so after you've created your project you can go ahead and %{repo_link}.") % { project_link: project_link(format), repo_link: repo_link(format) }, + s_("InProductMarketing|Making the switch? It's easier than you think to import your projects into GitLab. Move %{github_link}, or import something %{bitbucket_link}.") % { github_link: github_link(format), bitbucket_link: bitbucket_link(format) }, + s_("InProductMarketing|Sometimes you're not ready to make a full transition to a new tool. If you're not ready to fully commit, %{mirroring_link} gives you a safe way to try out GitLab in parallel with your current tool.") % { mirroring_link: mirroring_link(format) } + ], + verify: [ + s_("InProductMarketing|Tired of wrestling with disparate tool chains, information silos and inefficient processes? GitLab's CI/CD is built on a DevOps platform with source code management, planning, monitoring and more ready to go. Find out %{ci_link}.") % { ci_link: ci_link(format) }, + s_("InProductMarketing|GitLab's CI/CD makes software development easier. Don't believe us? Here are three ways you can take it for a fast (and satisfying) test drive:"), + s_("InProductMarketing|Get going with CI/CD quickly using our %{quick_start_link}. Start with an available runner and then create a CI .yml file – it's really that easy.") % { quick_start_link: quick_start_link(format) } + ], + trial: [ + [ + s_("InProductMarketing|GitLab's premium tiers are designed to make you, your team and your application more efficient and more secure with features including but not limited to:"), + list([ + s_('InProductMarketing|%{strong_start}Company wide portfolio management%{strong_end} — including multi-level epics, scoped labels').html_safe % strong_options(format), + s_('InProductMarketing|%{strong_start}Multiple approval roles%{strong_end} — including code owners and required merge approvals').html_safe % strong_options(format), + s_('InProductMarketing|%{strong_start}Advanced application security%{strong_end} — including SAST, DAST scanning, FUZZ testing, dependency scanning, license compliance, secrete detection').html_safe % strong_options(format), + s_('InProductMarketing|%{strong_start}Executive level insights%{strong_end} — including reporting on productivity, tasks by type, days to completion, value stream').html_safe % strong_options(format) + ], format) + ].join("\n"), + s_('InProductMarketing|GitLab provides static application security testing (SAST), dynamic application security testing (DAST), container scanning, and dependency scanning to help you deliver secure applications along with license compliance.'), + s_('InProductMarketing|By enabling code owners and required merge approvals the right person will review the right MR. This is a win-win: cleaner code and a more efficient review process.') + ], + team: [ + [ + s_('InProductMarketing|Did you know teams that use GitLab are far more efficient?'), + list([ + s_('InProductMarketing|Goldman Sachs went from 1 build every two weeks to thousands of builds a day'), + s_('InProductMarketing|Ticketmaster decreased their CI build time by 15X') + ], format) + ].join("\n"), + s_("InProductMarketing|We know a thing or two about efficiency and we don't want to keep that to ourselves. Sign up for a free trial of GitLab Gold and your teams will be on it from day one."), + [ + s_('InProductMarketing|Stop wondering and use GitLab to answer questions like:'), + list([ + s_('InProductMarketing|How long does it take us to close issues/MRs by types like feature requests, bugs, tech debt, security?'), + s_('InProductMarketing|How many days does it take our team to complete various tasks?'), + s_('InProductMarketing|What does our value stream timeline look like from product to development to review and production?') + ], format) + ].join("\n") + ] + }[track][series] + end + + def in_product_marketing_body_line2(track, series, format: nil) + { + create: [ + s_("InProductMarketing|That's all it takes to get going with GitLab, but if you're new to working with Git, check out our %{basics_link} for helpful tips and tricks for getting started.") % { basics_link: basics_link(format) }, + s_("InProductMarketing|Have a different instance you'd like to import? Here's our %{import_link}.") % { import_link: import_link(format) }, + s_("InProductMarketing|It's also possible to simply %{external_repo_link} in order to take advantage of GitLab's CI/CD.") % { external_repo_link: external_repo_link(format) } + ], + verify: [ + nil, + list([ + s_('InProductMarketing|Start by %{performance_link}').html_safe % { performance_link: performance_link(format) }, + s_('InProductMarketing|Move on to easily creating a Pages website %{ci_template_link}').html_safe % { ci_template_link: ci_template_link(format) }, + s_('InProductMarketing|And finally %{deploy_link} a Python application.').html_safe % { deploy_link: deploy_link(format) } + ], format), + nil + ], + trial: [ + s_('InProductMarketing|Start a GitLab Gold trial today in less than one minute, no credit card required.'), + s_('InProductMarketing|Get started today with a 30-day GitLab Gold trial, no credit card required.'), + s_('InProductMarketing|Code owners and required merge approvals are part of the paid tiers of GitLab. You can start a free 30-day trial of GitLab Gold and enable these features in less than 5 minutes with no credit card required.') + ], + team: [ + s_('InProductMarketing|Invite your colleagues and start shipping code faster.'), + s_("InProductMarketing|Streamline code review, know at a glance who's unavailable, communicate in comments or in email and integrate with Slack so everyone's on the same page."), + s_('InProductMarketing|When your team is on GitLab these answers are a click away.') + ] + }[track][series] + end + + def cta_link(track, series, group, format: nil) + case format + when :html + link_to in_product_marketing_cta_text(track, series), group_email_campaigns_url(group, track: track, series: series), target: '_blank', rel: 'noopener noreferrer' + else + [in_product_marketing_cta_text(track, series), group_email_campaigns_url(group, track: track, series: series)].join(' >> ') + end + end + + def in_product_marketing_progress(track, series) + s_('InProductMarketing|This is email %{series} of 3 in the %{track} series.') % { series: series + 1, track: track.to_s.humanize } + end + + def footer_links(format: nil) + links = [ + [s_('InProductMarketing|Blog'), 'https://about.gitlab.com/blog'], + [s_('InProductMarketing|Twitter'), 'https://twitter.com/gitlab'], + [s_('InProductMarketing|Facebook'), 'https://www.facebook.com/gitlab'], + [s_('InProductMarketing|YouTube'), 'https://www.youtube.com/channel/UCnMGQ8QHMAnVIsI3xJrihhg'] + ] + case format + when :html + links.map do |text, link| + link_to(text, link) + end + else + '| ' + links.map do |text, link| + [text, link].join(' ') + end.join("\n| ") + end + end + + def address(format: nil) + s_('InProductMarketing|%{strong_start}GitLab Inc.%{strong_end} 268 Bush Street, #350, San Francisco, CA 94104, USA').html_safe % strong_options(format) + end + + def unsubscribe(format: nil) + parts = [ + s_('InProductMarketing|If you no longer wish to receive marketing emails from us,'), + s_('InProductMarketing|you may %{unsubscribe_link} at any time.') % { unsubscribe_link: unsubscribe_link(format) } + ] + case format + when :html + parts.join(' ') + else + parts.join("\n" + ' ' * 16) + end + end + + private + + def in_product_marketing_cta_text(track, series) + { + create: [ + s_('InProductMarketing|Create your first project!'), + s_('InProductMarketing|Master the art of importing!'), + s_('InProductMarketing|Understand your project options') + ], + verify: [ + s_('InProductMarketing|Get to know GitLab CI/CD'), + s_('InProductMarketing|Try it yourself'), + s_('InProductMarketing|Explore GitLab CI/CD') + ], + trial: [ + s_('InProductMarketing|Start a trial'), + s_('InProductMarketing|Beef up your security'), + s_('InProductMarketing|Go for the gold!') + ], + team: [ + s_('InProductMarketing|Invite your colleagues today'), + s_('InProductMarketing|Invite your team in less than 60 seconds'), + s_('InProductMarketing|Invite your team now') + ] + }[track][series] + end + + def project_link(format) + link(s_('InProductMarketing|create a project'), help_page_url('gitlab-basics/create-project'), format) + end + + def repo_link(format) + link(s_('InProductMarketing|set up a repo'), help_page_url('user/project/repository/index', anchor: 'create-a-repository'), format) + end + + def github_link(format) + link(s_('InProductMarketing|GitHub Enterprise projects to GitLab'), help_page_url('integration/github'), format) + end + + def bitbucket_link(format) + link(s_('InProductMarketing|from Bitbucket'), help_page_url('user/project/import/bitbucket_server'), format) + end + + def mirroring_link(format) + link(s_('InProductMarketing|repository mirroring'), help_page_url('user/project/repository/repository_mirroring'), format) + end + + def ci_link(format) + link(s_('InProductMarketing|how easy it is to get started'), help_page_url('ci/README'), format) + end + + def performance_link(format) + link(s_('InProductMarketing|testing browser performance'), help_page_url('user/project/merge_requests/browser_performance_testing'), format) + end + + def ci_template_link(format) + link(s_('InProductMarketing|using a CI/CD template'), help_page_url('user/project/pages/getting_started/pages_ci_cd_template'), format) + end + + def deploy_link(format) + link(s_('InProductMarketing|test and deploy'), help_page_url('ci/examples/test-and-deploy-python-application-to-heroku'), format) + end + + def quick_start_link(format) + link(s_('InProductMarketing|quick start guide'), help_page_url('ci/quick_start/README'), format) + end + + def basics_link(format) + link(s_('InProductMarketing|Git basics'), help_page_url('gitlab-basics/README'), format) + end + + def import_link(format) + link(s_('InProductMarketing|comprehensive guide'), help_page_url('user/project/import/index'), format) + end + + def external_repo_link(format) + link(s_('InProductMarketing|connect an external repository'), new_project_url(anchor: 'cicd_for_external_repo'), format) + end + + def unsubscribe_link(format) + link(s_('InProductMarketing|unsubscribe'), '%tag_unsubscribe_url%', format) + end + + def link(text, link, format) + case format + when :html + link_to text, link + else + "#{text} (#{link})" + end + end + + def list(array, format) + case format + when :html + tag.ul { array.map { |item| concat tag.li item} } + else + '- ' + array.join("\n- ") + end + end + + def strong_options(format) + case format + when :html + { strong_start: '<b>'.html_safe, strong_end: '</b>'.html_safe } + else + { strong_start: '', strong_end: '' } + end + end + + def inline_image_link(folder, image, **options) + attachments[image] = File.read(Rails.root.join("app/assets/images", folder, image)) + image_tag attachments[image].url, **options + end +end diff --git a/app/helpers/invite_members_helper.rb b/app/helpers/invite_members_helper.rb index a643fea6d5a..889365e39de 100644 --- a/app/helpers/invite_members_helper.rb +++ b/app/helpers/invite_members_helper.rb @@ -3,10 +3,14 @@ module InviteMembersHelper include Gitlab::Utils::StrongMemoize - def invite_members_allowed?(group) + def can_invite_members_for_group?(group) Feature.enabled?(:invite_members_group_modal, group) && can?(current_user, :admin_group_member, group) end + def can_invite_members_for_project?(project) + Feature.enabled?(:invite_members_group_modal, project.group) && can_import_members? + end + def directly_invite_members? strong_memoize(:directly_invite_members) do experiment_enabled?(:invite_members_version_a) && can_import_members? @@ -27,8 +31,8 @@ module InviteMembersHelper link_to invite_members_url(form_model), data: { 'track-event': 'click_link', - 'track-label': tracking_label(current_user), - 'track-property': experiment_tracking_category_and_group(:invite_members_new_dropdown, subject: current_user) + 'track-label': tracking_label, + 'track-property': experiment_tracking_category_and_group(:invite_members_new_dropdown) } do invite_member_link_content end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index da142cbed0e..a5f64837c02 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -215,24 +215,12 @@ module IssuablesHelper state_title = titles[state] || state.to_s.humanize html = content_tag(:span, state_title) - if display_count - count = issuables_count_for_state(issuable_type, state) - tag = - if count == -1 - tooltip = _("Couldn't calculate number of %{issuables}.") % { issuables: issuable_type.to_s.humanize(capitalize: false) } - - content_tag( - :span, - '?', - class: 'badge badge-pill has-tooltip', - aria: { label: tooltip }, - title: tooltip - ) - else - content_tag(:span, number_with_delimiter(count), class: 'badge badge-pill') - end - - html << " " << tag + return html.html_safe unless display_count + + count = issuables_count_for_state(issuable_type, state) + + if count != -1 + html << " " << content_tag(:span, number_with_delimiter(count), class: 'badge badge-pill') end html.html_safe @@ -346,6 +334,7 @@ module IssuablesHelper def assignee_sidebar_data(assignee, merge_request: nil) { avatar_url: assignee.avatar_url, name: assignee.name, username: assignee.username }.tap do |data| data[:can_merge] = merge_request.can_be_merged_by?(assignee) if merge_request + data[:availability] = assignee.status.availability if assignee.association(:status).loaded? && assignee.status&.availability end end diff --git a/app/helpers/jira_connect_helper.rb b/app/helpers/jira_connect_helper.rb index f1527b9b85a..080883fd594 100644 --- a/app/helpers/jira_connect_helper.rb +++ b/app/helpers/jira_connect_helper.rb @@ -5,9 +5,15 @@ module JiraConnectHelper Feature.enabled?(:new_jira_connect_ui, type: :development, default_enabled: :yaml) end - def jira_connect_app_data + def jira_connect_app_data(subscriptions) + return {} unless new_jira_connect_ui? + + skip_groups = subscriptions.map(&:namespace_id) + { - groups_path: api_v4_groups_path(params: { min_access_level: Gitlab::Access::MAINTAINER }) + groups_path: api_v4_groups_path(params: { min_access_level: Gitlab::Access::MAINTAINER, skip_groups: skip_groups }), + subscriptions_path: jira_connect_subscriptions_path, + users_path: current_user ? nil : jira_connect_users_path } end end diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index 3c757a4ef26..c170e58b4ce 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -64,7 +64,7 @@ module NavHelper end def admin_analytics_nav_links - %w(dev_ops_report cohorts) + %w(dev_ops_report) end def group_issues_sub_menu_items diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index 871d19c6a8c..62580124c0f 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -175,7 +175,9 @@ module NotesHelper end end - def notes_data(issuable) + def notes_data(issuable, start_at_zero = false) + initial_last_fetched_at = start_at_zero ? 0 : Time.current.to_i * ::Gitlab::UpdatedNotesPaginator::MICROSECOND + data = { discussionsPath: discussions_path(issuable), registerPath: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'), @@ -186,7 +188,7 @@ module NotesHelper reopenPath: reopen_issuable_path(issuable), notesPath: notes_url, prerenderedNotesCount: issuable.capped_notes_count(MAX_PRERENDERED_NOTES), - lastFetchedAt: Time.now.to_i * ::Gitlab::UpdatedNotesPaginator::MICROSECOND + lastFetchedAt: initial_last_fetched_at } if issuable.is_a?(MergeRequest) diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb index 2b68d953431..729585be84a 100644 --- a/app/helpers/notifications_helper.rb +++ b/app/helpers/notifications_helper.rb @@ -125,4 +125,13 @@ module NotificationsHelper def can_read_project?(project) can?(current_user, :read_project, project) end + + def notification_dropdown_items(notification_setting) + NotificationSetting.levels.each_key.map do |level| + next if level == "custom" + next if level == "global" && notification_setting.source.nil? + + level + end.compact + end end diff --git a/app/helpers/operations_helper.rb b/app/helpers/operations_helper.rb index 6d721776f0d..51f4304911b 100644 --- a/app/helpers/operations_helper.rb +++ b/app/helpers/operations_helper.rb @@ -17,7 +17,7 @@ module OperationsHelper 'prometheus_authorization_key' => @project.alerting_setting&.token, 'prometheus_api_url' => prometheus_service.api_url, 'prometheus_url' => notify_project_prometheus_alerts_url(@project, format: :json), - 'alerts_setup_url' => help_page_path('operations/incident_management/alert_integrations.md', anchor: 'generic-http-endpoint'), + 'alerts_setup_url' => help_page_path('operations/incident_management/integrations.md', anchor: 'configuration'), 'alerts_usage_url' => project_alert_management_index_path(@project), 'disabled' => disabled.to_s, 'project_path' => @project.full_path, diff --git a/app/helpers/projects/project_members_helper.rb b/app/helpers/projects/project_members_helper.rb index 168526d2abb..99c1b742da4 100644 --- a/app/helpers/projects/project_members_helper.rb +++ b/app/helpers/projects/project_members_helper.rb @@ -26,4 +26,30 @@ module Projects::ProjectMembersHelper project.group.has_owner?(current_user) end + + def project_group_links_data_json(group_links) + GroupLink::ProjectGroupLinkSerializer.new.represent(group_links, { current_user: current_user }).to_json + end + + def project_members_data_json(project, members) + MemberSerializer.new.represent(members, { current_user: current_user, group: project.group, source: project }).to_json + end + + def project_members_list_data_attributes(project, members) + { + members: project_members_data_json(project, members), + member_path: project_project_member_path(project, ':id'), + source_id: project.id, + can_manage_members: can_manage_project_members?(project) + } + end + + def project_group_links_list_data_attributes(project, group_links) + { + members: project_group_links_data_json(group_links), + member_path: project_group_link_path(project, ':id'), + source_id: project.id, + can_manage_members: can_manage_project_members?(project) + } + end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index b21d3ca51db..a2e9952f350 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -139,6 +139,10 @@ module ProjectsHelper project_nav_tabs.include? name end + def any_project_nav_tab?(tabs) + tabs.any? { |tab| project_nav_tab?(tab) } + end + def project_for_deploy_key(deploy_key) if deploy_key.has_access_to?(@project) @project @@ -267,10 +271,6 @@ module ProjectsHelper "xcode://clone?repo=#{CGI.escape(default_url_to_repo(project))}" end - def link_to_filter_repo - link_to 'git filter-repo', 'https://github.com/newren/git-filter-repo', target: '_blank', rel: 'noopener noreferrer' - end - def explore_projects_tab? current_page?(explore_projects_path) || current_page?(trending_explore_projects_path) || @@ -378,6 +378,20 @@ module ProjectsHelper private + def can_read_security_configuration?(project, current_user) + ::Feature.enabled?(:secure_security_and_compliance_configuration_page_on_ce, @subject, default_enabled: :yaml) && + can?(current_user, :read_security_configuration, project) + end + + def get_project_security_nav_tabs(project, current_user) + if can_read_security_configuration?(project, current_user) + [:security_and_compliance, :security_configuration] + else + [] + end + end + + # rubocop:disable Metrics/CyclomaticComplexity def get_project_nav_tabs(project, current_user) nav_tabs = [:home] @@ -386,6 +400,8 @@ module ProjectsHelper nav_tabs << :releases if can?(current_user, :read_release, project) end + nav_tabs += get_project_security_nav_tabs(project, current_user) + if project.repo_exists? && can?(current_user, :read_merge_request, project) nav_tabs << :merge_requests end @@ -419,6 +435,7 @@ module ProjectsHelper nav_tabs end + # rubocop:enable Metrics/CyclomaticComplexity def package_nav_tabs(project, current_user) [].tap do |tabs| @@ -699,6 +716,12 @@ module ProjectsHelper "#{request.path}?#{options.to_param}" end + def sidebar_security_configuration_paths + %w[ + projects/security/configuration#show + ] + end + def sidebar_projects_paths %w[ projects#show @@ -763,6 +786,10 @@ module ProjectsHelper ] end + def sidebar_security_paths + %w[projects/security/configuration#show] + end + def user_can_see_auto_devops_implicitly_enabled_banner?(project, user) Ability.allowed?(user, :admin_project, project) && project.has_auto_devops_implicitly_enabled? && diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index bdc86043ddc..5eab737fb9e 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -129,6 +129,18 @@ module SearchHelper @search_service ||= ::SearchService.new(current_user, params.merge(confidential: Gitlab::Utils.to_boolean(params[:confidential]))) end + def search_sort_options + options = [] + options << { + title: _('Created date'), + sortable: true, + sortParam: { + asc: 'created_asc', + desc: 'created_desc' + } + } + end + private # Autocomplete results for various settings pages diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index 38758957dba..35c8b140bfe 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -30,8 +30,7 @@ module SortingHelper sort_value_contacted_date => sort_title_contacted_date, sort_value_relative_position => sort_title_relative_position, sort_value_size => sort_title_size, - sort_value_expire_date => sort_title_expire_date, - sort_value_relevant => sort_title_relevant + sort_value_expire_date => sort_title_expire_date } end @@ -85,13 +84,6 @@ module SortingHelper } end - def search_reverse_sort_options_hash - { - sort_value_recently_created => sort_value_oldest_created, - sort_value_oldest_created => sort_value_recently_created - } - end - def groups_sort_options_hash { sort_value_name => sort_title_name, @@ -229,10 +221,6 @@ module SortingHelper sort_options_hash[sort_value] end - def search_sort_option_title(sort_value) - sort_options_hash[sort_value] - end - def sort_direction_icon(sort_value) case sort_value when sort_value_milestone, sort_value_due_date, /_asc\z/ @@ -271,13 +259,6 @@ module SortingHelper sort_direction_button(url, reverse_sort, sort_value) end - def search_sort_direction_button(sort_value) - reverse_sort = search_reverse_sort_options_hash[sort_value] - url = page_filter_path(sort: reverse_sort) - - sort_direction_button(url, reverse_sort, sort_value) - end - def packages_sort_options_hash { sort_value_recently_created => sort_title_created_date, diff --git a/app/helpers/sorting_titles_values_helper.rb b/app/helpers/sorting_titles_values_helper.rb index 27f3638dc73..651a6437479 100644 --- a/app/helpers/sorting_titles_values_helper.rb +++ b/app/helpers/sorting_titles_values_helper.rb @@ -166,10 +166,6 @@ module SortingTitlesValuesHelper s_('SortOptions|Expired date') end - def sort_title_relevant - s_('SortOptions|Relevant') - end - # Values. def sort_value_access_level_asc 'access_level_asc' @@ -330,10 +326,6 @@ module SortingTitlesValuesHelper def sort_value_expire_date 'expired_asc' end - - def sort_value_relevant - 'relevant' - end end SortingHelper.include_if_ee('::EE::SortingTitlesValuesHelper') diff --git a/app/helpers/stat_anchors_helper.rb b/app/helpers/stat_anchors_helper.rb index 76e58b45912..1e8e6371284 100644 --- a/app/helpers/stat_anchors_helper.rb +++ b/app/helpers/stat_anchors_helper.rb @@ -11,14 +11,14 @@ module StatAnchorsHelper private def button_attribute(anchor) - "btn-#{anchor.class_modifier || 'missing'}" + "btn-#{anchor.class_modifier || 'dashed'}" end def extra_classes(anchor) if anchor.is_link 'stat-link' else - "btn #{button_attribute(anchor)}" + "gl-button btn #{button_attribute(anchor)}" end end end diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index f24aa5d3bcb..c44a67d3e66 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -6,28 +6,6 @@ module TreeHelper FILE_LIMIT = 1_000 - # Sorts a repository's tree so that folders are before files and renders - # their corresponding partials - # - # tree - A `Tree` object for the current tree - # rubocop: disable CodeReuse/ActiveRecord - def render_tree(tree) - # Sort submodules and folders together by name ahead of files - folders, files, submodules = tree.trees, tree.blobs, tree.submodules - tree = [] - items = (folders + submodules).sort_by(&:name) + files - - if items.size > FILE_LIMIT - tree << render(partial: 'projects/tree/truncated_notice_tree_row', - locals: { limit: FILE_LIMIT, total: items.size }) - items = items.take(FILE_LIMIT) - end - - tree << render(partial: 'projects/tree/tree_row', collection: items) if items.present? - tree.join.html_safe - end - # rubocop: enable CodeReuse/ActiveRecord - # Return an image icon depending on the file type and mode # # type - String type of the tree item; either 'folder' or 'file' @@ -37,20 +15,6 @@ module TreeHelper sprite_icon(file_type_icon_class(type, mode, name)) end - # Using Rails `*_path` methods can be slow, especially when generating - # many paths, as with a repository tree that has thousands of items. - def fast_project_blob_path(project, blob_path) - ActionDispatch::Journey::Router::Utils.escape_path( - File.join(relative_url_root, project.path_with_namespace, '-', 'blob', blob_path) - ) - end - - def fast_project_tree_path(project, tree_path) - ActionDispatch::Journey::Router::Utils.escape_path( - File.join(relative_url_root, project.path_with_namespace, '-', 'tree', tree_path) - ) - end - # Simple shortcut to File.join def tree_join(*args) File.join(*args) @@ -167,13 +131,6 @@ module TreeHelper Gitlab.config.gitlab.relative_url_root.presence || '/' end - # project and path are used on the EE version - def tree_content_data(logs_path, project, path) - { - "logs-path" => logs_path - } - end - def breadcrumb_data_attributes attrs = { can_collaborate: can_collaborate_with_project?(@project).to_s, diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb index a06a31ddf32..f55a6c3c9e5 100644 --- a/app/helpers/user_callouts_helper.rb +++ b/app/helpers/user_callouts_helper.rb @@ -11,6 +11,7 @@ module UserCalloutsHelper CUSTOMIZE_HOMEPAGE = 'customize_homepage' FEATURE_FLAGS_NEW_VERSION = 'feature_flags_new_version' REGISTRATION_ENABLED_CALLOUT = 'registration_enabled_callout' + UNFINISHED_TAG_CLEANUP_CALLOUT = 'unfinished_tag_cleanup_callout' def show_admin_integrations_moved? !user_dismissed?(ADMIN_INTEGRATIONS_MOVED) @@ -56,6 +57,10 @@ module UserCalloutsHelper !user_dismissed?(FEATURE_FLAGS_NEW_VERSION) end + def show_unfinished_tag_cleanup_callout? + !user_dismissed?(UNFINISHED_TAG_CLEANUP_CALLOUT) + end + def show_registration_enabled_user_callout? !Gitlab.com? && current_user&.admin? && diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index a5d4d6872df..1ea2d4412b1 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -299,6 +299,27 @@ module UsersHelper html_escape(s_('Profile|%{job_title} at %{organization}')) % { job_title: job_title, organization: organization } end + + def user_table_headers + [ + { + section_class_name: 'section-40', + header_text: _('Name') + }, + { + section_class_name: 'section-10', + header_text: _('Projects') + }, + { + section_class_name: 'section-15', + header_text: _('Created on') + }, + { + section_class_name: 'section-15', + header_text: _('Last activity') + } + ] + end end UsersHelper.prepend_if_ee('EE::UsersHelper') diff --git a/app/mailers/emails/in_product_marketing.rb b/app/mailers/emails/in_product_marketing.rb new file mode 100644 index 00000000000..0be9ec5f915 --- /dev/null +++ b/app/mailers/emails/in_product_marketing.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Emails + module InProductMarketing + include InProductMarketingHelper + + FROM_ADDRESS = 'GitLab <team@gitlab.com>'.freeze + CUSTOM_HEADERS = { + 'X-Mailgun-Track' => 'yes', + 'X-Mailgun-Track-Clicks' => 'yes', + 'X-Mailgun-Track-Opens' => 'yes', + 'X-Mailgun-Tag' => 'marketing' + }.freeze + + def in_product_marketing_email(recipient_id, group_id, track, series) + @track = track + @series = series + @group = Group.find(group_id) + + email = User.find(recipient_id).notification_email_for(@group) + subject = subject_line(track, series) + mail_to(to: email, subject: subject) + end + + private + + def mail_to(to:, subject:) + mail(to: to, subject: subject, from: FROM_ADDRESS, reply_to: FROM_ADDRESS, **CUSTOM_HEADERS) do |format| + format.html { render layout: nil } + format.text { render layout: nil } + end + end + end +end diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb index 69f5fe1430a..f4d3676dc5c 100644 --- a/app/mailers/emails/members.rb +++ b/app/mailers/emails/members.rb @@ -4,9 +4,11 @@ module Emails module Members extend ActiveSupport::Concern include MembersHelper + include Gitlab::Experiment::Dsl included do helper_method :member_source, :member + helper_method :experiment end def member_access_requested_email(member_source_type, member_id, recipient_id) diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb index 4faa1a11276..494d9875ce4 100644 --- a/app/mailers/emails/merge_requests.rb +++ b/app/mailers/emails/merge_requests.rb @@ -82,6 +82,13 @@ module Emails mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason)) end + def request_review_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil) + setup_merge_request_mail(merge_request_id, recipient_id) + + @updated_by = User.find(updated_by_user_id) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason)) + end + def merge_request_status_email(recipient_id, merge_request_id, status, updated_by_user_id, reason = nil) setup_merge_request_mail(merge_request_id, recipient_id) diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index ebf6dd68ec7..8f947ea7113 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -21,6 +21,7 @@ class Notify < ApplicationMailer include Emails::Groups include Emails::Reviews include Emails::ServiceDesk + include Emails::InProductMarketing helper TimeboxesHelper helper MergeRequestsHelper @@ -32,6 +33,7 @@ class Notify < ApplicationMailer helper AvatarsHelper helper GitlabRoutingHelper helper IssuablesHelper + helper InProductMarketingHelper def test_email(recipient_email, subject, body) mail(to: recipient_email, diff --git a/app/models/active_session.rb b/app/models/active_session.rb index dded0eb1dc3..823685f78f4 100644 --- a/app/models/active_session.rb +++ b/app/models/active_session.rb @@ -1,5 +1,24 @@ # frozen_string_literal: true +# Backing store for GitLab session data. +# +# The raw session information is stored by the Rails session store +# (config/initializers/session_store.rb). These entries are accessible by the +# rack_key_name class method and consistute the base of the session data +# entries. All other entries in the session store can be traced back to these +# entries. +# +# After a user logs in (config/initializers/warden.rb) a further entry is made +# in Redis. This entry holds a record of the user's logged in session. These +# are accessible with the key_name(user_id, session_id) class method. These +# entries will expire. Lookups to these entries are lazilly cleaned on future +# user access. +# +# There is a reference to all sessions that belong to a specific user. A +# user may login through multiple browsers/devices and thus record multiple +# login sessions. These are accessible through the lookup_key_name(user_id) +# class method. +# class ActiveSession include ActiveModel::Model @@ -143,6 +162,10 @@ class ActiveSession list(user).reject(&:is_impersonated) end + def self.rack_key_name(session_id) + "#{Gitlab::Redis::SharedState::SESSION_NAMESPACE}:#{session_id}" + end + def self.key_name(user_id, session_id = '*') "#{Gitlab::Redis::SharedState::USER_SESSIONS_NAMESPACE}:#{user_id}:#{session_id}" end @@ -197,7 +220,7 @@ class ActiveSession end def self.rack_session_keys(rack_session_ids) - rack_session_ids.map { |session_id| "#{Gitlab::Redis::SharedState::SESSION_NAMESPACE}:#{session_id}" } + rack_session_ids.map { |session_id| rack_key_name(session_id)} end def self.raw_active_session_entries(redis, session_ids, user_id) diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 5655ea4d4bf..6d23bd661d2 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -43,6 +43,8 @@ class ApplicationSetting < ApplicationRecord serialize :domain_allowlist, Array # rubocop:disable Cop/ActiveRecordSerialize serialize :domain_denylist, Array # rubocop:disable Cop/ActiveRecordSerialize serialize :repository_storages # rubocop:disable Cop/ActiveRecordSerialize + serialize :asset_proxy_allowlist, Array # rubocop:disable Cop/ActiveRecordSerialize + # See https://gitlab.com/gitlab-org/gitlab/-/issues/300916 serialize :asset_proxy_whitelist, Array # rubocop:disable Cop/ActiveRecordSerialize cache_markdown_field :sign_in_text diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index b05355f14b4..4fca087cf20 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -269,13 +269,13 @@ module ApplicationSettingImplementation self.protected_paths = strings_to_array(values) end - def asset_proxy_whitelist=(values) + def asset_proxy_allowlist=(values) values = strings_to_array(values) if values.is_a?(String) - # make sure we always whitelist the running host + # make sure we always allow the running host values << Gitlab.config.gitlab.host unless values.include?(Gitlab.config.gitlab.host) - self[:asset_proxy_whitelist] = values + self[:asset_proxy_allowlist] = values end def repository_storages diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb index d1c0bb11dc8..32c9d44f836 100644 --- a/app/models/audit_event.rb +++ b/app/models/audit_event.rb @@ -55,15 +55,20 @@ class AuditEvent < ApplicationRecord end def author_name - lazy_author.name + author&.name end def formatted_details details.merge(details.slice(:from, :to).transform_values(&:to_s)) end + def author + lazy_author&.itself.presence || + ::Gitlab::Audit::NullAuthor.for(author_id, (self[:author_name] || details[:author_name])) + end + def lazy_author - BatchLoader.for(author_id).batch(default_value: default_author_value, replace_methods: false) do |author_ids, loader| + BatchLoader.for(author_id).batch(replace_methods: false) do |author_ids, loader| User.select(:id, :name, :username).where(id: author_ids).find_each do |user| loader.call(user.id, user) end diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb index a4d0b7485ba..16224fde502 100644 --- a/app/models/bulk_imports/entity.rb +++ b/app/models/bulk_imports/entity.rb @@ -43,6 +43,8 @@ class BulkImports::Entity < ApplicationRecord validate :validate_parent_is_a_group, if: :parent validate :validate_imported_entity_type + validate :validate_destination_namespace_ascendency, if: :group_entity? + enum source_type: { group_entity: 0, project_entity: 1 } state_machine :status, initial: :created do @@ -107,4 +109,17 @@ class BulkImports::Entity < ApplicationRecord ) end end + + def validate_destination_namespace_ascendency + source = Group.find_by_full_path(source_full_path) + + return unless source + + if source.self_and_descendants.any? { |namespace| namespace.full_path == destination_namespace } + errors.add( + :destination_namespace, + s_('BulkImport|destination group cannot be part of the source group tree') + ) + end + end end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 5e3f42d7c2c..d880fe10e88 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -20,7 +20,6 @@ module Ci belongs_to :runner belongs_to :trigger_request belongs_to :erased_by, class_name: 'User' - belongs_to :resource_group, class_name: 'Ci::ResourceGroup', inverse_of: :builds belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :commit_id RUNNER_FEATURES = { @@ -38,7 +37,6 @@ module Ci DEGRADATION_THRESHOLD_VARIABLE_NAME = 'DEGRADATION_THRESHOLD' has_one :deployment, as: :deployable, class_name: 'Deployment' - has_one :resource, class_name: 'Ci::Resource', inverse_of: :build has_one :pending_state, class_name: 'Ci::BuildPendingState', inverse_of: :build has_many :trace_sections, class_name: 'Ci::BuildTraceSection' has_many :trace_chunks, class_name: 'Ci::BuildTraceChunk', foreign_key: :build_id, inverse_of: :build @@ -236,21 +234,14 @@ module Ci state_machine :status do event :enqueue do - transition [:created, :skipped, :manual, :scheduled] => :waiting_for_resource, if: :requires_resource? transition [:created, :skipped, :manual, :scheduled] => :preparing, if: :any_unmet_prerequisites? end event :enqueue_scheduled do - transition scheduled: :waiting_for_resource, if: :requires_resource? transition scheduled: :preparing, if: :any_unmet_prerequisites? transition scheduled: :pending end - event :enqueue_waiting_for_resource do - transition waiting_for_resource: :preparing, if: :any_unmet_prerequisites? - transition waiting_for_resource: :pending - end - event :enqueue_preparing do transition preparing: :pending end @@ -279,23 +270,6 @@ module Ci build.scheduled_at = build.options_scheduled_at end - before_transition any => :waiting_for_resource do |build| - build.waiting_for_resource_at = Time.current - end - - before_transition on: :enqueue_waiting_for_resource do |build| - next unless build.requires_resource? - - build.resource_group.assign_resource_to(build) # If false is returned, it stops the transition - end - - after_transition any => :waiting_for_resource do |build| - build.run_after_commit do - Ci::ResourceGroups::AssignResourceFromResourceGroupWorker - .perform_async(build.resource_group_id) - end - end - before_transition on: :enqueue_preparing do |build| !build.any_unmet_prerequisites? # If false is returned, it stops the transition end @@ -328,16 +302,6 @@ module Ci end end - after_transition any => ::Ci::Build.completed_statuses do |build| - next unless build.resource_group_id.present? - next unless build.resource_group.release_resource_from(build) - - build.run_after_commit do - Ci::ResourceGroups::AssignResourceFromResourceGroupWorker - .perform_async(build.resource_group_id) - end - end - after_transition any => [:success, :failed, :canceled] do |build| build.run_after_commit do build.run_status_commit_hooks! @@ -403,7 +367,7 @@ module Ci def detailed_status(current_user) Gitlab::Ci::Status::Build::Factory - .new(self, current_user) + .new(self.present, current_user) .fabricate! end @@ -467,6 +431,11 @@ module Ci pipeline.builds.retried.where(name: self.name).count end + override :all_met_to_become_pending? + def all_met_to_become_pending? + super && !any_unmet_prerequisites? + end + def any_unmet_prerequisites? prerequisites.present? end @@ -501,10 +470,6 @@ module Ci end end - def requires_resource? - self.resource_group_id.present? - end - def has_environment? environment.present? end @@ -1122,7 +1087,6 @@ module Ci end def conditionally_allow_failure!(exit_code) - return unless ::Gitlab::Ci::Features.allow_failure_with_exit_codes_enabled? return unless exit_code if allowed_to_fail_with_code?(exit_code) diff --git a/app/models/ci/build_dependencies.rb b/app/models/ci/build_dependencies.rb index a6abeb517c1..b50ecf99439 100644 --- a/app/models/ci/build_dependencies.rb +++ b/app/models/ci/build_dependencies.rb @@ -103,7 +103,7 @@ module Ci end def valid_local? - return true if Feature.enabled?(:ci_disable_validates_dependencies) + return true unless Gitlab::Ci::Features.validate_build_dependencies?(project) local.all?(&:valid_dependency?) end diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb index ceefb6a8b8a..59403967753 100644 --- a/app/models/ci/build_trace_chunk.rb +++ b/app/models/ci/build_trace_chunk.rb @@ -77,6 +77,22 @@ module Ci end ## + # Sometime we need to ensure that the first read goes to a primary + # database, what is especially important in EE. This method does not + # change the behavior in CE. + # + def with_read_consistency(build, &block) + return yield unless consistent_reads_enabled?(build) + + ::Gitlab::Database::Consistency + .with_read_consistency(&block) + end + + def consistent_reads_enabled?(build) + Feature.enabled?(:gitlab_ci_trace_read_consistency, build.project, type: :development, default_enabled: false) + end + + ## # Sometimes we do not want to read raw data. This method makes it easier # to find attributes that are just metadata excluding raw data. # @@ -154,8 +170,8 @@ module Ci in_lock(lock_key, **lock_params) do # exclusive Redis lock is acquired first raise FailedToPersistDataError, 'Modifed build trace chunk detected' if has_changes_to_save? - self.reset.then do |chunk| # we ensure having latest lock_version - chunk.unsafe_persist_data! # we migrate the data and update data store + self.class.with_read_consistency(build) do + self.reset.then { |chunk| chunk.unsafe_persist_data! } end end rescue FailedToObtainLockError diff --git a/app/models/ci/build_trace_chunks/fog.rb b/app/models/ci/build_trace_chunks/fog.rb index 27b579bf428..cbf0c0a1696 100644 --- a/app/models/ci/build_trace_chunks/fog.rb +++ b/app/models/ci/build_trace_chunks/fog.rb @@ -14,15 +14,7 @@ module Ci end def set_data(model, new_data) - if Feature.enabled?(:ci_live_trace_use_fog_attributes, default_enabled: true) - files.create(create_attributes(model, new_data)) - else - # TODO: Support AWS S3 server side encryption - files.create({ - key: key(model), - body: new_data - }) - end + files.create(create_attributes(model, new_data)) end def append_data(model, new_data, offset) diff --git a/app/models/ci/build_trace_section.rb b/app/models/ci/build_trace_section.rb index 8be42eb48d6..5091e3ff04a 100644 --- a/app/models/ci/build_trace_section.rb +++ b/app/models/ci/build_trace_section.rb @@ -2,6 +2,7 @@ module Ci class BuildTraceSection < ApplicationRecord + extend SuppressCompositePrimaryKeyWarning extend Gitlab::Ci::Model belongs_to :build, class_name: 'Ci::Build' diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 4a579892e3f..c6a3fe24186 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -250,6 +250,7 @@ module Ci after_transition any => ::Ci::Pipeline.completed_statuses do |pipeline| pipeline.run_after_commit do ::Ci::PipelineArtifacts::CoverageReportWorker.perform_async(pipeline.id) + ::Ci::PipelineArtifacts::CreateQualityReportWorker.perform_async(pipeline.id) end end @@ -262,8 +263,6 @@ module Ci end after_transition any => any do |pipeline| - next unless Feature.enabled?(:jira_sync_builds, pipeline.project) - pipeline.run_after_commit do # Passing the seq-id ensures this is idempotent seq_id = ::Atlassian::JiraConnect::Client.generate_update_sequence_id @@ -677,7 +676,7 @@ module Ci def number_of_warnings BatchLoader.for(id).batch(default_value: 0) do |pipeline_ids, loader| - ::Ci::Build.where(commit_id: pipeline_ids) + ::CommitStatus.where(commit_id: pipeline_ids) .latest .failed_but_allowed .group(:commit_id) @@ -804,7 +803,7 @@ module Ci variables.concat(merge_request.predefined_variables) end - if Gitlab::Ci::Features.pipeline_open_merge_requests?(project) && open_merge_requests_refs.any? + if open_merge_requests_refs.any? variables.append(key: 'CI_OPEN_MERGE_REQUESTS', value: open_merge_requests_refs.join(',')) end @@ -961,7 +960,7 @@ module Ci def detailed_status(current_user) Gitlab::Ci::Status::Pipeline::Factory - .new(self, current_user) + .new(self.present, current_user) .fabricate! end @@ -997,13 +996,23 @@ module Ci end def has_coverage_reports? - pipeline_artifacts&.has_code_coverage? + pipeline_artifacts&.has_report?(:code_coverage) end def can_generate_coverage_reports? has_reports?(Ci::JobArtifact.coverage_reports) end + def has_codequality_mr_diff_report? + pipeline_artifacts&.has_report?(:code_quality_mr_diff) + end + + def can_generate_codequality_reports? + return false unless ::Gitlab::Ci::Features.display_quality_on_mr_diff?(project) + + has_reports?(Ci::JobArtifact.codequality_reports) + end + def test_report_summary strong_memoize(:test_report_summary) do Gitlab::Ci::Reports::TestReportSummary.new(latest_builds_report_results) @@ -1205,6 +1214,11 @@ module Ci end # rubocop:enable Rails/FindEach + # EE-only + def merge_train_pipeline? + false + end + private def add_message(severity, content) diff --git a/app/models/ci/pipeline_artifact.rb b/app/models/ci/pipeline_artifact.rb index b6db8cad667..46a60914342 100644 --- a/app/models/ci/pipeline_artifact.rb +++ b/app/models/ci/pipeline_artifact.rb @@ -14,7 +14,8 @@ module Ci EXPIRATION_DATE = 1.week.freeze DEFAULT_FILE_NAMES = { - code_coverage: 'code_coverage.json' + code_coverage: 'code_coverage.json', + code_quality_mr_diff: 'code_quality_mr_diff.json' }.freeze belongs_to :project, class_name: "Project", inverse_of: :pipeline_artifacts @@ -30,15 +31,18 @@ module Ci update_project_statistics project_statistics_name: :pipeline_artifacts_size enum file_type: { - code_coverage: 1 + code_coverage: 1, + code_quality_mr_diff: 2 } - def self.has_code_coverage? - where(file_type: :code_coverage).exists? - end + class << self + def has_report?(file_type) + where(file_type: file_type).exists? + end - def self.find_with_code_coverage - find_by(file_type: :code_coverage) + def find_by_file_type(file_type) + find_by(file_type: file_type) + end end def present diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb index 8c9ad343f32..2fae077dd87 100644 --- a/app/models/ci/pipeline_schedule.rb +++ b/app/models/ci/pipeline_schedule.rb @@ -21,7 +21,7 @@ module Ci validates :cron_timezone, cron_timezone: true, presence: { unless: :importing? } validates :ref, presence: { unless: :importing? } validates :description, presence: true - validates :variables, variable_duplicates: true + validates :variables, nested_attributes_duplicates: true strip_attributes :cron diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb index 6aaf6ac530b..fae65ed0632 100644 --- a/app/models/ci/processable.rb +++ b/app/models/ci/processable.rb @@ -3,6 +3,11 @@ module Ci class Processable < ::CommitStatus include Gitlab::Utils::StrongMemoize + extend ::Gitlab::Utils::Override + + has_one :resource, class_name: 'Ci::Resource', foreign_key: 'build_id', inverse_of: :processable + + belongs_to :resource_group, class_name: 'Ci::ResourceGroup', inverse_of: :processables accepts_nested_attributes_for :needs @@ -20,6 +25,48 @@ module Ci where('NOT EXISTS (?)', needs) end + state_machine :status do + event :enqueue do + transition [:created, :skipped, :manual, :scheduled] => :waiting_for_resource, if: :with_resource_group? + end + + event :enqueue_scheduled do + transition scheduled: :waiting_for_resource, if: :with_resource_group? + end + + event :enqueue_waiting_for_resource do + transition waiting_for_resource: :preparing, if: :any_unmet_prerequisites? + transition waiting_for_resource: :pending + end + + before_transition any => :waiting_for_resource do |processable| + processable.waiting_for_resource_at = Time.current + end + + before_transition on: :enqueue_waiting_for_resource do |processable| + next unless processable.with_resource_group? + + processable.resource_group.assign_resource_to(processable) + end + + after_transition any => :waiting_for_resource do |processable| + processable.run_after_commit do + Ci::ResourceGroups::AssignResourceFromResourceGroupWorker + .perform_async(processable.resource_group_id) + end + end + + after_transition any => ::Ci::Processable.completed_statuses do |processable| + next unless processable.with_resource_group? + next unless processable.resource_group.release_resource_from(processable) + + processable.run_after_commit do + Ci::ResourceGroups::AssignResourceFromResourceGroupWorker + .perform_async(processable.resource_group_id) + end + end + end + def self.select_with_aggregated_needs(project) aggregated_needs_names = Ci::BuildNeed .scoped_build @@ -77,6 +124,15 @@ module Ci raise NotImplementedError end + override :all_met_to_become_pending? + def all_met_to_become_pending? + super && !with_resource_group? + end + + def with_resource_group? + self.resource_group_id.present? + end + # Overriding scheduling_type enum's method for nil `scheduling_type`s def scheduling_type_dag? scheduling_type.nil? ? find_legacy_scheduling_type == :dag : super diff --git a/app/models/ci/resource.rb b/app/models/ci/resource.rb index ee5b6546165..e0e1fab642d 100644 --- a/app/models/ci/resource.rb +++ b/app/models/ci/resource.rb @@ -5,9 +5,9 @@ module Ci extend Gitlab::Ci::Model belongs_to :resource_group, class_name: 'Ci::ResourceGroup', inverse_of: :resources - belongs_to :build, class_name: 'Ci::Build', inverse_of: :resource + belongs_to :processable, class_name: 'Ci::Processable', foreign_key: 'build_id', inverse_of: :resource - scope :free, -> { where(build: nil) } - scope :retained_by, -> (build) { where(build: build) } + scope :free, -> { where(processable: nil) } + scope :retained_by, -> (processable) { where(processable: processable) } end end diff --git a/app/models/ci/resource_group.rb b/app/models/ci/resource_group.rb index eb18f3da0bf..85fbe03e1c9 100644 --- a/app/models/ci/resource_group.rb +++ b/app/models/ci/resource_group.rb @@ -7,7 +7,7 @@ module Ci belongs_to :project, inverse_of: :resource_groups has_many :resources, class_name: 'Ci::Resource', inverse_of: :resource_group - has_many :builds, class_name: 'Ci::Build', inverse_of: :resource_group + has_many :processables, class_name: 'Ci::Processable', inverse_of: :resource_group validates :key, length: { maximum: 255 }, @@ -19,12 +19,12 @@ module Ci ## # NOTE: This is concurrency-safe method that the subquery in the `UPDATE` # works as explicit locking. - def assign_resource_to(build) - resources.free.limit(1).update_all(build_id: build.id) > 0 + def assign_resource_to(processable) + resources.free.limit(1).update_all(build_id: processable.id) > 0 end - def release_resource_from(build) - resources.retained_by(build).update_all(build_id: nil) > 0 + def release_resource_from(processable) + resources.retained_by(processable).update_all(build_id: nil) > 0 end private diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index cc6bd1870b9..ae80692d598 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -118,7 +118,7 @@ module Ci def number_of_warnings BatchLoader.for(id).batch(default_value: 0) do |stage_ids, loader| - ::Ci::Build.where(stage_id: stage_ids) + ::CommitStatus.where(stage_id: stage_ids) .latest .failed_but_allowed .group(:stage_id) diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index 56acac53e0b..f87eccecf9f 100644 --- a/app/models/clusters/applications/runner.rb +++ b/app/models/clusters/applications/runner.rb @@ -3,7 +3,7 @@ module Clusters module Applications class Runner < ApplicationRecord - VERSION = '0.24.0' + VERSION = '0.25.0' self.table_name = 'clusters_applications_runners' diff --git a/app/models/commit.rb b/app/models/commit.rb index edce9ad293e..bf168aaacc5 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -36,7 +36,7 @@ class Commit LINK_EXTENSION_PATTERN = /(patch)/.freeze cache_markdown_field :title, pipeline: :single_line - cache_markdown_field :full_title, pipeline: :single_line + cache_markdown_field :full_title, pipeline: :single_line, limit: 1.kilobyte cache_markdown_field :description, pipeline: :commit_description, limit: 1.megabyte class << self diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index a399ffc32de..e0c2b308247 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -255,15 +255,7 @@ class CommitStatus < ApplicationRecord end def all_met_to_become_pending? - !any_unmet_prerequisites? && !requires_resource? - end - - def any_unmet_prerequisites? - false - end - - def requires_resource? - false + true end def auto_canceled? diff --git a/app/models/concerns/analytics/cycle_analytics/stage.rb b/app/models/concerns/analytics/cycle_analytics/stage.rb index f1c39dda49d..080ff07ec0c 100644 --- a/app/models/concerns/analytics/cycle_analytics/stage.rb +++ b/app/models/concerns/analytics/cycle_analytics/stage.rb @@ -49,6 +49,14 @@ module Analytics end end + def start_event_identifier + backward_compatible_identifier(:start_event_identifier) || super + end + + def end_event_identifier + backward_compatible_identifier(:end_event_identifier) || super + end + def start_event_label_based? start_event_identifier && start_event.label_based? end @@ -128,6 +136,17 @@ module Analytics .id_in(label_id) .exists? end + + # Temporary, will be removed in 13.10 + def backward_compatible_identifier(attribute_name) + removed_identifier = 6 # References IssueFirstMentionedInCommit removed on https://gitlab.com/gitlab-org/gitlab/-/merge_requests/51975 + replacement_identifier = :issue_first_mentioned_in_commit + + # ActiveRecord returns nil if the column value is not part of the Enum definition + if self[attribute_name].nil? && read_attribute_before_type_cast(attribute_name) == removed_identifier + replacement_identifier + end + end end end end diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb index baa99fa5a7f..bbf9ecbcfe9 100644 --- a/app/models/concerns/atomic_internal_id.rb +++ b/app/models/concerns/atomic_internal_id.rb @@ -26,20 +26,31 @@ module AtomicInternalId extend ActiveSupport::Concern + MissingValueError = Class.new(StandardError) + class_methods do def has_internal_id( # rubocop:disable Naming/PredicateName - column, scope:, init: :not_given, ensure_if: nil, track_if: nil, - presence: true, backfill: false, hook_names: :create) + column, scope:, init: :not_given, ensure_if: nil, track_if: nil, presence: true, hook_names: :create) raise "has_internal_id init must not be nil if given." if init.nil? raise "has_internal_id needs to be defined on association." unless self.reflect_on_association(scope) init = infer_init(scope) if init == :not_given - before_validation :"track_#{scope}_#{column}!", on: hook_names, if: track_if - before_validation :"ensure_#{scope}_#{column}!", on: hook_names, if: ensure_if - validates column, presence: presence + callback_names = Array.wrap(hook_names).map { |hook_name| :"before_#{hook_name}" } + callback_names.each do |callback_name| + # rubocop:disable GitlabSecurity/PublicSend + public_send(callback_name, :"track_#{scope}_#{column}!", if: track_if) + public_send(callback_name, :"ensure_#{scope}_#{column}!", if: ensure_if) + # rubocop:enable GitlabSecurity/PublicSend + end + after_rollback :"clear_#{scope}_#{column}!", on: hook_names, if: ensure_if + + if presence + before_create :"validate_#{column}_exists!" + before_update :"validate_#{column}_exists!" + end define_singleton_internal_id_methods(scope, column, init) - define_instance_internal_id_methods(scope, column, init, backfill) + define_instance_internal_id_methods(scope, column, init) end private @@ -62,10 +73,8 @@ module AtomicInternalId # - track_{scope}_{column}! # - reset_{scope}_{column} # - {column}= - def define_instance_internal_id_methods(scope, column, init, backfill) + def define_instance_internal_id_methods(scope, column, init) define_method("ensure_#{scope}_#{column}!") do - return if backfill && self.class.where(column => nil).exists? - scope_value = internal_id_read_scope(scope) value = read_attribute(column) return value unless scope_value @@ -79,6 +88,8 @@ module AtomicInternalId internal_id_scope_usage, init) write_attribute(column, value) + + @internal_id_set_manually = false end value @@ -110,6 +121,7 @@ module AtomicInternalId super(value).tap do |v| # Indicate the iid was set from externally @internal_id_needs_tracking = true + @internal_id_set_manually = true end end @@ -128,6 +140,20 @@ module AtomicInternalId read_attribute(column) end + + define_method("clear_#{scope}_#{column}!") do + return if @internal_id_set_manually + + return unless public_send(:"#{column}_previously_changed?") # rubocop:disable GitlabSecurity/PublicSend + + write_attribute(column, nil) + end + + define_method("validate_#{column}_exists!") do + value = read_attribute(column) + + raise MissingValueError, "#{column} was unexpectedly blank!" if value.blank? + end end # Defines class methods: diff --git a/app/models/concerns/cacheable_attributes.rb b/app/models/concerns/cacheable_attributes.rb index de176ffde5c..ee56322cce7 100644 --- a/app/models/concerns/cacheable_attributes.rb +++ b/app/models/concerns/cacheable_attributes.rb @@ -83,6 +83,6 @@ module CacheableAttributes end def cache! - self.class.cache_backend.write(self.class.cache_key, self, expires_in: 1.minute) + self.class.cache_backend.write(self.class.cache_key, self, expires_in: Gitlab.config.gitlab['application_settings_cache_seconds'] || 60) end end diff --git a/app/models/concerns/can_move_repository_storage.rb b/app/models/concerns/can_move_repository_storage.rb index 52c3a4106e3..1132e4e79ac 100644 --- a/app/models/concerns/can_move_repository_storage.rb +++ b/app/models/concerns/can_move_repository_storage.rb @@ -16,10 +16,10 @@ module CanMoveRepositoryStorage !skip_git_transfer_check && git_transfer_in_progress? raise RepositoryReadOnlyError, _('Repository already read-only') if - self.class.where(id: id).pick(:repository_read_only) + _safe_read_repository_read_only_column raise ActiveRecord::RecordNotSaved, _('Database update failed') unless - update_column(:repository_read_only, true) + _update_repository_read_only_column(true) nil end @@ -30,7 +30,7 @@ module CanMoveRepositoryStorage def set_repository_writable! with_lock do raise ActiveRecord::RecordNotSaved, _('Database update failed') unless - update_column(:repository_read_only, false) + _update_repository_read_only_column(false) nil end @@ -43,4 +43,19 @@ module CanMoveRepositoryStorage def reference_counter(type:) Gitlab::ReferenceCounter.new(type.identifier_for_container(self)) end + + private + + # Not all resources that can move repositories have the `repository_read_only` + # in their table, for example groups. We need these methods to override the + # behavior in those classes in order to access the column. + def _safe_read_repository_read_only_column + # This was added originally this way because of + # https://gitlab.com/gitlab-org/gitlab/-/commit/43f9b98302d3985312c9f8b66018e2835d8293d2 + self.class.where(id: id).pick(:repository_read_only) + end + + def _update_repository_read_only_column(value) + update_column(:repository_read_only, value) + end end diff --git a/app/models/concerns/featurable.rb b/app/models/concerns/featurable.rb index 20b72957ec2..ed9bce87da1 100644 --- a/app/models/concerns/featurable.rb +++ b/app/models/concerns/featurable.rb @@ -88,9 +88,6 @@ module Featurable end def feature_available?(feature, user) - # This feature might not be behind a feature flag at all, so default to true - return false unless ::Feature.enabled?(feature, user, default_enabled: true) - get_permission(user, feature) end diff --git a/app/models/concerns/has_repository.rb b/app/models/concerns/has_repository.rb index 9692941d8b2..b9ad78c14fd 100644 --- a/app/models/concerns/has_repository.rb +++ b/app/models/concerns/has_repository.rb @@ -15,15 +15,6 @@ module HasRepository delegate :base_dir, :disk_path, to: :storage - class_methods do - def pick_repository_storage - # We need to ensure application settings are fresh when we pick - # a repository storage to use. - Gitlab::CurrentSettings.expire_current_application_settings - Gitlab::CurrentSettings.pick_repository_storage - end - end - def valid_repo? repository.exists? rescue diff --git a/app/models/concerns/optimized_issuable_label_filter.rb b/app/models/concerns/optimized_issuable_label_filter.rb index 82055822cfb..c7af841e450 100644 --- a/app/models/concerns/optimized_issuable_label_filter.rb +++ b/app/models/concerns/optimized_issuable_label_filter.rb @@ -13,7 +13,7 @@ module OptimizedIssuableLabelFilter def by_label(items) return items unless params.labels? - return super if Feature.disabled?(:optimized_issuable_label_filter) + return super if Feature.disabled?(:optimized_issuable_label_filter, default_enabled: :yaml) target_model = items.model @@ -29,7 +29,7 @@ module OptimizedIssuableLabelFilter # Taken from IssuableFinder def count_by_state return super if root_namespace.nil? - return super if Feature.disabled?(:optimized_issuable_label_filter) + return super if Feature.disabled?(:optimized_issuable_label_filter, default_enabled: :yaml) count_params = params.merge(state: nil, sort: nil, force_cte: true) finder = self.class.new(current_user, count_params) diff --git a/app/models/concerns/packages/debian/component.rb b/app/models/concerns/packages/debian/component.rb new file mode 100644 index 00000000000..e37110231ce --- /dev/null +++ b/app/models/concerns/packages/debian/component.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Packages + module Debian + module Component + extend ActiveSupport::Concern + + included do + belongs_to :distribution, class_name: "Packages::Debian::#{container_type.capitalize}Distribution", inverse_of: :components + + validates :distribution, + presence: true + + validates :name, + presence: true, + length: { maximum: 255 }, + uniqueness: { scope: %i[distribution_id] }, + format: { with: Gitlab::Regex.debian_component_regex } + + scope :with_distribution, ->(distribution) { where(distribution: distribution) } + scope :with_name, ->(name) { where(name: name) } + end + end + end +end diff --git a/app/models/concerns/packages/debian/distribution.rb b/app/models/concerns/packages/debian/distribution.rb index 285d293c9ee..546d866d670 100644 --- a/app/models/concerns/packages/debian/distribution.rb +++ b/app/models/concerns/packages/debian/distribution.rb @@ -18,6 +18,10 @@ module Packages belongs_to container_type belongs_to :creator, class_name: 'User' + has_many :components, + class_name: "Packages::Debian::#{container_type.capitalize}Component", + foreign_key: :distribution_id, + inverse_of: :distribution has_many :architectures, class_name: "Packages::Debian::#{container_type.capitalize}Architecture", foreign_key: :distribution_id, diff --git a/app/models/concerns/repositories/can_housekeep_repository.rb b/app/models/concerns/repositories/can_housekeep_repository.rb index 2b79851a07c..946f82c5f36 100644 --- a/app/models/concerns/repositories/can_housekeep_repository.rb +++ b/app/models/concerns/repositories/can_housekeep_repository.rb @@ -16,6 +16,10 @@ module Repositories Gitlab::Redis::SharedState.with { |redis| redis.del(pushes_since_gc_redis_shared_state_key) } end + def git_garbage_collect_worker_klass + raise NotImplementedError + end + private def pushes_since_gc_redis_shared_state_key diff --git a/app/models/concerns/repository_storage_movable.rb b/app/models/concerns/repository_storage_movable.rb index a45b4626628..e584922025a 100644 --- a/app/models/concerns/repository_storage_movable.rb +++ b/app/models/concerns/repository_storage_movable.rb @@ -20,7 +20,7 @@ module RepositoryStorageMovable validate :container_repository_writable, on: :create default_value_for(:destination_storage_name, allows_nil: false) do - pick_repository_storage + Repository.pick_storage_shard end state_machine initial: :initial do @@ -82,16 +82,6 @@ module RepositoryStorageMovable end end - class_methods do - private - - def pick_repository_storage - container_klass = reflect_on_association(:container).class_name.constantize - - container_klass.pick_repository_storage - end - end - # Projects, snippets, and group wikis has different db structure. In projects, # we need to update some columns in this step, but we don't with the other resources. # diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb index 9cd1a22b203..2daea388939 100644 --- a/app/models/concerns/spammable.rb +++ b/app/models/concerns/spammable.rb @@ -45,6 +45,17 @@ module Spammable self.needs_recaptcha = true end + ## + # Indicates if a recaptcha should be rendered before allowing this model to be saved. + # + def render_recaptcha? + return false unless Gitlab::Recaptcha.enabled? + + return false if self.errors.count > 1 # captcha should not be rendered if are still other errors + + self.needs_recaptcha? + end + def spam! self.spam = true end diff --git a/app/models/concerns/suppress_composite_primary_key_warning.rb b/app/models/concerns/suppress_composite_primary_key_warning.rb new file mode 100644 index 00000000000..32634e7bc72 --- /dev/null +++ b/app/models/concerns/suppress_composite_primary_key_warning.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# When extended, silences this warning below: +# WARNING: Active Record does not support composite primary key. +# +# project_authorizations has composite primary key. Composite primary key is ignored. +# +# See https://gitlab.com/gitlab-org/gitlab/-/issues/292909 +module SuppressCompositePrimaryKeyWarning + extend ActiveSupport::Concern + + private + + def suppress_composite_primary_key(pk) + silence_warnings do + super + end + end +end diff --git a/app/models/concerns/token_authenticatable_strategies/encrypted.rb b/app/models/concerns/token_authenticatable_strategies/encrypted.rb index 4728cb658dc..672402ee4d6 100644 --- a/app/models/concerns/token_authenticatable_strategies/encrypted.rb +++ b/app/models/concerns/token_authenticatable_strategies/encrypted.rb @@ -85,10 +85,18 @@ module TokenAuthenticatableStrategies end def find_by_encrypted_token(token, unscoped) - encrypted_value = Gitlab::CryptoHelper.aes256_gcm_encrypt(token) + nonce = Feature.enabled?(:dynamic_nonce_creation) ? find_hashed_iv(token) : Gitlab::CryptoHelper::AES256_GCM_IV_STATIC + encrypted_value = Gitlab::CryptoHelper.aes256_gcm_encrypt(token, nonce: nonce) + relation(unscoped).find_by(encrypted_field => encrypted_value) end + def find_hashed_iv(token) + token_record = TokenWithIv.find_by_plaintext_token(token) + + token_record&.iv || Gitlab::CryptoHelper::AES256_GCM_IV_STATIC + end + def insecure_strategy @insecure_strategy ||= TokenAuthenticatableStrategies::Insecure .new(klass, token_field, options) diff --git a/app/models/concerns/triggerable_hooks.rb b/app/models/concerns/triggerable_hooks.rb index 473b430bb04..db5df6c2c9f 100644 --- a/app/models/concerns/triggerable_hooks.rb +++ b/app/models/concerns/triggerable_hooks.rb @@ -16,7 +16,8 @@ module TriggerableHooks deployment_hooks: :deployment_events, feature_flag_hooks: :feature_flag_events, release_hooks: :releases_events, - member_hooks: :member_events + member_hooks: :member_events, + subgroup_hooks: :subgroup_events }.freeze extend ActiveSupport::Concern diff --git a/app/models/dependency_proxy.rb b/app/models/dependency_proxy.rb index 9cbaf7e9884..0ed17921aaa 100644 --- a/app/models/dependency_proxy.rb +++ b/app/models/dependency_proxy.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true module DependencyProxy URL_SUFFIX = '/dependency_proxy/containers' + DISTRIBUTION_API_VERSION = 'registry/2.0' def self.table_name_prefix 'dependency_proxy_' diff --git a/app/models/dependency_proxy/manifest.rb b/app/models/dependency_proxy/manifest.rb index f3c7f34e0d7..d613d5708f0 100644 --- a/app/models/dependency_proxy/manifest.rb +++ b/app/models/dependency_proxy/manifest.rb @@ -12,5 +12,10 @@ class DependencyProxy::Manifest < ApplicationRecord mount_file_store_uploader DependencyProxy::FileUploader - scope :find_or_initialize_by_file_name, ->(file_name) { find_or_initialize_by(file_name: file_name) } + def self.find_or_initialize_by_file_name_or_digest(file_name:, digest:) + result = find_by(file_name: file_name) || find_by(digest: digest) + return result if result + + new(file_name: file_name, digest: digest) + end end diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 6f40466394a..7bcf7c702f6 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -112,7 +112,6 @@ class Deployment < ApplicationRecord after_transition any => any - [:skipped] do |deployment, transition| next if transition.loopback? - next unless Feature.enabled?(:jira_sync_deployments, deployment.project) deployment.run_after_commit do ::JiraConnect::SyncDeploymentsWorker.perform_async(id) @@ -121,8 +120,6 @@ class Deployment < ApplicationRecord end after_create unless: :importing? do |deployment| - next unless Feature.enabled?(:jira_sync_deployments, deployment.project) - run_after_commit do ::JiraConnect::SyncDeploymentsWorker.perform_async(deployment.id) end @@ -353,6 +350,13 @@ class Deployment < ApplicationRecord File.join(environment.ref_path, 'deployments', iid.to_s) end + def equal_to?(params) + ref == params[:ref] && + tag == params[:tag] && + sha == params[:sha] && + status == params[:status] + end + private def legacy_finished_at diff --git a/app/models/deployment_merge_request.rb b/app/models/deployment_merge_request.rb index 64a578e16bf..7949bd81605 100644 --- a/app/models/deployment_merge_request.rb +++ b/app/models/deployment_merge_request.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class DeploymentMergeRequest < ApplicationRecord + extend SuppressCompositePrimaryKeyWarning + belongs_to :deployment, optional: false belongs_to :merge_request, optional: false diff --git a/app/models/experiment.rb b/app/models/experiment.rb index 7dbc95f617a..354b1e0b6b9 100644 --- a/app/models/experiment.rb +++ b/app/models/experiment.rb @@ -10,6 +10,10 @@ class Experiment < ApplicationRecord find_or_create_by!(name: name).record_user_and_group(user, group_type, context) end + def self.add_group(name, variant:, group:) + find_or_create_by!(name: name).record_group_and_variant!(group, variant) + end + def self.record_conversion_event(name, user) find_or_create_by!(name: name).record_conversion_event_for_user(user) end @@ -24,4 +28,8 @@ class Experiment < ApplicationRecord def record_conversion_event_for_user(user) experiment_users.find_by(user: user, converted_at: nil)&.touch(:converted_at) end + + def record_group_and_variant!(group, variant) + experiment_subjects.find_or_initialize_by(group: group).update!(variant: variant) + end end diff --git a/app/models/group.rb b/app/models/group.rb index 903d0154969..aa79d379fac 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -84,7 +84,7 @@ class Group < Namespace validate :visibility_level_allowed_by_sub_groups validate :visibility_level_allowed_by_parent validate :two_factor_authentication_allowed - validates :variables, variable_duplicates: true + validates :variables, nested_attributes_duplicates: true validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 } @@ -169,6 +169,15 @@ class Group < Namespace where('NOT EXISTS (?)', services) end + # This method can be used only if all groups have the same top-level + # group + def preset_root_ancestor_for(groups) + return groups if groups.size < 2 + + root = groups.first.root_ancestor + groups.drop(1).each { |group| group.root_ancestor = root } + end + private def public_to_user_arel(user) diff --git a/app/models/issue.rb b/app/models/issue.rb index 5da9f67f6ef..79d0229a281 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -132,7 +132,7 @@ class Issue < ApplicationRecord scope :counts_by_state, -> { reorder(nil).group(:state_id).count } scope :service_desk, -> { where(author: ::User.support_bot) } - scope :inc_relations_for_view, -> { includes(author: :status) } + scope :inc_relations_for_view, -> { includes(author: :status, assignees: :status) } # An issue can be uniquely identified by project_id and iid # Takes one or more sets of composite IDs, expressed as hash-like records of diff --git a/app/models/issue_assignee.rb b/app/models/issue_assignee.rb index 7f3d552b3d9..d62f0eb170c 100644 --- a/app/models/issue_assignee.rb +++ b/app/models/issue_assignee.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class IssueAssignee < ApplicationRecord + extend SuppressCompositePrimaryKeyWarning + belongs_to :issue belongs_to :assignee, class_name: "User", foreign_key: :user_id, inverse_of: :issue_assignees diff --git a/app/models/issue_link.rb b/app/models/issue_link.rb index 5448ebdf50b..ba97874ed39 100644 --- a/app/models/issue_link.rb +++ b/app/models/issue_link.rb @@ -17,9 +17,11 @@ class IssueLink < ApplicationRecord TYPE_RELATES_TO = 'relates_to' TYPE_BLOCKS = 'blocks' + # we don't store is_blocked_by in the db but need it for displaying the relation + # from the target (used in IssueLink.inverse_link_type) TYPE_IS_BLOCKED_BY = 'is_blocked_by' - enum link_type: { TYPE_RELATES_TO => 0, TYPE_BLOCKS => 1, TYPE_IS_BLOCKED_BY => 2 } + enum link_type: { TYPE_RELATES_TO => 0, TYPE_BLOCKS => 1 } def self.inverse_link_type(type) type diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 64b8223a1f0..73418270a34 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -50,12 +50,15 @@ class MergeRequest < ApplicationRecord end end - has_many :merge_request_diffs + has_many :merge_request_diffs, + -> { regular }, inverse_of: :merge_request has_many :merge_request_context_commits, inverse_of: :merge_request has_many :merge_request_context_commit_diff_files, through: :merge_request_context_commits, source: :diff_files has_one :merge_request_diff, - -> { order('merge_request_diffs.id DESC') }, inverse_of: :merge_request + -> { regular.order('merge_request_diffs.id DESC') }, inverse_of: :merge_request + has_one :merge_head_diff, + -> { merge_head }, inverse_of: :merge_request, class_name: 'MergeRequestDiff' has_one :cleanup_schedule, inverse_of: :merge_request belongs_to :latest_merge_request_diff, class_name: 'MergeRequestDiff' @@ -270,8 +273,7 @@ class MergeRequest < ApplicationRecord by_commit_sha(sha), by_squash_commit_sha(sha), by_merge_commit_sha(sha) - ], - remove_duplicates: false + ] ) end scope :by_cherry_pick_sha, -> (sha) do @@ -477,13 +479,17 @@ class MergeRequest < ApplicationRecord # This is used after project import, to reset the IDs to the correct # values. It is not intended to be called without having already scoped the # relation. + # + # Only set `regular` merge request diffs as latest so `merge_head` diff + # won't be considered as `MergeRequest#merge_request_diff`. def self.set_latest_merge_request_diff_ids! - update = ' + update = " latest_merge_request_diff_id = ( SELECT MAX(id) FROM merge_request_diffs WHERE merge_requests.id = merge_request_diffs.merge_request_id - )'.squish + AND merge_request_diffs.diff_type = #{MergeRequestDiff.diff_types[:regular]} + )".squish self.each_batch do |batch| batch.update_all(update) @@ -922,7 +928,7 @@ class MergeRequest < ApplicationRecord def create_merge_request_diff fetch_ref! - # n+1: https://gitlab.com/gitlab-org/gitlab-foss/issues/37435 + # n+1: https://gitlab.com/gitlab-org/gitlab/-/issues/19377 Gitlab::GitalyClient.allow_n_plus_1_calls do merge_request_diffs.create! reload_merge_request_diff @@ -996,7 +1002,7 @@ class MergeRequest < ApplicationRecord # rubocop: enable CodeReuse/ServiceClass def diffable_merge_ref? - open? && merge_ref_head.present? && (Feature.enabled?(:display_merge_conflicts_in_diff, project) || can_be_merged?) + open? && merge_head_diff.present? && (Feature.enabled?(:display_merge_conflicts_in_diff, project) || can_be_merged?) end # Returns boolean indicating the merge_status should be rechecked in order to @@ -1478,8 +1484,26 @@ class MergeRequest < ApplicationRecord compare_reports(Ci::GenerateCoverageReportsService) end + def has_codequality_mr_diff_report? + return false unless ::Gitlab::Ci::Features.display_quality_on_mr_diff?(project) + + actual_head_pipeline&.has_codequality_mr_diff_report? + end + + # TODO: this method and compare_test_reports use the same + # result type, which is handled by the controller's #reports_response. + # we should minimize mistakes by isolating the common parts. + # issue: https://gitlab.com/gitlab-org/gitlab/issues/34224 + def find_codequality_mr_diff_reports + unless has_codequality_mr_diff_report? + return { status: :error, status_reason: 'This merge request does not have codequality mr diff reports' } + end + + compare_reports(Ci::GenerateCodequalityMrDiffReportService) + end + def has_codequality_reports? - return false unless Feature.enabled?(:codequality_mr_diff, project) + return false unless ::Gitlab::Ci::Features.display_codequality_backend_comparison?(project) actual_head_pipeline&.has_reports?(Ci::JobArtifact.codequality_reports) end @@ -1771,6 +1795,10 @@ class MergeRequest < ApplicationRecord true end + def find_reviewer(user) + merge_request_reviewers.find_by(user_id: user.id) + end + private def with_rebase_lock diff --git a/app/models/merge_request/metrics.rb b/app/models/merge_request/metrics.rb index d3fe256fb1b..5c611da0684 100644 --- a/app/models/merge_request/metrics.rb +++ b/app/models/merge_request/metrics.rb @@ -5,12 +5,14 @@ class MergeRequest::Metrics < ApplicationRecord belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :pipeline_id belongs_to :latest_closed_by, class_name: 'User' belongs_to :merged_by, class_name: 'User' + belongs_to :target_project, class_name: 'Project', inverse_of: :merge_requests before_save :ensure_target_project_id scope :merged_after, ->(date) { where(arel_table[:merged_at].gteq(date)) } scope :merged_before, ->(date) { where(arel_table[:merged_at].lteq(date)) } scope :with_valid_time_to_merge, -> { where(arel_table[:merged_at].gt(arel_table[:created_at])) } + scope :by_target_project, ->(project) { where(target_project_id: project) } def self.time_to_merge_expression Arel.sql('EXTRACT(epoch FROM SUM(AGE(merge_request_metrics.merged_at, merge_request_metrics.created_at)))') @@ -21,6 +23,12 @@ class MergeRequest::Metrics < ApplicationRecord def ensure_target_project_id self.target_project_id ||= merge_request.target_project_id end + + def self.total_time_to_merge + with_valid_time_to_merge + .pluck(time_to_merge_expression) + .first + end end MergeRequest::Metrics.prepend_if_ee('EE::MergeRequest::Metrics') diff --git a/app/models/merge_request_context_commit.rb b/app/models/merge_request_context_commit.rb index 59cc82cfaf5..e081a96dc10 100644 --- a/app/models/merge_request_context_commit.rb +++ b/app/models/merge_request_context_commit.rb @@ -12,6 +12,9 @@ class MergeRequestContextCommit < ApplicationRecord validates :sha, presence: true validates :sha, uniqueness: { message: 'has already been added' } + serialize :trailers, Serializers::JSON # rubocop:disable Cop/ActiveRecordSerialize + validates :trailers, json_schema: { filename: 'git_trailers' } + # Sort by committed date in descending order to ensure latest commits comes on the top scope :order_by_committed_date_desc, -> { order('committed_date DESC') } diff --git a/app/models/merge_request_context_commit_diff_file.rb b/app/models/merge_request_context_commit_diff_file.rb index b89d1983ce3..6f15df1b70f 100644 --- a/app/models/merge_request_context_commit_diff_file.rb +++ b/app/models/merge_request_context_commit_diff_file.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class MergeRequestContextCommitDiffFile < ApplicationRecord + extend SuppressCompositePrimaryKeyWarning + include Gitlab::EncodingHelper include ShaAttribute include DiffFile diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index d23e66b9697..5500ee7f74a 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -32,6 +32,7 @@ class MergeRequestDiff < ApplicationRecord has_many :merge_request_diff_commits, -> { order(:merge_request_diff_id, :relative_order) } validates :base_commit_sha, :head_commit_sha, :start_commit_sha, sha: true + validates :merge_request_id, uniqueness: { scope: :diff_type }, if: :merge_head? state_machine :state, initial: :empty do event :clean do @@ -50,6 +51,11 @@ class MergeRequestDiff < ApplicationRecord state :overflow_diff_lines_limit end + enum diff_type: { + regular: 1, + merge_head: 2 + } + scope :with_files, -> { without_states(:without_files, :empty) } scope :viewable, -> { without_state(:empty) } scope :by_commit_sha, ->(sha) do @@ -72,6 +78,7 @@ class MergeRequestDiff < ApplicationRecord join_condition = merge_requests[:id].eq(mr_diffs[:merge_request_id]) .and(mr_diffs[:id].not_eq(merge_requests[:latest_merge_request_diff_id])) + .and(mr_diffs[:diff_type].eq(diff_types[:regular])) arel_join = mr_diffs.join(merge_requests).on(join_condition) joins(arel_join.join_sources) @@ -196,6 +203,10 @@ class MergeRequestDiff < ApplicationRecord end def set_as_latest_diff + # Don't set merge_head diff as latest so it won't get considered as the + # MergeRequest#merge_request_diff. + return if merge_head? + MergeRequest .where('id = ? AND COALESCE(latest_merge_request_diff_id, 0) < ?', self.merge_request_id, self.id) .update_all(latest_merge_request_diff_id: self.id) @@ -203,8 +214,16 @@ class MergeRequestDiff < ApplicationRecord def ensure_commit_shas self.start_commit_sha ||= merge_request.target_branch_sha - self.head_commit_sha ||= merge_request.source_branch_sha - self.base_commit_sha ||= find_base_sha + + if merge_head? && merge_request.merge_ref_head.present? + diff_refs = merge_request.merge_ref_head.diff_refs + + self.head_commit_sha ||= diff_refs.head_sha + self.base_commit_sha ||= diff_refs.base_sha + else + self.head_commit_sha ||= merge_request.source_branch_sha + self.base_commit_sha ||= find_base_sha + end end # Override head_commit_sha to keep compatibility with merge request diff diff --git a/app/models/merge_request_diff_commit.rb b/app/models/merge_request_diff_commit.rb index 9f6933d0879..259690ef308 100644 --- a/app/models/merge_request_diff_commit.rb +++ b/app/models/merge_request_diff_commit.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class MergeRequestDiffCommit < ApplicationRecord + extend SuppressCompositePrimaryKeyWarning + include BulkInsertSafe include ShaAttribute include CachedCommit @@ -10,6 +12,9 @@ class MergeRequestDiffCommit < ApplicationRecord sha_attribute :sha alias_attribute :id, :sha + serialize :trailers, Serializers::JSON # rubocop:disable Cop/ActiveRecordSerialize + validates :trailers, json_schema: { filename: 'git_trailers' } + # Deprecated; use `bulk_insert!` from `BulkInsertSafe` mixin instead. # cf. https://gitlab.com/gitlab-org/gitlab/issues/207989 for progress def self.create_bulk(merge_request_diff_id, commits) @@ -23,10 +28,30 @@ class MergeRequestDiffCommit < ApplicationRecord relative_order: index, sha: Gitlab::Database::ShaAttribute.serialize(sha), # rubocop:disable Cop/ActiveRecordSerialize authored_date: Gitlab::Database.sanitize_timestamp(commit_hash[:authored_date]), - committed_date: Gitlab::Database.sanitize_timestamp(commit_hash[:committed_date]) + committed_date: Gitlab::Database.sanitize_timestamp(commit_hash[:committed_date]), + trailers: commit_hash.fetch(:trailers, {}).to_json ) end Gitlab::Database.bulk_insert(self.table_name, rows) # rubocop:disable Gitlab/BulkInsert end + + def self.oldest_merge_request_id_per_commit(project_id, shas) + # This method is defined here and not on MergeRequest, otherwise the SHA + # values used in the WHERE below won't be encoded correctly. + select(['merge_request_diff_commits.sha AS sha', 'min(merge_requests.id) AS merge_request_id']) + .joins(:merge_request_diff) + .joins( + 'INNER JOIN merge_requests ' \ + 'ON merge_requests.latest_merge_request_diff_id = merge_request_diffs.id' + ) + .where(sha: shas) + .where( + merge_requests: { + target_project_id: project_id, + state_id: MergeRequest.available_states[:merged] + } + ) + .group(:sha) + end end diff --git a/app/models/merge_request_diff_file.rb b/app/models/merge_request_diff_file.rb index 817e77bf12f..f3f64971426 100644 --- a/app/models/merge_request_diff_file.rb +++ b/app/models/merge_request_diff_file.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class MergeRequestDiffFile < ApplicationRecord + extend SuppressCompositePrimaryKeyWarning + include BulkInsertSafe include Gitlab::EncodingHelper include DiffFile diff --git a/app/models/merge_request_reviewer.rb b/app/models/merge_request_reviewer.rb index c4e5274f832..4a1f31a7f39 100644 --- a/app/models/merge_request_reviewer.rb +++ b/app/models/merge_request_reviewer.rb @@ -1,6 +1,15 @@ # frozen_string_literal: true class MergeRequestReviewer < ApplicationRecord + enum state: { + unreviewed: 0, + reviewed: 1 + } + + validates :state, + presence: true, + inclusion: { in: MergeRequestReviewer.states.keys } + belongs_to :merge_request belongs_to :reviewer, class_name: 'User', foreign_key: :user_id, inverse_of: :merge_request_reviewers end diff --git a/app/models/milestone_release.rb b/app/models/milestone_release.rb index 2f2bf91e436..c6b5a967af9 100644 --- a/app/models/milestone_release.rb +++ b/app/models/milestone_release.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class MilestoneRelease < ApplicationRecord + extend SuppressCompositePrimaryKeyWarning + belongs_to :milestone belongs_to :release diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 6f7b377ee52..cd550bd6dfa 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -103,6 +103,10 @@ class Namespace < ApplicationRecord ) end + # Make sure that the name is same as strong_memoize name in root_ancestor + # method + attr_writer :root_ancestor + class << self def by_path(path) find_by('lower(path) = :value', value: path.downcase) @@ -315,6 +319,8 @@ class Namespace < ApplicationRecord def root_ancestor return self if persisted? && parent_id.nil? + # Make sure that strong_memoize name is in sync with root_ancestor's + # attr_writer name strong_memoize(:root_ancestor) do self_and_ancestors.reorder(nil).find_by(parent_id: nil) end diff --git a/app/models/onboarding_progress.rb b/app/models/onboarding_progress.rb index 419bbd595e9..38a9489a3ad 100644 --- a/app/models/onboarding_progress.rb +++ b/app/models/onboarding_progress.rb @@ -22,6 +22,24 @@ class OnboardingProgress < ApplicationRecord :repository_mirrored ].freeze + scope :incomplete_actions, -> (actions) do + Array.wrap(actions).inject(self) { |scope, action| scope.where(column_name(action) => nil) } + end + + scope :completed_actions, -> (actions) do + Array.wrap(actions).inject(self) { |scope, action| scope.where.not(column_name(action) => nil) } + end + + scope :completed_actions_with_latest_in_range, -> (actions, range) do + actions = Array(actions) + if actions.size == 1 + where(column_name(actions[0]) => range) + else + action_columns = actions.map { |action| arel_table[column_name(action)] } + completed_actions(actions).where(Arel::Nodes::NamedFunction.new('GREATEST', action_columns).between(range)) + end + end + class << self def onboard(namespace) return unless root_namespace?(namespace) @@ -44,12 +62,12 @@ class OnboardingProgress < ApplicationRecord where(namespace: namespace).where.not(action_column => nil).exists? end - private - def column_name(action) :"#{action}_at" end + private + def root_namespace?(namespace) namespace && namespace.root? end diff --git a/app/models/packages/composer/cache_file.rb b/app/models/packages/composer/cache_file.rb new file mode 100644 index 00000000000..92659644b06 --- /dev/null +++ b/app/models/packages/composer/cache_file.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Packages + module Composer + class CacheFile < ApplicationRecord + include FileStoreMounter + + self.table_name = 'packages_composer_cache_files' + + mount_file_store_uploader Packages::Composer::CacheUploader + + belongs_to :group, -> { where(type: 'Group') }, foreign_key: 'namespace_id' + belongs_to :namespace + + validates :namespace, presence: true + + scope :with_namespace, ->(namespace) { where(namespace: namespace) } + scope :with_sha, ->(sha) { where(file_sha256: sha) } + end + end +end diff --git a/app/models/packages/composer/metadatum.rb b/app/models/packages/composer/metadatum.rb index 3026f5ea878..363858a3ed1 100644 --- a/app/models/packages/composer/metadatum.rb +++ b/app/models/packages/composer/metadatum.rb @@ -9,6 +9,9 @@ module Packages belongs_to :package, -> { where(package_type: :composer) }, inverse_of: :composer_metadatum validates :package, :target_sha, :composer_json, presence: true + + scope :for_package, ->(name, project_id) { joins(:package).where(packages_packages: { name: name, project_id: project_id, package_type: Packages::Package.package_types[:composer] }) } + scope :locked_for_update, -> { lock('FOR UPDATE') } end end end diff --git a/app/models/packages/debian/group_component.rb b/app/models/packages/debian/group_component.rb new file mode 100644 index 00000000000..81e02c363b0 --- /dev/null +++ b/app/models/packages/debian/group_component.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Packages::Debian::GroupComponent < ApplicationRecord + def self.container_type + :group + end + + include Packages::Debian::Component +end diff --git a/app/models/packages/debian/project_component.rb b/app/models/packages/debian/project_component.rb new file mode 100644 index 00000000000..98cd7fd589b --- /dev/null +++ b/app/models/packages/debian/project_component.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Packages::Debian::ProjectComponent < ApplicationRecord + def self.container_type + :project + end + + include Packages::Debian::Component +end diff --git a/app/models/pages/lookup_path.rb b/app/models/pages/lookup_path.rb index 84928468ad1..fa2d7bb316c 100644 --- a/app/models/pages/lookup_path.rb +++ b/app/models/pages/lookup_path.rb @@ -4,6 +4,9 @@ module Pages class LookupPath include Gitlab::Utils::StrongMemoize + LegacyStorageDisabledError = Class.new(::StandardError) + MIGRATED_FILE_NAME = "_migrated.zip" + def initialize(project, trim_prefix: nil, domain: nil) @project = project @domain = domain @@ -24,7 +27,7 @@ module Pages end def source - zip_source || file_source + zip_source || legacy_source end def prefix @@ -52,6 +55,8 @@ module Pages return if deployment.file.file_storage? && !Feature.enabled?(:pages_serve_with_zip_file_protocol, project) + return if deployment.file.filename == MIGRATED_FILE_NAME && !Feature.enabled?(:pages_serve_from_migrated_zip, project) + global_id = ::Gitlab::GlobalId.build(deployment, id: deployment.id).to_s { @@ -64,11 +69,17 @@ module Pages } end - def file_source + def legacy_source + raise LegacyStorageDisabledError unless Feature.enabled?(:pages_serve_from_legacy_storage, default_enabled: true) + { type: 'file', path: File.join(project.full_path, 'public/') } + rescue LegacyStorageDisabledError => e + Gitlab::ErrorTracking.track_exception(e) + + nil end end end diff --git a/app/models/pages/virtual_domain.rb b/app/models/pages/virtual_domain.rb index 7e42b8e6ae2..90cb8253b52 100644 --- a/app/models/pages/virtual_domain.rb +++ b/app/models/pages/virtual_domain.rb @@ -17,9 +17,16 @@ module Pages end def lookup_paths - projects.map do |project| + paths = projects.map do |project| project.pages_lookup_path(trim_prefix: trim_prefix, domain: domain) - end.sort_by(&:prefix).reverse + end + + # TODO: remove in https://gitlab.com/gitlab-org/gitlab/-/issues/297524 + # source can only be nil if pages_serve_from_legacy_storage FF is disabled + # we can remove this filtering once we remove legacy storage + paths = paths.select(&:source) + + paths.sort_by(&:prefix).reverse end private diff --git a/app/models/project.rb b/app/models/project.rb index ec790798806..aad2e7d2259 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -75,7 +75,7 @@ class Project < ApplicationRecord default_value_for :resolve_outdated_diff_discussions, false default_value_for :container_registry_enabled, gitlab_config_features.container_registry default_value_for(:repository_storage) do - pick_repository_storage + Repository.pick_storage_shard end default_value_for(:shared_runners_enabled) { Gitlab::CurrentSettings.shared_runners_enabled } @@ -218,6 +218,7 @@ class Project < ApplicationRecord # Merge Requests for target project should be removed with it has_many :merge_requests, foreign_key: 'target_project_id', inverse_of: :target_project + has_many :merge_request_metrics, foreign_key: 'target_project', class_name: 'MergeRequest::Metrics', inverse_of: :target_project has_many :source_of_merge_requests, foreign_key: 'source_project_id', class_name: 'MergeRequest' has_many :issues has_many :labels, class_name: 'ProjectLabel' @@ -456,7 +457,7 @@ class Project < ApplicationRecord validates :repository_storage, presence: true, inclusion: { in: ->(_object) { Gitlab.config.repositories.storages.keys } } - validates :variables, variable_duplicates: { scope: :environment_scope } + validates :variables, nested_attributes_duplicates: { scope: :environment_scope } validates :bfg_object_map, file_size: { maximum: :max_attachment_size } validates :max_artifacts_size, numericality: { only_integer: true, greater_than: 0, allow_nil: true } @@ -1314,21 +1315,11 @@ class Project < ApplicationRecord end def external_issue_tracker - if has_external_issue_tracker.nil? - cache_has_external_issue_tracker - end + cache_has_external_issue_tracker if has_external_issue_tracker.nil? - if has_external_issue_tracker? - strong_memoize(:external_issue_tracker) do - services.external_issue_trackers.first - end - else - nil - end - end + return unless has_external_issue_tracker? - def cache_has_external_issue_tracker - update_column(:has_external_issue_tracker, services.external_issue_trackers.any?) if Gitlab::Database.read_write? + @external_issue_tracker ||= services.external_issue_trackers.first end def external_references_supported? @@ -2532,6 +2523,11 @@ class Project < ApplicationRecord tracing_setting&.external_url end + override :git_garbage_collect_worker_klass + def git_garbage_collect_worker_klass + Projects::GitGarbageCollectWorker + end + private def find_service(services, name) @@ -2690,6 +2686,10 @@ class Project < ApplicationRecord def cache_has_external_wiki update_column(:has_external_wiki, services.external_wikis.any?) if Gitlab::Database.read_write? end + + def cache_has_external_issue_tracker + update_column(:has_external_issue_tracker, services.external_issue_trackers.any?) if Gitlab::Database.read_write? + end end Project.prepend_if_ee('EE::Project') diff --git a/app/models/project_authorization.rb b/app/models/project_authorization.rb index 366852d93bf..2c3f70654f8 100644 --- a/app/models/project_authorization.rb +++ b/app/models/project_authorization.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class ProjectAuthorization < ApplicationRecord + extend SuppressCompositePrimaryKeyWarning include FromUnion belongs_to :user diff --git a/app/models/project_pages_metadatum.rb b/app/models/project_pages_metadatum.rb index 2bef0056732..58dbac9057f 100644 --- a/app/models/project_pages_metadatum.rb +++ b/app/models/project_pages_metadatum.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class ProjectPagesMetadatum < ApplicationRecord + extend SuppressCompositePrimaryKeyWarning + include EachBatch self.primary_key = :project_id @@ -11,4 +13,5 @@ class ProjectPagesMetadatum < ApplicationRecord scope :deployed, -> { where(deployed: true) } scope :only_on_legacy_storage, -> { deployed.where(pages_deployment: nil) } + scope :with_project_route_and_deployment, -> { preload(:pages_deployment, project: [:namespace, :route]) } end diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb index c9e97efb4ac..1d50d5cf19e 100644 --- a/app/models/project_services/chat_notification_service.rb +++ b/app/models/project_services/chat_notification_service.rb @@ -11,11 +11,13 @@ class ChatNotificationService < Service tag_push pipeline wiki_page deployment ].freeze + SUPPORTED_EVENTS_FOR_LABEL_FILTER = %w[issue confidential_issue merge_request note confidential_note].freeze + EVENT_CHANNEL = proc { |event| "#{event}_channel" } default_value_for :category, 'chat' - prop_accessor :webhook, :username, :channel, :branches_to_be_notified + prop_accessor :webhook, :username, :channel, :branches_to_be_notified, :labels_to_be_notified # Custom serialized properties initialization prop_accessor(*SUPPORTED_EVENTS.map { |event| EVENT_CHANNEL[event] }) @@ -62,12 +64,16 @@ class ChatNotificationService < Service { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}", required: true }.freeze, { type: 'text', name: 'username', placeholder: 'e.g. GitLab' }.freeze, { type: 'checkbox', name: 'notify_only_broken_pipelines' }.freeze, - { type: 'select', name: 'branches_to_be_notified', choices: branch_choices }.freeze + { type: 'select', name: 'branches_to_be_notified', choices: branch_choices }.freeze, + { type: 'text', name: 'labels_to_be_notified', placeholder: 'e.g. ~backend', help: 'Only supported for issue, merge request and note events.' }.freeze ].freeze end def execute(data) return unless supported_events.include?(data[:object_kind]) + + return unless notify_label?(data) + return unless webhook.present? object_kind = data[:object_kind] @@ -114,6 +120,22 @@ class ChatNotificationService < Service private + def labels_to_be_notified_list + return [] if labels_to_be_notified.nil? + + labels_to_be_notified.delete('~').split(',').map(&:strip) + end + + def notify_label?(data) + return true unless SUPPORTED_EVENTS_FOR_LABEL_FILTER.include?(data[:object_kind]) && labels_to_be_notified.present? + + issue_labels = data.dig(:issue, :labels) || [] + merge_request_labels = data.dig(:merge_request, :labels) || [] + label_titles = (issue_labels + merge_request_labels).pluck(:title) + + (labels_to_be_notified_list & label_titles).any? + end + # every notifier must implement this independently def notify(message, opts) raise NotImplementedError diff --git a/app/models/project_services/confluence_service.rb b/app/models/project_services/confluence_service.rb index 6db446fc04c..8a6f4de540c 100644 --- a/app/models/project_services/confluence_service.rb +++ b/app/models/project_services/confluence_service.rb @@ -30,8 +30,8 @@ class ConfluenceService < Service s_('ConfluenceService|Connect a Confluence Cloud Workspace to GitLab') end - def detailed_description - return unless project.wiki_enabled? + def help + return unless project&.wiki_enabled? if activated? wiki_url = project.wiki.web_url diff --git a/app/models/project_services/datadog_service.rb b/app/models/project_services/datadog_service.rb index 3a742bfdcda..a48dea71645 100644 --- a/app/models/project_services/datadog_service.rb +++ b/app/models/project_services/datadog_service.rb @@ -12,14 +12,22 @@ class DatadogService < Service prop_accessor :datadog_site, :api_url, :api_key, :datadog_service, :datadog_env - with_options presence: true, if: :activated? do - validates :api_key, format: { with: /\A\w+\z/ } - validates :datadog_site, format: { with: /\A[\w\.]+\z/ }, unless: :api_url - validates :api_url, public_url: true, unless: :datadog_site + with_options if: :activated? do + validates :api_key, presence: true, format: { with: /\A\w+\z/ } + validates :datadog_site, format: { with: /\A[\w\.]+\z/, allow_blank: true } + validates :api_url, public_url: { allow_blank: true } + validates :datadog_site, presence: true, unless: -> (obj) { obj.api_url.present? } + validates :api_url, presence: true, unless: -> (obj) { obj.datadog_site.present? } end after_save :compose_service_hook, if: :activated? + def initialize_properties + super + + self.datadog_site ||= DEFAULT_SITE + end + def self.supported_events SUPPORTED_EVENTS end @@ -54,27 +62,37 @@ class DatadogService < Service def fields [ { - type: 'text', name: 'datadog_site', - placeholder: DEFAULT_SITE, default: DEFAULT_SITE, + type: 'text', + name: 'datadog_site', + placeholder: DEFAULT_SITE, help: 'Choose the Datadog site to send data to. Set to "datadoghq.eu" to send data to the EU site', required: false }, { - type: 'text', name: 'api_url', title: 'Custom URL', + type: 'text', + name: 'api_url', + title: 'API URL', help: '(Advanced) Define the full URL for your Datadog site directly', required: false }, { - type: 'password', name: 'api_key', title: 'API key', + type: 'password', + name: 'api_key', + title: 'API key', help: "<a href=\"#{api_keys_url}\" target=\"_blank\">API key</a> used for authentication with Datadog", required: true }, { - type: 'text', name: 'datadog_service', title: 'Service', placeholder: 'gitlab-ci', + type: 'text', + name: 'datadog_service', + title: 'Service', + placeholder: 'gitlab-ci', help: 'Name of this GitLab instance that all data will be tagged with' }, { - type: 'text', name: 'datadog_env', title: 'Env', + type: 'text', + name: 'datadog_env', + title: 'Env', help: 'The environment tag that traces will be tagged with' } ] @@ -90,7 +108,7 @@ class DatadogService < Service url = api_url.presence || sprintf(URL_TEMPLATE, datadog_site: datadog_site) url = URI.parse(url) url.path = File.join(url.path || '/', api_key) - query = { service: datadog_service, env: datadog_env }.compact + query = { service: datadog_service.presence, env: datadog_env.presence }.compact url.query = query.to_query unless query.empty? url.to_s end diff --git a/app/models/project_services/mock_deployment_service.rb b/app/models/project_services/mock_deployment_service.rb deleted file mode 100644 index e55335d9aae..00000000000 --- a/app/models/project_services/mock_deployment_service.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -# Deprecated, to be deleted in 13.8 (https://gitlab.com/gitlab-org/gitlab/-/issues/293914) -# -# This was a class used only in development environment but became unusable -# since DeploymentService was deleted -class MockDeploymentService < Service - default_value_for :category, 'deployment' - - def title - 'Mock deployment' - end - - def description - 'Mock deployment service' - end - - def self.to_param - 'mock_deployment' - end - - # No terminals support - def terminals(environment) - [] - end - - def self.supported_events - %w() - end - - def predefined_variables(project:, environment_name:) - [] - end - - def can_test? - false - end -end diff --git a/app/models/push_event_payload.rb b/app/models/push_event_payload.rb index 6a32c480b04..2786ecb641a 100644 --- a/app/models/push_event_payload.rb +++ b/app/models/push_event_payload.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class PushEventPayload < ApplicationRecord + extend SuppressCompositePrimaryKeyWarning + include ShaAttribute belongs_to :event, inverse_of: :push_event_payload diff --git a/app/models/readme_blob.rb b/app/models/readme_blob.rb deleted file mode 100644 index 695b4e3ffe3..00000000000 --- a/app/models/readme_blob.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -class ReadmeBlob < SimpleDelegator - include BlobActiveModel - - attr_reader :repository - - def initialize(blob, repository) - @repository = repository - - super(blob) - end - - def rendered_markup - repository.rendered_readme - end -end diff --git a/app/models/release.rb b/app/models/release.rb index 2b82fdc37f6..60c2abcacb3 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -24,6 +24,7 @@ class Release < ApplicationRecord validates :project, :tag, presence: true validates_associated :milestone_releases, message: -> (_, obj) { obj[:value].map(&:errors).map(&:full_messages).join(",") } + validates :links, nested_attributes_duplicates: { scope: :release, child_attributes: %i[name url filepath] } scope :sorted, -> { order(released_at: :desc) } scope :preloaded, -> { includes(:evidences, :milestones, project: [:project_feature, :route, { namespace: :route }]) } diff --git a/app/models/repository.rb b/app/models/repository.rb index c19448332f8..ba13b67d101 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -39,7 +39,7 @@ class Repository # # For example, for entry `:commit_count` there's a method called `commit_count` which # stores its data in the `commit_count` cache key. - CACHED_METHODS = %i(size commit_count rendered_readme readme_path contribution_guide + CACHED_METHODS = %i(size commit_count readme_path contribution_guide changelog license_blob license_key gitignore gitlab_ci_yml branch_names tag_names branch_count tag_count avatar exists? root_ref merged_branch_names @@ -53,7 +53,7 @@ class Repository # 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(rendered_readme readme_path), + readme: %i(readme_path), changelog: :changelog, license: %i(license_blob license_key license), contributing: :contribution_guide, @@ -151,7 +151,8 @@ class Repository all: !!opts[:all], first_parent: !!opts[:first_parent], order: opts[:order], - literal_pathspec: opts.fetch(:literal_pathspec, true) + literal_pathspec: opts.fetch(:literal_pathspec, true), + trailers: opts[:trailers] } commits = Gitlab::Git::Commit.where(options) @@ -497,23 +498,7 @@ class Repository end def blob_at(sha, path) - blob = Blob.decorate(raw_repository.blob_at(sha, path), container) - - # Don't attempt to return a special result if there is no blob at all - return unless blob - - # Don't attempt to return a special result if this can't be a README - return blob unless Gitlab::FileDetector.type_of(blob.name) == :readme - - # Don't attempt to return a special result unless we're looking at HEAD - return blob unless head_commit&.sha == sha - - case path - when head_tree&.readme_path - ReadmeBlob.new(blob, self) - else - blob - end + Blob.decorate(raw_repository.blob_at(sha, path), container) rescue Gitlab::Git::Repository::NoRepository nil end @@ -611,15 +596,6 @@ class Repository end cache_method :readme_path - def rendered_readme - return unless readme - - context = { project: project } - - MarkupHelper.markup_unsafe(readme.name, readme.data, context) - end - cache_method :rendered_readme - def contribution_guide file_on_head(:contributing) end @@ -1058,6 +1034,10 @@ class Repository blob_data_at(sha, '.lfsconfig') end + def changelog_config(ref = 'HEAD') + blob_data_at(ref, Gitlab::Changelog::Config::FILE_PATH) + end + def fetch_ref(source_repository, source_ref:, target_ref:) raw_repository.fetch_ref(source_repository.raw_repository, source_ref: source_ref, target_ref: target_ref) end @@ -1142,6 +1122,13 @@ class Repository end end + # Choose one of the available repository storage options based on a normalized weighted probability. + # We should always use the latest settings, to avoid picking a deleted shard. + def self.pick_storage_shard(expire: true) + Gitlab::CurrentSettings.expire_current_application_settings if expire + Gitlab::CurrentSettings.pick_repository_storage + end + private # TODO Genericize finder, later split this on finders by Ref or Oid diff --git a/app/models/repository_language.rb b/app/models/repository_language.rb index 6b1793a551f..b7a96211fb1 100644 --- a/app/models/repository_language.rb +++ b/app/models/repository_language.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class RepositoryLanguage < ApplicationRecord + extend SuppressCompositePrimaryKeyWarning + belongs_to :project belongs_to :programming_language diff --git a/app/models/service.rb b/app/models/service.rb index e5626462dd3..c49e0869b21 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -46,7 +46,6 @@ class Service < ApplicationRecord after_initialize :initialize_properties after_commit :reset_updated_properties - after_commit :cache_project_has_external_issue_tracker belongs_to :project, inverse_of: :services belongs_to :group, inverse_of: :services @@ -55,11 +54,11 @@ class Service < ApplicationRecord validates :project_id, presence: true, unless: -> { template? || instance? || group_id } validates :group_id, presence: true, unless: -> { template? || instance? || project_id } validates :project_id, :group_id, absence: true, if: -> { template? || instance? } - validates :type, uniqueness: { scope: :project_id }, unless: -> { template? || instance? || group_id }, on: :create - validates :type, uniqueness: { scope: :group_id }, unless: -> { template? || instance? || project_id } validates :type, presence: true - validates :template, uniqueness: { scope: :type }, if: -> { template? } - validates :instance, uniqueness: { scope: :type }, if: -> { instance? } + validates :type, uniqueness: { scope: :template }, if: :template? + validates :type, uniqueness: { scope: :instance }, if: :instance? + validates :type, uniqueness: { scope: :project_id }, if: :project_id? + validates :type, uniqueness: { scope: :group_id }, if: :group_id? validate :validate_is_instance_or_template validate :validate_belongs_to_project_or_group @@ -438,10 +437,6 @@ class Service < ApplicationRecord ProjectServiceWorker.perform_async(id, data) end - def external_issue_tracker? - category == :issue_tracker && active? - end - def external_wiki? type == 'ExternalWikiService' && active? end @@ -461,12 +456,6 @@ class Service < ApplicationRecord errors.add(:project_id, 'The service cannot belong to both a project and a group') if project_id && group_id end - def cache_project_has_external_issue_tracker - if project && !project.destroyed? - project.cache_has_external_issue_tracker - end - end - def valid_recipients? activated? && !importing? end diff --git a/app/models/snippet.rb b/app/models/snippet.rb index c4a7c5e25dc..ab8782ed87f 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -317,7 +317,7 @@ class Snippet < ApplicationRecord end def repository_storage - snippet_repository&.shard_name || self.class.pick_repository_storage + snippet_repository&.shard_name || Repository.pick_storage_shard end # Repositories are created by default with the `master` branch. diff --git a/app/models/terraform/state.rb b/app/models/terraform/state.rb index efbbd86ae4a..eb7d465d585 100644 --- a/app/models/terraform/state.rb +++ b/app/models/terraform/state.rb @@ -22,7 +22,9 @@ module Terraform scope :versioning_not_enabled, -> { where(versioning_enabled: false) } scope :ordered_by_name, -> { order(:name) } + scope :with_name, -> (name) { where(name: name) } + validates :name, presence: true, uniqueness: { scope: :project_id } validates :project_id, presence: true validates :uuid, presence: true, uniqueness: true, length: { is: UUID_LENGTH }, format: { with: HEX_REGEXP, message: 'only allows hex characters' } diff --git a/app/models/terraform/state_version.rb b/app/models/terraform/state_version.rb index 19d708616fc..be0803fee0e 100644 --- a/app/models/terraform/state_version.rb +++ b/app/models/terraform/state_version.rb @@ -9,6 +9,8 @@ module Terraform belongs_to :build, class_name: 'Ci::Build', optional: true, foreign_key: :ci_build_id scope :ordered_by_version_desc, -> { order(version: :desc) } + scope :with_files_stored_locally, -> { where(file_store: Terraform::StateUploader::Store::LOCAL) } + scope :preload_state, -> { includes(:terraform_state) } default_value_for(:file_store) { StateUploader.default_store } diff --git a/app/models/token_with_iv.rb b/app/models/token_with_iv.rb new file mode 100644 index 00000000000..115f40b4a82 --- /dev/null +++ b/app/models/token_with_iv.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +# rubocop: todo Gitlab/NamespacedClass +class TokenWithIv < ApplicationRecord + validates :hashed_token, presence: true + validates :iv, presence: true + validates :hashed_plaintext_token, presence: true + + def self.find_by_hashed_token(value) + find_by(hashed_token: ::Digest::SHA256.digest(value)) + end + + def self.find_by_plaintext_token(value) + find_by(hashed_plaintext_token: ::Digest::SHA256.digest(value)) + end + + def self.find_nonce_by_hashed_token(value) + return unless table_exists? + + token_record = find_by_hashed_token(value) + token_record&.iv + end +end diff --git a/app/models/u2f_registration.rb b/app/models/u2f_registration.rb index 1a389081913..65dc7a47533 100644 --- a/app/models/u2f_registration.rb +++ b/app/models/u2f_registration.rb @@ -4,11 +4,19 @@ class U2fRegistration < ApplicationRecord belongs_to :user - after_commit :schedule_webauthn_migration, on: :create - after_commit :update_webauthn_registration, on: :update, if: :counter_changed? - def schedule_webauthn_migration - BackgroundMigrationWorker.perform_async('MigrateU2fWebauthn', [id, id]) + after_create :create_webauthn_registration + after_update :update_webauthn_registration, if: :counter_changed? + + def create_webauthn_registration + converter = Gitlab::Auth::U2fWebauthnConverter.new(self) + WebauthnRegistration.create!(converter.convert) + rescue StandardError => ex + Gitlab::AppJsonLogger.error( + event: 'u2f_migration', + error: ex.class.name, + backtrace: ::Gitlab::BacktraceCleaner.clean_backtrace(ex.backtrace), + message: "U2F to WebAuthn conversion failed") end def update_webauthn_registration diff --git a/app/models/user.rb b/app/models/user.rb index b4ec6064ff8..4a2ca64fbe9 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -960,8 +960,8 @@ class User < ApplicationRecord end # rubocop: disable CodeReuse/ServiceClass - def refresh_authorized_projects - Users::RefreshAuthorizedProjectsService.new(self).execute + def refresh_authorized_projects(source: nil) + Users::RefreshAuthorizedProjectsService.new(self, source: source).execute end # rubocop: enable CodeReuse/ServiceClass diff --git a/app/models/user_callout.rb b/app/models/user_callout.rb index ad5651f9439..d93fe611538 100644 --- a/app/models/user_callout.rb +++ b/app/models/user_callout.rb @@ -7,19 +7,19 @@ class UserCallout < ApplicationRecord gke_cluster_integration: 1, gcp_signup_offer: 2, cluster_security_warning: 3, - gold_trial: 4, # EE-only - geo_enable_hashed_storage: 5, # EE-only - geo_migrate_hashed_storage: 6, # EE-only - canary_deployment: 7, # EE-only - gold_trial_billings: 8, # EE-only + gold_trial: 4, # EE-only + geo_enable_hashed_storage: 5, # EE-only + geo_migrate_hashed_storage: 6, # EE-only + canary_deployment: 7, # EE-only + gold_trial_billings: 8, # EE-only suggest_popover_dismissed: 9, tabs_position_highlight: 10, - threat_monitoring_info: 11, # EE-only - account_recovery_regular_check: 12, # EE-only + threat_monitoring_info: 11, # EE-only + account_recovery_regular_check: 12, # EE-only webhooks_moved: 13, service_templates_deprecated: 14, admin_integrations_moved: 15, - web_ide_alert_dismissed: 16, # no longer in use + web_ide_alert_dismissed: 16, # no longer in use active_user_count_threshold: 18, # EE-only buy_pipeline_minutes_notification_dot: 19, # EE-only personal_access_token_expiry: 21, # EE-only @@ -27,7 +27,9 @@ class UserCallout < ApplicationRecord customize_homepage: 23, feature_flags_new_version: 24, registration_enabled_callout: 25, - new_user_signups_cap_reached: 26 # EE-only + new_user_signups_cap_reached: 26, # EE-only + unfinished_tag_cleanup_callout: 27, + eoa_bronze_plan_banner: 28 # EE-only } validates :user, presence: true diff --git a/app/models/user_interacted_project.rb b/app/models/user_interacted_project.rb index 7e7a387d3d4..4c8cc5fc83a 100644 --- a/app/models/user_interacted_project.rb +++ b/app/models/user_interacted_project.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class UserInteractedProject < ApplicationRecord + extend SuppressCompositePrimaryKeyWarning + belongs_to :user belongs_to :project diff --git a/app/models/vulnerability.rb b/app/models/vulnerability.rb index ab29afd0d08..17e3e285b40 100644 --- a/app/models/vulnerability.rb +++ b/app/models/vulnerability.rb @@ -25,4 +25,4 @@ class Vulnerability < ApplicationRecord end end -Vulnerability.prepend_if_ee('EE::Vulnerability') +Vulnerability.prepend_ee_mod diff --git a/app/models/wiki.rb b/app/models/wiki.rb index 11c10a61d18..ab53515ec48 100644 --- a/app/models/wiki.rb +++ b/app/models/wiki.rb @@ -256,6 +256,15 @@ class Wiki def after_post_receive end + override :git_garbage_collect_worker_klass + def git_garbage_collect_worker_klass + Wikis::GitGarbageCollectWorker + end + + def cleanup + @repository = nil + end + private def commit_details(action, message = nil, title = nil) diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 03cb53f55be..83acf0c12d7 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -30,6 +30,9 @@ class ProjectPolicy < BasePolicy desc "User has maintainer access" condition(:maintainer) { team_access_level >= Gitlab::Access::MAINTAINER } + desc "User is a project bot" + condition(:project_bot) { user.project_bot? && team_member? } + desc "Project is public" condition(:public_project, scope: :subject, score: 0) { project.public? } @@ -578,6 +581,10 @@ class ProjectPolicy < BasePolicy enable :read_issue_link end + rule { can?(:developer_access) }.policy do + enable :read_security_configuration + end + # Design abilities could also be prevented in the issue policy. rule { design_management_disabled }.policy do prevent :read_design @@ -616,10 +623,14 @@ class ProjectPolicy < BasePolicy prevent :read_project end + rule { project_bot }.enable :project_bot_access + rule { resource_access_token_available & can?(:admin_project) }.policy do enable :admin_resource_access_tokens end + rule { can?(:project_bot_access) }.prevent :admin_resource_access_tokens + rule { user_defined_variables_allowed | can?(:maintainer_access) }.policy do enable :set_pipeline_variables end diff --git a/app/presenters/ci/build_presenter.rb b/app/presenters/ci/build_presenter.rb index 03cbb57eb84..51a81158f78 100644 --- a/app/presenters/ci/build_presenter.rb +++ b/app/presenters/ci/build_presenter.rb @@ -50,3 +50,5 @@ module Ci end end end + +Ci::BuildPresenter.prepend_if_ee('EE::Ci::BuildPresenter') diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb index ffa33dc9f15..dbb77143e2e 100644 --- a/app/presenters/ci/build_runner_presenter.rb +++ b/app/presenters/ci/build_runner_presenter.rb @@ -93,22 +93,10 @@ module Ci end def refspec_for_persistent_ref - # - # End-to-end test coverage for CI fetching seems to not be strong, so we - # are using a feature flag here to close the confidence gap. My (JV) - # confidence about the change is very high but if something is wrong - # with it after all, this would cause all CI jobs on gitlab.com to fail. - # - # The roll-out will be tracked in + # Use persistent_ref.sha because it sometimes causes 'git fetch' to do + # less work. See # https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/746. - # - if Feature.enabled?(:scalability_ci_fetch_sha, type: :ops) - # Use persistent_ref.sha because it causes 'git fetch' to do less work. - # See https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/746. - "+#{pipeline.persistent_ref.sha}:#{pipeline.persistent_ref.path}" - else - "+#{pipeline.persistent_ref.path}:#{pipeline.persistent_ref.path}" - end + "+#{pipeline.persistent_ref.sha}:#{pipeline.persistent_ref.path}" end def persistent_ref_exist? diff --git a/app/presenters/ci/pipeline_artifacts/code_coverage_presenter.rb b/app/presenters/ci/pipeline_artifacts/code_coverage_presenter.rb index 098e839132c..6312bd44118 100644 --- a/app/presenters/ci/pipeline_artifacts/code_coverage_presenter.rb +++ b/app/presenters/ci/pipeline_artifacts/code_coverage_presenter.rb @@ -2,7 +2,7 @@ module Ci module PipelineArtifacts - class CodeCoveragePresenter < ProcessablePresenter + class CodeCoveragePresenter < Gitlab::View::Presenter::Delegated include Gitlab::Utils::StrongMemoize def for_files(filenames) diff --git a/app/presenters/ci/pipeline_artifacts/code_quality_mr_diff_presenter.rb b/app/presenters/ci/pipeline_artifacts/code_quality_mr_diff_presenter.rb new file mode 100644 index 00000000000..2fe3104fe69 --- /dev/null +++ b/app/presenters/ci/pipeline_artifacts/code_quality_mr_diff_presenter.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Ci + module PipelineArtifacts + class CodeQualityMrDiffPresenter < Gitlab::View::Presenter::Delegated + include Gitlab::Utils::StrongMemoize + + def for_files(filenames) + quality_files = raw_report["files"].select { |key| filenames.include?(key) } + + { files: quality_files } + end + + private + + def raw_report + strong_memoize(:raw_report) do + self.each_blob do |blob| + Gitlab::Json.parse(blob).with_indifferent_access + end + end + end + end + end +end diff --git a/app/presenters/dev_ops_score/metric_presenter.rb b/app/presenters/dev_ops_report/metric_presenter.rb index e7363293435..e7363293435 100644 --- a/app/presenters/dev_ops_score/metric_presenter.rb +++ b/app/presenters/dev_ops_report/metric_presenter.rb diff --git a/app/presenters/gitlab/whats_new/item_presenter.rb b/app/presenters/gitlab/whats_new/item_presenter.rb deleted file mode 100644 index 9f66e19ade0..00000000000 --- a/app/presenters/gitlab/whats_new/item_presenter.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module WhatsNew - class ItemPresenter - DICTIONARY = { - core: 'Free', - starter: 'Bronze', - premium: 'Silver', - ultimate: 'Gold' - }.freeze - - def self.present(item) - if Gitlab.com? - item['packages'] = item['packages'].map { |p| DICTIONARY[p.downcase.to_sym] } - end - - item - end - end - end -end diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index 55b550d8544..e13ef7a3811 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -17,7 +17,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated MAX_TOPICS_TO_SHOW = 3 def statistic_icon(icon_name = 'plus-square-o') - sprite_icon(icon_name, css_class: 'icon gl-mr-2') + sprite_icon(icon_name, css_class: 'icon gl-mr-2 gl-text-gray-500') end def statistics_anchors(show_auto_devops_callout:) @@ -239,7 +239,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated AnchorData.new(false, statistic_icon + _('New file'), new_file_path, - 'missing') + 'dashed') end end diff --git a/app/serializers/README.md b/app/serializers/README.md index 89721f572e0..97e9625eb6f 100644 --- a/app/serializers/README.md +++ b/app/serializers/README.md @@ -99,7 +99,7 @@ create a JSON response according to your needs. ```ruby class PipelineSerializer < BaseSerializer - entity PipelineEntity + entity Ci::PipelineEntity def represent_details(resource) represent(resource, only: [:details]) diff --git a/app/serializers/admin/user_entity.rb b/app/serializers/admin/user_entity.rb index ad96c101822..8908d610046 100644 --- a/app/serializers/admin/user_entity.rb +++ b/app/serializers/admin/user_entity.rb @@ -10,6 +10,7 @@ module Admin expose :email expose :last_activity_on expose :avatar_url + expose :note expose :badges do |user| user_badges_in_admin_section(user) end diff --git a/app/serializers/admin/user_serializer.rb b/app/serializers/admin/user_serializer.rb index 09036428bab..edd28e88553 100644 --- a/app/serializers/admin/user_serializer.rb +++ b/app/serializers/admin/user_serializer.rb @@ -2,6 +2,6 @@ module Admin class UserSerializer < BaseSerializer - entity UserEntity + entity Admin::UserEntity end end diff --git a/app/serializers/base_discussion_entity.rb b/app/serializers/base_discussion_entity.rb index 5ca4d1d6cc9..8d4c3906847 100644 --- a/app/serializers/base_discussion_entity.rb +++ b/app/serializers/base_discussion_entity.rb @@ -15,6 +15,7 @@ class BaseDiscussionEntity < Grape::Entity expose :for_commit?, as: :for_commit expose :individual_note?, as: :individual_note expose :resolvable?, as: :resolvable + expose :resolved_by_push?, as: :resolved_by_push expose :truncated_diff_lines, using: DiffLineEntity, if: -> (d, _) { d.diff_discussion? && d.on_text? && (d.expanded? || render_truncated_diff_lines?) } diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb index 2432a6a0e4d..ea72b2b89e7 100644 --- a/app/serializers/build_details_entity.rb +++ b/app/serializers/build_details_entity.rb @@ -9,7 +9,7 @@ class BuildDetailsEntity < JobEntity expose :user, using: UserEntity expose :runner, using: RunnerEntity expose :metadata, using: BuildMetadataEntity - expose :pipeline, using: PipelineEntity + expose :pipeline, using: Ci::PipelineEntity expose :deployment_status, if: -> (*) { build.starts_environment? } do expose :deployment_status, as: :status diff --git a/app/serializers/ci/codequality_mr_diff_entity.rb b/app/serializers/ci/codequality_mr_diff_entity.rb new file mode 100644 index 00000000000..99e7cc54017 --- /dev/null +++ b/app/serializers/ci/codequality_mr_diff_entity.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Ci + class CodequalityMrDiffEntity < Grape::Entity + expose :files + end +end diff --git a/app/serializers/ci/codequality_mr_diff_report_serializer.rb b/app/serializers/ci/codequality_mr_diff_report_serializer.rb new file mode 100644 index 00000000000..e9b51930b99 --- /dev/null +++ b/app/serializers/ci/codequality_mr_diff_report_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Ci + class CodequalityMrDiffReportSerializer < BaseSerializer + entity CodequalityMrDiffEntity + end +end diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/ci/pipeline_entity.rb index 2d278f0e30d..86f93929a5d 100644 --- a/app/serializers/pipeline_entity.rb +++ b/app/serializers/ci/pipeline_entity.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class PipelineEntity < Grape::Entity +class Ci::PipelineEntity < Grape::Entity include RequestAwareEntity include Gitlab::Utils::StrongMemoize @@ -120,3 +120,5 @@ class PipelineEntity < Grape::Entity end end end + +Ci::PipelineEntity.prepend_if_ee('EE::Ci::PipelineEntity') diff --git a/app/serializers/concerns/user_status_tooltip.rb b/app/serializers/concerns/user_status_tooltip.rb index fcf6700cb59..ca2854224a7 100644 --- a/app/serializers/concerns/user_status_tooltip.rb +++ b/app/serializers/concerns/user_status_tooltip.rb @@ -16,6 +16,10 @@ module UserStatusTooltip status_loaded? && show_status_emoji?(user.status) end + expose :availability, if: -> (*) { status_loaded? } do |user| + user.status&.availability + end + private def status_loaded? diff --git a/app/serializers/diff_file_metadata_entity.rb b/app/serializers/diff_file_metadata_entity.rb index 460f4967e99..70a5b266be1 100644 --- a/app/serializers/diff_file_metadata_entity.rb +++ b/app/serializers/diff_file_metadata_entity.rb @@ -7,6 +7,7 @@ class DiffFileMetadataEntity < Grape::Entity expose :old_path expose :new_file?, as: :new_file expose :deleted_file?, as: :deleted_file + expose :submodule?, as: :submodule expose :file_identifier_hash expose :file_hash end diff --git a/app/serializers/diffs_entity.rb b/app/serializers/diffs_entity.rb index f573bbe8385..4bc6644a5cb 100644 --- a/app/serializers/diffs_entity.rb +++ b/app/serializers/diffs_entity.rb @@ -79,7 +79,9 @@ class DiffsEntity < Grape::Entity end expose :definition_path_prefix do |diffs| - project_blob_path(merge_request.project, diffs.diff_refs&.head_sha) + next unless merge_request.diff_head_sha + + project_blob_path(merge_request.project, merge_request.diff_head_sha) end def merge_request diff --git a/app/serializers/group_group_link_entity.rb b/app/serializers/group_group_link_entity.rb deleted file mode 100644 index 1e736214f54..00000000000 --- a/app/serializers/group_group_link_entity.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true - -class GroupGroupLinkEntity < Grape::Entity - include RequestAwareEntity - - expose :id - expose :created_at - expose :expires_at do |group_link| - group_link.expires_at&.to_time - end - - expose :can_update do |group_link| - can_manage?(group_link) - end - - expose :can_remove do |group_link| - can_manage?(group_link) - end - - expose :access_level do - expose :human_access, as: :string_value - expose :group_access, as: :integer_value - end - - expose :valid_roles do |group_link| - group_link.class.access_options - end - - expose :shared_with_group do - expose :avatar_url do |group_link| - group_link.shared_with_group.avatar_url(only_path: false) - end - - expose :web_url do |group_link| - group_link.shared_with_group.web_url - end - - expose :shared_with_group, merge: true, using: GroupBasicEntity - end - - private - - def current_user - options[:current_user] - end - - def can_manage?(group_link) - can?(current_user, :admin_group_member, group_link.shared_group) - end -end diff --git a/app/serializers/group_group_link_serializer.rb b/app/serializers/group_group_link_serializer.rb deleted file mode 100644 index 6ae8daf9207..00000000000 --- a/app/serializers/group_group_link_serializer.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -class GroupGroupLinkSerializer < BaseSerializer - entity GroupGroupLinkEntity -end diff --git a/app/serializers/group_link/group_group_link_entity.rb b/app/serializers/group_link/group_group_link_entity.rb new file mode 100644 index 00000000000..cedc8bd8582 --- /dev/null +++ b/app/serializers/group_link/group_group_link_entity.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module GroupLink + class GroupGroupLinkEntity < GroupLink::GroupLinkEntity + include RequestAwareEntity + + expose :can_update do |group_link| + can_manage?(group_link) + end + + expose :can_remove do |group_link| + can_manage?(group_link) + end + + private + + def current_user + options[:current_user] + end + + def can_manage?(group_link) + can?(current_user, :admin_group_member, group_link.shared_group) + end + end +end diff --git a/app/serializers/group_link/group_group_link_serializer.rb b/app/serializers/group_link/group_group_link_serializer.rb new file mode 100644 index 00000000000..1e3f861f09a --- /dev/null +++ b/app/serializers/group_link/group_group_link_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module GroupLink + class GroupGroupLinkSerializer < BaseSerializer + entity GroupLink::GroupGroupLinkEntity + end +end diff --git a/app/serializers/group_link/group_link_entity.rb b/app/serializers/group_link/group_link_entity.rb new file mode 100644 index 00000000000..12349320b6f --- /dev/null +++ b/app/serializers/group_link/group_link_entity.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module GroupLink + class GroupLinkEntity < Grape::Entity + include RequestAwareEntity + + expose :id + expose :created_at + expose :expires_at do |group_link| + group_link.expires_at&.to_time + end + + expose :access_level do + expose :human_access, as: :string_value + expose :group_access, as: :integer_value + end + + expose :valid_roles do |group_link| + group_link.class.access_options + end + + expose :shared_with_group do + expose :avatar_url do |group_link| + group_link.shared_with_group.avatar_url(only_path: false, size: Member::AVATAR_SIZE) + end + + expose :web_url do |group_link| + group_link.shared_with_group.web_url + end + + expose :shared_with_group, merge: true, using: GroupBasicEntity + end + end +end diff --git a/app/serializers/group_link/project_group_link_entity.rb b/app/serializers/group_link/project_group_link_entity.rb new file mode 100644 index 00000000000..2ff275fff01 --- /dev/null +++ b/app/serializers/group_link/project_group_link_entity.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module GroupLink + class ProjectGroupLinkEntity < GroupLink::GroupLinkEntity + include RequestAwareEntity + include Projects::ProjectMembersHelper + + expose :can_update do |group_link| + can_manage_project_members?(group_link.project) + end + + expose :can_remove do |group_link| + can_manage_project_members?(group_link.project) + end + + private + + def current_user + options[:current_user] + end + end +end diff --git a/app/serializers/group_link/project_group_link_serializer.rb b/app/serializers/group_link/project_group_link_serializer.rb new file mode 100644 index 00000000000..b2559e61e31 --- /dev/null +++ b/app/serializers/group_link/project_group_link_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module GroupLink + class ProjectGroupLinkSerializer < BaseSerializer + entity GroupLink::ProjectGroupLinkEntity + end +end diff --git a/app/serializers/member_entity.rb b/app/serializers/member_entity.rb index 584ba4c62de..e8f2bb28d60 100644 --- a/app/serializers/member_entity.rb +++ b/app/serializers/member_entity.rb @@ -23,6 +23,10 @@ class MemberEntity < Grape::Entity member.can_remove? end + expose :is_direct_member do |member, options| + member.source == options[:source] + end + expose :access_level do expose :human_access, as: :string_value expose :access_level, as: :integer_value diff --git a/app/serializers/merge_request_sidebar_extras_entity.rb b/app/serializers/merge_request_sidebar_extras_entity.rb index 261b6e8e519..b1638ce71e2 100644 --- a/app/serializers/merge_request_sidebar_extras_entity.rb +++ b/app/serializers/merge_request_sidebar_extras_entity.rb @@ -1,11 +1,11 @@ # frozen_string_literal: true class MergeRequestSidebarExtrasEntity < IssuableSidebarExtrasEntity - expose :assignees do |merge_request| - MergeRequestUserEntity.represent(merge_request.assignees, merge_request: merge_request) + expose :assignees do |merge_request, options| + MergeRequestUserEntity.represent(merge_request.assignees, options.merge(merge_request: merge_request)) end - expose :reviewers, if: -> (m) { m.allows_reviewers? } do |merge_request| - MergeRequestUserEntity.represent(merge_request.reviewers, merge_request: merge_request) + expose :reviewers, if: -> (m) { m.allows_reviewers? } do |merge_request, options| + MergeRequestUserEntity.represent(merge_request.reviewers, options.merge(merge_request: merge_request)) end end diff --git a/app/serializers/merge_request_user_entity.rb b/app/serializers/merge_request_user_entity.rb index 604c9cabd50..edb7e10bac5 100644 --- a/app/serializers/merge_request_user_entity.rb +++ b/app/serializers/merge_request_user_entity.rb @@ -1,9 +1,22 @@ # frozen_string_literal: true class MergeRequestUserEntity < ::API::Entities::UserBasic + include UserStatusTooltip + include RequestAwareEntity + expose :can_merge do |reviewer, options| options[:merge_request]&.can_be_merged_by?(reviewer) end + + expose :can_update_merge_request do |reviewer, options| + request.current_user&.can?(:update_merge_request, options[:merge_request]) + end + + expose :reviewed, if: -> (_, options) { options[:merge_request] && options[:merge_request].allows_reviewers? } do |reviewer, options| + reviewer = options[:merge_request].find_reviewer(reviewer) + + reviewer&.reviewed? + end end MergeRequestUserEntity.prepend_if_ee('EE::MergeRequestUserEntity') diff --git a/app/serializers/pipeline_details_entity.rb b/app/serializers/pipeline_details_entity.rb index e53fa7873ac..4fec543eca8 100644 --- a/app/serializers/pipeline_details_entity.rb +++ b/app/serializers/pipeline_details_entity.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class PipelineDetailsEntity < PipelineEntity +class PipelineDetailsEntity < Ci::PipelineEntity expose :project, using: ProjectEntity expose :flags do diff --git a/app/services/alert_management/http_integrations/update_service.rb b/app/services/alert_management/http_integrations/update_service.rb index 220c4e759f0..af079f670b8 100644 --- a/app/services/alert_management/http_integrations/update_service.rb +++ b/app/services/alert_management/http_integrations/update_service.rb @@ -9,7 +9,7 @@ module AlertManagement def initialize(integration, current_user, params) @integration = integration @current_user = current_user - @params = params + @params = params.with_indifferent_access end def execute @@ -17,7 +17,7 @@ module AlertManagement params[:token] = nil if params.delete(:regenerate_token) - if integration.update(params) + if integration.update(permitted_params) success else error(integration.errors.full_messages.to_sentence) @@ -32,6 +32,15 @@ module AlertManagement current_user&.can?(:admin_operations, integration) end + def permitted_params + params.slice(*permitted_params_keys) + end + + # overriden in EE + def permitted_params_keys + %i[name active token] + end + def error(message) ServiceResponse.error(message: message) end @@ -46,3 +55,5 @@ module AlertManagement end end end + +::AlertManagement::HttpIntegrations::UpdateService.prepend_if_ee('::EE::AlertManagement::HttpIntegrations::UpdateService') diff --git a/app/services/alert_management/process_prometheus_alert_service.rb b/app/services/alert_management/process_prometheus_alert_service.rb index 753162bfdbf..545c5581f72 100644 --- a/app/services/alert_management/process_prometheus_alert_service.rb +++ b/app/services/alert_management/process_prometheus_alert_service.rb @@ -2,9 +2,8 @@ module AlertManagement class ProcessPrometheusAlertService - include BaseServiceUtility - include Gitlab::Utils::StrongMemoize - include ::IncidentManagement::Settings + extend ::Gitlab::Utils::Override + include ::AlertManagement::AlertProcessing def initialize(project, payload) @project = project @@ -14,11 +13,10 @@ module AlertManagement def execute return bad_request unless incoming_payload.has_required_attributes? - process_alert_management_alert + process_alert return bad_request unless alert.persisted? - process_incident_issues if process_issues? - send_alert_email if send_email? + complete_post_processing_tasks ServiceResponse.success end @@ -27,110 +25,31 @@ module AlertManagement attr_reader :project, :payload - def process_alert_management_alert - if incoming_payload.resolved? - process_resolved_alert_management_alert - else - process_firing_alert_management_alert - end - end - - def process_firing_alert_management_alert - if alert.persisted? - alert.register_new_event! - reset_alert_management_alert_status - else - create_alert_management_alert - end - end + override :process_new_alert + def process_new_alert + return if resolving_alert? - def reset_alert_management_alert_status - return if alert.trigger - - logger.warn( - message: 'Unable to update AlertManagement::Alert status to triggered', - project_id: project.id, - alert_id: alert.id - ) + super end - def create_alert_management_alert - if alert.save - alert.execute_services - SystemNoteService.create_new_alert(alert, Gitlab::AlertManagement::Payload::MONITORING_TOOLS[:prometheus]) - return - end + override :process_firing_alert + def process_firing_alert + super - logger.warn( - message: 'Unable to create AlertManagement::Alert', - project_id: project.id, - alert_errors: alert.errors.messages - ) + reset_alert_status end - def process_resolved_alert_management_alert - return unless alert.persisted? - return unless auto_close_incident? - - if alert.resolve(incoming_payload.ends_at) - close_issue(alert.issue) - return - end + def reset_alert_status + return if alert.trigger logger.warn( - message: 'Unable to update AlertManagement::Alert status to resolved', + message: 'Unable to update AlertManagement::Alert status to triggered', project_id: project.id, alert_id: alert.id ) end - def close_issue(issue) - return if issue.blank? || issue.closed? - - Issues::CloseService - .new(project, User.alert_bot) - .execute(issue, system_note: false) - - SystemNoteService.auto_resolve_prometheus_alert(issue, project, User.alert_bot) if issue.reset.closed? - end - - def process_incident_issues - return if alert.issue || alert.resolved? - - IncidentManagement::ProcessAlertWorker.perform_async(nil, nil, alert.id) - end - - def send_alert_email - notification_service - .async - .prometheus_alerts_fired(project, [alert]) - end - - def logger - @logger ||= Gitlab::AppLogger - end - - def alert - strong_memoize(:alert) do - existing_alert || new_alert - end - end - - def existing_alert - strong_memoize(:existing_alert) do - AlertManagement::Alert.not_resolved.for_fingerprint(project, incoming_payload.gitlab_fingerprint).first - end - end - - def new_alert - strong_memoize(:new_alert) do - AlertManagement::Alert.new( - **incoming_payload.alert_params, - ended_at: nil - ) - end - end - + override :incoming_payload def incoming_payload strong_memoize(:incoming_payload) do Gitlab::AlertManagement::Payload.parse( @@ -141,6 +60,11 @@ module AlertManagement end end + override :resolving_alert? + def resolving_alert? + incoming_payload.resolved? + end + def bad_request ServiceResponse.error(message: 'Bad Request', http_status: :bad_request) end diff --git a/app/services/application_settings/update_service.rb b/app/services/application_settings/update_service.rb index 7792b811b4e..5e5c8ae2177 100644 --- a/app/services/application_settings/update_service.rb +++ b/app/services/application_settings/update_service.rb @@ -6,7 +6,7 @@ module ApplicationSettings attr_reader :params, :application_setting - MARKDOWN_CACHE_INVALIDATING_PARAMS = %w(asset_proxy_enabled asset_proxy_url asset_proxy_secret_key asset_proxy_whitelist).freeze + MARKDOWN_CACHE_INVALIDATING_PARAMS = %w(asset_proxy_enabled asset_proxy_url asset_proxy_secret_key asset_proxy_allowlist).freeze def execute result = update_settings diff --git a/app/services/authorized_project_update/recalculate_for_user_range_service.rb b/app/services/authorized_project_update/recalculate_for_user_range_service.rb index 14b0f5d6117..f300c45f019 100644 --- a/app/services/authorized_project_update/recalculate_for_user_range_service.rb +++ b/app/services/authorized_project_update/recalculate_for_user_range_service.rb @@ -9,7 +9,7 @@ module AuthorizedProjectUpdate def execute User.where(id: start_user_id..end_user_id).select(:id).find_each do |user| # rubocop: disable CodeReuse/ActiveRecord - Users::RefreshAuthorizedProjectsService.new(user).execute + Users::RefreshAuthorizedProjectsService.new(user, source: self.class.name).execute end end diff --git a/app/services/boards/create_service.rb b/app/services/boards/create_service.rb index 2ccaea64d14..54dab581686 100644 --- a/app/services/boards/create_service.rb +++ b/app/services/boards/create_service.rb @@ -13,11 +13,11 @@ module Boards private def can_create_board? - parent.boards.empty? || parent.multiple_issue_boards_available? + parent_board_collection.empty? || parent.multiple_issue_boards_available? end def create_board! - board = parent.boards.create(params) + board = parent_board_collection.create(params) unless board.persisted? return ServiceResponse.error(message: "There was an error when creating a board.", payload: board) @@ -30,6 +30,10 @@ module Boards ServiceResponse.success(payload: board) end + + def parent_board_collection + parent.boards + end end end diff --git a/app/services/bulk_create_integration_service.rb b/app/services/bulk_create_integration_service.rb index df78c3645c7..ae756d0856e 100644 --- a/app/services/bulk_create_integration_service.rb +++ b/app/services/bulk_create_integration_service.rb @@ -11,8 +11,6 @@ class BulkCreateIntegrationService service_list = ServiceList.new(batch, service_hash, association).to_array Service.transaction do - run_callbacks(batch) if association == 'project' - results = bulk_insert(*service_list) if integration.data_fields_present? @@ -33,14 +31,6 @@ class BulkCreateIntegrationService klass.insert_all(items_to_insert, returning: [:id]) end - # rubocop: disable CodeReuse/ActiveRecord - def run_callbacks(batch) - if integration.external_issue_tracker? - Project.where(id: batch.select(:id)).update_all(has_external_issue_tracker: true) - end - end - # rubocop: enable CodeReuse/ActiveRecord - def service_hash if integration.template? integration.to_service_hash diff --git a/app/services/captcha/captcha_verification_service.rb b/app/services/captcha/captcha_verification_service.rb new file mode 100644 index 00000000000..45a5a52367c --- /dev/null +++ b/app/services/captcha/captcha_verification_service.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Captcha + ## + # Encapsulates logic of checking captchas. + # + class CaptchaVerificationService + include Recaptcha::Verify + + ## + # Performs verification of a captcha response. + # + # 'captcha_response' parameter is the response from the user solving a client-side captcha. + # + # 'request' parameter is the request which submitted the captcha. + # + # NOTE: Currently only supports reCAPTCHA, and is not yet used in all places of the app in which + # captchas are verified, but these can be addressed in future MRs. See: + # https://gitlab.com/gitlab-org/gitlab/-/issues/273480 + def execute(captcha_response: nil, request:) + return false unless captcha_response + + @request = request + + Gitlab::Recaptcha.load_configurations! + + # NOTE: We could pass the model and let the recaptcha gem automatically add errors to it, + # but we do not, for two reasons: + # + # 1. We want control over when the errors are added + # 2. We want control over the wording and i18n of the message + # 3. We want a consistent interface and behavior when adding support for other captcha + # libraries which may not support automatically adding errors to the model. + verify_recaptcha(response: captcha_response) + end + + private + + # The recaptcha library's Recaptcha::Verify#verify_recaptcha method requires that + # 'request' be a readable attribute - it doesn't support passing it as an options argument. + attr_reader :request + end +end diff --git a/app/services/ci/generate_codequality_mr_diff_report_service.rb b/app/services/ci/generate_codequality_mr_diff_report_service.rb new file mode 100644 index 00000000000..3b1bd319a4f --- /dev/null +++ b/app/services/ci/generate_codequality_mr_diff_report_service.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Ci + # TODO: a couple of points with this approach: + # + reuses existing architecture and reactive caching + # - it's not a report comparison and some comparing features must be turned off. + # see CompareReportsBaseService for more notes. + # issue: https://gitlab.com/gitlab-org/gitlab/issues/34224 + class GenerateCodequalityMrDiffReportService < CompareReportsBaseService + def execute(base_pipeline, head_pipeline) + merge_request = MergeRequest.find_by_id(params[:id]) + { + status: :parsed, + key: key(base_pipeline, head_pipeline), + data: head_pipeline.pipeline_artifacts.find_by_file_type(:code_quality_mr_diff).present.for_files(merge_request.new_paths) + } + rescue => e + Gitlab::ErrorTracking.track_exception(e, project_id: project.id) + { + status: :error, + key: key(base_pipeline, head_pipeline), + status_reason: _('An error occurred while fetching codequality mr diff reports.') + } + end + + def latest?(base_pipeline, head_pipeline, data) + data&.fetch(:key, nil) == key(base_pipeline, head_pipeline) + end + end +end diff --git a/app/services/ci/generate_coverage_reports_service.rb b/app/services/ci/generate_coverage_reports_service.rb index 063fb966183..b3aa7b3091b 100644 --- a/app/services/ci/generate_coverage_reports_service.rb +++ b/app/services/ci/generate_coverage_reports_service.rb @@ -12,7 +12,7 @@ module Ci { status: :parsed, key: key(base_pipeline, head_pipeline), - data: head_pipeline.pipeline_artifacts.find_with_code_coverage.present.for_files(merge_request.new_paths) + data: head_pipeline.pipeline_artifacts.find_by_file_type(:code_coverage).present.for_files(merge_request.new_paths) } rescue => e Gitlab::ErrorTracking.track_exception(e, project_id: project.id) 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 new file mode 100644 index 00000000000..8a4ba039fa3 --- /dev/null +++ b/app/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true +module Ci + module PipelineArtifacts + class CreateCodeQualityMrDiffReportService + def execute(pipeline) + return unless pipeline.can_generate_codequality_reports? + return if pipeline.has_codequality_mr_diff_report? + + file = build_carrierwave_file(pipeline) + + pipeline.pipeline_artifacts.create!( + project_id: pipeline.project_id, + file_type: :code_quality_mr_diff, + file_format: :raw, + size: file["tempfile"].size, + file: file, + expire_at: Ci::PipelineArtifact::EXPIRATION_DATE.from_now + ) + end + + private + + def build_carrierwave_file(pipeline) + CarrierWaveStringFile.new_file( + file_content: build_quality_mr_diff_report(pipeline), + filename: Ci::PipelineArtifact::DEFAULT_FILE_NAMES.fetch(:code_quality_mr_diff), + content_type: 'application/json' + ) + end + + def build_quality_mr_diff_report(pipeline) + mr_diff_report = Gitlab::Ci::Reports::CodequalityMrDiff.new(pipeline.codequality_reports) + + Ci::CodequalityMrDiffReportSerializer.new.represent(mr_diff_report).to_json # rubocop: disable CodeReuse/Serializer + end + end + end +end diff --git a/app/services/ci/process_build_service.rb b/app/services/ci/process_build_service.rb index dd7b562cdb7..733aa75f255 100644 --- a/app/services/ci/process_build_service.rb +++ b/app/services/ci/process_build_service.rb @@ -26,7 +26,7 @@ module Ci end def valid_statuses_for_build(build) - if ::Feature.enabled?(:skip_dag_manual_and_delayed_jobs, default_enabled: :yaml) + if ::Feature.enabled?(:skip_dag_manual_and_delayed_jobs, build.project, default_enabled: :yaml) current_valid_statuses_for_build(build) else legacy_valid_statuses_for_build(build) diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb index e511e26adfe..678b386fbbf 100644 --- a/app/services/ci/process_pipeline_service.rb +++ b/app/services/ci/process_pipeline_service.rb @@ -38,10 +38,15 @@ module Ci # mark builds that are retried if latest_statuses.any? - pipeline.latest_statuses - .where(name: latest_statuses.map(&:second)) - .where.not(id: latest_statuses.map(&:first)) - .update_all(retried: true) + updated_count = pipeline.latest_statuses + .where(name: latest_statuses.map(&:second)) + .where.not(id: latest_statuses.map(&:first)) + .update_all(retried: true) + + # This counter is temporary. It will be used to check whether if we still use this method or not + # after setting correct value of `GenericCommitStatus#retried`. + # More info: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/50465#note_491657115 + metrics.legacy_update_jobs_counter.increment if updated_count > 0 end end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/services/ci/prometheus_metrics/observe_histograms_service.rb b/app/services/ci/prometheus_metrics/observe_histograms_service.rb new file mode 100644 index 00000000000..ee22ea75df9 --- /dev/null +++ b/app/services/ci/prometheus_metrics/observe_histograms_service.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Ci + module PrometheusMetrics + class ObserveHistogramsService + class << self + def available_histograms + @available_histograms ||= [ + histogram(:pipeline_graph_link_calculation_duration_seconds, 'Total time spent calculating links, in seconds', {}, [0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 0.8, 1, 2]), + histogram(:pipeline_graph_links_total, 'Number of links per graph', {}, [1, 5, 10, 25, 50, 100, 200]), + histogram(:pipeline_graph_links_per_job_ratio, 'Ratio of links to job per graph', {}, [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1]) + ].to_h + end + + private + + def histogram(name, *attrs) + [name.to_s, proc { Gitlab::Metrics.histogram(name, *attrs) }] + end + end + + def initialize(project, params) + @project = project + @params = params + end + + def execute + return ServiceResponse.success(http_status: :accepted) unless enabled? + + params + .fetch(:histograms, []) + .each(&method(:observe)) + + ServiceResponse.success(http_status: :created) + end + + private + + attr_reader :project, :params + + def observe(data) + histogram = find_histogram(data[:name]) + histogram.observe({ project: project.full_path }, data[:value].to_f) + end + + def find_histogram(name) + self.class.available_histograms + .fetch(name) { raise ActiveRecord::RecordNotFound } + .call + end + + def enabled? + ::Feature.enabled?(:ci_accept_frontend_prometheus_metrics, project, default_enabled: :yaml) + end + end + end +end diff --git a/app/services/ci/resource_groups/assign_resource_from_resource_group_service.rb b/app/services/ci/resource_groups/assign_resource_from_resource_group_service.rb index a4bcca8e8b3..9e3e6de3928 100644 --- a/app/services/ci/resource_groups/assign_resource_from_resource_group_service.rb +++ b/app/services/ci/resource_groups/assign_resource_from_resource_group_service.rb @@ -7,8 +7,8 @@ module Ci def execute(resource_group) free_resources = resource_group.resources.free.count - resource_group.builds.waiting_for_resource.take(free_resources).each do |build| - build.enqueue_waiting_for_resource + resource_group.processables.waiting_for_resource.take(free_resources).each do |processable| + processable.enqueue_waiting_for_resource end end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/services/clusters/kubernetes/configure_istio_ingress_service.rb b/app/services/clusters/kubernetes/configure_istio_ingress_service.rb index 53c3c686f07..3b7e094bc97 100644 --- a/app/services/clusters/kubernetes/configure_istio_ingress_service.rb +++ b/app/services/clusters/kubernetes/configure_istio_ingress_service.rb @@ -60,7 +60,7 @@ module Clusters cert.public_key = key.public_key cert.subject = name cert.issuer = name - cert.sign(key, OpenSSL::Digest::SHA256.new) + cert.sign(key, OpenSSL::Digest.new('SHA256')) serverless_domain_cluster.update!( key: key.to_pem, diff --git a/app/services/concerns/alert_management/alert_processing.rb b/app/services/concerns/alert_management/alert_processing.rb new file mode 100644 index 00000000000..4143a4668f5 --- /dev/null +++ b/app/services/concerns/alert_management/alert_processing.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +module AlertManagement + # Module to support the processing of new alert payloads + # from various sources. Payloads may be for new alerts, + # existing alerts, or acting as a resolving alert. + # + # Performs processing-related tasks, such as creating system + # notes, creating or resolving related issues, and notifying + # stakeholders of the alert. + # + # Requires #project [Project] and #payload [Hash] methods + # to be defined. + module AlertProcessing + include BaseServiceUtility + include Gitlab::Utils::StrongMemoize + include ::IncidentManagement::Settings + + # Updates or creates alert from payload for project + # including system notes + def process_alert + if alert.persisted? + process_existing_alert + else + process_new_alert + end + end + + # Creates or closes issue for alert and notifies stakeholders + def complete_post_processing_tasks + process_incident_issues if process_issues? + send_alert_email if send_email? + end + + def process_existing_alert + if resolving_alert? + process_resolved_alert + else + process_firing_alert + end + end + + def process_resolved_alert + return unless auto_close_incident? + return close_issue(alert.issue) if alert.resolve(incoming_payload.ends_at) + + logger.warn( + message: 'Unable to update AlertManagement::Alert status to resolved', + project_id: project.id, + alert_id: alert.id + ) + end + + def process_firing_alert + alert.register_new_event! + end + + def close_issue(issue) + return if issue.blank? || issue.closed? + + ::Issues::CloseService + .new(project, User.alert_bot) + .execute(issue, system_note: false) + + SystemNoteService.auto_resolve_prometheus_alert(issue, project, User.alert_bot) if issue.reset.closed? + end + + def process_new_alert + if alert.save + alert.execute_services + SystemNoteService.create_new_alert(alert, alert_source) + else + logger.warn( + message: "Unable to create AlertManagement::Alert from #{alert_source}", + project_id: project.id, + alert_errors: alert.errors.messages + ) + end + end + + def process_incident_issues + return if alert.issue || alert.resolved? + + ::IncidentManagement::ProcessAlertWorker.perform_async(nil, nil, alert.id) + end + + def send_alert_email + notification_service + .async + .prometheus_alerts_fired(project, [alert]) + end + + def incoming_payload + strong_memoize(:incoming_payload) do + Gitlab::AlertManagement::Payload.parse(project, payload.to_h) + end + end + + def alert + strong_memoize(:alert) do + find_existing_alert || build_new_alert + end + end + + def find_existing_alert + return unless incoming_payload.gitlab_fingerprint + + AlertManagement::Alert.not_resolved.for_fingerprint(project, incoming_payload.gitlab_fingerprint).first + end + + def build_new_alert + AlertManagement::Alert.new(**incoming_payload.alert_params, ended_at: nil) + end + + def resolving_alert? + incoming_payload.ends_at.present? + end + + def alert_source + alert.monitoring_tool + end + + def logger + @logger ||= Gitlab::AppLogger + end + end +end diff --git a/app/services/concerns/integrations/project_test_data.rb b/app/services/concerns/integrations/project_test_data.rb index 72c12cfb394..11eb2cd4ca7 100644 --- a/app/services/concerns/integrations/project_test_data.rb +++ b/app/services/concerns/integrations/project_test_data.rb @@ -8,22 +8,41 @@ module Integrations Gitlab::DataBuilder::Push.build_sample(project, current_user) end + def use_optimal_query? + Feature.enabled?(:integrations_test_webhook_optimizations, project) + end + def note_events_data - note = project.notes.first + note = if use_optimal_query? + NotesFinder.new(current_user, project: project, target: project).execute.reorder(nil).last # rubocop: disable CodeReuse/ActiveRecord + else + project.notes.first + end + return { error: s_('TestHooks|Ensure the project has notes.') } unless note.present? Gitlab::DataBuilder::Note.build(note, current_user) end def issues_events_data - issue = project.issues.first + issue = if use_optimal_query? + IssuesFinder.new(current_user, project_id: project.id, sort: 'created_desc').execute.first + else + project.issues.first + end + return { error: s_('TestHooks|Ensure the project has issues.') } unless issue.present? issue.to_hook_data(current_user) end def merge_requests_events_data - merge_request = project.merge_requests.first + merge_request = if use_optimal_query? + MergeRequestsFinder.new(current_user, project_id: project.id, sort: 'created_desc').execute.first + else + project.merge_requests.first + end + return { error: s_('TestHooks|Ensure the project has merge requests.') } unless merge_request.present? merge_request.to_hook_data(current_user) diff --git a/app/services/concerns/spam_check_methods.rb b/app/services/concerns/spam_check_methods.rb deleted file mode 100644 index 939f8f183ab..00000000000 --- a/app/services/concerns/spam_check_methods.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -# SpamCheckMethods -# -# Provide helper methods for checking if a given spammable object has -# potential spam data. -# -# Dependencies: -# - params with :request - -module SpamCheckMethods - # rubocop:disable Gitlab/ModuleWithInstanceVariables - def filter_spam_check_params - @request = params.delete(:request) - @api = params.delete(:api) - @recaptcha_verified = params.delete(:recaptcha_verified) - @spam_log_id = params.delete(:spam_log_id) - end - # rubocop:enable Gitlab/ModuleWithInstanceVariables - - # In order to be proceed to the spam check process, @spammable has to be - # a dirty instance, which means it should be already assigned with the new - # attribute values. - # rubocop:disable Gitlab/ModuleWithInstanceVariables - def spam_check(spammable, user, action:) - raise ArgumentError.new('Please provide an action, such as :create') unless action - - Spam::SpamActionService.new( - spammable: spammable, - request: @request, - user: user, - context: { action: action } - ).execute( - api: @api, - recaptcha_verified: @recaptcha_verified, - spam_log_id: @spam_log_id) - end - # rubocop:enable Gitlab/ModuleWithInstanceVariables -end diff --git a/app/services/concerns/update_repository_storage_methods.rb b/app/services/concerns/update_repository_storage_methods.rb index c3a55e9379e..6e4824bd784 100644 --- a/app/services/concerns/update_repository_storage_methods.rb +++ b/app/services/concerns/update_repository_storage_methods.rb @@ -1,8 +1,9 @@ # frozen_string_literal: true module UpdateRepositoryStorageMethods + include Gitlab::Utils::StrongMemoize + Error = Class.new(StandardError) - SameFilesystemError = Class.new(Error) attr_reader :repository_storage_move delegate :container, :source_storage_name, :destination_storage_name, to: :repository_storage_move @@ -18,9 +19,7 @@ module UpdateRepositoryStorageMethods repository_storage_move.start! end - raise SameFilesystemError if same_filesystem?(source_storage_name, destination_storage_name) - - mirror_repositories + mirror_repositories unless same_filesystem? repository_storage_move.transaction do repository_storage_move.finish_replication! @@ -28,8 +27,10 @@ module UpdateRepositoryStorageMethods track_repository(destination_storage_name) end - remove_old_paths - enqueue_housekeeping + unless same_filesystem? + remove_old_paths + enqueue_housekeeping + end repository_storage_move.finish_cleanup! @@ -80,8 +81,10 @@ module UpdateRepositoryStorageMethods end end - def same_filesystem?(old_storage, new_storage) - Gitlab::GitalyClient.filesystem_id(old_storage) == Gitlab::GitalyClient.filesystem_id(new_storage) + def same_filesystem? + strong_memoize(:same_filesystem) do + Gitlab::GitalyClient.filesystem_id(source_storage_name) == Gitlab::GitalyClient.filesystem_id(destination_storage_name) + end end def remove_old_paths diff --git a/app/services/dependency_proxy/find_or_create_manifest_service.rb b/app/services/dependency_proxy/find_or_create_manifest_service.rb index 6b46f5e4c59..ee608d715aa 100644 --- a/app/services/dependency_proxy/find_or_create_manifest_service.rb +++ b/app/services/dependency_proxy/find_or_create_manifest_service.rb @@ -13,7 +13,7 @@ module DependencyProxy def execute @manifest = @group.dependency_proxy_manifests - .find_or_initialize_by_file_name(@file_name) + .find_or_initialize_by_file_name_or_digest(file_name: @file_name, digest: @tag) head_result = DependencyProxy::HeadManifestService.new(@image, @tag, @token).execute @@ -30,6 +30,7 @@ module DependencyProxy def pull_new_manifest DependencyProxy::PullManifestService.new(@image, @tag, @token).execute_with_manifest do |new_manifest| @manifest.update!( + content_type: new_manifest[:content_type], digest: new_manifest[:digest], file: new_manifest[:file], size: new_manifest[:file].size @@ -38,7 +39,9 @@ module DependencyProxy end def cached_manifest_matches?(head_result) - @manifest && @manifest.digest == head_result[:digest] + return false if head_result[:status] == :error + + @manifest && @manifest.digest == head_result[:digest] && @manifest.content_type == head_result[:content_type] end def respond diff --git a/app/services/dependency_proxy/head_manifest_service.rb b/app/services/dependency_proxy/head_manifest_service.rb index 87d9c417c98..ecc3eb77399 100644 --- a/app/services/dependency_proxy/head_manifest_service.rb +++ b/app/services/dependency_proxy/head_manifest_service.rb @@ -2,6 +2,8 @@ module DependencyProxy class HeadManifestService < DependencyProxy::BaseService + ACCEPT_HEADERS = ::ContainerRegistry::Client::ACCEPTED_TYPES.join(',') + def initialize(image, tag, token) @image = image @tag = tag @@ -9,10 +11,10 @@ module DependencyProxy end def execute - response = Gitlab::HTTP.head(manifest_url, headers: auth_headers) + response = Gitlab::HTTP.head(manifest_url, headers: auth_headers.merge(Accept: ACCEPT_HEADERS)) if response.success? - success(digest: response.headers['docker-content-digest']) + success(digest: response.headers['docker-content-digest'], content_type: response.headers['content-type']) else error(response.body, response.code) end diff --git a/app/services/dependency_proxy/pull_manifest_service.rb b/app/services/dependency_proxy/pull_manifest_service.rb index 5c804489fd1..737414c396e 100644 --- a/app/services/dependency_proxy/pull_manifest_service.rb +++ b/app/services/dependency_proxy/pull_manifest_service.rb @@ -11,7 +11,7 @@ module DependencyProxy def execute_with_manifest raise ArgumentError, 'Block must be provided' unless block_given? - response = Gitlab::HTTP.get(manifest_url, headers: auth_headers) + response = Gitlab::HTTP.get(manifest_url, headers: auth_headers.merge(Accept: ::ContainerRegistry::Client::ACCEPTED_TYPES.join(','))) if response.success? file = Tempfile.new @@ -20,7 +20,7 @@ module DependencyProxy file.write(response) file.flush - yield(success(file: file, digest: response.headers['docker-content-digest'])) + yield(success(file: file, digest: response.headers['docker-content-digest'], content_type: response.headers['content-type'])) ensure file.close file.unlink diff --git a/app/services/deployments/create_service.rb b/app/services/deployments/create_service.rb index 7355747d778..ebf2b077bca 100644 --- a/app/services/deployments/create_service.rb +++ b/app/services/deployments/create_service.rb @@ -11,6 +11,8 @@ module Deployments end def execute + return last_deployment if last_deployment&.equal_to?(params) + environment.deployments.build(deployment_attributes).tap do |deployment| # Deployment#change_status already saves the model, so we only need to # call #save ourselves if no status is provided. @@ -36,5 +38,11 @@ module Deployments on_stop: params[:on_stop] } end + + private + + def last_deployment + @environment.last_deployment + end end end diff --git a/app/services/discussions/resolve_service.rb b/app/services/discussions/resolve_service.rb index cd5925cd9be..91c3cf136a4 100644 --- a/app/services/discussions/resolve_service.rb +++ b/app/services/discussions/resolve_service.rb @@ -40,7 +40,13 @@ module Discussions discussion.resolve!(current_user) @resolved_count += 1 - MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(merge_request) if merge_request + if merge_request + Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter + .track_resolve_thread_action(user: current_user) + + MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(merge_request) + end + SystemNoteService.discussion_continued_in_issue(discussion, project, current_user, follow_up_issue) if follow_up_issue end diff --git a/app/services/discussions/unresolve_service.rb b/app/services/discussions/unresolve_service.rb new file mode 100644 index 00000000000..fbd96ceafe7 --- /dev/null +++ b/app/services/discussions/unresolve_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Discussions + class UnresolveService < Discussions::BaseService + include Gitlab::Utils::StrongMemoize + + def initialize(discussion, user) + @discussion = discussion + @user = user + + super + end + + def execute + @discussion.unresolve! + + Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter + .track_unresolve_thread_action(user: @user) + end + end +end diff --git a/app/services/draft_notes/publish_service.rb b/app/services/draft_notes/publish_service.rb index 316abff4552..82917241347 100644 --- a/app/services/draft_notes/publish_service.rb +++ b/app/services/draft_notes/publish_service.rb @@ -38,6 +38,8 @@ module DraftNotes end draft_notes.delete_all + set_reviewed + notification_service.async.new_review(review) MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(merge_request) end @@ -64,5 +66,9 @@ module DraftNotes discussion.unresolve! end end + + def set_reviewed + ::MergeRequests::MarkReviewerReviewedService.new(project, current_user).execute(merge_request) + end end end diff --git a/app/services/feature_flags/base_service.rb b/app/services/feature_flags/base_service.rb index c11c465252e..f48f95e2550 100644 --- a/app/services/feature_flags/base_service.rb +++ b/app/services/feature_flags/base_service.rb @@ -41,7 +41,6 @@ module FeatureFlags def sync_to_jira(feature_flag) return unless feature_flag.present? - return unless Feature.enabled?(:jira_sync_feature_flags, feature_flag.project) seq_id = ::Atlassian::JiraConnect::Client.generate_update_sequence_id feature_flag.run_after_commit do diff --git a/app/services/git/branch_hooks_service.rb b/app/services/git/branch_hooks_service.rb index 4edcff0e3d0..19b9b439fed 100644 --- a/app/services/git/branch_hooks_service.rb +++ b/app/services/git/branch_hooks_service.rb @@ -44,11 +44,7 @@ module Git def invalidated_file_types return super unless default_branch? && !creating_branch? - paths = limited_commits.each_with_object(Set.new) do |commit, set| - commit.raw_deltas.each do |diff| - set << diff.new_path - end - end + paths = commit_paths.values.reduce(&:merge) || Set.new Gitlab::FileDetector.types_in_paths(paths) end @@ -77,6 +73,7 @@ module Git enqueue_process_commit_messages enqueue_jira_connect_sync_messages enqueue_metrics_dashboard_sync + track_ci_config_change_event end def branch_remove_hooks @@ -89,6 +86,18 @@ module Git ::Metrics::Dashboard::SyncDashboardsWorker.perform_async(project.id) end + def track_ci_config_change_event + return unless Gitlab::CurrentSettings.usage_ping_enabled? + return unless ::Feature.enabled?(:usage_data_unique_users_committing_ciconfigfile, project, default_enabled: :yaml) + return unless default_branch? + + commits_changing_ci_config.each do |commit| + Gitlab::UsageDataCounters::HLLRedisCounter.track_event( + 'o_pipeline_authoring_unique_users_committing_ciconfigfile', values: commit.author&.id + ) + end + end + # Schedules processing of commit messages def enqueue_process_commit_messages referencing_commits = limited_commits.select(&:matches_cross_reference_regex?) @@ -190,6 +199,23 @@ module Git set end + + def commits_changing_ci_config + commit_paths.select do |commit, paths| + next if commit.merge_commit? + + paths.include?(project.ci_config_path_or_default) + end.keys + end + + def commit_paths + strong_memoize(:commit_paths) do + limited_commits.map do |commit| + paths = Set.new(commit.raw_deltas.map(&:new_path)) + [commit, paths] + end.to_h + end + end end end diff --git a/app/services/git/wiki_push_service.rb b/app/services/git/wiki_push_service.rb index 87e2be858c0..99659bc8ab2 100644 --- a/app/services/git/wiki_push_service.rb +++ b/app/services/git/wiki_push_service.rb @@ -16,6 +16,7 @@ module Git wiki.after_post_receive process_changes + perform_housekeeping if Feature.enabled?(:wiki_housekeeping, wiki.container) end private @@ -72,6 +73,14 @@ module Git def default_branch_changes @default_branch_changes ||= changes.select { |change| on_default_branch?(change) } end + + def perform_housekeeping + housekeeping = Repositories::HousekeepingService.new(wiki) + housekeeping.increment! + housekeeping.execute if housekeeping.needed? + rescue Repositories::HousekeepingService::LeaseTaken + # no-op + end end end diff --git a/app/services/groups/import_export/export_service.rb b/app/services/groups/import_export/export_service.rb index abac0ffc5d9..a436aec1b39 100644 --- a/app/services/groups/import_export/export_service.rb +++ b/app/services/groups/import_export/export_service.rb @@ -12,40 +12,44 @@ module Groups end def async_execute - GroupExportWorker.perform_async(@current_user.id, @group.id, @params) + GroupExportWorker.perform_async(current_user.id, group.id, params) end def execute validate_user_permissions - remove_existing_export! if @group.export_file_exists? + remove_existing_export! if group.export_file_exists? save! ensure - remove_base_tmp_dir + remove_archive_tmp_dir end private + attr_reader :group, :current_user, :params attr_accessor :shared def validate_user_permissions - unless @current_user.can?(:admin_group, @group) - @shared.error(::Gitlab::ImportExport::Error.permission_error(@current_user, @group)) + unless current_user.can?(:admin_group, group) + shared.error(::Gitlab::ImportExport::Error.permission_error(current_user, group)) notify_error! end end def remove_existing_export! - import_export_upload = @group.import_export_upload + import_export_upload = group.import_export_upload import_export_upload.remove_export_file! import_export_upload.save end def save! - if savers.all?(&:save) + # We cannot include the file_saver with the other savers because + # it removes the tmp dir. This means that if we want to add new savers + # in EE the data won't be available. + if savers.all?(&:save) && file_saver.save notify_success else notify_error! @@ -53,36 +57,40 @@ module Groups end def savers - [version_saver, tree_exporter, file_saver] + [version_saver, tree_exporter] end def tree_exporter tree_exporter_class.new( - group: @group, - current_user: @current_user, - shared: @shared, - params: @params + group: group, + current_user: current_user, + shared: shared, + params: params ) end def tree_exporter_class - if ::Feature.enabled?(:group_export_ndjson, @group&.parent, default_enabled: true) + if ndjson? Gitlab::ImportExport::Group::TreeSaver else Gitlab::ImportExport::Group::LegacyTreeSaver end end + def ndjson? + ::Feature.enabled?(:group_export_ndjson, group&.parent, default_enabled: :yaml) + end + def version_saver Gitlab::ImportExport::VersionSaver.new(shared: shared) end def file_saver - Gitlab::ImportExport::Saver.new(exportable: @group, shared: @shared) + Gitlab::ImportExport::Saver.new(exportable: group, shared: shared) end - def remove_base_tmp_dir - FileUtils.rm_rf(shared.base_path) if shared&.base_path + def remove_archive_tmp_dir + FileUtils.rm_rf(shared.archive_path) if shared&.archive_path end def notify_error! @@ -94,22 +102,22 @@ module Groups def notify_success @logger.info( message: 'Group Export succeeded', - group_id: @group.id, - group_name: @group.name + group_id: group.id, + group_name: group.name ) - notification_service.group_was_exported(@group, @current_user) + notification_service.group_was_exported(group, current_user) end def notify_error @logger.error( message: 'Group Export failed', - group_id: @group.id, - group_name: @group.name, - errors: @shared.errors.join(', ') + group_id: group.id, + group_name: group.name, + errors: shared.errors.join(', ') ) - notification_service.group_was_not_exported(@group, @current_user, @shared.errors) + notification_service.group_was_not_exported(group, current_user, shared.errors) end def notification_service @@ -118,3 +126,5 @@ module Groups end end end + +Groups::ImportExport::ExportService.prepend_if_ee('EE::Groups::ImportExport::ExportService') diff --git a/app/services/groups/import_export/import_service.rb b/app/services/groups/import_export/import_service.rb index a0ddc50e5e0..bf3f09f22d4 100644 --- a/app/services/groups/import_export/import_service.rb +++ b/app/services/groups/import_export/import_service.rb @@ -3,7 +3,7 @@ module Groups module ImportExport class ImportService - attr_reader :current_user, :group, :params + attr_reader :current_user, :group, :shared def initialize(group:, user:) @group = group @@ -26,10 +26,10 @@ module Groups end def execute - if valid_user_permissions? && import_file && restorer.restore + if valid_user_permissions? && import_file && restorers.all?(&:restore) notify_success - @group + group else notify_error! end @@ -43,37 +43,41 @@ module Groups def import_file @import_file ||= Gitlab::ImportExport::FileImporter.import( - importable: @group, + importable: group, archive_file: nil, - shared: @shared + shared: shared ) end - def restorer - @restorer ||= + def restorers + [tree_restorer] + end + + def tree_restorer + @tree_restorer ||= if ndjson? Gitlab::ImportExport::Group::TreeRestorer.new( - user: @current_user, - shared: @shared, - group: @group + user: current_user, + shared: shared, + group: group ) else Gitlab::ImportExport::Group::LegacyTreeRestorer.new( - user: @current_user, - shared: @shared, - group: @group, + user: current_user, + shared: shared, + group: group, group_hash: nil ) end end def ndjson? - ::Feature.enabled?(:group_import_ndjson, @group&.parent, default_enabled: true) && - File.exist?(File.join(@shared.export_path, 'tree/groups/_all.ndjson')) + ::Feature.enabled?(:group_import_ndjson, group&.parent, default_enabled: true) && + File.exist?(File.join(shared.export_path, 'tree/groups/_all.ndjson')) end def remove_import_file - upload = @group.import_export_upload + upload = group.import_export_upload return unless upload&.import_file&.file @@ -85,7 +89,7 @@ module Groups if current_user.can?(:admin_group, group) true else - @shared.error(::Gitlab::ImportExport::Error.permission_error(current_user, group)) + shared.error(::Gitlab::ImportExport::Error.permission_error(current_user, group)) false end @@ -93,16 +97,16 @@ module Groups def notify_success @logger.info( - group_id: @group.id, - group_name: @group.name, + group_id: group.id, + group_name: group.name, message: 'Group Import/Export: Import succeeded' ) end def notify_error @logger.error( - group_id: @group.id, - group_name: @group.name, + group_id: group.id, + group_name: group.name, message: "Group Import/Export: Errors occurred, see '#{Gitlab::ErrorTracking::Logger.file_name}' for details" ) end @@ -110,12 +114,14 @@ module Groups def notify_error! notify_error - raise Gitlab::ImportExport::Error.new(@shared.errors.to_sentence) + raise Gitlab::ImportExport::Error.new(shared.errors.to_sentence) end def remove_base_tmp_dir - FileUtils.rm_rf(@shared.base_path) + FileUtils.rm_rf(shared.base_path) end end end end + +Groups::ImportExport::ImportService.prepend_if_ee('EE::Groups::ImportExport::ImportService') diff --git a/app/services/groups/open_issues_count_service.rb b/app/services/groups/open_issues_count_service.rb new file mode 100644 index 00000000000..db1ca09212a --- /dev/null +++ b/app/services/groups/open_issues_count_service.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Groups + # Service class for counting and caching the number of open issues of a group. + class OpenIssuesCountService < BaseCountService + include Gitlab::Utils::StrongMemoize + + VERSION = 1 + PUBLIC_COUNT_KEY = 'group_public_open_issues_count' + TOTAL_COUNT_KEY = 'group_total_open_issues_count' + CACHED_COUNT_THRESHOLD = 1000 + EXPIRATION_TIME = 24.hours + + attr_reader :group, :user + + def initialize(group, user = nil) + @group = group + @user = user + end + + # Reads count value from cache and return it if present. + # If empty or expired, #uncached_count will calculate the issues count for the group and + # compare it with the threshold. If it is greater, it will be written to the cache and returned. + # If below, it will be returned without being cached. + # This results in only caching large counts and calculating the rest with every call to maintain + # accuracy. + def count + cached_count = Rails.cache.read(cache_key) + return cached_count unless cached_count.blank? + + refreshed_count = uncached_count + update_cache_for_key(cache_key) { refreshed_count } if refreshed_count > CACHED_COUNT_THRESHOLD + refreshed_count + end + + def cache_key(key = nil) + ['groups', 'open_issues_count_service', VERSION, group.id, cache_key_name] + end + + private + + def cache_options + super.merge({ expires_in: EXPIRATION_TIME }) + end + + def cache_key_name + public_only? ? PUBLIC_COUNT_KEY : TOTAL_COUNT_KEY + end + + def public_only? + !user_is_at_least_reporter? + end + + def user_is_at_least_reporter? + strong_memoize(:user_is_at_least_reporter) do + group.member?(user, Gitlab::Access::REPORTER) + end + end + + def relation_for_count + IssuesFinder.new(user, group_id: group.id, state: 'opened', non_archived: true, include_subgroups: true, public_only: public_only?).execute + end + end +end diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 6d41d449683..7c508237c8d 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -190,11 +190,7 @@ class IssuableBaseService < BaseService change_additional_attributes(issuable) old_associations = associations_before_update(issuable) - label_ids = process_label_ids(params, existing_label_ids: issuable.label_ids) - if labels_changing?(issuable.label_ids, label_ids) - params[:label_ids] = label_ids - issuable.touch - end + assign_requested_labels(issuable) if issuable.changed? || params.present? issuable.assign_attributes(params) @@ -297,10 +293,6 @@ class IssuableBaseService < BaseService update_task(issuable) end - def labels_changing?(old_label_ids, new_label_ids) - old_label_ids.sort != new_label_ids.sort - end - def has_title_or_description_changed?(issuable) issuable.title_changed? || issuable.description_changed? end @@ -349,6 +341,20 @@ class IssuableBaseService < BaseService end # rubocop: enable CodeReuse/ActiveRecord + def assign_requested_labels(issuable) + label_ids = process_label_ids(params, existing_label_ids: issuable.label_ids) + return unless ids_changing?(issuable.label_ids, label_ids) + + params[:label_ids] = label_ids + issuable.touch + end + + # Arrays of ids are used, but we should really use sets of ids, so + # let's have an helper to properly check if some ids are changing + def ids_changing?(old_array, new_array) + old_array.sort != new_array.sort + end + def toggle_award(issuable) award = params.delete(:emoji_award) AwardEmojis::ToggleService.new(issuable, award, current_user).execute if award diff --git a/app/services/issue_rebalancing_service.rb b/app/services/issue_rebalancing_service.rb index 4138c6441c8..849afc4edb8 100644 --- a/app/services/issue_rebalancing_service.rb +++ b/app/services/issue_rebalancing_service.rb @@ -17,8 +17,21 @@ class IssueRebalancingService start = RelativePositioning::START_POSITION - (gaps / 2) * gap_size - Issue.transaction do - indexed_ids.each_slice(100) { |pairs| assign_positions(start, pairs) } + if Feature.enabled?(:issue_rebalancing_optimization) + Issue.transaction do + assign_positions(start, indexed_ids) + .sort_by(&:first) + .each_slice(100) do |pairs_with_position| + update_positions(pairs_with_position, 'rebalance issue positions in batches ordered by id') + end + end + else + Issue.transaction do + indexed_ids.each_slice(100) do |pairs| + pairs_with_position = assign_positions(start, pairs) + update_positions(pairs_with_position, 'rebalance issue positions') + end + end end end @@ -32,13 +45,22 @@ class IssueRebalancingService end # rubocop: enable CodeReuse/ActiveRecord - # rubocop: disable CodeReuse/ActiveRecord - def assign_positions(start, positions) - values = positions.map do |id, index| - "(#{id}, #{start + (index * gap_size)})" + def assign_positions(start, pairs) + pairs.map do |id, index| + [id, start + (index * gap_size)] + end + end + + def update_positions(pairs_with_position, query_name) + values = pairs_with_position.map do |id, index| + "(#{id}, #{index})" end.join(', ') - Issue.connection.exec_query(<<~SQL, "rebalance issue positions") + run_update_query(values, query_name) + end + + def run_update_query(values, query_name) + Issue.connection.exec_query(<<~SQL, query_name) WITH cte(cte_id, new_pos) AS ( SELECT * FROM (VALUES #{values}) as t (id, pos) @@ -49,7 +71,6 @@ class IssueRebalancingService WHERE cte_id = id SQL end - # rubocop: enable CodeReuse/ActiveRecord def issue_count @issue_count ||= base.count diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index 44de8eb6389..d2285a375a1 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -2,20 +2,26 @@ module Issues class CreateService < Issues::BaseService - include SpamCheckMethods include ResolveDiscussions def execute(skip_system_notes: false) + @request = params.delete(:request) + @spam_params = Spam::SpamActionService.filter_spam_params!(params) + @issue = BuildService.new(project, current_user, params).execute - filter_spam_check_params filter_resolve_discussion_params create(@issue, skip_system_notes: skip_system_notes) end def before_create(issue) - spam_check(issue, current_user, action: :create) + Spam::SpamActionService.new( + spammable: issue, + request: request, + user: current_user, + action: :create + ).execute(spam_params: spam_params) # current_user (defined in BaseService) is not available within run_after_commit block user = current_user @@ -46,8 +52,10 @@ module Issues private + attr_reader :request, :spam_params + def user_agent_detail_service - UserAgentDetailService.new(@issue, @request) + UserAgentDetailService.new(@issue, request) end # Applies label "incident" (creates it if missing) to incident issues. diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index 127ed04cf51..2906bdf62a7 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -2,12 +2,14 @@ module Issues class UpdateService < Issues::BaseService - include SpamCheckMethods extend ::Gitlab::Utils::Override def execute(issue) handle_move_between_ids(issue) - filter_spam_check_params + + @request = params.delete(:request) + @spam_params = Spam::SpamActionService.filter_spam_params!(params) + change_issue_duplicate(issue) move_issue_to_new_project(issue) || clone_issue(issue) || update_task_event(issue) || update(issue) end @@ -30,7 +32,14 @@ module Issues end def before_update(issue, skip_spam_check: false) - spam_check(issue, current_user, action: :update) unless skip_spam_check + return if skip_spam_check + + Spam::SpamActionService.new( + spammable: issue, + request: request, + user: current_user, + action: :update + ).execute(spam_params: spam_params) end def after_update(issue) @@ -126,6 +135,8 @@ module Issues private + attr_reader :request, :spam_params + def clone_issue(issue) target_project = params.delete(:target_clone_project) with_notes = params.delete(:clone_with_notes) diff --git a/app/services/jira/requests/base.rb b/app/services/jira/requests/base.rb index 098aae9284c..bae8298d5c8 100644 --- a/app/services/jira/requests/base.rb +++ b/app/services/jira/requests/base.rb @@ -18,15 +18,15 @@ module Jira request end + private + + attr_reader :jira_service, :project + # We have to add the context_path here because the Jira client is not taking it into account def base_api_url "#{context_path}/rest/api/#{api_version}" end - private - - attr_reader :jira_service, :project - def context_path client.options[:context_path].to_s end diff --git a/app/services/members/update_service.rb b/app/services/members/update_service.rb index b5c27caafa2..5c6e51201c2 100644 --- a/app/services/members/update_service.rb +++ b/app/services/members/update_service.rb @@ -16,7 +16,11 @@ module Members enqueue_delete_todos(member) if downgrading_to_guest? end - member + if member.errors.any? + error(member.errors.full_messages.to_sentence, pass_back: { member: member }) + else + success(member: member) + end end private diff --git a/app/services/merge_requests/add_context_service.rb b/app/services/merge_requests/add_context_service.rb index bb82fa23468..b693f8509a2 100644 --- a/app/services/merge_requests/add_context_service.rb +++ b/app/services/merge_requests/add_context_service.rb @@ -66,7 +66,8 @@ module MergeRequests relative_order: index, sha: sha, authored_date: Gitlab::Database.sanitize_timestamp(commit_hash[:authored_date]), - committed_date: Gitlab::Database.sanitize_timestamp(commit_hash[:committed_date]) + committed_date: Gitlab::Database.sanitize_timestamp(commit_hash[:committed_date]), + trailers: commit_hash.fetch(:trailers, {}).to_json ) end end diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb index 80991657688..12c901aa1a1 100644 --- a/app/services/merge_requests/build_service.rb +++ b/app/services/merge_requests/build_service.rb @@ -16,30 +16,17 @@ module MergeRequests merge_request.source_project = find_source_project merge_request.target_project = find_target_project - # Source project sets the default source branch removal setting - merge_request.merge_params['force_remove_source_branch'] = - if params.key?(:force_remove_source_branch) - params.delete(:force_remove_source_branch) - else - merge_request.source_project.remove_source_branch_after_merge? - end + # Force remove the source branch? + merge_request.merge_params['force_remove_source_branch'] = force_remove_source_branch + # Only assign merge requests params that are allowed self.params = assign_allowed_merge_params(merge_request, params) + # Filter out params that are either not allowed or invalid filter_params(merge_request) - # merge_request.assign_attributes(...) below is a Rails - # method that only work if all the params it is passed have - # corresponding fields in the database. As there are no fields - # in the database for :add_label_ids and :remove_label_ids, we - # need to remove them from the params before the call to - # merge_request.assign_attributes(...) - # - # IssuableBaseService#process_label_ids takes care - # of the removal. - params[:label_ids] = process_label_ids(params, extra_label_ids: merge_request.label_ids.to_a) - - merge_request.assign_attributes(params.to_h.compact) + # Filter out :add_label_ids and :remove_label_ids params + filter_label_id_params merge_request.compare_commits = [] set_merge_request_target_branch @@ -74,6 +61,29 @@ module MergeRequests :errors, to: :merge_request + def force_remove_source_branch + if params.key?(:force_remove_source_branch) + params.delete(:force_remove_source_branch) + else + merge_request.source_project.remove_source_branch_after_merge? + end + end + + def filter_label_id_params + # merge_request.assign_attributes(...) below is a Rails + # method that only work if all the params it is passed have + # corresponding fields in the database. As there are no fields + # in the database for :add_label_ids and :remove_label_ids, we + # need to remove them from the params before the call to + # merge_request.assign_attributes(...) + # + # IssuableBaseService#process_label_ids takes care + # of the removal. + params[:label_ids] = process_label_ids(params, extra_label_ids: merge_request.label_ids.to_a) + + merge_request.assign_attributes(params.to_h.compact) + end + def find_source_project source_project = project_from_params(:source_project) return source_project if source_project.present? && can?(current_user, :create_merge_request_from, source_project) diff --git a/app/services/merge_requests/mark_reviewer_reviewed_service.rb b/app/services/merge_requests/mark_reviewer_reviewed_service.rb new file mode 100644 index 00000000000..766a4ca0a49 --- /dev/null +++ b/app/services/merge_requests/mark_reviewer_reviewed_service.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module MergeRequests + class MarkReviewerReviewedService < MergeRequests::BaseService + def execute(merge_request) + return error("Invalid permissions") unless can?(current_user, :update_merge_request, merge_request) + + reviewer = merge_request.find_reviewer(current_user) + + if reviewer + return error("Failed to update reviewer") unless reviewer.update(state: :reviewed) + + success + else + error("Reviewer not found") + end + end + end +end diff --git a/app/services/merge_requests/mergeability_check_service.rb b/app/services/merge_requests/mergeability_check_service.rb index 96a2322f6a0..9fecab85cc1 100644 --- a/app/services/merge_requests/mergeability_check_service.rb +++ b/app/services/merge_requests/mergeability_check_service.rb @@ -114,6 +114,7 @@ module MergeRequests merge_to_ref_success = merge_to_ref + reload_merge_head_diff update_diff_discussion_positions! if merge_to_ref_success if merge_to_ref_success && can_git_merge? @@ -123,6 +124,10 @@ module MergeRequests end end + def reload_merge_head_diff + MergeRequests::ReloadMergeHeadDiffService.new(merge_request).execute + end + def update_diff_discussion_positions! Discussions::CaptureDiffNotePositionsService.new(merge_request).execute end @@ -153,6 +158,7 @@ module MergeRequests def merge_to_ref params = { allow_conflicts: Feature.enabled?(:display_merge_conflicts_in_diff, project) } result = MergeRequests::MergeToRefService.new(project, merge_request.author, params).execute(merge_request) + result[:status] == :success end diff --git a/app/services/merge_requests/reload_merge_head_diff_service.rb b/app/services/merge_requests/reload_merge_head_diff_service.rb new file mode 100644 index 00000000000..66fcb5c022b --- /dev/null +++ b/app/services/merge_requests/reload_merge_head_diff_service.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module MergeRequests + class ReloadMergeHeadDiffService + include BaseServiceUtility + + def initialize(merge_request) + @merge_request = merge_request + end + + def execute + return error("default_merge_ref_for_diffs feature flag is disabled") unless enabled? + return error("Merge request has no merge ref head.") unless merge_request.merge_ref_head.present? + + error_msg = recreate_merge_head_diff + + return error(error_msg) if error_msg + + success + end + + private + + attr_reader :merge_request + + def enabled? + Feature.enabled?(:default_merge_ref_for_diffs, merge_request.project) + end + + def recreate_merge_head_diff + merge_request.merge_head_diff&.destroy! + + # n+1: https://gitlab.com/gitlab-org/gitlab/-/issues/19377 + Gitlab::GitalyClient.allow_n_plus_1_calls do + merge_request.create_merge_head_diff! + end + + # Reset the merge request so it won't load the merge head diff as the + # MergeRequest#merge_request_diff. + merge_request.reset + + nil + rescue StandardError => e + message = "Failed to recreate merge head diff: #{e.message}" + + Gitlab::AppLogger.error(message: message, merge_request_id: merge_request.id) + message + end + end +end diff --git a/app/services/merge_requests/request_review_service.rb b/app/services/merge_requests/request_review_service.rb new file mode 100644 index 00000000000..b061ed45fee --- /dev/null +++ b/app/services/merge_requests/request_review_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module MergeRequests + class RequestReviewService < MergeRequests::BaseService + def execute(merge_request, user) + return error("Invalid permissions") unless can?(current_user, :update_merge_request, merge_request) + + reviewer = merge_request.find_reviewer(user) + + if reviewer + return error("Failed to update reviewer") unless reviewer.update(state: :unreviewed) + + notify_reviewer(merge_request, user) + + success + else + error("Reviewer not found") + end + end + + private + + def notify_reviewer(merge_request, reviewer) + notification_service.async.review_requested_of_merge_request(merge_request, current_user, reviewer) + todo_service.create_request_review_todo(merge_request, current_user, reviewer) + end + end +end diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index d2e5a2a1619..45f81d972db 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -109,6 +109,9 @@ module MergeRequests create_assignee_note(merge_request, old_assignees) notification_service.async.reassigned_merge_request(merge_request, current_user, old_assignees) todo_service.reassigned_assignable(merge_request, current_user, old_assignees) + + new_assignees = merge_request.assignees - old_assignees + merge_request_activity_counter.track_users_assigned_to_mr(users: new_assignees) end def handle_reviewers_change(merge_request, old_reviewers) @@ -117,6 +120,9 @@ module MergeRequests notification_service.async.changed_reviewer_of_merge_request(merge_request, current_user, old_reviewers) todo_service.reassigned_reviewable(merge_request, current_user, old_reviewers) invalidate_cache_counts(merge_request, users: affected_reviewers.compact) + + new_reviewers = merge_request.reviewers - old_reviewers + merge_request_activity_counter.track_users_review_requested(users: new_reviewers) end def create_branch_change_note(issuable, branch_type, old_branch, new_branch) diff --git a/app/services/namespaces/in_product_marketing_emails_service.rb b/app/services/namespaces/in_product_marketing_emails_service.rb new file mode 100644 index 00000000000..45b4619ddbe --- /dev/null +++ b/app/services/namespaces/in_product_marketing_emails_service.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +module Namespaces + class InProductMarketingEmailsService + include Gitlab::Experimentation::GroupTypes + + INTERVAL_DAYS = [1, 5, 10].freeze + TRACKS = { + create: :git_write, + verify: :pipeline_created, + trial: :trial_started, + team: :user_added + }.freeze + + def self.send_for_all_tracks_and_intervals + TRACKS.each_key do |track| + INTERVAL_DAYS.each do |interval| + new(track, interval).execute + end + end + end + + def initialize(track, interval) + @track = track + @interval = interval + @sent_email_user_ids = [] + end + + def execute + groups_for_track.each_batch do |groups| + groups.each do |group| + send_email_for_group(group) + end + end + end + + private + + attr_reader :track, :interval, :sent_email_user_ids + + def send_email_for_group(group) + experiment_enabled_for_group = experiment_enabled_for_group?(group) + experiment_add_group(group, experiment_enabled_for_group) + return unless experiment_enabled_for_group + + users_for_group(group).each do |user| + send_email(user, group) if can_perform_action?(user, group) + end + end + + def experiment_enabled_for_group?(group) + Gitlab::Experimentation.in_experiment_group?(:in_product_marketing_emails, subject: group) + end + + def experiment_add_group(group, experiment_enabled_for_group) + variant = experiment_enabled_for_group ? GROUP_EXPERIMENTAL : GROUP_CONTROL + Experiment.add_group(:in_product_marketing_emails, variant: variant, group: group) + end + + # rubocop: disable CodeReuse/ActiveRecord + def groups_for_track + onboarding_progress_scope = OnboardingProgress + .completed_actions_with_latest_in_range(completed_actions, range) + .incomplete_actions(incomplete_action) + + Group.joins(:onboarding_progress).merge(onboarding_progress_scope) + end + + def users_for_group(group) + group.users.where(email_opted_in: true) + .where.not(id: sent_email_user_ids) + end + # rubocop: enable CodeReuse/ActiveRecord + + def can_perform_action?(user, group) + case track + when :create + user.can?(:create_projects, group) + when :verify + user.can?(:create_projects, group) + when :trial + user.can?(:start_trial, group) + when :team + user.can?(:admin_group_member, group) + else + raise NotImplementedError, "No ability defined for track #{track}" + end + end + + def send_email(user, group) + NotificationService.new.in_product_marketing(user.id, group.id, track, series) + sent_email_user_ids << user.id + end + + def completed_actions + index = TRACKS.keys.index(track) + index == 0 ? [:created] : TRACKS.values[0..index - 1] + end + + def range + (interval + 1).days.ago.beginning_of_day..(interval + 1).days.ago.end_of_day + end + + def incomplete_action + TRACKS[track] + end + + def series + INTERVAL_DAYS.index(interval) + end + end +end diff --git a/app/services/notification_recipients/build_service.rb b/app/services/notification_recipients/build_service.rb index 040ecc29d3a..52070abbad7 100644 --- a/app/services/notification_recipients/build_service.rb +++ b/app/services/notification_recipients/build_service.rb @@ -36,5 +36,9 @@ module NotificationRecipients def self.build_new_review_recipients(*args) ::NotificationRecipients::Builder::NewReview.new(*args).notification_recipients end + + def self.build_requested_review_recipients(*args) + ::NotificationRecipients::Builder::RequestReview.new(*args).notification_recipients + end end end diff --git a/app/services/notification_recipients/builder/request_review.rb b/app/services/notification_recipients/builder/request_review.rb new file mode 100644 index 00000000000..911d89c6a8e --- /dev/null +++ b/app/services/notification_recipients/builder/request_review.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module NotificationRecipients + module Builder + class RequestReview < Base + attr_reader :merge_request, :current_user, :reviewer + + def initialize(merge_request, current_user, reviewer) + @merge_request, @current_user, @reviewer = merge_request, current_user, reviewer + end + + def target + merge_request + end + + def build! + add_recipients(reviewer, :mention, NotificationReason::REVIEW_REQUESTED) + end + end + end +end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 5a71e0eac7c..50247532f69 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -265,6 +265,14 @@ class NotificationService end end + def review_requested_of_merge_request(merge_request, current_user, reviewer) + recipients = NotificationRecipients::BuildService.build_requested_review_recipients(merge_request, current_user, reviewer) + + recipients.each do |recipient| + mailer.request_review_merge_request_email(recipient.user.id, merge_request.id, current_user.id, recipient.reason).deliver_later + end + end + # When we add labels to a merge request we should send an email to: # # * watchers of the mr's labels @@ -664,6 +672,10 @@ class NotificationService end end + def in_product_marketing(user_id, group_id, track, series) + mailer.in_product_marketing_email(user_id, group_id, track, series).deliver_later + end + protected def new_resource_email(target, method) diff --git a/app/services/packages/debian/destroy_distribution_service.rb b/app/services/packages/debian/destroy_distribution_service.rb new file mode 100644 index 00000000000..bef1127fece --- /dev/null +++ b/app/services/packages/debian/destroy_distribution_service.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Packages + module Debian + class DestroyDistributionService + def initialize(distribution) + @distribution = distribution + end + + def execute + destroy_distribution + end + + private + + def destroy_distribution + if @distribution.destroy + success + else + error("Unable to destroy Debian #{@distribution.model_name.human.downcase}") + end + end + + def success + ServiceResponse.success + end + + def error(message) + ServiceResponse.error(message: message, payload: { distribution: @distribution }) + end + end + end +end diff --git a/app/services/packages/debian/update_distribution_service.rb b/app/services/packages/debian/update_distribution_service.rb new file mode 100644 index 00000000000..5bb59b854e9 --- /dev/null +++ b/app/services/packages/debian/update_distribution_service.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module Packages + module Debian + class UpdateDistributionService + def initialize(distribution, params) + @distribution, @params = distribution, params + + @components = params.delete(:components) + + @architectures = params.delete(:architectures) + @architectures += ['all'] unless @architectures.nil? + + @errors = [] + end + + def execute + update_distribution + end + + private + + attr_reader :distribution, :params, :components, :architectures, :errors + + def append_errors(record, prefix = '') + return if record.valid? + + prefix = "#{prefix} " unless prefix.empty? + @errors += record.errors.full_messages.map { |message| "#{prefix}#{message}" } + end + + def update_distribution + distribution.transaction do + if distribution.update(params) + update_components if components + update_architectures if architectures + + success + else + append_errors(distribution) + error + end + end || error + end + + def update_components + update_objects(distribution.components, components, error_label: 'Component') + end + + def update_architectures + update_objects(distribution.architectures, architectures, error_label: 'Architecture') + end + + def update_objects(objects, object_names_from_params, error_label: ) + current_object_names = objects.map(&:name) + missing_object_names = object_names_from_params - current_object_names + extra_object_names = current_object_names - object_names_from_params + + missing_object_names.each do |name| + new_object = objects.create(name: name) + append_errors(new_object, error_label) + raise ActiveRecord::Rollback unless new_object.persisted? + end + + extra_object_names.each do |name| + object = objects.with_name(name).first + raise ActiveRecord::Rollback unless object.destroy + end + end + + def success + ServiceResponse.success(payload: { distribution: distribution }) + end + + def error + ServiceResponse.error(message: errors.to_sentence, payload: { distribution: distribution }) + end + end + end +end diff --git a/app/services/packages/maven/find_or_create_package_service.rb b/app/services/packages/maven/find_or_create_package_service.rb index 8ee449cbfdc..6e0346058e8 100644 --- a/app/services/packages/maven/find_or_create_package_service.rb +++ b/app/services/packages/maven/find_or_create_package_service.rb @@ -11,12 +11,7 @@ module Packages .execute unless Namespace::PackageSetting.duplicates_allowed?(package) - files = package&.package_files || [] - current_maven_files = files.map { |file| extname(file.file_name) } - - if current_maven_files.compact.include?(extname(params[:file_name])) - return ServiceResponse.error(message: 'Duplicate package is not allowed') - end + return ServiceResponse.error(message: 'Duplicate package is not allowed') if target_package_is_duplicate?(package) end unless package @@ -67,6 +62,17 @@ module Packages File.extname(filename) end + + def target_package_is_duplicate?(package) + # duplicate metadata files can be uploaded multiple times + return false if package.version.nil? + + package + .package_files + .map { |file| extname(file.file_name) } + .compact + .include?(extname(params[:file_name])) + end end end end diff --git a/app/services/pages/migrate_from_legacy_storage_service.rb b/app/services/pages/migrate_from_legacy_storage_service.rb new file mode 100644 index 00000000000..d805ae2418c --- /dev/null +++ b/app/services/pages/migrate_from_legacy_storage_service.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Pages + class MigrateFromLegacyStorageService + def initialize(logger, migration_threads, batch_size) + @logger = logger + @migration_threads = migration_threads + @batch_size = batch_size + + @migrated = 0 + @errored = 0 + @counters_lock = Mutex.new + end + + def execute + @queue = SizedQueue.new(1) + + threads = start_migration_threads + + ProjectPagesMetadatum.only_on_legacy_storage.each_batch(of: @batch_size) do |batch| + @queue.push(batch) + end + + @queue.close + + @logger.info("Waiting for threads to finish...") + threads.each(&:join) + + { migrated: @migrated, errored: @errored } + end + + def start_migration_threads + Array.new(@migration_threads) do + Thread.new do + while batch = @queue.pop + Rails.application.executor.wrap do + process_batch(batch) + end + end + end + end + end + + def process_batch(batch) + batch.with_project_route_and_deployment.each do |metadatum| + project = metadatum.project + + migrate_project(project) + end + + @logger.info("#{@migrated} projects are migrated successfully, #{@errored} projects failed to be migrated") + rescue => e + # This method should never raise exception otherwise all threads might be killed + # and this will result in queue starving (and deadlock) + Gitlab::ErrorTracking.track_exception(e) + @logger.error("failed processing a batch: #{e.message}") + end + + def migrate_project(project) + result = nil + time = Benchmark.realtime do + result = ::Pages::MigrateLegacyStorageToDeploymentService.new(project).execute + end + + if result[:status] == :success + @logger.info("project_id: #{project.id} #{project.pages_path} has been migrated in #{time} seconds") + @counters_lock.synchronize { @migrated += 1 } + else + @logger.error("project_id: #{project.id} #{project.pages_path} failed to be migrated in #{time} seconds: #{result[:message]}") + @counters_lock.synchronize { @errored += 1 } + end + rescue => e + @counters_lock.synchronize { @errored += 1 } + @logger.error("#{e.message} project_id: #{project&.id}") + Gitlab::ErrorTracking.track_exception(e, project_id: project&.id) + end + end +end diff --git a/app/services/projects/alerting/notify_service.rb b/app/services/projects/alerting/notify_service.rb index 014fb0e3ed3..2ba64b73699 100644 --- a/app/services/projects/alerting/notify_service.rb +++ b/app/services/projects/alerting/notify_service.rb @@ -3,9 +3,8 @@ module Projects module Alerting class NotifyService - include BaseServiceUtility - include Gitlab::Utils::StrongMemoize - include ::IncidentManagement::Settings + extend ::Gitlab::Utils::Override + include ::AlertManagement::AlertProcessing def initialize(project, payload) @project = project @@ -22,8 +21,7 @@ module Projects process_alert return bad_request unless alert.persisted? - process_incident_issues if process_issues? - send_alert_email if send_email? + complete_post_processing_tasks ServiceResponse.success end @@ -32,93 +30,15 @@ module Projects attr_reader :project, :payload, :integration - def process_alert - if alert.persisted? - process_existing_alert - else - create_alert - end - end - - def process_existing_alert - if incoming_payload.ends_at.present? - process_resolved_alert - else - alert.register_new_event! - end - - alert - end - - def process_resolved_alert - return unless auto_close_incident? - - if alert.resolve(incoming_payload.ends_at) - close_issue(alert.issue) - end - - alert - end - - def close_issue(issue) - return if issue.blank? || issue.closed? - - ::Issues::CloseService - .new(project, User.alert_bot) - .execute(issue, system_note: false) - - SystemNoteService.auto_resolve_prometheus_alert(issue, project, User.alert_bot) if issue.reset.closed? - end - - def create_alert - return unless alert.save - - alert.execute_services - SystemNoteService.create_new_alert(alert, notification_source) - end - - def process_incident_issues - return if alert.issue || alert.resolved? - - ::IncidentManagement::ProcessAlertWorker.perform_async(nil, nil, alert.id) - end - - def send_alert_email - notification_service - .async - .prometheus_alerts_fired(project, [alert]) - end - - def alert - strong_memoize(:alert) do - existing_alert || new_alert - end - end - - def existing_alert - return unless incoming_payload.gitlab_fingerprint - - AlertManagement::Alert.not_resolved.for_fingerprint(project, incoming_payload.gitlab_fingerprint).first - end - - def new_alert - AlertManagement::Alert.new(**incoming_payload.alert_params, ended_at: nil) - end - - def incoming_payload - strong_memoize(:incoming_payload) do - Gitlab::AlertManagement::Payload.parse(project, payload.to_h) - end + def valid_payload_size? + Gitlab::Utils::DeepSize.new(payload).valid? end - def notification_source + override :alert_source + def alert_source alert.monitoring_tool || integration&.name || 'Generic Alert Endpoint' end - def valid_payload_size? - Gitlab::Utils::DeepSize.new(payload).valid? - end - def active_integration? integration&.active? end diff --git a/app/services/projects/cleanup_service.rb b/app/services/projects/cleanup_service.rb index 6e3b320afbe..7bcaee75813 100644 --- a/app/services/projects/cleanup_service.rb +++ b/app/services/projects/cleanup_service.rb @@ -40,7 +40,7 @@ module Projects apply_bfg_object_map! # Remove older objects that are no longer referenced - GitGarbageCollectWorker.new.perform(project.id, :prune, "project_cleanup:gc:#{project.id}") + Projects::GitGarbageCollectWorker.new.perform(project.id, :prune, "project_cleanup:gc:#{project.id}") # The cache may now be inaccurate, and holding onto it could prevent # bugs assuming the presence of some object from manifesting for some diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index a01db4b498c..08f569662a8 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -127,7 +127,7 @@ module Projects access_level: group_access_level) end - if Feature.enabled?(:specialized_project_authorization_workers) + if Feature.enabled?(:specialized_project_authorization_workers, default_enabled: :yaml) AuthorizedProjectUpdate::ProjectCreateWorker.perform_async(@project.id) # AuthorizedProjectsWorker uses an exclusive lease per user but # specialized workers might have synchronization issues. Until we @@ -210,16 +210,22 @@ module Projects end def set_project_name_from_path - # Set project name from path - if @project.name.present? && @project.path.present? - # if both name and path set - everything is ok - elsif @project.path.present? + # if both name and path set - everything is ok + return if @project.name.present? && @project.path.present? + + if @project.path.present? # Set project name from path @project.name = @project.path.dup elsif @project.name.present? # For compatibility - set path from name - # TODO: remove this in 8.0 - @project.path = @project.name.dup.parameterize + @project.path = @project.name.dup + + # TODO: Retained for backwards compatibility. Remove in API v5. + # When removed, validation errors will get bubbled up automatically. + # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/52725 + unless @project.path.match?(Gitlab::PathRegex.project_path_format_regex) + @project.path = @project.path.parameterize + end end end diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb index 050bfdd862d..fd9b64a4ee0 100644 --- a/app/services/projects/fork_service.rb +++ b/app/services/projects/fork_service.rb @@ -43,8 +43,8 @@ module Projects def new_fork_params new_params = { forked_from_project: @project, - visibility_level: allowed_visibility_level, - description: @project.description, + visibility_level: target_visibility_level, + description: target_description, name: target_name, path: target_path, shared_runners_enabled: @project.shared_runners_enabled, @@ -107,6 +107,10 @@ module Projects @target_name ||= @params[:name] || @project.name end + def target_description + @target_description ||= @params[:description] || @project.description + end + def target_namespace @target_namespace ||= @params[:namespace] || current_user.namespace end @@ -115,8 +119,9 @@ module Projects @skip_disk_validation ||= @params[:skip_disk_validation] || false end - def allowed_visibility_level + def target_visibility_level target_level = [@project.visibility_level, target_namespace.visibility_level].min + target_level = [target_level, Gitlab::VisibilityLevel.level_value(params[:visibility])].min if params.key?(:visibility) Gitlab::VisibilityLevel.closest_allowed_level(target_level) end diff --git a/app/services/projects/import_export/export_service.rb b/app/services/projects/import_export/export_service.rb index 031b99753c3..c2a8db7b657 100644 --- a/app/services/projects/import_export/export_service.rb +++ b/app/services/projects/import_export/export_service.rb @@ -86,11 +86,11 @@ module Projects end def repo_saver - Gitlab::ImportExport::RepoSaver.new(project: project, shared: shared) + Gitlab::ImportExport::RepoSaver.new(exportable: project, shared: shared) end def wiki_repo_saver - Gitlab::ImportExport::WikiRepoSaver.new(project: project, shared: shared) + Gitlab::ImportExport::WikiRepoSaver.new(exportable: project, shared: shared) end def lfs_saver @@ -102,7 +102,7 @@ module Projects end def design_repo_saver - Gitlab::ImportExport::DesignRepoSaver.new(project: project, shared: shared) + Gitlab::ImportExport::DesignRepoSaver.new(exportable: project, shared: shared) end def cleanup diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb index 25d46ada885..29e92d725e2 100644 --- a/app/services/projects/update_pages_service.rb +++ b/app/services/projects/update_pages_service.rb @@ -80,6 +80,10 @@ module Projects end def deploy_to_legacy_storage(artifacts_path) + # path today used by one project can later be used by another + # so we can't really scope this feature flag by project or group + return unless Feature.enabled?(:pages_update_legacy_storage, default_enabled: true) + # Create temporary directory in which we will extract the artifacts make_secure_tmp_dir(tmp_path) do |tmp_path| extract_archive!(artifacts_path, tmp_path) diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb index 50a544ed1a5..8384bfa813f 100644 --- a/app/services/projects/update_service.rb +++ b/app/services/projects/update_service.rb @@ -56,11 +56,25 @@ module Projects raise ValidationError.new(s_('UpdateProject|Cannot rename project because it contains container registry tags!')) end - if changing_default_branch? - raise ValidationError.new(s_("UpdateProject|Could not set the default branch")) unless project.change_head(params[:default_branch]) + validate_default_branch_change + end + + def validate_default_branch_change + return unless changing_default_branch? + + previous_default_branch = project.default_branch + + if project.change_head(params[:default_branch]) + after_default_branch_change(previous_default_branch) + else + raise ValidationError.new(s_("UpdateProject|Could not set the default branch")) end end + def after_default_branch_change(previous_default_branch) + # overridden by EE module + end + def remove_unallowed_params params.delete(:emails_disabled) unless can?(current_user, :set_emails_disabled, project) end diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb index de1cd7cd981..ea90d8e3dd8 100644 --- a/app/services/quick_actions/interpret_service.rb +++ b/app/services/quick_actions/interpret_service.rb @@ -164,6 +164,7 @@ module QuickActions next unless definition definition.execute(self, arg) + usage_ping_tracking(name, arg) end end @@ -178,6 +179,14 @@ module QuickActions ext.references(type) end # rubocop: enable CodeReuse/ActiveRecord + + def usage_ping_tracking(quick_action_name, arg) + Gitlab::UsageDataCounters::QuickActionActivityUniqueCounter.track_unique_action( + quick_action_name, + args: arg&.strip, + user: current_user + ) + end end end diff --git a/app/services/repositories/changelog_service.rb b/app/services/repositories/changelog_service.rb new file mode 100644 index 00000000000..f30b64b9b32 --- /dev/null +++ b/app/services/repositories/changelog_service.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module Repositories + # A service class for generating a changelog section. + class ChangelogService + DEFAULT_TRAILER = 'Changelog' + DEFAULT_FILE = 'CHANGELOG.md' + + # The `project` specifies the `Project` to generate the changelog section + # for. + # + # The `user` argument specifies a `User` to use for committing the changes + # to the Git repository. + # + # The `version` arguments must be a version `String` using semantic + # versioning as the format. + # + # The arguments `from` and `to` must specify a Git ref or SHA to use for + # fetching the commits to include in the changelog. The SHA/ref set in the + # `from` argument isn't included in the list. + # + # The `date` argument specifies the date of the release, and defaults to the + # current time/date. + # + # The `branch` argument specifies the branch to commit the changes to. The + # branch must already exist. + # + # The `trailer` argument is the Git trailer to use for determining what + # commits to include in the changelog. + # + # The `file` arguments specifies the name/path of the file to commit the + # changes to. If the file doesn't exist, it's created automatically. + # + # The `message` argument specifies the commit message to use when committing + # the changelog changes. + # + # rubocop: disable Metrics/ParameterLists + def initialize( + project, + user, + version:, + from:, + to:, + date: DateTime.now, + branch: project.default_branch_or_master, + trailer: DEFAULT_TRAILER, + file: DEFAULT_FILE, + message: "Add changelog for version #{version}" + ) + @project = project + @user = user + @version = version + @from = from + @to = to + @date = date + @branch = branch + @trailer = trailer + @file = file + @message = message + end + # rubocop: enable Metrics/ParameterLists + + def execute + # For every entry we want to only include the merge request that + # originally introduced the commit, which is the oldest merge request that + # contains the commit. We fetch there merge requests in batches, reducing + # the number of SQL queries needed to get this data. + mrs_finder = MergeRequests::OldestPerCommitFinder.new(@project) + config = Gitlab::Changelog::Config.from_git(@project) + release = Gitlab::Changelog::Release + .new(version: @version, date: @date, config: config) + + commits = + CommitsWithTrailerFinder.new(project: @project, from: @from, to: @to) + + commits.each_page(@trailer) do |page| + mrs = mrs_finder.execute(page) + + # Preload the authors. This ensures we only need a single SQL query per + # batch of commits, instead of needing a query for every commit. + page.each(&:lazy_author) + + page.each do |commit| + release.add_entry( + title: commit.title, + commit: commit, + category: commit.trailers.fetch(@trailer), + author: commit.author, + merge_request: mrs[commit.id] + ) + end + end + + Gitlab::Changelog::Committer + .new(@project, @user) + .commit(release: release, file: @file, branch: @branch, message: @message) + end + end +end diff --git a/app/services/repositories/housekeeping_service.rb b/app/services/repositories/housekeeping_service.rb index 6a2fa95d25f..de80390e60b 100644 --- a/app/services/repositories/housekeeping_service.rb +++ b/app/services/repositories/housekeeping_service.rb @@ -45,7 +45,7 @@ module Repositories private def execute_gitlab_shell_gc(lease_uuid) - GitGarbageCollectWorker.perform_async(@resource.id, task, lease_key, lease_uuid) + @resource.git_garbage_collect_worker_klass.perform_async(@resource.id, task, lease_key, lease_uuid) ensure if pushes_since_gc >= gc_period Gitlab::Metrics.measure(:reset_pushes_since_gc) do diff --git a/app/services/resource_access_tokens/create_service.rb b/app/services/resource_access_tokens/create_service.rb index 70e09be9407..36858f33b49 100644 --- a/app/services/resource_access_tokens/create_service.rb +++ b/app/services/resource_access_tokens/create_service.rb @@ -10,7 +10,7 @@ module ResourceAccessTokens end def execute - return error("User does not have permission to create #{resource_type} Access Token") unless has_permission_to_create? + return error("User does not have permission to create #{resource_type} access token") unless has_permission_to_create? user = create_user @@ -26,6 +26,7 @@ module ResourceAccessTokens token_response = create_personal_access_token(user) if token_response.success? + log_event(token_response.payload[:personal_access_token]) success(token_response.payload[:personal_access_token]) else delete_failed_user(user) @@ -105,6 +106,10 @@ module ResourceAccessTokens resource.add_user(user, :maintainer, expires_at: params[:expires_at]) end + def log_event(token) + ::Gitlab::AppLogger.info "PROJECT ACCESS TOKEN CREATION: created_by: #{current_user.username}, project_id: #{resource.id}, token_user: #{token.user.name}, token_id: #{token.id}" + end + def error(message) ServiceResponse.error(message: message) end @@ -114,3 +119,5 @@ module ResourceAccessTokens end end end + +ResourceAccessTokens::CreateService.prepend_if_ee('EE::ResourceAccessTokens::CreateService') diff --git a/app/services/resource_access_tokens/revoke_service.rb b/app/services/resource_access_tokens/revoke_service.rb index ece928dac31..59402701ddc 100644 --- a/app/services/resource_access_tokens/revoke_service.rb +++ b/app/services/resource_access_tokens/revoke_service.rb @@ -21,6 +21,8 @@ module ResourceAccessTokens destroy_bot_user + log_event + success("Access token #{access_token.name} has been revoked and the bot user has been scheduled for deletion.") rescue StandardError => error log_error("Failed to revoke access token for #{bot_user.name}: #{error.message}") @@ -57,6 +59,10 @@ module ResourceAccessTokens end end + def log_event + ::Gitlab::AppLogger.info "PROJECT ACCESS TOKEN REVOCATION: revoked_by: #{current_user.username}, project_id: #{resource.id}, token_user: #{access_token.user.name}, token_id: #{access_token.id}" + end + def error(message) ServiceResponse.error(message: message) end @@ -66,3 +72,5 @@ module ResourceAccessTokens end end end + +ResourceAccessTokens::RevokeService.prepend_if_ee('EE::ResourceAccessTokens::RevokeService') diff --git a/app/services/resource_events/base_change_timebox_service.rb b/app/services/resource_events/base_change_timebox_service.rb index 5c83f7b12f7..d802bbee107 100644 --- a/app/services/resource_events/base_change_timebox_service.rb +++ b/app/services/resource_events/base_change_timebox_service.rb @@ -2,12 +2,11 @@ module ResourceEvents class BaseChangeTimeboxService - attr_reader :resource, :user, :event_created_at + attr_reader :resource, :user - def initialize(resource, user, created_at: Time.current) + def initialize(resource, user) @resource = resource @user = user - @event_created_at = created_at end def execute @@ -27,7 +26,7 @@ module ResourceEvents { user_id: user.id, - created_at: event_created_at, + created_at: resource.system_note_timestamp, key => resource.id } end diff --git a/app/services/resource_events/change_milestone_service.rb b/app/services/resource_events/change_milestone_service.rb index dcdf87599ac..24935a3327a 100644 --- a/app/services/resource_events/change_milestone_service.rb +++ b/app/services/resource_events/change_milestone_service.rb @@ -4,8 +4,8 @@ module ResourceEvents class ChangeMilestoneService < BaseChangeTimeboxService attr_reader :milestone, :old_milestone - def initialize(resource, user, created_at: Time.current, old_milestone:) - super(resource, user, created_at: created_at) + def initialize(resource, user, old_milestone:) + super(resource, user) @milestone = resource&.milestone @old_milestone = old_milestone diff --git a/app/services/security/ci_configuration/sast_create_service.rb b/app/services/security/ci_configuration/sast_create_service.rb new file mode 100644 index 00000000000..8fc3b8d078c --- /dev/null +++ b/app/services/security/ci_configuration/sast_create_service.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Security + module CiConfiguration + class SastCreateService < ::BaseService + def initialize(project, current_user, params) + @project = project + @current_user = current_user + @params = params + @branch_name = @project.repository.next_branch('set-sast-config') + end + + def execute + attributes_for_commit = attributes + result = ::Files::MultiService.new(@project, @current_user, attributes_for_commit).execute + + if result[:status] == :success + result[:success_path] = successful_change_path + track_event(attributes_for_commit) + else + result[:errors] = result[:message] + end + + result + + rescue Gitlab::Git::PreReceiveError => e + { status: :error, errors: e.message } + end + + private + + def attributes + actions = Security::CiConfiguration::SastBuildActions.new(@project.auto_devops_enabled?, @params, existing_gitlab_ci_content).generate + + @project.repository.add_branch(@current_user, @branch_name, @project.default_branch) + message = _('Set .gitlab-ci.yml to enable or configure SAST') + + { + commit_message: message, + branch_name: @branch_name, + start_branch: @branch_name, + actions: actions + } + end + + def existing_gitlab_ci_content + gitlab_ci_yml = @project.repository.gitlab_ci_yml_for(@project.repository.root_ref_sha) + YAML.safe_load(gitlab_ci_yml) if gitlab_ci_yml + end + + def successful_change_path + description = _('Set .gitlab-ci.yml to enable or configure SAST security scanning using the GitLab managed template. You can [add variable overrides](https://docs.gitlab.com/ee/user/application_security/sast/#customizing-the-sast-settings) to customize SAST settings.') + merge_request_params = { source_branch: @branch_name, description: description } + Gitlab::Routing.url_helpers.project_new_merge_request_url(@project, merge_request: merge_request_params) + end + + def track_event(attributes_for_commit) + action = attributes_for_commit[:actions].first + + Gitlab::Tracking.event( + self.class.to_s, action[:action], label: action[:default_values_overwritten].to_s + ) + end + end + end +end diff --git a/app/services/security/ci_configuration/sast_parser_service.rb b/app/services/security/ci_configuration/sast_parser_service.rb new file mode 100644 index 00000000000..a8fe5764d19 --- /dev/null +++ b/app/services/security/ci_configuration/sast_parser_service.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +module Security + module CiConfiguration + # This class parses SAST template file and .gitlab-ci.yml to populate default and current values into the JSON + # read from app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json + class SastParserService < ::BaseService + include Gitlab::Utils::StrongMemoize + + SAST_UI_SCHEMA_PATH = 'app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json' + + def initialize(project) + @project = project + end + + def configuration + result = Gitlab::Json.parse(File.read(Rails.root.join(SAST_UI_SCHEMA_PATH))).with_indifferent_access + populate_default_value_for(result, :global) + populate_default_value_for(result, :pipeline) + fill_current_value_with_default_for(result, :global) + fill_current_value_with_default_for(result, :pipeline) + populate_current_value_for(result, :global) + populate_current_value_for(result, :pipeline) + + fill_current_value_with_default_for_analyzers(result) + populate_current_value_for_analyzers(result) + + result + end + + private + + def sast_template_content + Gitlab::Template::GitlabCiYmlTemplate.find('SAST').content + end + + def populate_default_value_for(config, level) + set_each(config[level], key: :default_value, with: sast_template_attributes) + end + + def populate_current_value_for(config, level) + set_each(config[level], key: :value, with: gitlab_ci_yml_attributes) + end + + def fill_current_value_with_default_for(config, level) + set_each(config[level], key: :value, with: sast_template_attributes) + end + + def set_each(config_attributes, key:, with:) + config_attributes.each do |entity| + entity[key] = with[entity[:field]] if with[entity[:field]] + end + end + + def fill_current_value_with_default_for_analyzers(result) + result[:analyzers].each do |analyzer| + analyzer[:variables].each do |entity| + entity[:value] = entity[:default_value] if entity[:default_value] + end + end + end + + def populate_current_value_for_analyzers(result) + result[:analyzers].each do |analyzer| + analyzer[:enabled] = analyzer_enabled?(analyzer[:name]) + populate_current_value_for(analyzer, :variables) + end + end + + def analyzer_enabled?(analyzer_name) + # Unless explicitly listed in the excluded analyzers, consider it enabled + sast_excluded_analyzers.exclude?(analyzer_name) + end + + def sast_excluded_analyzers + strong_memoize(:sast_excluded_analyzers) do + all_analyzers = Security::CiConfiguration::SastBuildActions::SAST_DEFAULT_ANALYZERS.split(', ') rescue [] + enabled_analyzers = sast_default_analyzers.split(',').map(&:strip) rescue [] + + excluded_analyzers = gitlab_ci_yml_attributes["SAST_EXCLUDED_ANALYZERS"] || sast_template_attributes["SAST_EXCLUDED_ANALYZERS"] + excluded_analyzers = excluded_analyzers.split(',').map(&:strip) rescue [] + ((all_analyzers - enabled_analyzers) + excluded_analyzers).uniq + end + end + + def sast_default_analyzers + @sast_default_analyzers ||= gitlab_ci_yml_attributes["SAST_DEFAULT_ANALYZERS"] || sast_template_attributes["SAST_DEFAULT_ANALYZERS"] + end + + def sast_template_attributes + @sast_template_attributes ||= build_sast_attributes(sast_template_content) + end + + def gitlab_ci_yml_attributes + @gitlab_ci_yml_attributes ||= begin + config_content = @project.repository.blob_data_at(@project.repository.root_ref_sha, ci_config_file) + return {} unless config_content + + build_sast_attributes(config_content) + end + end + + def ci_config_file + '.gitlab-ci.yml' + end + + def build_sast_attributes(content) + options = { project: @project, user: current_user, sha: @project.repository.commit.sha } + yaml_result = Gitlab::Ci::YamlProcessor.new(content, options).execute + return {} unless yaml_result.valid? + + sast_attributes = yaml_result.build_attributes(:sast) + extract_required_attributes(sast_attributes) + end + + def extract_required_attributes(attributes) + result = {} + attributes[:yaml_variables].each do |variable| + result[variable[:key]] = variable[:value] + end + + result[:stage] = attributes[:stage] + result.with_indifferent_access + end + end + end +end diff --git a/app/services/snippets/base_service.rb b/app/services/snippets/base_service.rb index 278857b7933..415cfcb7d8f 100644 --- a/app/services/snippets/base_service.rb +++ b/app/services/snippets/base_service.rb @@ -2,8 +2,6 @@ module Snippets class BaseService < ::BaseService - include SpamCheckMethods - UPDATE_COMMIT_MSG = 'Update snippet' INITIAL_COMMIT_MSG = 'Initial commit' @@ -18,8 +16,6 @@ module Snippets input_actions = Array(@params.delete(:snippet_actions).presence) @snippet_actions = SnippetInputActionCollection.new(input_actions, allowed_actions: restricted_files_actions) - - filter_spam_check_params end private diff --git a/app/services/snippets/create_service.rb b/app/services/snippets/create_service.rb index 0881be73eaf..802bfd813dc 100644 --- a/app/services/snippets/create_service.rb +++ b/app/services/snippets/create_service.rb @@ -3,20 +3,32 @@ module Snippets class CreateService < Snippets::BaseService def execute + # NOTE: disable_spam_action_service can be removed when the ':snippet_spam' feature flag is removed. + disable_spam_action_service = params.delete(:disable_spam_action_service) == true + @request = params.delete(:request) + @spam_params = Spam::SpamActionService.filter_spam_params!(params) + @snippet = build_from_params return invalid_params_error(@snippet) unless valid_params? - unless visibility_allowed?(@snippet, @snippet.visibility_level) - return forbidden_visibility_error(@snippet) + unless visibility_allowed?(snippet, snippet.visibility_level) + return forbidden_visibility_error(snippet) end @snippet.author = current_user - spam_check(@snippet, current_user, action: :create) + unless disable_spam_action_service + Spam::SpamActionService.new( + spammable: @snippet, + request: request, + user: current_user, + action: :create + ).execute(spam_params: spam_params) + end if save_and_commit - UserAgentDetailService.new(@snippet, @request).create + UserAgentDetailService.new(@snippet, request).create Gitlab::UsageDataCounters::SnippetCounter.count(:create) move_temporary_files @@ -29,6 +41,8 @@ module Snippets private + attr_reader :snippet, :request, :spam_params + def build_from_params if project project.snippets.build(create_params) diff --git a/app/services/snippets/update_service.rb b/app/services/snippets/update_service.rb index b982ff98747..5b427817a02 100644 --- a/app/services/snippets/update_service.rb +++ b/app/services/snippets/update_service.rb @@ -7,6 +7,11 @@ module Snippets UpdateError = Class.new(StandardError) def execute(snippet) + # NOTE: disable_spam_action_service can be removed when the ':snippet_spam' feature flag is removed. + disable_spam_action_service = params.delete(:disable_spam_action_service) == true + @request = params.delete(:request) + @spam_params = Spam::SpamActionService.filter_spam_params!(params) + return invalid_params_error(snippet) unless valid_params? if visibility_changed?(snippet) && !visibility_allowed?(snippet, visibility_level) @@ -14,12 +19,20 @@ module Snippets end update_snippet_attributes(snippet) - spam_check(snippet, current_user, action: :update) + + unless disable_spam_action_service + Spam::SpamActionService.new( + spammable: snippet, + request: request, + user: current_user, + action: :update + ).execute(spam_params: spam_params) + end if save_and_commit(snippet) Gitlab::UsageDataCounters::SnippetCounter.count(:update) - ServiceResponse.success(payload: { snippet: snippet } ) + ServiceResponse.success(payload: { snippet: snippet }) else snippet_error_response(snippet, 400) end @@ -27,6 +40,8 @@ module Snippets private + attr_reader :request, :spam_params + def visibility_changed?(snippet) visibility_level && visibility_level.to_i != snippet.visibility_level end diff --git a/app/services/spam/spam_action_service.rb b/app/services/spam/spam_action_service.rb index b3d617256ff..ff32bc32d93 100644 --- a/app/services/spam/spam_action_service.rb +++ b/app/services/spam/spam_action_service.rb @@ -4,37 +4,69 @@ module Spam class SpamActionService include SpamConstants + ## + # Utility method to filter SpamParams from parameters, which will later be passed to #execute + # after the spammable is created/updated based on the remaining parameters. + # + # Takes a hash of parameters from an incoming request to modify a model (via a controller, + # service, or GraphQL mutation). + # + # Deletes the parameters which are related to spam and captcha processing, and returns + # them in a SpamParams parameters object. See: + # https://refactoring.com/catalog/introduceParameterObject.html + def self.filter_spam_params!(params) + # NOTE: The 'captcha_response' field can be expanded to multiple fields when we move to future + # alternative captcha implementations such as FriendlyCaptcha. See + # https://gitlab.com/gitlab-org/gitlab/-/issues/273480 + captcha_response = params.delete(:captcha_response) + + SpamParams.new( + api: params.delete(:api), + captcha_response: captcha_response, + spam_log_id: params.delete(:spam_log_id) + ) + end + attr_accessor :target, :request, :options attr_reader :spam_log - def initialize(spammable:, request:, user:, context: {}) + def initialize(spammable:, request:, user:, action:) @target = spammable @request = request @user = user - @context = context + @action = action @options = {} + end - if @request - @options[:ip_address] = @request.env['action_dispatch.remote_ip'].to_s - @options[:user_agent] = @request.env['HTTP_USER_AGENT'] - @options[:referrer] = @request.env['HTTP_REFERRER'] + def execute(spam_params:) + if request + options[:ip_address] = request.env['action_dispatch.remote_ip'].to_s + options[:user_agent] = request.env['HTTP_USER_AGENT'] + options[:referrer] = request.env['HTTP_REFERRER'] else - @options[:ip_address] = @target.ip_address - @options[:user_agent] = @target.user_agent + # TODO: This code is never used, because we do not perform a verification if there is not a + # request. Why? Should it be deleted? Or should we check even if there is no request? + options[:ip_address] = target.ip_address + options[:user_agent] = target.user_agent end - end - def execute(api: false, recaptcha_verified:, spam_log_id:) + recaptcha_verified = Captcha::CaptchaVerificationService.new.execute( + captcha_response: spam_params.captcha_response, + request: request + ) + if recaptcha_verified - # If it's a request which is already verified through reCAPTCHA, + # If it's a request which is already verified through captcha, # update the spam log accordingly. - SpamLog.verify_recaptcha!(user_id: user.id, id: spam_log_id) + SpamLog.verify_recaptcha!(user_id: user.id, id: spam_params.spam_log_id) + ServiceResponse.success(message: "Captcha was successfully verified") else - return if allowlisted?(user) - return unless request - return unless check_for_spam? + return ServiceResponse.success(message: 'Skipped spam check because user was allowlisted') if allowlisted?(user) + return ServiceResponse.success(message: 'Skipped spam check because request was not present') unless request + return ServiceResponse.success(message: 'Skipped spam check because it was not required') unless check_for_spam? - perform_spam_service_check(api) + perform_spam_service_check(spam_params.api) + ServiceResponse.success(message: "Spam check performed, check #{target.class.name} spammable model for any errors or captcha requirement") end end @@ -42,13 +74,27 @@ module Spam private - attr_reader :user, :context + attr_reader :user, :action + + ## + # In order to be proceed to the spam check process, the target must be + # a dirty instance, which means it should be already assigned with the new + # attribute values. + def ensure_target_is_dirty + msg = "Target instance of #{target.class.name} must be dirty (must have changes to save)" + raise(msg) unless target.has_changes_to_save? + end def allowlisted?(user) user.try(:gitlab_employee?) || user.try(:gitlab_bot?) || user.try(:gitlab_service_user?) end + ## + # Performs the spam check using the spam verdict service, and modifies the target model + # accordingly based on the result. def perform_spam_service_check(api) + ensure_target_is_dirty + # since we can check for spam, and recaptcha is not verified, # ask the SpamVerdictService what to do with the target. spam_verdict_service.execute.tap do |result| @@ -79,7 +125,7 @@ module Spam description: target.spam_description, source_ip: options[:ip_address], user_agent: options[:user_agent], - noteable_type: notable_type, + noteable_type: noteable_type, via_api: api } ) @@ -88,14 +134,19 @@ module Spam end def spam_verdict_service + context = { + action: action, + target_type: noteable_type + } + SpamVerdictService.new(target: target, user: user, - request: @request, + request: request, options: options, - context: context.merge(target_type: notable_type)) + context: context) end - def notable_type + def noteable_type @notable_type ||= target.class.to_s end end diff --git a/app/services/spam/spam_params.rb b/app/services/spam/spam_params.rb new file mode 100644 index 00000000000..fef5355c7f3 --- /dev/null +++ b/app/services/spam/spam_params.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Spam + ## + # This class is a Parameter Object (https://refactoring.com/catalog/introduceParameterObject.html) + # which acts as an container abstraction for multiple parameter values related to spam and + # captcha processing for a request. + # + # Values contained are: + # + # api: A boolean flag indicating if the request was submitted via the REST or GraphQL API + # captcha_response: The response resulting from the user solving a captcha. Currently it is + # a scalar reCAPTCHA response string, but it can be expanded to an object in the future to + # support other captcha implementations such as FriendlyCaptcha. + # spam_log_id: The id of a SpamLog record. + class SpamParams + attr_reader :api, :captcha_response, :spam_log_id + + def initialize(api:, captcha_response:, spam_log_id:) + @api = api.present? + @captcha_response = captcha_response + @spam_log_id = spam_log_id + end + + def ==(other) + other.class == self.class && + other.api == self.api && + other.captcha_response == self.captcha_response && + other.spam_log_id == self.spam_log_id + end + end +end diff --git a/app/services/suggestions/apply_service.rb b/app/services/suggestions/apply_service.rb index ab80b23a37b..f9783f4271f 100644 --- a/app/services/suggestions/apply_service.rb +++ b/app/services/suggestions/apply_service.rb @@ -2,8 +2,9 @@ module Suggestions class ApplyService < ::BaseService - def initialize(current_user, *suggestions) + def initialize(current_user, *suggestions, message: nil) @current_user = current_user + @message = message @suggestion_set = Gitlab::Suggestions::SuggestionSet.new(suggestions) end @@ -30,6 +31,9 @@ module Suggestions Suggestion.id_in(suggestion_set.suggestions) .update_all(commit_id: result[:result], applied: true) + + Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter + .track_apply_suggestion_action(user: current_user) end def multi_service @@ -44,7 +48,7 @@ module Suggestions end def commit_message - Gitlab::Suggestions::CommitMessage.new(current_user, suggestion_set).message + Gitlab::Suggestions::CommitMessage.new(current_user, suggestion_set, @message).message end end end diff --git a/app/services/suggestions/create_service.rb b/app/services/suggestions/create_service.rb index 93d2bd11426..a97c36fa0ca 100644 --- a/app/services/suggestions/create_service.rb +++ b/app/services/suggestions/create_service.rb @@ -27,6 +27,8 @@ module Suggestions rows.in_groups_of(100, false) do |rows| Gitlab::Database.bulk_insert('suggestions', rows) # rubocop:disable Gitlab/BulkInsert end + + Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter.track_add_suggestion_action(user: @note.author) end end end diff --git a/app/services/system_hooks_service.rb b/app/services/system_hooks_service.rb index 881a139437a..5273dedb56f 100644 --- a/app/services/system_hooks_service.rb +++ b/app/services/system_hooks_service.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class SystemHooksService - BUILDER_DRIVEN_EVENT_DATA_AVAILABLE_FOR_CLASSES = [GroupMember].freeze + BUILDER_DRIVEN_EVENT_DATA_AVAILABLE_FOR_CLASSES = [GroupMember, Group].freeze def execute_hooks_for(model, event) data = build_event_data(model, event) @@ -58,15 +58,6 @@ class SystemHooksService end when ProjectMember data.merge!(project_member_data(model)) - when Group - data.merge!(group_data(model)) - - if event == :rename - data.merge!( - old_path: model.path_before_last_save, - old_full_path: model.full_path_before_last_save - ) - end end data @@ -114,19 +105,6 @@ class SystemHooksService } end - def group_data(model) - owner = model.owner - - { - name: model.name, - path: model.path, - full_path: model.full_path, - group_id: model.id, - owner_name: owner.try(:name), - owner_email: owner.try(:email) - } - end - def user_data(model) { name: model.name, @@ -141,10 +119,14 @@ class SystemHooksService end def builder_driven_event_data(model, event) - case model - when GroupMember - Gitlab::HookData::GroupMemberBuilder.new(model).build(event) - end + builder_class = case model + when GroupMember + Gitlab::HookData::GroupMemberBuilder + when Group + Gitlab::HookData::GroupBuilder + end + + builder_class.new(model).build(event) end end diff --git a/app/services/terraform/remote_state_handler.rb b/app/services/terraform/remote_state_handler.rb index 7e79cb9e007..9500a821071 100644 --- a/app/services/terraform/remote_state_handler.rb +++ b/app/services/terraform/remote_state_handler.rb @@ -68,12 +68,14 @@ module Terraform find_params = { project: project, name: params[:name] } - if find_only - Terraform::State.find_by(find_params) || # rubocop: disable CodeReuse/ActiveRecord - raise(ActiveRecord::RecordNotFound.new("Couldn't find state")) - else - Terraform::State.create_or_find_by(find_params) - end + return find_state!(find_params) if find_only + + state = Terraform::State.create_or_find_by(find_params) + + # https://github.com/rails/rails/issues/36027 + return state unless state.errors.of_kind? :name, :taken + + find_state(find_params) end def lock_matches?(state) @@ -86,5 +88,13 @@ module Terraform def can_modify_state? current_user.can?(:admin_terraform_state, project) end + + def find_state(find_params) + Terraform::State.find_by(find_params) # rubocop: disable CodeReuse/ActiveRecord + end + + def find_state!(find_params) + find_state(find_params) || raise(ActiveRecord::RecordNotFound.new("Couldn't find state")) + end end end diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index 12d26fe890b..dea116c8546 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -212,6 +212,11 @@ class TodoService current_user.update_todos_count_cache end + def create_request_review_todo(target, author, reviewers) + attributes = attributes_for_todo(target.project, target, author, Todo::REVIEW_REQUESTED) + create_todos(reviewers, attributes) + end + private def create_todos(users, attributes) @@ -266,8 +271,7 @@ class TodoService def create_reviewer_todo(target, author, old_reviewers = []) if target.reviewers.any? reviewers = target.reviewers - old_reviewers - attributes = attributes_for_todo(target.project, target, author, Todo::REVIEW_REQUESTED) - create_todos(reviewers, attributes) + create_request_review_todo(target, author, reviewers) end end diff --git a/app/services/users/approve_service.rb b/app/services/users/approve_service.rb index debd1e8cd17..fea7fc55d90 100644 --- a/app/services/users/approve_service.rb +++ b/app/services/users/approve_service.rb @@ -8,8 +8,7 @@ module Users def execute(user) return error(_('You are not allowed to approve a user'), :forbidden) unless allowed? - return error(_('The user you are trying to approve is not pending an approval'), :conflict) if user.active? - return error(_('The user you are trying to approve is not pending an approval'), :conflict) unless approval_required?(user) + return error(_('The user you are trying to approve is not pending approval'), :conflict) if user.active? || !approval_required?(user) if user.activate # Resends confirmation email if the user isn't confirmed yet. @@ -18,6 +17,7 @@ module Users user.accept_pending_invitations! if user.active_for_authentication? DeviseMailer.user_admin_approval(user).deliver_later + log_event(user) after_approve_hook(user) success(message: 'Success', http_status: :created) else @@ -40,6 +40,10 @@ module Users def approval_required?(user) user.blocked_pending_approval? end + + def log_event(user) + Gitlab::AppLogger.info(message: "User instance access request approved", user: "#{user.username}", email: "#{user.email}", approved_by: "#{current_user.username}", ip_address: "#{current_user.current_sign_in_ip}") + end end end diff --git a/app/services/users/refresh_authorized_projects_service.rb b/app/services/users/refresh_authorized_projects_service.rb index d0939d5a542..24e3fb73370 100644 --- a/app/services/users/refresh_authorized_projects_service.rb +++ b/app/services/users/refresh_authorized_projects_service.rb @@ -14,13 +14,14 @@ module Users # service = Users::RefreshAuthorizedProjectsService.new(some_user) # service.execute class RefreshAuthorizedProjectsService - attr_reader :user + attr_reader :user, :source LEASE_TIMEOUT = 1.minute.to_i # user - The User for which to refresh the authorized projects. - def initialize(user, incorrect_auth_found_callback: nil, missing_auth_found_callback: nil) + def initialize(user, source: nil, incorrect_auth_found_callback: nil, missing_auth_found_callback: nil) @user = user + @source = source @incorrect_auth_found_callback = incorrect_auth_found_callback @missing_auth_found_callback = missing_auth_found_callback @@ -91,6 +92,8 @@ module Users # remove - The IDs of the authorization rows to remove. # add - Rows to insert in the form `[user id, project id, access level]` def update_authorizations(remove = [], add = []) + log_refresh_details(remove.length, add.length) + User.transaction do user.remove_project_authorizations(remove) unless remove.empty? ProjectAuthorization.insert_authorizations(add) unless add.empty? @@ -101,6 +104,13 @@ module Users user.reset end + def log_refresh_details(rows_deleted, rows_added) + Gitlab::AppJsonLogger.info(event: 'authorized_projects_refresh', + 'authorized_projects_refresh.source': source, + 'authorized_projects_refresh.rows_deleted': rows_deleted, + 'authorized_projects_refresh.rows_added': rows_added) + end + def fresh_access_levels_per_project fresh_authorizations.each_with_object({}) do |row, hash| hash[row.project_id] = row.access_level diff --git a/app/services/users/reject_service.rb b/app/services/users/reject_service.rb index dd72547c688..0e3eb3e5dde 100644 --- a/app/services/users/reject_service.rb +++ b/app/services/users/reject_service.rb @@ -12,8 +12,12 @@ module Users user.delete_async(deleted_by: current_user, params: { hard_delete: true }) + after_reject_hook(user) + NotificationService.new.user_admin_rejection(user.name, user.email) + log_event(user) + success end @@ -24,5 +28,15 @@ module Users def allowed? can?(current_user, :reject_user) end + + def after_reject_hook(user) + # overridden by EE module + end + + def log_event(user) + Gitlab::AppLogger.info(message: "User instance access request rejected", user: "#{user.username}", email: "#{user.email}", rejected_by: "#{current_user.username}", ip_address: "#{current_user.current_sign_in_ip}") + end end end + +Users::RejectService.prepend_if_ee('EE::Users::RejectService') diff --git a/app/uploaders/packages/composer/cache_uploader.rb b/app/uploaders/packages/composer/cache_uploader.rb new file mode 100644 index 00000000000..f8052ec4810 --- /dev/null +++ b/app/uploaders/packages/composer/cache_uploader.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true +class Packages::Composer::CacheUploader < GitlabUploader + include ObjectStorage::Concern + + storage_options Gitlab.config.packages + + after :store, :schedule_background_upload + + alias_method :upload, :model + + def filename + "#{model.file_sha256}.json" + end + + def store_dir + dynamic_segment + end + + private + + def dynamic_segment + raise ObjectNotReadyError, 'Package model not ready' unless model.id + + Gitlab::HashedPath.new("packages", "composer_cache", model.namespace_id, root_hash: model.namespace_id) + end +end diff --git a/app/uploaders/terraform/state_uploader.rb b/app/uploaders/terraform/state_uploader.rb index d80725cb051..091b253b0ed 100644 --- a/app/uploaders/terraform/state_uploader.rb +++ b/app/uploaders/terraform/state_uploader.rb @@ -6,6 +6,10 @@ module Terraform storage_options Gitlab.config.terraform_state + # TODO: Remove this line + # See https://gitlab.com/gitlab-org/gitlab/-/issues/232917 + alias_method :upload, :model + delegate :terraform_state, :project_id, to: :model # Use Lockbox to encrypt/decrypt the stored file (registers CarrierWave callbacks) diff --git a/app/validators/json_schemas/git_trailers.json b/app/validators/json_schemas/git_trailers.json new file mode 100644 index 00000000000..18ac97226a7 --- /dev/null +++ b/app/validators/json_schemas/git_trailers.json @@ -0,0 +1,9 @@ +{ + "description": "Git trailer key/value pairs", + "type": "object", + "patternProperties": { + ".*": { + "type": "string" + } + } +} diff --git a/app/validators/variable_duplicates_validator.rb b/app/validators/nested_attributes_duplicates_validator.rb index d36a56e81b9..b60350a6311 100644 --- a/app/validators/variable_duplicates_validator.rb +++ b/app/validators/nested_attributes_duplicates_validator.rb @@ -1,13 +1,13 @@ # frozen_string_literal: true -# VariableDuplicatesValidator +# NestedAttributesDuplicates # # This validator is designed for especially the following condition # - Use `accepts_nested_attributes_for :xxx` in a parent model # - Use `validates :xxx, uniqueness: { scope: :xxx_id }` in a child model -class VariableDuplicatesValidator < ActiveModel::EachValidator +class NestedAttributesDuplicatesValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) - return if record.errors.include?(:"#{attribute}.key") + return if child_attributes.any? { |child_attribute| record.errors.include?(:"#{attribute}.#{child_attribute}") } if options[:scope] scoped = value.group_by do |variable| @@ -23,12 +23,18 @@ class VariableDuplicatesValidator < ActiveModel::EachValidator # rubocop: disable CodeReuse/ActiveRecord def validate_duplicates(record, attribute, values) - duplicates = values.reject(&:marked_for_destruction?).group_by(&:key).select { |_, v| v.many? }.map(&:first) - if duplicates.any? - error_message = +"have duplicate values (#{duplicates.join(", ")})" - error_message << " for #{values.first.send(options[:scope])} scope" if options[:scope] # rubocop:disable GitlabSecurity/PublicSend - record.errors.add(attribute, error_message) + child_attributes.each do |child_attribute| + duplicates = values.reject(&:marked_for_destruction?).group_by(&:"#{child_attribute}").select { |_, v| v.many? }.map(&:first) + if duplicates.any? + error_message = +"have duplicate values (#{duplicates.join(", ")})" + error_message << " for #{values.first.send(options[:scope])} scope" if options[:scope] # rubocop:disable GitlabSecurity/PublicSend + record.errors.add(attribute, error_message) + end end end # rubocop: enable CodeReuse/ActiveRecord + + def child_attributes + options[:child_attributes] || %i[key] + end end diff --git a/app/views/abuse_reports/new.html.haml b/app/views/abuse_reports/new.html.haml index c6781e91cfd..09b16c54700 100644 --- a/app/views/abuse_reports/new.html.haml +++ b/app/views/abuse_reports/new.html.haml @@ -25,4 +25,4 @@ = _("Explain the problem. If appropriate, provide a link to the relevant issue or comment.") .form-actions - = f.submit _("Send report"), class: "btn btn-success" + = f.submit _("Send report"), class: "gl-button btn btn-success" diff --git a/app/views/admin/abuse_reports/_abuse_report.html.haml b/app/views/admin/abuse_reports/_abuse_report.html.haml index c3d7fac6df7..3e1a76f31e1 100644 --- a/app/views/admin/abuse_reports/_abuse_report.html.haml +++ b/app/views/admin/abuse_reports/_abuse_report.html.haml @@ -13,7 +13,7 @@ %td %strong.subheading.d-block.d-sm-none = _('Reported by %{reporter}').html_safe % { reporter: reporter ? link_to(reporter.name, reporter) : _('(removed)') } - .light.gl-display-none.gl-display-sm-block + .light.gl-display-none.gl-sm-display-block = link_to(reporter.name, reporter) .light.small = time_ago_with_tooltip(abuse_report.created_at) diff --git a/app/views/admin/appearances/_form.html.haml b/app/views/admin/appearances/_form.html.haml index 67ac9d1c7b8..e6f12f4785a 100644 --- a/app/views/admin/appearances/_form.html.haml +++ b/app/views/admin/appearances/_form.html.haml @@ -54,10 +54,10 @@ .col-lg-8 .form-group = f.label :title, class: 'col-form-label label-bold' - = f.text_field :title, class: "form-control" + = f.text_field :title, class: "form-control gl-form-input" .form-group = f.label :description, class: 'col-form-label label-bold' - = f.text_area :description, class: "form-control", rows: 10 + = f.text_area :description, class: "form-control gl-form-input", rows: 10 .hint = parsed_with_gfm .form-group @@ -83,7 +83,7 @@ .form-group = f.label :new_project_guidelines, class: 'col-form-label label-bold' %p - = f.text_area :new_project_guidelines, class: "form-control", rows: 10 + = f.text_area :new_project_guidelines, class: "form-control gl-form-input", rows: 10 .hint = parsed_with_gfm @@ -96,7 +96,7 @@ .form-group = f.label :profile_image_guidelines, class: 'col-form-label label-bold' %p - = f.text_area :profile_image_guidelines, class: "form-control", rows: 10 + = f.text_area :profile_image_guidelines, class: "form-control gl-form-input", rows: 10 .hint = parsed_with_gfm diff --git a/app/views/admin/appearances/_system_header_footer_form.html.haml b/app/views/admin/appearances/_system_header_footer_form.html.haml index b50778a1076..4571d34a497 100644 --- a/app/views/admin/appearances/_system_header_footer_form.html.haml +++ b/app/views/admin/appearances/_system_header_footer_form.html.haml @@ -9,10 +9,10 @@ .col-lg-8 .form-group = form.label :header_message, _('Header message'), class: 'col-form-label label-bold' - = form.text_area :header_message, placeholder: _('State your message to activate'), class: "form-control js-autosize" + = form.text_area :header_message, placeholder: _('State your message to activate'), class: "form-control gl-form-input js-autosize" .form-group = form.label :footer_message, _('Footer message'), class: 'col-form-label label-bold' - = form.text_area :footer_message, placeholder: _('State your message to activate'), class: "form-control js-autosize" + = form.text_area :footer_message, placeholder: _('State your message to activate'), class: "form-control gl-form-input js-autosize" .form-group .form-check = form.check_box :email_header_and_footer_enabled, class: 'form-check-input' @@ -27,7 +27,7 @@ = _('Customize colors') .form-group.js-toggle-colors-container.hide = form.label :message_background_color, _('Background Color'), class: 'col-form-label label-bold' - = form.color_field :message_background_color, class: "form-control" + = form.color_field :message_background_color, class: "form-control gl-form-input" .form-group.js-toggle-colors-container.hide = form.label :message_font_color, _('Font Color'), class: 'col-form-label label-bold' - = form.color_field :message_font_color, class: "form-control" + = form.color_field :message_font_color, class: "form-control gl-form-input" diff --git a/app/views/admin/appearances/preview_sign_in.html.haml b/app/views/admin/appearances/preview_sign_in.html.haml index eec4719c13c..6e5bb45c3cc 100644 --- a/app/views/admin/appearances/preview_sign_in.html.haml +++ b/app/views/admin/appearances/preview_sign_in.html.haml @@ -3,10 +3,10 @@ %form.gl-show-field-errors .form-group = label_tag :login - = text_field_tag :login, nil, class: "form-control top", title: 'Please provide your username or email address.' + = text_field_tag :login, nil, class: "form-control gl-form-input top", title: 'Please provide your username or email address.' .form-group = label_tag :password - = password_field_tag :password, nil, class: "form-control bottom", title: 'This field is required.' + = password_field_tag :password, nil, class: "form-control gl-form-input bottom", title: 'This field is required.' .form-group = button_tag "Sign in", class: "btn gl-button btn-success" diff --git a/app/views/admin/application_settings/_abuse.html.haml b/app/views/admin/application_settings/_abuse.html.haml index c77615f9040..ea9bdbed9ae 100644 --- a/app/views/admin/application_settings/_abuse.html.haml +++ b/app/views/admin/application_settings/_abuse.html.haml @@ -4,7 +4,7 @@ %fieldset .form-group = f.label :abuse_notification_email, 'Abuse reports notification email', class: 'label-bold' - = f.text_field :abuse_notification_email, class: 'form-control' + = f.text_field :abuse_notification_email, class: 'form-control gl-form-input' .form-text.text-muted Abuse reports will be sent to this address if it is set. Abuse reports are always available in the admin area. 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 46155f3f670..aa57a2e2e85 100644 --- a/app/views/admin/application_settings/_account_and_limit.html.haml +++ b/app/views/admin/application_settings/_account_and_limit.html.haml @@ -10,27 +10,28 @@ .form-group = f.label :default_projects_limit, _('Default projects limit'), class: 'label-bold' - = f.number_field :default_projects_limit, class: 'form-control', title: _('Maximum number of projects.'), data: { toggle: 'tooltip', container: 'body' } + = f.number_field :default_projects_limit, class: 'form-control gl-form-input', title: _('Maximum number of projects.'), data: { toggle: 'tooltip', container: 'body' } .form-group = f.label :max_attachment_size, _('Maximum attachment size (MB)'), class: 'label-bold' - = f.number_field :max_attachment_size, class: 'form-control', title: _('Maximum size of individual attachments in comments.'), data: { toggle: 'tooltip', container: 'body' } + = f.number_field :max_attachment_size, class: 'form-control gl-form-input', title: _('Maximum size of individual attachments in comments.'), data: { toggle: 'tooltip', container: 'body' } = render_if_exists 'admin/application_settings/repository_size_limit_setting', form: f .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 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 qa-receive-max-input-size-field', title: _('Maximum size limit for a single commit.'), data: { toggle: 'tooltip', container: 'body' } .form-group = f.label :max_import_size, _('Maximum import size (MB)'), class: 'label-light' - = f.number_field :max_import_size, class: 'form-control 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 qa-receive-max-import-size-field', title: _('Maximum size of import files.'), data: { toggle: 'tooltip', container: 'body' } %span.form-text.text-muted= _('0 for unlimited, only effective with remote storage enabled.') .form-group = f.label :session_expire_delay, _('Session duration (minutes)'), class: 'label-light' - = f.number_field :session_expire_delay, class: 'form-control', title: _('Maximum duration of a session.'), data: { toggle: 'tooltip', container: 'body' } + = f.number_field :session_expire_delay, class: 'form-control gl-form-input', title: _('Maximum duration of a session.'), data: { toggle: 'tooltip', container: 'body' } %span.form-text.text-muted#session_expire_delay_help_block= _('GitLab restart is required to apply changes.') = render_if_exists 'admin/application_settings/personal_access_token_expiration_policy', form: f = render_if_exists 'admin/application_settings/enforce_pat_expiration', form: f + = render_if_exists 'admin/application_settings/enforce_ssh_key_expiration', form: f .form-group = f.label :user_oauth_applications, _('User OAuth applications'), class: 'label-bold' @@ -46,14 +47,14 @@ = _('Newly registered users will by default be external') .gl-mt-3 = _('Internal users') - = f.text_field :user_default_internal_regex, placeholder: _('Regex pattern'), class: 'form-control gl-mt-2' + = f.text_field :user_default_internal_regex, placeholder: _('Regex pattern'), class: 'form-control gl-form-input gl-mt-2' .help-block = _('Specify an e-mail address regex pattern to identify default internal users.') = link_to _('More information'), help_page_path('user/permissions', anchor: 'setting-new-users-to-external'), target: '_blank' .form-group = f.label :personal_access_token_prefix, _('Personal Access Token prefix'), class: 'label-light' - = f.text_field :personal_access_token_prefix, placeholder: _('Max 20 characters'), class: 'form-control' + = f.text_field :personal_access_token_prefix, placeholder: _('Max 20 characters'), class: 'form-control gl-form-input' .form-group = f.label :user_show_add_ssh_key_message, _('Prompt users to upload SSH keys'), class: 'label-bold' .form-check diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml index 9f384519c3a..331b028f176 100644 --- a/app/views/admin/application_settings/_ci_cd.html.haml +++ b/app/views/admin/application_settings/_ci_cd.html.haml @@ -14,7 +14,7 @@ = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank' .form-group = f.label :auto_devops_domain, s_('AdminSettings|Auto DevOps domain'), class: 'label-bold' - = f.text_field :auto_devops_domain, class: 'form-control', placeholder: 'domain.com' + = f.text_field :auto_devops_domain, class: 'form-control gl-form-input', placeholder: 'domain.com' .form-text.text-muted = s_("AdminSettings|Specify a domain to use by default for every project's Auto Review Apps and Auto Deploy stages.") .form-group @@ -27,23 +27,23 @@ .form-group = f.label :shared_runners_text, class: 'label-bold' - = f.text_area :shared_runners_text, class: 'form-control', rows: 4 + = f.text_area :shared_runners_text, class: 'form-control gl-form-input', rows: 4 .form-text.text-muted= _("Markdown enabled") .form-group = f.label :max_artifacts_size, _('Maximum artifacts size (MB)'), class: 'label-bold' - = f.number_field :max_artifacts_size, class: 'form-control' + = f.number_field :max_artifacts_size, class: 'form-control gl-form-input' .form-text.text-muted = _("Set the maximum file size for each job's artifacts") = link_to sprite_icon('question-o'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size') .form-group = f.label :default_artifacts_expire_in, _('Default artifacts expiration'), class: 'label-bold' - = f.text_field :default_artifacts_expire_in, class: 'form-control' + = f.text_field :default_artifacts_expire_in, class: 'form-control gl-form-input' .form-text.text-muted = html_escape(_("Set the default expiration time for each job's artifacts. 0 for unlimited. The default unit is in seconds, but you can define an alternative. For example: %{code_open}4 mins 2 sec%{code_close}, %{code_open}2h42min%{code_close}.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } = link_to sprite_icon('question-o'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'default-artifacts-expiration') .form-group = f.label :archive_builds_in_human_readable, _('Archive jobs'), class: 'label-bold' - = f.text_field :archive_builds_in_human_readable, class: 'form-control', placeholder: 'never' + = f.text_field :archive_builds_in_human_readable, class: 'form-control gl-form-input', placeholder: 'never' .form-text.text-muted = html_escape(_("Set the duration for which the jobs will be considered as old and expired. Once that time passes, the jobs will be archived and no longer able to be retried. Make it empty to never expire jobs. It has to be no less than 1 day, for example: %{code_open}15 days%{code_close}, %{code_open}1 month%{code_close}, %{code_open}2 years%{code_close}.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } .form-group @@ -55,9 +55,9 @@ = s_('AdminSettings|When creating a new environment variable it will be protected by default.') .form-group = f.label :ci_config_path, _('Default CI configuration path'), class: 'label-bold' - = f.text_field :default_ci_config_path, class: 'form-control', placeholder: '.gitlab-ci.yml' + = f.text_field :default_ci_config_path, class: 'form-control gl-form-input', placeholder: '.gitlab-ci.yml' %p.form-text.text-muted = _("The default CI configuration path for new projects.").html_safe - = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'custom-ci-configuration-path'), target: '_blank' + = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'custom-cicd-configuration-path'), target: '_blank' = f.submit _('Save changes'), class: "gl-button btn btn-success" diff --git a/app/views/admin/application_settings/_diff_limits.html.haml b/app/views/admin/application_settings/_diff_limits.html.haml index 6811c1e10d6..494558a6c2d 100644 --- a/app/views/admin/application_settings/_diff_limits.html.haml +++ b/app/views/admin/application_settings/_diff_limits.html.haml @@ -4,7 +4,7 @@ %fieldset .form-group = f.label :diff_max_patch_bytes, 'Maximum diff patch size (Bytes)', class: 'label-light' - = f.number_field :diff_max_patch_bytes, class: 'form-control' + = f.number_field :diff_max_patch_bytes, class: 'form-control gl-form-input' %span.form-text.text-muted Diff files surpassing this limit will be presented as 'too large' and won't be expandable. diff --git a/app/views/admin/application_settings/_eks.html.haml b/app/views/admin/application_settings/_eks.html.haml index 589d754be04..8897d0eb14b 100644 --- a/app/views/admin/application_settings/_eks.html.haml +++ b/app/views/admin/application_settings/_eks.html.haml @@ -20,16 +20,16 @@ Enable Amazon EKS integration .form-group = f.label :eks_account_id, 'Account ID', class: 'label-bold' - = f.text_field :eks_account_id, class: 'form-control' + = f.text_field :eks_account_id, class: 'form-control gl-form-input' .form-group = f.label :eks_access_key_id, 'Access key ID', class: 'label-bold' - = f.text_field :eks_access_key_id, class: 'form-control' + = f.text_field :eks_access_key_id, class: 'form-control gl-form-input' .form-text.text-muted = _('AWS Access Key. Only required if not using role instance credentials') .form-group = f.label :eks_secret_access_key, 'Secret access key', class: 'label-bold' - = f.password_field :eks_secret_access_key, autocomplete: 'off', class: 'form-control' + = f.password_field :eks_secret_access_key, autocomplete: 'off', class: 'form-control gl-form-input' .form-text.text-muted = _('AWS Secret Access Key. Only required if not using role instance credentials') diff --git a/app/views/admin/application_settings/_email.html.haml b/app/views/admin/application_settings/_email.html.haml index dd1be876505..89946c63bb0 100644 --- a/app/views/admin/application_settings/_email.html.haml +++ b/app/views/admin/application_settings/_email.html.haml @@ -18,7 +18,7 @@ = _('By default GitLab sends emails in HTML and plain text formats so mail clients can choose what format to use. Disable this option if you only want to send emails in plain text format.') .form-group = f.label :commit_email_hostname, _('Custom hostname (for private commit emails)'), class: 'label-bold' - = f.text_field :commit_email_hostname, class: 'form-control' + = f.text_field :commit_email_hostname, class: 'form-control gl-form-input' .form-text.text-muted - commit_email_hostname_docs_link = link_to _('Learn more'), help_page_path('user/admin_area/settings/email.md', anchor: 'custom-hostname-for-private-commit-emails'), target: '_blank' = _("This setting will update the hostname that is used to generate private commit emails. %{learn_more}").html_safe % { learn_more: commit_email_hostname_docs_link } 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 c8c1f3e6214..07256c9f4fe 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 @@ -2,7 +2,7 @@ .settings-header %h4 = _('External authentication') - %button.btn.js-settings-toggle{ type: 'button' } + %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p = _('External Classification Policy Authorization') @@ -22,29 +22,29 @@ = link_to sprite_icon('question-o'), help_page_path('user/admin_area/settings/external_authorization') .form-group = f.label :external_authorization_service_url, _('Service URL'), class: 'label-bold' - = f.text_field :external_authorization_service_url, class: 'form-control' + = f.text_field :external_authorization_service_url, class: 'form-control gl-form-input' %span.form-text.text-muted = external_authorization_url_help_text .form-group = f.label :external_authorization_service_timeout, _('External authorization request timeout'), class: 'label-bold' - = f.number_field :external_authorization_service_timeout, class: 'form-control', min: 0.001, max: 10, step: 0.001 + = f.number_field :external_authorization_service_timeout, class: 'form-control gl-form-input', min: 0.001, max: 10, step: 0.001 %span.form-text.text-muted = external_authorization_timeout_help_text = f.label :external_auth_client_cert, _('Client authentication certificate'), class: 'label-bold' - = f.text_area :external_auth_client_cert, class: 'form-control' + = f.text_area :external_auth_client_cert, class: 'form-control gl-form-input' %span.form-text.text-muted = external_authorization_client_certificate_help_text .form-group = f.label :external_auth_client_key, _('Client authentication key'), class: 'label-bold' - = f.text_area :external_auth_client_key, class: 'form-control' + = f.text_area :external_auth_client_key, class: 'form-control gl-form-input' %span.form-text.text-muted = external_authorization_client_key_help_text .form-group = f.label :external_auth_client_key_pass, _('Client authentication key password'), class: 'label-bold' - = f.password_field :external_auth_client_key_pass, class: 'form-control' + = f.password_field :external_auth_client_key_pass, class: 'form-control gl-form-input' %span.form-text.text-muted = external_authorization_client_pass_help_text .form-group = f.label :external_authorization_service_default_label, _('Default classification label'), class: 'label-bold' - = f.text_field :external_authorization_service_default_label, class: 'form-control' + = f.text_field :external_authorization_service_default_label, class: 'form-control gl-form-input' = f.submit 'Save changes', class: "gl-button btn btn-success" diff --git a/app/views/admin/application_settings/_gitaly.html.haml b/app/views/admin/application_settings/_gitaly.html.haml index a0cd70b4d7c..56ec35d9329 100644 --- a/app/views/admin/application_settings/_gitaly.html.haml +++ b/app/views/admin/application_settings/_gitaly.html.haml @@ -4,7 +4,7 @@ %fieldset .form-group = f.label :gitaly_timeout_default, 'Default Timeout Period', class: 'label-bold' - = f.number_field :gitaly_timeout_default, class: 'form-control' + = f.number_field :gitaly_timeout_default, class: 'form-control gl-form-input' .form-text.text-muted Timeout for Gitaly calls from the GitLab application (in seconds). This timeout is not enforced for git fetch/push operations or Sidekiq jobs. @@ -13,14 +13,14 @@ the worker. .form-group = f.label :gitaly_timeout_fast, 'Fast Timeout Period', class: 'label-bold' - = f.number_field :gitaly_timeout_fast, class: 'form-control' + = f.number_field :gitaly_timeout_fast, class: 'form-control gl-form-input' .form-text.text-muted Fast operation timeout (in seconds). Some Gitaly operations are expected to be fast. If they exceed this threshold, there may be a problem with a storage shard and 'failing fast' can help maintain the stability of the GitLab instance. .form-group = f.label :gitaly_timeout_medium, 'Medium Timeout Period', class: 'label-bold' - = f.number_field :gitaly_timeout_medium, class: 'form-control' + = f.number_field :gitaly_timeout_medium, class: 'form-control gl-form-input' .form-text.text-muted Medium operation timeout (in seconds). This should be a value between the Fast and the Default timeout. diff --git a/app/views/admin/application_settings/_gitpod.html.haml b/app/views/admin/application_settings/_gitpod.html.haml index 7f78cce4575..cca81136bb9 100644 --- a/app/views/admin/application_settings/_gitpod.html.haml +++ b/app/views/admin/application_settings/_gitpod.html.haml @@ -22,7 +22,7 @@ = f.label :gitpod_enabled, s_('Gitpod|Enable Gitpod integration'), class: 'form-check-label' .form-group = f.label :gitpod_url, s_('Gitpod|Gitpod URL'), class: 'label-bold' - = f.text_field :gitpod_url, class: 'form-control', placeholder: s_('Gitpod|e.g. https://gitpod.example.com') + = f.text_field :gitpod_url, class: 'form-control gl-form-input', placeholder: s_('Gitpod|e.g. https://gitpod.example.com') .form-text.text-muted = s_('Gitpod|Add the URL to your Gitpod instance configured to read your GitLab projects.') = f.submit s_('Save changes'), class: 'gl-button btn btn-success' diff --git a/app/views/admin/application_settings/_grafana.html.haml b/app/views/admin/application_settings/_grafana.html.haml index bd2b2094311..368b4db4549 100644 --- a/app/views/admin/application_settings/_grafana.html.haml +++ b/app/views/admin/application_settings/_grafana.html.haml @@ -12,6 +12,6 @@ = _('Enable access to Grafana') .form-group = f.label :grafana_url, _('Grafana URL'), class: 'label-bold' - = f.text_field :grafana_url, class: 'form-control', placeholder: '/-/grafana' + = f.text_field :grafana_url, class: 'form-control gl-form-input', placeholder: '/-/grafana' = f.submit _('Save changes'), class: "gl-button btn btn-success" diff --git a/app/views/admin/application_settings/_help_page.html.haml b/app/views/admin/application_settings/_help_page.html.haml index fc31f612b8c..858df44bd98 100644 --- a/app/views/admin/application_settings/_help_page.html.haml +++ b/app/views/admin/application_settings/_help_page.html.haml @@ -6,7 +6,7 @@ .form-group = f.label :help_page_text, class: 'label-bold' - = f.text_area :help_page_text, class: 'form-control', rows: 4 + = f.text_area :help_page_text, class: 'form-control gl-form-input', rows: 4 .form-text.text-muted= _('Markdown enabled') .form-group .form-check @@ -15,12 +15,12 @@ = _('Hide marketing-related entries from help') .form-group = f.label :help_page_support_url, _('Support page URL'), class: 'label-bold' - = f.text_field :help_page_support_url, class: 'form-control', placeholder: 'http://company.example.com/getting-help', :'aria-describedby' => 'support_help_block' + = f.text_field :help_page_support_url, class: 'form-control gl-form-input', placeholder: 'http://company.example.com/getting-help', :'aria-describedby' => 'support_help_block' %span.form-text.text-muted#support_help_block= _('Alternate support URL for help page and help dropdown') - if show_documentation_base_url_field? .form-group = f.label :help_page_documentation_base_url, _('Documentation pages URL'), class: 'label-bold' - = f.text_field :help_page_documentation_base_url, class: 'form-control', placeholder: 'https://docs.gitlab.com' + = f.text_field :help_page_documentation_base_url, class: 'form-control gl-form-input', placeholder: 'https://docs.gitlab.com' = f.submit _('Save changes'), class: "gl-button btn btn-success" 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 58218a41282..e1a58c888a5 100644 --- a/app/views/admin/application_settings/_import_export_limits.html.haml +++ b/app/views/admin/application_settings/_import_export_limits.html.haml @@ -4,31 +4,31 @@ %fieldset .form-group = f.label :project_import_limit, _('Max Project Import requests per minute per user'), class: 'label-bold' - = f.number_field :project_import_limit, class: 'form-control' + = f.number_field :project_import_limit, class: 'form-control gl-form-input' %fieldset .form-group = f.label :project_export_limit, _('Max Project Export requests per minute per user'), class: 'label-bold' - = f.number_field :project_export_limit, class: 'form-control' + = f.number_field :project_export_limit, class: 'form-control gl-form-input' %fieldset .form-group = f.label :project_download_export_limit, _('Max Project Export Download requests per minute per user'), class: 'label-bold' - = f.number_field :project_download_export_limit, class: 'form-control' + = f.number_field :project_download_export_limit, class: 'form-control gl-form-input' %fieldset .form-group = f.label :group_import_limit, _('Max Group Import requests per minute per user'), class: 'label-bold' - = f.number_field :group_import_limit, class: 'form-control' + = f.number_field :group_import_limit, class: 'form-control gl-form-input' %fieldset .form-group = f.label :group_export_limit, _('Max Group Export requests per minute per user'), class: 'label-bold' - = f.number_field :group_export_limit, class: 'form-control' + = f.number_field :group_export_limit, class: 'form-control gl-form-input' %fieldset .form-group = f.label :group_download_export_limit, _('Max Group Export Download requests per minute per user'), class: 'label-bold' - = f.number_field :group_download_export_limit, class: 'form-control' + = f.number_field :group_download_export_limit, class: 'form-control gl-form-input' = f.submit 'Save changes', class: "gl-button btn btn-success", data: { qa_selector: 'save_changes_button' } diff --git a/app/views/admin/application_settings/_initial_branch_name.html.haml b/app/views/admin/application_settings/_initial_branch_name.html.haml index bab841fcade..e7718f94b90 100644 --- a/app/views/admin/application_settings/_initial_branch_name.html.haml +++ b/app/views/admin/application_settings/_initial_branch_name.html.haml @@ -6,7 +6,7 @@ %fieldset .form-group = f.label :default_branch_name, _('Default initial branch name'), class: 'label-light' - = f.text_field :default_branch_name, placeholder: 'master', class: 'form-control' + = f.text_field :default_branch_name, placeholder: 'master', class: 'form-control gl-form-input' %span.form-text.text-muted = (_("Changes affect new repositories only. If not specified, Git's default name %{branch_name_default} will be used.") % { branch_name_default: fallback_branch_name } ).html_safe diff --git a/app/views/admin/application_settings/_ip_limits.html.haml b/app/views/admin/application_settings/_ip_limits.html.haml index 11ffe3f56e3..a603eaec913 100644 --- a/app/views/admin/application_settings/_ip_limits.html.haml +++ b/app/views/admin/application_settings/_ip_limits.html.haml @@ -13,10 +13,10 @@ Helps reduce request volume (e.g. from crawlers or abusive bots) .form-group = f.label :throttle_unauthenticated_requests_per_period, 'Max unauthenticated requests per period per IP', class: 'label-bold' - = f.number_field :throttle_unauthenticated_requests_per_period, class: 'form-control' + = f.number_field :throttle_unauthenticated_requests_per_period, class: 'form-control gl-form-input' .form-group = f.label :throttle_unauthenticated_period_in_seconds, 'Unauthenticated rate limit period in seconds', class: 'label-bold' - = f.number_field :throttle_unauthenticated_period_in_seconds, class: 'form-control' + = f.number_field :throttle_unauthenticated_period_in_seconds, class: 'form-control gl-form-input' %hr %h5 = _('Authenticated API request rate limit') @@ -29,10 +29,10 @@ Helps reduce request volume (e.g. from crawlers or abusive bots) .form-group = f.label :throttle_authenticated_api_requests_per_period, 'Max authenticated API requests per period per user', class: 'label-bold' - = f.number_field :throttle_authenticated_api_requests_per_period, class: 'form-control' + = f.number_field :throttle_authenticated_api_requests_per_period, class: 'form-control gl-form-input' .form-group = f.label :throttle_authenticated_api_period_in_seconds, 'Authenticated API rate limit period in seconds', class: 'label-bold' - = f.number_field :throttle_authenticated_api_period_in_seconds, class: 'form-control' + = f.number_field :throttle_authenticated_api_period_in_seconds, class: 'form-control gl-form-input' %hr %h5 = _('Authenticated web request rate limit') @@ -45,16 +45,16 @@ Helps reduce request volume (e.g. from crawlers or abusive bots) .form-group = f.label :throttle_authenticated_web_requests_per_period, 'Max authenticated web requests per period per user', class: 'label-bold' - = f.number_field :throttle_authenticated_web_requests_per_period, class: 'form-control' + = f.number_field :throttle_authenticated_web_requests_per_period, class: 'form-control gl-form-input' .form-group = f.label :throttle_authenticated_web_period_in_seconds, 'Authenticated web rate limit period in seconds', class: 'label-bold' - = f.number_field :throttle_authenticated_web_period_in_seconds, class: 'form-control' + = f.number_field :throttle_authenticated_web_period_in_seconds, class: 'form-control gl-form-input' %hr %h5 = _('Response text') .form-group = f.label :rate_limiting_response_text, class: 'label-bold' do = _('A plain-text response to show to clients that hit the rate limit.') - = f.text_area :rate_limiting_response_text, placeholder: ::Gitlab::Throttle::DEFAULT_RATE_LIMITING_RESPONSE_TEXT, class: 'form-control', rows: 5 + = f.text_area :rate_limiting_response_text, placeholder: ::Gitlab::Throttle::DEFAULT_RATE_LIMITING_RESPONSE_TEXT, class: 'form-control gl-form-input', rows: 5 = f.submit 'Save changes', class: "gl-button btn btn-success", data: { qa_selector: 'save_changes_button' } diff --git a/app/views/admin/application_settings/_issue_limits.html.haml b/app/views/admin/application_settings/_issue_limits.html.haml index 200ea3a8ec1..e16561b4489 100644 --- a/app/views/admin/application_settings/_issue_limits.html.haml +++ b/app/views/admin/application_settings/_issue_limits.html.haml @@ -4,6 +4,6 @@ %fieldset .form-group = f.label :issues_create_limit, 'Max requests per minute per user', class: 'label-bold' - = f.number_field :issues_create_limit, class: 'form-control' + = f.number_field :issues_create_limit, class: 'form-control gl-form-input' = f.submit 'Save changes', class: "gl-button btn btn-success", data: { qa_selector: 'save_changes_button' } diff --git a/app/views/admin/application_settings/_kroki.html.haml b/app/views/admin/application_settings/_kroki.html.haml index 1547b28c651..23848fb8b9b 100644 --- a/app/views/admin/application_settings/_kroki.html.haml +++ b/app/views/admin/application_settings/_kroki.html.haml @@ -18,7 +18,7 @@ = f.label :kroki_enabled, _('Enable Kroki'), class: 'form-check-label' .form-group = f.label :kroki_url, 'Kroki URL', class: 'label-bold' - = f.text_field :kroki_url, class: 'form-control', placeholder: 'http://your-kroki-instance:8000' + = f.text_field :kroki_url, class: 'form-control gl-form-input', placeholder: 'http://your-kroki-instance:8000' .form-text.text-muted = (_('When Kroki is enabled, GitLab sends diagrams to an instance of Kroki to display them as images. You can use the free public cloud instance %{kroki_public_url} or you can %{install_link} on your own infrastructure. Once you\'ve installed Kroki, make sure to update the server URL to point to your instance.') % { kroki_public_url: '<code>https://kroki.io</code>', install_link: link_to('install Kroki', 'https://docs.kroki.io/kroki/setup/install/', target: '_blank') }).html_safe diff --git a/app/views/admin/application_settings/_outbound.html.haml b/app/views/admin/application_settings/_outbound.html.haml index db0a87c366e..694cc9deab6 100644 --- a/app/views/admin/application_settings/_outbound.html.haml +++ b/app/views/admin/application_settings/_outbound.html.haml @@ -15,7 +15,7 @@ .form-group = f.label :outbound_local_requests_allowlist_raw, class: 'label-bold' do = _('Local IP addresses and domain names that hooks and services may access.') - = f.text_area :outbound_local_requests_allowlist_raw, placeholder: "example.com, 192.168.1.1", class: 'form-control', rows: 8 + = f.text_area :outbound_local_requests_allowlist_raw, placeholder: "example.com, 192.168.1.1", class: 'form-control gl-form-input', rows: 8 %span.form-text.text-muted = _('Requests to these domain(s)/address(es) on the local network will be allowed when local requests from hooks and services are not allowed. IP ranges such as 1:0:0:0:0:0:0:0/124 or 127.0.0.0/28 are supported. Domain wildcards are not supported currently. Use comma, semicolon, or newline to separate multiple entries. The allowlist can hold a maximum of 1000 entries. Domains should use IDNA encoding. Ex: example.com, 192.168.1.1, 127.0.0.0/28, xn--itlab-j1a.com.') diff --git a/app/views/admin/application_settings/_package_registry.html.haml b/app/views/admin/application_settings/_package_registry.html.haml index 8c956a43e22..86df1aa6e02 100644 --- a/app/views/admin/application_settings/_package_registry.html.haml +++ b/app/views/admin/application_settings/_package_registry.html.haml @@ -31,20 +31,20 @@ = f.hidden_field(:plan_id, value: plan.id) .form-group = f.label :conan_max_file_size, _('Maximum Conan package file size in bytes'), class: 'label-bold' - = f.number_field :conan_max_file_size, class: 'form-control' + = f.number_field :conan_max_file_size, class: 'form-control gl-form-input' .form-group = f.label :maven_max_file_size, _('Maximum Maven package file size in bytes'), class: 'label-bold' - = f.number_field :maven_max_file_size, class: 'form-control' + = f.number_field :maven_max_file_size, class: 'form-control gl-form-input' .form-group = f.label :npm_max_file_size, _('Maximum NPM package file size in bytes'), class: 'label-bold' - = f.number_field :npm_max_file_size, class: 'form-control' + = f.number_field :npm_max_file_size, class: 'form-control gl-form-input' .form-group = f.label :nuget_max_file_size, _('Maximum NuGet package file size in bytes'), class: 'label-bold' - = f.number_field :nuget_max_file_size, class: 'form-control' + = f.number_field :nuget_max_file_size, class: 'form-control gl-form-input' .form-group = f.label :pypi_max_file_size, _('Maximum PyPI package file size in bytes'), class: 'label-bold' - = f.number_field :pypi_max_file_size, class: 'form-control' + = f.number_field :pypi_max_file_size, class: 'form-control gl-form-input' .form-group = f.label :generic_packages_max_file_size, _('Generic package file size in bytes'), class: 'label-bold' - = f.number_field :generic_packages_max_file_size, class: 'form-control' + = 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-success' diff --git a/app/views/admin/application_settings/_pages.html.haml b/app/views/admin/application_settings/_pages.html.haml index d42987eb7d8..503aae861d0 100644 --- a/app/views/admin/application_settings/_pages.html.haml +++ b/app/views/admin/application_settings/_pages.html.haml @@ -4,7 +4,7 @@ %fieldset .form-group = f.label :max_pages_size, 'Maximum size of pages (MB)', class: 'label-bold' - = f.number_field :max_pages_size, class: 'form-control' + = f.number_field :max_pages_size, class: 'form-control gl-form-input' .form-text.text-muted = _("0 for unlimited") .form-group @@ -31,7 +31,7 @@ = _("%{lets_encrypt_link_start}Let's Encrypt%{lets_encrypt_link_end} is a free, automated, and open certificate authority (CA), that give digital certificates in order to enable HTTPS (SSL/TLS) for websites.").html_safe % { lets_encrypt_link_start: lets_encrypt_link_start, lets_encrypt_link_end: '</a>'.html_safe } .form-group = f.label :lets_encrypt_notification_email, _("Email"), class: 'label-bold' - = f.text_field :lets_encrypt_notification_email, class: 'form-control' + = f.text_field :lets_encrypt_notification_email, class: 'form-control gl-form-input' .form-text.text-muted = _("A Let's Encrypt account will be configured for this GitLab installation using your email address. You will receive emails to warn of expiring certificates.") .form-group diff --git a/app/views/admin/application_settings/_performance.html.haml b/app/views/admin/application_settings/_performance.html.haml index 2d27bceef10..3efe163de7b 100644 --- a/app/views/admin/application_settings/_performance.html.haml +++ b/app/views/admin/application_settings/_performance.html.haml @@ -17,17 +17,17 @@ .form-group = f.label :raw_blob_request_limit, _('Raw blob request rate limit per minute'), class: 'label-bold' - = f.number_field :raw_blob_request_limit, class: 'form-control' + = f.number_field :raw_blob_request_limit, class: 'form-control gl-form-input' .form-text.text-muted = _('Highest number of requests per minute for each raw path, default to 300. To disable throttling set to 0.') .form-group = f.label :push_event_hooks_limit, class: 'label-bold' - = f.number_field :push_event_hooks_limit, class: 'form-control' + = f.number_field :push_event_hooks_limit, class: 'form-control gl-form-input' .form-text.text-muted = _("Number of changes (branches or tags) in a single push to determine whether webhooks and services will be fired or not. Webhooks and services won't be submitted if it surpasses that value.") .form-group = f.label :push_event_activities_limit, class: 'label-bold' - = f.number_field :push_event_activities_limit, class: 'form-control' + = f.number_field :push_event_activities_limit, class: 'form-control gl-form-input' .form-text.text-muted = _('Number of changes (branches or tags) in a single push to determine whether individual push events or bulk push event will be created. Bulk push event will be created if it surpasses that value.') diff --git a/app/views/admin/application_settings/_performance_bar.html.haml b/app/views/admin/application_settings/_performance_bar.html.haml index 1036cc94bd0..2db22552596 100644 --- a/app/views/admin/application_settings/_performance_bar.html.haml +++ b/app/views/admin/application_settings/_performance_bar.html.haml @@ -9,6 +9,6 @@ Enable access to the Performance Bar .form-group = f.label :performance_bar_allowed_group_path, 'Allowed group', class: 'label-bold' - = f.text_field :performance_bar_allowed_group_path, class: 'form-control', placeholder: 'my-org/my-group', value: @application_setting.performance_bar_allowed_group&.full_path + = 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-success qa-save-changes-button' diff --git a/app/views/admin/application_settings/_plantuml.html.haml b/app/views/admin/application_settings/_plantuml.html.haml index 77a310c73a8..93fcc90f044 100644 --- a/app/views/admin/application_settings/_plantuml.html.haml +++ b/app/views/admin/application_settings/_plantuml.html.haml @@ -18,7 +18,7 @@ = f.label :plantuml_enabled, _('Enable PlantUML'), class: 'form-check-label' .form-group = f.label :plantuml_url, 'PlantUML URL', class: 'label-bold' - = f.text_field :plantuml_url, class: 'form-control', placeholder: 'http://your-plantuml-instance:8080' + = f.text_field :plantuml_url, class: 'form-control gl-form-input', placeholder: 'http://your-plantuml-instance:8080' .form-text.text-muted Allow rendering of = link_to "PlantUML", "http://plantuml.com" diff --git a/app/views/admin/application_settings/_prometheus.html.haml b/app/views/admin/application_settings/_prometheus.html.haml index c571ec1c1b0..c394bc65046 100644 --- a/app/views/admin/application_settings/_prometheus.html.haml +++ b/app/views/admin/application_settings/_prometheus.html.haml @@ -25,7 +25,7 @@ = link_to sprite_icon('question-o'), help_page_path('administration/monitoring/prometheus/gitlab_metrics', anchor: 'metrics-shared-directory') .form-group = f.label :metrics_method_call_threshold, 'Method Call Threshold (ms)', class: 'label-bold' - = f.number_field :metrics_method_call_threshold, class: 'form-control' + = f.number_field :metrics_method_call_threshold, class: 'form-control gl-form-input' .form-text.text-muted A method call is only tracked when it takes longer to complete than the given amount of milliseconds. diff --git a/app/views/admin/application_settings/_protected_paths.html.haml b/app/views/admin/application_settings/_protected_paths.html.haml index fce64369f17..57bba4f970a 100644 --- a/app/views/admin/application_settings/_protected_paths.html.haml +++ b/app/views/admin/application_settings/_protected_paths.html.haml @@ -17,15 +17,15 @@ = _('Helps reduce request volume for protected paths') .form-group = f.label :throttle_protected_paths_requests_per_period, 'Max requests per period per user', class: 'label-bold' - = f.number_field :throttle_protected_paths_requests_per_period, class: 'form-control' + = f.number_field :throttle_protected_paths_requests_per_period, class: 'form-control gl-form-input' .form-group = f.label :throttle_protected_paths_period_in_seconds, 'Rate limit period in seconds', class: 'label-bold' - = f.number_field :throttle_protected_paths_period_in_seconds, class: 'form-control' + = f.number_field :throttle_protected_paths_period_in_seconds, class: 'form-control gl-form-input' .form-group = f.label :protected_paths, class: 'label-bold' do - relative_url_link = 'https://docs.gitlab.com/omnibus/settings/configuration.html#configuring-a-relative-url-for-gitlab' - 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 URL%{relative_url_link_end}.').html_safe % { relative_url_link_start: relative_url_link_start, relative_url_link_end: '</a>'.html_safe } - = f.text_area :protected_paths_raw, placeholder: '/users/sign_in,/users/password', class: 'form-control', rows: 10 + = f.text_area :protected_paths_raw, placeholder: '/users/sign_in,/users/password', class: 'form-control gl-form-input', rows: 10 = f.submit 'Save changes', class: 'gl-button btn btn-success' diff --git a/app/views/admin/application_settings/_realtime.html.haml b/app/views/admin/application_settings/_realtime.html.haml index cf0b2b53eff..2b54a15d615 100644 --- a/app/views/admin/application_settings/_realtime.html.haml +++ b/app/views/admin/application_settings/_realtime.html.haml @@ -4,7 +4,7 @@ %fieldset .form-group = f.label :polling_interval_multiplier, 'Polling interval multiplier', class: 'label-bold' - = f.text_field :polling_interval_multiplier, class: 'form-control' + = f.text_field :polling_interval_multiplier, class: 'form-control gl-form-input' .form-text.text-muted Change this value to influence how frequently the GitLab UI polls for updates. If you set the value to 2 all polling intervals are multiplied diff --git a/app/views/admin/application_settings/_registry.html.haml b/app/views/admin/application_settings/_registry.html.haml index dd64d0ae419..5fb5effaa55 100644 --- a/app/views/admin/application_settings/_registry.html.haml +++ b/app/views/admin/application_settings/_registry.html.haml @@ -4,7 +4,7 @@ %fieldset .form-group = f.label :container_registry_token_expire_delay, 'Authorization token duration (minutes)', class: 'label-bold' - = f.number_field :container_registry_token_expire_delay, class: 'form-control' + = f.number_field :container_registry_token_expire_delay, class: 'form-control gl-form-input' .form-group .form-check = f.check_box :container_expiration_policies_enable_historic_entries, class: 'form-check-input' @@ -14,11 +14,21 @@ .form-text.text-muted = _("Existing projects will be able to use expiration policies. Avoid enabling this if an external Container Registry is being used, as there is a performance risk if many images exist on one project.") = link_to sprite_icon('question-o'), help_page_path('user/packages/container_registry/index', anchor: 'use-with-external-container-registries') - - if limit_delete_tags_service? + - if container_registry_expiration_policies_throttling? .form-group = f.label :container_registry_delete_tags_service_timeout, _('Cleanup policy maximum processing time (seconds)'), class: 'label-bold' - = f.number_field :container_registry_delete_tags_service_timeout, min: 0, class: 'form-control' + = f.number_field :container_registry_delete_tags_service_timeout, min: 0, class: 'form-control gl-form-input' .form-text.text-muted = _("Tags are deleted until the timeout is reached. Any remaining tags are included the next time the policy runs. To remove the time limit, set it to 0.") + .form-group + = f.label :container_registry_expiration_policies_worker_capacity, _('Cleanup policy maximum workers running concurrently'), class: 'label-bold' + = f.number_field :container_registry_expiration_policies_worker_capacity, min: 0, class: 'form-control' + .form-text.text-muted + = _("Cleanup policies are executed by background workers. This setting defines the maximum number of workers that can run concurrently. Set it to 0 to remove all workers and not execute the cleanup policies.") + .form-group + = f.label :container_registry_cleanup_tags_service_max_list_size, _('Cleanup policy maximum number of tags to be deleted'), class: 'label-bold' + = f.number_field :container_registry_cleanup_tags_service_max_list_size, min: 0, class: 'form-control' + .form-text.text-muted + = _("The maximum number of tags that a single worker accepts for cleanup. If the number of tags goes above this limit, the list of tags to delete is truncated to this number. To remove this limit, set it to 0.") = f.submit 'Save changes', class: "gl-button btn btn-success" diff --git a/app/views/admin/application_settings/_repository_check.html.haml b/app/views/admin/application_settings/_repository_check.html.haml index b9c2e406b78..24e74dd0f1b 100644 --- a/app/views/admin/application_settings/_repository_check.html.haml +++ b/app/views/admin/application_settings/_repository_check.html.haml @@ -41,17 +41,17 @@ bitmaps should accelerate 'git clone' performance. .form-group = f.label :housekeeping_incremental_repack_period, 'Incremental repack period', class: 'label-bold' - = f.number_field :housekeeping_incremental_repack_period, class: 'form-control' + = f.number_field :housekeeping_incremental_repack_period, class: 'form-control gl-form-input' .form-text.text-muted Number of Git pushes after which an incremental 'git repack' is run. .form-group = f.label :housekeeping_full_repack_period, 'Full repack period', class: 'label-bold' - = f.number_field :housekeeping_full_repack_period, class: 'form-control' + = f.number_field :housekeeping_full_repack_period, class: 'form-control gl-form-input' .form-text.text-muted Number of Git pushes after which a full 'git repack' is run. .form-group = f.label :housekeeping_gc_period, 'Git GC period', class: 'label-bold' - = f.number_field :housekeeping_gc_period, class: 'form-control' + = f.number_field :housekeeping_gc_period, class: 'form-control gl-form-input' .form-text.text-muted Number of Git pushes after which 'git gc' is run. diff --git a/app/views/admin/application_settings/_repository_static_objects.html.haml b/app/views/admin/application_settings/_repository_static_objects.html.haml index 00b9b4b8964..42fe2b24bb2 100644 --- a/app/views/admin/application_settings/_repository_static_objects.html.haml +++ b/app/views/admin/application_settings/_repository_static_objects.html.haml @@ -5,13 +5,13 @@ .form-group = f.label :static_objects_external_storage_url, class: 'label-bold' do = _('External storage URL') - = f.text_field :static_objects_external_storage_url, class: 'form-control' + = f.text_field :static_objects_external_storage_url, class: 'form-control gl-form-input' %span.form-text.text-muted#static_objects_external_storage_url_help_block = _('URL of the external storage that will serve the repository static objects (e.g. archives, blobs, ...).') .form-group = f.label :static_objects_external_storage_auth_token, class: 'label-bold' do = _('External storage authentication token') - = f.text_field :static_objects_external_storage_auth_token, class: 'form-control' + = f.text_field :static_objects_external_storage_auth_token, class: 'form-control gl-form-input' %span.form-text.text-muted#static_objects_external_storage_auth_token_help_block = _('A secure token that identifies an external storage request.') diff --git a/app/views/admin/application_settings/_signin.html.haml b/app/views/admin/application_settings/_signin.html.haml index 66fd0087c3e..23a7856e483 100644 --- a/app/views/admin/application_settings/_signin.html.haml +++ b/app/views/admin/application_settings/_signin.html.haml @@ -43,18 +43,18 @@ target: '_blank' .form-group = f.label :two_factor_authentication, 'Two-factor grace period (hours)', class: 'label-bold' - = f.number_field :two_factor_grace_period, min: 0, class: 'form-control', placeholder: '0' + = f.number_field :two_factor_grace_period, min: 0, class: 'form-control gl-form-input', placeholder: '0' .form-text.text-muted Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication .form-group = f.label :home_page_url, 'Home page URL', class: 'label-bold' - = f.text_field :home_page_url, class: 'form-control', placeholder: 'http://company.example.com', :'aria-describedby' => 'home_help_block' + = f.text_field :home_page_url, class: 'form-control gl-form-input', placeholder: 'http://company.example.com', :'aria-describedby' => 'home_help_block' %span.form-text.text-muted#home_help_block We will redirect non-logged in users to this page .form-group = f.label :after_sign_out_path, _('After sign-out path'), class: 'label-bold' - = f.text_field :after_sign_out_path, class: 'form-control', placeholder: 'http://company.example.com', :'aria-describedby' => 'after_sign_out_path_help_block' + = f.text_field :after_sign_out_path, class: 'form-control gl-form-input', placeholder: 'http://company.example.com', :'aria-describedby' => 'after_sign_out_path_help_block' %span.form-text.text-muted#after_sign_out_path_help_block We will redirect users to this page after they sign out .form-group = f.label :sign_in_text, _('Sign-in text'), class: 'label-bold' - = f.text_area :sign_in_text, class: 'form-control', rows: 4 + = f.text_area :sign_in_text, class: 'form-control gl-form-input', rows: 4 .form-text.text-muted Markdown enabled = f.submit 'Save changes', class: "gl-button btn btn-success" diff --git a/app/views/admin/application_settings/_signup.html.haml b/app/views/admin/application_settings/_signup.html.haml index 92477dff3d8..82824f1d436 100644 --- a/app/views/admin/application_settings/_signup.html.haml +++ b/app/views/admin/application_settings/_signup.html.haml @@ -26,13 +26,13 @@ .form-group = f.label :minimum_password_length, _('Minimum password length (number of characters)'), class: 'label-bold' - = f.number_field :minimum_password_length, class: 'form-control', rows: 4, min: ApplicationSetting::DEFAULT_MINIMUM_PASSWORD_LENGTH, max: Devise.password_length.max + = f.number_field :minimum_password_length, class: 'form-control gl-form-input', rows: 4, min: ApplicationSetting::DEFAULT_MINIMUM_PASSWORD_LENGTH, max: Devise.password_length.max - password_policy_guidelines_link = link_to _('Password Policy Guidelines'), 'https://about.gitlab.com/handbook/security/#gitlab-password-policy-guidelines', target: '_blank', rel: 'noopener noreferrer nofollow' .form-text.text-muted = _("See GitLab's %{password_policy_guidelines}").html_safe % { password_policy_guidelines: password_policy_guidelines_link } .form-group = f.label :domain_allowlist, _('Allowed domains for sign-ups'), class: 'label-bold' - = f.text_area :domain_allowlist_raw, placeholder: 'domain.com', class: 'form-control', rows: 8 + = f.text_area :domain_allowlist_raw, placeholder: 'domain.com', class: 'form-control gl-form-input', rows: 8 .form-text.text-muted ONLY users with e-mail addresses that match these domain(s) will be able to sign-up. Wildcards allowed. Use separate lines for multiple entries. Ex: domain.com, *.domain.com .form-group = f.label :domain_denylist_enabled, _('Domain denylist'), class: 'label-bold' @@ -53,11 +53,11 @@ Enter denylist manually .form-group.js-denylist-file = f.label :domain_denylist_file, _('Denylist file'), class: 'label-bold' - = f.file_field :domain_denylist_file, class: 'form-control', accept: '.txt,.conf' + = f.file_field :domain_denylist_file, class: 'form-control gl-form-input', accept: '.txt,.conf' .form-text.text-muted Users with e-mail addresses that match these domain(s) will NOT be able to sign-up. Wildcards allowed. Use separate lines or commas for multiple entries. .form-group.js-denylist-raw = f.label :domain_denylist, _('Denied domains for sign-ups'), class: 'label-bold' - = f.text_area :domain_denylist_raw, placeholder: 'domain.com', class: 'form-control', rows: 8 + = f.text_area :domain_denylist_raw, placeholder: 'domain.com', class: 'form-control gl-form-input', rows: 8 .form-text.text-muted Users with e-mail addresses that match these domain(s) will NOT be able to sign-up. Wildcards allowed. Use separate lines for multiple entries. Ex: domain.com, *.domain.com .form-group = f.label :email_restrictions_enabled, _('Email restrictions'), class: 'label-bold' @@ -67,7 +67,7 @@ = _('Enable email restrictions for sign ups') .form-group = f.label :email_restrictions, _('Email restrictions for sign-ups'), class: 'label-bold' - = f.text_area :email_restrictions, class: 'form-control', rows: 4 + = f.text_area :email_restrictions, class: 'form-control gl-form-input', rows: 4 .form-text.text-muted - supported_syntax_link_url = 'https://github.com/google/re2/wiki/Syntax' - supported_syntax_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: supported_syntax_link_url } @@ -75,6 +75,6 @@ .form-group = f.label :after_sign_up_text, class: 'label-bold' - = f.text_area :after_sign_up_text, class: 'form-control', rows: 4 + = f.text_area :after_sign_up_text, class: 'form-control gl-form-input', rows: 4 .form-text.text-muted Markdown enabled = f.submit 'Save changes', class: "gl-button btn btn-success", data: { qa_selector: 'save_changes_button' } diff --git a/app/views/admin/application_settings/_snowplow.html.haml b/app/views/admin/application_settings/_snowplow.html.haml index c1edaf9ff29..5f5a3a6992c 100644 --- a/app/views/admin/application_settings/_snowplow.html.haml +++ b/app/views/admin/application_settings/_snowplow.html.haml @@ -18,12 +18,12 @@ = f.label :snowplow_enabled, _('Enable snowplow tracking'), class: 'form-check-label' .form-group = f.label :snowplow_collector_hostname, _('Collector hostname'), class: 'label-light' - = f.text_field :snowplow_collector_hostname, class: 'form-control', placeholder: 'snowplow.example.com' + = f.text_field :snowplow_collector_hostname, class: 'form-control gl-form-input', placeholder: 'snowplow.example.com' .form-group = f.label :snowplow_app_id, _('App ID'), class: 'label-light' - = f.text_field :snowplow_app_id, class: 'form-control' + = f.text_field :snowplow_app_id, class: 'form-control gl-form-input' .form-group = f.label :snowplow_cookie_domain, _('Cookie domain'), class: 'label-light' - = f.text_field :snowplow_cookie_domain, class: 'form-control' + = f.text_field :snowplow_cookie_domain, class: 'form-control gl-form-input' = f.submit _('Save changes'), class: 'gl-button btn btn-success' diff --git a/app/views/admin/application_settings/_sourcegraph.html.haml b/app/views/admin/application_settings/_sourcegraph.html.haml index 2a4e8f87c31..e1af269c6fd 100644 --- a/app/views/admin/application_settings/_sourcegraph.html.haml +++ b/app/views/admin/application_settings/_sourcegraph.html.haml @@ -32,7 +32,7 @@ = s_('SourcegraphAdmin|If checked, only public projects will have code intelligence and communicate with Sourcegraph.') .form-group = f.label :sourcegraph_url, s_('SourcegraphAdmin|Sourcegraph URL'), class: 'label-bold' - = f.text_field :sourcegraph_url, class: 'form-control', placeholder: s_('SourcegraphAdmin|e.g. https://sourcegraph.example.com') + = f.text_field :sourcegraph_url, class: 'form-control gl-form-input', placeholder: s_('SourcegraphAdmin|e.g. 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-success' diff --git a/app/views/admin/application_settings/_spam.html.haml b/app/views/admin/application_settings/_spam.html.haml index 2b871d3693c..6085cea4f5d 100644 --- a/app/views/admin/application_settings/_spam.html.haml +++ b/app/views/admin/application_settings/_spam.html.haml @@ -18,7 +18,7 @@ = _('Helps prevent bots from brute-force attacks.') .form-group = f.label :recaptcha_site_key, 'reCAPTCHA Site Key', class: 'label-bold' - = f.text_field :recaptcha_site_key, class: 'form-control' + = f.text_field :recaptcha_site_key, class: 'form-control gl-form-input' .form-text.text-muted Generate site and private keys at %a{ href: 'http://www.google.com/recaptcha', target: 'blank' } http://www.google.com/recaptcha @@ -26,7 +26,7 @@ .form-group = f.label :recaptcha_private_key, 'reCAPTCHA Private Key', class: 'label-bold' .form-group - = f.text_field :recaptcha_private_key, class: 'form-control' + = f.text_field :recaptcha_private_key, class: 'form-control gl-form-input' .form-group .form-check @@ -45,7 +45,7 @@ .form-group = f.label :akismet_api_key, 'Akismet API Key', class: 'label-bold' - = f.text_field :akismet_api_key, class: 'form-control' + = f.text_field :akismet_api_key, class: 'form-control gl-form-input' .form-text.text-muted Generate API key at %a{ href: 'http://www.akismet.com', target: 'blank' } http://www.akismet.com @@ -60,13 +60,13 @@ .form-group = f.label :unique_ips_limit_per_user, 'IPs per user', class: 'label-bold' - = f.number_field :unique_ips_limit_per_user, class: 'form-control' + = f.number_field :unique_ips_limit_per_user, class: 'form-control gl-form-input' .form-text.text-muted Maximum number of unique IPs per user .form-group = f.label :unique_ips_limit_time_window, 'IP expiration time', class: 'label-bold' - = f.number_field :unique_ips_limit_time_window, class: 'form-control' + = f.number_field :unique_ips_limit_time_window, class: 'form-control gl-form-input' .form-text.text-muted How many seconds an IP will be counted towards the limit @@ -77,6 +77,6 @@ .form-text.text-muted= _('Define custom rules for what constitutes spam, independent of Akismet') .form-group = f.label :spam_check_endpoint_url, _('URL of the external Spam Check endpoint'), class: 'label-bold' - = f.text_field :spam_check_endpoint_url, class: 'form-control' + = f.text_field :spam_check_endpoint_url, class: 'form-control gl-form-input' = f.submit 'Save changes', class: "gl-button btn btn-success" diff --git a/app/views/admin/application_settings/_terminal.html.haml b/app/views/admin/application_settings/_terminal.html.haml index 7bc5b2405e8..8f89cf27291 100644 --- a/app/views/admin/application_settings/_terminal.html.haml +++ b/app/views/admin/application_settings/_terminal.html.haml @@ -4,7 +4,7 @@ %fieldset .form-group = f.label :terminal_max_session_time, 'Max session time', class: 'label-bold' - = f.number_field :terminal_max_session_time, class: 'form-control' + = f.number_field :terminal_max_session_time, class: 'form-control gl-form-input' .form-text.text-muted Maximum time for web terminal websocket connection (in seconds). 0 for unlimited. diff --git a/app/views/admin/application_settings/_terms.html.haml b/app/views/admin/application_settings/_terms.html.haml index 10db1e23d7b..717b2220336 100644 --- a/app/views/admin/application_settings/_terms.html.haml +++ b/app/views/admin/application_settings/_terms.html.haml @@ -12,7 +12,7 @@ .form-group = f.label :terms do = _("Terms of Service Agreement and Privacy Policy") - = f.text_area :terms, class: 'form-control', rows: 8 + = f.text_area :terms, class: 'form-control gl-form-input', rows: 8 .form-text.text-muted = _("Markdown enabled") = f.submit _("Save changes"), class: "gl-button btn btn-success" diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml index 709ce497132..0931ba50aa7 100644 --- a/app/views/admin/application_settings/_visibility_and_access.html.haml +++ b/app/views/admin/application_settings/_visibility_and_access.html.haml @@ -35,11 +35,11 @@ .form-check= source %span.form-text.text-muted#import-sources-help = _('Enabled sources for code import during project creation. OmniAuth must be configured for GitHub') - = link_to "(?)", help_page_path("integration/github") + = link_to sprite_icon('question-o'), help_page_path("integration/github") , Bitbucket - = link_to "(?)", help_page_path("integration/bitbucket") + = link_to sprite_icon('question-o'), help_page_path("integration/bitbucket") and GitLab.com - = link_to "(?)", help_page_path("integration/gitlab") + = link_to sprite_icon('question-o'), help_page_path("integration/gitlab") = render_if_exists 'admin/application_settings/ldap_access_setting', form: f @@ -57,7 +57,7 @@ .form-group = f.label :custom_http_clone_url_root, _('Custom Git clone URL for HTTP(S)'), class: 'label-bold' - = f.text_field :custom_http_clone_url_root, class: 'form-control', placeholder: 'https://git.example.com', :'aria-describedby' => 'custom_http_clone_url_root_help_block' + = f.text_field :custom_http_clone_url_root, class: 'form-control gl-form-input', placeholder: 'https://git.example.com', :'aria-describedby' => 'custom_http_clone_url_root_help_block' %span.form-text.text-muted#custom_http_clone_url_root_help_block = _('Replaces the clone URL root.') diff --git a/app/views/admin/application_settings/ci/_header.html.haml b/app/views/admin/application_settings/ci/_header.html.haml index 0a0f8aaf032..a54d8ff61e0 100644 --- a/app/views/admin/application_settings/ci/_header.html.haml +++ b/app/views/admin/application_settings/ci/_header.html.haml @@ -2,19 +2,18 @@ %h4 = _('Variables') - = link_to sprite_icon('question-o', css_class: 'gl-vertical-align-baseline!'), help_page_path('ci/variables/README', anchor: 'custom-environment-variables'), target: '_blank', rel: 'noopener noreferrer' %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded ? _('Collapse') : _('Expand') %p - = html_escape(_('Environment variables are applied to environments via the Runner. You can use environment variables for passwords, secret keys, etc. Make variables available to the running application by prepending the variable key with %{code_open}K8S_SECRET_%{code_close}. You can set variables to be:')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - + = _('Variables store information, like passwords and secret keys, that you can use in job scripts. All projects on the instance can use these variables.') + = link_to s_('Learn more.'), help_page_path('ci/variables/README', anchor: 'instance-level-cicd-environment-variables'), target: '_blank', rel: 'noopener noreferrer' +%p + = _('Variables can be:') %ul %li - = html_escape(_('%{code_open}Protected%{code_close} variables are only exposed to protected branches or tags.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } + = html_escape(_('%{code_open}Protected:%{code_close} Only exposed to protected branches or tags.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } %li - = html_escape(_('%{code_open}Masked%{code_close} variables are hidden in job logs (though they must match certain regexp requirements to do so).')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - -%p - = link_to _('More information'), help_page_path('ci/variables/README', anchor: 'instance-level-cicd-environment-variables') + = html_escape(_('%{code_open}Masked:%{code_close} Hidden in job logs. Must match masking requirements.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } + = link_to _('Learn more.'), help_page_path('ci/variables/README', anchor: 'masked-variable-requirements'), target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/admin/application_settings/ci_cd.html.haml b/app/views/admin/application_settings/ci_cd.html.haml index b05e8621d07..485fb71d111 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 @@ - if ci_variable_protected_by_default? %p.settings-message.text-center - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('ci/variables/README', anchor: 'protect-a-custom-variable') } - = s_('Environment variables on this GitLab instance are configured to be %{link_start}protected%{link_end} by default').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } + = 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} } %section.settings.as-ci-cd.no-animate#js-ci-cd-settings{ class: ('expanded' if expanded_by_default?) } diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml index 8f15dcac40a..794e02787f5 100644 --- a/app/views/admin/application_settings/general.html.haml +++ b/app/views/admin/application_settings/general.html.haml @@ -104,7 +104,6 @@ = f.submit _('Save changes'), class: "gl-button btn btn-success" = render_if_exists 'admin/application_settings/maintenance_mode_settings_form' -= render_if_exists 'admin/application_settings/elasticsearch_form' = render 'admin/application_settings/gitpod' = render 'admin/application_settings/kroki' = render 'admin/application_settings/plantuml' diff --git a/app/views/admin/application_settings/integrations.html.haml b/app/views/admin/application_settings/integrations.html.haml index ed4f63d0b82..949908b09a7 100644 --- a/app/views/admin/application_settings/integrations.html.haml +++ b/app/views/admin/application_settings/integrations.html.haml @@ -9,9 +9,9 @@ = sprite_icon('close', css_class: 'gl-icon') .gl-alert-body %h4.gl-alert-title= s_('AdminSettings|Some settings have moved') - = html_escape_once(s_('AdminSettings|Elasticsearch, PlantUML, Slack application, Third party offers, Snowplow, Amazon EKS have moved to Settings > General.')).html_safe + = html_escape_once(s_('AdminSettings|PlantUML, Slack application, Third party offers, Snowplow, Amazon EKS have moved to Settings > General.')).html_safe .gl-alert-actions - = link_to s_('AdminSettings|Go to General Settings'), general_admin_application_settings_path, class: 'btn gl-alert-action btn-info new-gl-button' + = link_to s_('AdminSettings|Go to General Settings'), general_admin_application_settings_path, class: 'btn gl-alert-action btn-info gl-button' %h4= s_('AdminSettings|Apply integration settings to all Projects') %p diff --git a/app/views/admin/application_settings/metrics_and_profiling.html.haml b/app/views/admin/application_settings/metrics_and_profiling.html.haml index 4959e596148..113ff20e910 100644 --- a/app/views/admin/application_settings/metrics_and_profiling.html.haml +++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml @@ -1,3 +1,5 @@ +- add_page_specific_style 'page_bundles/admin/application_settings_metrics_and_profiling' + - breadcrumb_title _("Metrics and profiling") - page_title _("Metrics and profiling") - @content_class = "limit-container-width" unless fluid_layout diff --git a/app/views/admin/application_settings/preferences.html.haml b/app/views/admin/application_settings/preferences.html.haml index 787760516ce..fd5ce890648 100644 --- a/app/views/admin/application_settings/preferences.html.haml +++ b/app/views/admin/application_settings/preferences.html.haml @@ -6,7 +6,7 @@ .settings-header %h4 = _('Email') - %button.btn.btn-default.js-settings-toggle{ type: 'button' } + %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded_by_default? ? _('Collapse') : _('Expand') %p = _('Various email settings.') @@ -17,7 +17,7 @@ .settings-header %h4 = _('Help page') - %button.btn.btn-default.js-settings-toggle{ type: 'button' } + %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded_by_default? ? _('Collapse') : _('Expand') %p = _('Help page text and support page url.') @@ -28,7 +28,7 @@ .settings-header %h4 = _('Pages') - %button.btn.btn-default.js-settings-toggle{ type: 'button' } + %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded_by_default? ? _('Collapse') : _('Expand') %p = _('Size and domain settings for static websites') @@ -39,7 +39,7 @@ .settings-header %h4 = _('Real-time features') - %button.btn.btn-default.js-settings-toggle{ type: 'button' } + %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded_by_default? ? _('Collapse') : _('Expand') %p = _('Change this value to influence how frequently the GitLab UI polls for updates.') @@ -50,7 +50,7 @@ .settings-header %h4 = _('Gitaly') - %button.btn.btn-default.js-settings-toggle{ type: 'button' } + %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded_by_default? ? _('Collapse') : _('Expand') %p = _('Configure Gitaly timeouts.') @@ -61,7 +61,7 @@ .settings-header %h4 = _('Localization') - %button.btn.btn-default.js-settings-toggle{ type: 'button' } + %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded_by_default? ? _('Collapse') : _('Expand') %p = _('Various localization settings.') diff --git a/app/views/admin/application_settings/reporting.html.haml b/app/views/admin/application_settings/reporting.html.haml index 6ea139844d4..914a09ff5db 100644 --- a/app/views/admin/application_settings/reporting.html.haml +++ b/app/views/admin/application_settings/reporting.html.haml @@ -6,7 +6,7 @@ .settings-header %h4 = _('Spam and Anti-bot Protection') - %button.btn.btn-default.js-settings-toggle{ type: 'button' } + %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded_by_default? ? _('Collapse') : _('Expand') %p - recaptcha_v2_link_url = 'https://developers.google.com/recaptcha/docs/versions' @@ -19,7 +19,7 @@ .settings-header %h4 = _('Abuse reports') - %button.btn.btn-default.js-settings-toggle{ type: 'button' } + %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded_by_default? ? _('Collapse') : _('Expand') %p = _('Set notification email for abuse reports.') diff --git a/app/views/admin/application_settings/repository.html.haml b/app/views/admin/application_settings/repository.html.haml index 18e093f7b2c..4365d8937bd 100644 --- a/app/views/admin/application_settings/repository.html.haml +++ b/app/views/admin/application_settings/repository.html.haml @@ -7,7 +7,7 @@ .settings-header %h4 = _('Default initial branch name') - %button.gl-button.js-settings-toggle{ type: 'button' } + %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded_by_default? ? _('Collapse') : _('Expand') %p = _('Set the default name of the initial branch when creating new repositories through the user interface.') @@ -18,7 +18,7 @@ .settings-header %h4 = _('Repository mirroring') - %button.btn.js-settings-toggle{ type: 'button' } + %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded_by_default? ? 'Collapse' : 'Expand' %p = _('Configure repository mirroring.') @@ -29,7 +29,7 @@ .settings-header %h4 = _('Repository storage') - %button.btn.btn-default.js-settings-toggle{ type: 'button' } + %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded_by_default? ? _('Collapse') : _('Expand') %p = _('Configure storage path settings.') @@ -40,7 +40,7 @@ .settings-header %h4 = _('Repository maintenance') - %button.btn.btn-default.js-settings-toggle{ type: 'button' } + %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded_by_default? ? _('Collapse') : _('Expand') %p = _('Configure automatic git checks and housekeeping on repositories.') @@ -51,7 +51,7 @@ .settings-header %h4 = _('Repository static objects') - %button.btn.btn-default.js-settings-toggle{ type: 'button' } + %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded_by_default? ? _('Collapse') : _('Expand') %p = _('Serve repository static objects (e.g. archives, blobs, ...) from an external storage (e.g. a CDN).') diff --git a/app/views/admin/applications/_form.html.haml b/app/views/admin/applications/_form.html.haml index 0c3a4e73e30..14044d6eecb 100644 --- a/app/views/admin/applications/_form.html.haml +++ b/app/views/admin/applications/_form.html.haml @@ -5,14 +5,14 @@ .col-sm-2.col-form-label = f.label :name .col-sm-10 - = f.text_field :name, class: 'form-control' + = f.text_field :name, class: 'form-control gl-form-input' = doorkeeper_errors_for application, :name = content_tag :div, class: 'form-group row' do .col-sm-2.col-form-label = f.label :redirect_uri .col-sm-10 - = f.text_area :redirect_uri, class: 'form-control' + = f.text_area :redirect_uri, class: 'form-control gl-form-input' = doorkeeper_errors_for application, :redirect_uri %span.form-text.text-muted Use one line per URI @@ -23,7 +23,7 @@ .col-sm-10 = f.check_box :trusted %span.form-text.text-muted - Trusted applications are automatically authorized on GitLab OAuth flow. + Trusted applications are automatically authorized on GitLab OAuth flow. It's highly recommended for the security of users that trusted applications have the confidential setting set to true. = content_tag :div, class: 'form-group row' do .col-sm-2.col-form-label.pt-0 diff --git a/app/views/admin/applications/show.html.haml b/app/views/admin/applications/show.html.haml index f029da6b3af..8d643a7a4bc 100644 --- a/app/views/admin/applications/show.html.haml +++ b/app/views/admin/applications/show.html.haml @@ -45,5 +45,5 @@ = render "shared/tokens/scopes_list", token: @application .form-actions - = link_to 'Edit', edit_admin_application_path(@application), class: 'gl-button btn btn-primary wide float-left' - = render 'delete_form', application: @application, submit_btn_css: 'btn btn-danger gl-ml-3' + = link_to 'Edit', edit_admin_application_path(@application), class: 'gl-button btn btn-confirm wide float-left' + = render 'delete_form', application: @application, submit_btn_css: 'gl-button btn btn-danger gl-ml-3' diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml index 9693a97367f..89fccff954d 100644 --- a/app/views/admin/broadcast_messages/_form.html.haml +++ b/app/views/admin/broadcast_messages/_form.html.haml @@ -21,7 +21,7 @@ .col-sm-2.col-form-label = f.label :message .col-sm-10 - = f.text_area :message, class: "form-control js-autosize js-broadcast-message-message", + = f.text_area :message, class: "form-control gl-form-input js-autosize js-broadcast-message-message", required: true, dir: 'auto', data: { preview_path: preview_admin_broadcast_messages_path } @@ -38,7 +38,7 @@ .input-group-prepend .input-group-text.label-color-preview{ :style => 'background-color: ' + @broadcast_message.color + '; color: ' + @broadcast_message.font } = ' '.html_safe - = f.text_field :color, class: "form-control js-broadcast-message-color" + = f.text_field :color, class: "form-control gl-form-input js-broadcast-message-color" .form-text.text-muted = _('Choose any color.') %br @@ -57,12 +57,12 @@ .col-sm-2.col-form-label = f.label :font, "Font Color" .col-sm-10 - = f.color_field :font, class: "form-control text-font-color" + = f.color_field :font, class: "form-control gl-form-input text-font-color" .form-group.row .col-sm-2.col-form-label = f.label :target_path, _('Target Path') .col-sm-10 - = f.text_field :target_path, class: "form-control" + = f.text_field :target_path, class: "form-control gl-form-input" .form-text.text-muted = _('Paths can contain wildcards, like */welcome') .form-group.row diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 8cc04392752..f6ebc4c465d 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -15,49 +15,63 @@ = render_if_exists 'admin/licenses/breakdown' .admin-dashboard.gl-mt-3 + .h3.gl-mb-5.gl-mt-0= _('Instance overview') .row - .col-sm-4 - .info-well.dark-well.flex-fill - .well-segment.well-centered - = link_to admin_projects_path do - %h3.text-center - = s_('AdminArea|Projects: %{number_of_projects}') % { number_of_projects: approximate_count_with_delimiters(@counts, Project) } - %hr - = link_to(s_('AdminArea|New project'), new_project_path, class: "btn gl-button btn-success gl-w-full") - .col-sm-4 - .info-well.dark-well - .well-segment.well-centered.gl-text-center - = link_to admin_users_path do - %h3.gl-display-inline-block.gl-mb-0 - = s_('AdminArea|Users: %{number_of_users}') % { number_of_users: approximate_count_with_delimiters(@counts, User) } - - %span.gl-outline-0.gl-ml-2{ href: "#", tabindex: "0", data: { container: "body", - toggle: "popover", - placement: "top", - html: "true", - trigger: "focus", - content: s_("AdminArea|All users created in the instance, including users who are not %{billable_users_link_start}billable users%{billable_users_link_end}.").html_safe % { billable_users_link_start: billable_users_link_start, billable_users_link_end: '</a>'.html_safe }, - } } - = sprite_icon('question', size: 16, css_class: 'gl-text-gray-700 gl-mb-1') - - %hr - .btn-group.d-flex{ role: 'group' } - = link_to s_('AdminArea|New user'), new_admin_user_path, class: "btn gl-button btn-success gl-w-full" - = link_to s_('AdminArea|Users statistics'), admin_dashboard_stats_path, class: 'btn gl-button btn-info gl-w-full' - .col-sm-4 - .info-well.dark-well - .well-segment.well-centered - = link_to admin_groups_path do - %h3.text-center - = s_('AdminArea|Groups: %{number_of_groups}') % { number_of_groups: approximate_count_with_delimiters(@counts, Group) } - %hr - = link_to s_('AdminArea|New group'), new_admin_group_path, class: "btn gl-button btn-success gl-w-full" + .col-md-4.gl-mb-6 + .gl-card + .gl-card-body.d-flex.justify-content-between.align-items-center.gl-p-6 + %span + .d-flex.align-items-center + = sprite_icon('project', size: 16, css_class: 'gl-text-gray-700') + %h3.gl-m-0.gl-ml-3= approximate_count_with_delimiters(@counts, Project) + .gl-mt-3.text-uppercase= s_('AdminArea|Projects') + = link_to(s_('AdminArea|New project'), new_project_path, class: "btn gl-button btn-default") + .gl-card-footer.gl-bg-transparent + .d-flex.align-items-center + = link_to(s_('AdminArea|View latest projects'), admin_projects_path) + = sprite_icon('angle-right', size: 12, css_class: 'gl-text-gray-700 gl-ml-2') + .col-md-4.gl-mb-6 + .gl-card + .gl-card-body.d-flex.justify-content-between.align-items-center.gl-p-6 + %span + .d-flex.align-items-center + = sprite_icon('users', size: 16, css_class: 'gl-text-gray-700') + %h3.gl-m-0.gl-ml-3= approximate_count_with_delimiters(@counts, User) + %span.gl-outline-0.gl-ml-3{ tabindex: "0", data: { container: "body", + toggle: "popover", + placement: "top", + html: "true", + trigger: "focus", + content: s_("AdminArea|All users created in the instance, including users who are not %{billable_users_link_start}billable users%{billable_users_link_end}.").html_safe % { billable_users_link_start: billable_users_link_start, billable_users_link_end: '</a>'.html_safe }, + } } + = sprite_icon('question', size: 16, css_class: 'gl-text-gray-700') + .gl-mt-3.text-uppercase + = s_('AdminArea|Users') + = link_to(s_('AdminArea|Users statistics'), admin_dashboard_stats_path, class: "text-capitalize gl-ml-2") + = link_to(s_('AdminArea|New user'), new_admin_user_path, class: "btn gl-button btn-default") + .gl-card-footer.gl-bg-transparent + .d-flex.align-items-center + = link_to(s_('AdminArea|View latest users'), admin_users_path) + = sprite_icon('angle-right', size: 12, css_class: 'gl-text-gray-700 gl-ml-2') + .col-md-4.gl-mb-6 + .gl-card + .gl-card-body.d-flex.justify-content-between.align-items-center.gl-p-6 + %span + .d-flex.align-items-center + = sprite_icon('group', size: 16, css_class: 'gl-text-gray-700') + %h3.gl-m-0.gl-ml-3= approximate_count_with_delimiters(@counts, Group) + .gl-mt-3.text-uppercase= s_('AdminArea|Projects') + = link_to(s_('AdminArea|New group'), new_admin_group_path, class: "btn gl-button btn-default") + .gl-card-footer.gl-bg-transparent + .d-flex.align-items-center + = link_to(s_('AdminArea|View latest groups'), admin_groups_path) + = sprite_icon('angle-right', size: 12, css_class: 'gl-text-gray-700 gl-ml-2') .row - .col-md-4 + .col-md-4.gl-mb-6 #js-admin-statistics-container - .col-md-4 - .info-well - .well-segment.admin-well.admin-well-features + .col-md-4.gl-mb-6 + .gl-card + .gl-card-body %h4= s_('AdminArea|Features') = feature_entry(_('Sign up'), href: general_admin_application_settings_path(anchor: 'js-signup-settings'), @@ -94,9 +108,9 @@ = feature_entry(_('Shared Runners'), href: admin_runners_path, enabled: Gitlab.config.gitlab_ci.shared_runners_enabled) - .col-md-4 - .info-well - .well-segment.admin-well + .col-md-4.gl-mb-6 + .gl-card + .gl-card-body %h4 = s_('AdminArea|Components') - if Gitlab::CurrentSettings.version_check_enabled @@ -146,18 +160,18 @@ %p = link_to _("Gitaly Servers"), admin_gitaly_servers_path .row - .col-md-4 - .info-well - .well-segment.admin-well + .col-md-4.gl-mb-6 + .gl-card + .gl-card-body %h4= s_('AdminArea|Latest projects') - @projects.each do |project| %p = link_to project.full_name, admin_project_path(project), class: 'str-truncated-60' %span.light.float-right #{time_ago_with_tooltip(project.created_at)} - .col-md-4 - .info-well - .well-segment.admin-well + .col-md-4.gl-mb-6 + .gl-card + .gl-card-body %h4= s_('AdminArea|Latest users') - @users.each do |user| %p @@ -165,9 +179,9 @@ = user.name %span.light.float-right #{time_ago_with_tooltip(user.created_at)} - .col-md-4 - .info-well - .well-segment.admin-well + .col-md-4.gl-mb-6 + .gl-card + .gl-card-body %h4= s_('AdminArea|Latest groups') - @groups.each do |group| %p diff --git a/app/views/admin/groups/_group.html.haml b/app/views/admin/groups/_group.html.haml index a667fc7ca04..dc122d74e90 100644 --- a/app/views/admin/groups/_group.html.haml +++ b/app/views/admin/groups/_group.html.haml @@ -14,7 +14,7 @@ .description = markdown_field(group, :description) - .stats.gl-text-gray-500.gl-flex-shrink-0.gl-display-none.gl-display-sm-flex + .stats.gl-text-gray-500.gl-flex-shrink-0.gl-display-none.gl-sm-display-flex %span.badge.badge-pill = storage_counter(group.storage_size) @@ -33,5 +33,5 @@ = visibility_level_icon(group.visibility_level) .controls.gl-flex-shrink-0.gl-ml-5 - = link_to _('Edit'), admin_group_edit_path(group), id: "edit_#{dom_id(group)}", class: 'btn' + = link_to _('Edit'), admin_group_edit_path(group), id: "edit_#{dom_id(group)}", class: 'gl-button btn' = link_to _('Delete'), [:admin, group], data: { confirm: _("Are you sure you want to remove %{group_name}?") % { group_name: group.name } }, method: :delete, class: 'gl-button btn btn-danger' diff --git a/app/views/admin/hooks/_form.html.haml b/app/views/admin/hooks/_form.html.haml index e6abd8ff85a..ecaf7b9b38c 100644 --- a/app/views/admin/hooks/_form.html.haml +++ b/app/views/admin/hooks/_form.html.haml @@ -2,10 +2,10 @@ .form-group = form.label :url, _('URL'), class: 'label-bold' - = form.text_field :url, class: 'form-control' + = form.text_field :url, class: 'form-control gl-form-input' .form-group = form.label :token, _('Secret Token'), class: 'label-bold' - = form.text_field :token, class: 'form-control' + = form.text_field :token, class: 'form-control gl-form-input' %p.form-text.text-muted= _('Use this token to validate received payloads') .form-group = form.label :url, _('Trigger'), class: 'label-bold' diff --git a/app/views/admin/jobs/index.html.haml b/app/views/admin/jobs/index.html.haml index ce377eeea54..670628f7463 100644 --- a/app/views/admin/jobs/index.html.haml +++ b/app/views/admin/jobs/index.html.haml @@ -1,4 +1,5 @@ - add_page_specific_style 'page_bundles/ci_status' +- add_page_specific_style 'page_bundles/admin/jobs_index' - breadcrumb_title _("Jobs") - page_title _("Jobs") diff --git a/app/views/admin/labels/_form.html.haml b/app/views/admin/labels/_form.html.haml index 664081339f3..12c7acd7668 100644 --- a/app/views/admin/labels/_form.html.haml +++ b/app/views/admin/labels/_form.html.haml @@ -5,12 +5,12 @@ .col-sm-2.col-form-label = f.label :title .col-sm-10 - = f.text_field :title, class: "form-control", required: true + = f.text_field :title, class: "form-control gl-form-input", required: true .form-group.row .col-sm-2.col-form-label = f.label :description .col-sm-10 - = f.text_field :description, class: "form-control js-quick-submit" + = f.text_field :description, class: "form-control gl-form-input js-quick-submit" .form-group.row .col-sm-2.col-form-label = f.label :color, _("Background color") @@ -18,7 +18,7 @@ .input-group .input-group-prepend .input-group-text.label-color-preview - = f.text_field :color, class: "form-control" + = f.text_field :color, class: "form-control gl-form-input" .form-text.text-muted = _('Choose any color.') %br diff --git a/app/views/admin/projects/_projects.html.haml b/app/views/admin/projects/_projects.html.haml index 44317eb7f6e..4131c8b7edd 100644 --- a/app/views/admin/projects/_projects.html.haml +++ b/app/views/admin/projects/_projects.html.haml @@ -4,8 +4,8 @@ - @projects.each_with_index 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" - %button.delete-project-button.btn.btn-danger{ data: { delete_project_url: admin_project_path(project), project_name: project.name } } + = link_to 'Edit', edit_project_path(project), id: "edit_#{dom_id(project)}", class: "gl-button btn" + %button.delete-project-button.gl-button.btn.btn-danger{ data: { delete_project_url: admin_project_path(project), project_name: project.name } } = s_('AdminProjects|Delete') .stats diff --git a/app/views/admin/projects/index.html.haml b/app/views/admin/projects/index.html.haml index bcf09dfc0d2..d9ff4404519 100644 --- a/app/views/admin/projects/index.html.haml +++ b/app/views/admin/projects/index.html.haml @@ -32,6 +32,6 @@ = render 'shared/projects/dropdown' = link_to new_project_path, class: 'gl-button btn btn-success' do New Project - = button_tag "Search", class: "gl-button btn btn-primary btn-search hide" + = button_tag "Search", class: "gl-button btn btn-confirm btn-search hide" = render 'projects' diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index aae1d5b6a4e..2085515e349 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -150,7 +150,7 @@ .form-group.row .offset-sm-3.col-sm-9 - = f.submit _('Transfer'), class: 'gl-button btn btn-primary' + = f.submit _('Transfer'), class: 'gl-button btn btn-confirm' .card.repository-check .card-header @@ -170,7 +170,7 @@ = link_to sprite_icon('question-o'), help_page_path('administration/repository_checks') .form-group - = f.submit _('Trigger repository check'), class: 'gl-button btn btn-primary' + = f.submit _('Trigger repository check'), class: 'gl-button btn btn-confirm' .col-md-6 - if @group diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml index 9f19d3f5d4e..8e62dae6c4d 100644 --- a/app/views/admin/runners/index.html.haml +++ b/app/views/admin/runners/index.html.haml @@ -39,7 +39,9 @@ = render partial: 'ci/runner/how_to_setup_runner', locals: { registration_token: Gitlab::CurrentSettings.runners_registration_token, type: 'shared', - reset_token_url: reset_registration_token_admin_application_settings_path } + reset_token_url: reset_registration_token_admin_application_settings_path, + project_path: '', + group_path: '' } .row .col-sm-9 @@ -48,7 +50,7 @@ .filtered-search-box = dropdown_tag(_('Recent searches'), options: { wrapper_class: 'filtered-search-history-dropdown-wrapper', - toggle_class: 'gl-button btn filtered-search-history-dropdown-toggle-button', + toggle_class: 'btn filtered-search-history-dropdown-toggle-button', dropdown_class: 'filtered-search-history-dropdown', content_class: 'filtered-search-history-dropdown-content' }) do .js-filtered-search-history-dropdown{ data: { full_path: admin_runners_path } } diff --git a/app/views/admin/users/_access_levels.html.haml b/app/views/admin/users/_access_levels.html.haml index 3ba01e8a350..573580bc5c5 100644 --- a/app/views/admin/users/_access_levels.html.haml +++ b/app/views/admin/users/_access_levels.html.haml @@ -5,7 +5,7 @@ .col-sm-2.col-form-label = f.label :projects_limit .col-sm-10 - = f.number_field :projects_limit, min: 0, max: Gitlab::Database::MAX_INT_VALUE, class: 'form-control' + = f.number_field :projects_limit, min: 0, max: Gitlab::Database::MAX_INT_VALUE, class: 'form-control gl-form-input' .form-group.row .col-sm-2.col-form-label.gl-pt-0 diff --git a/app/views/admin/users/_admin_notes.html.haml b/app/views/admin/users/_admin_notes.html.haml index 4da70a504f7..a20b2fbffc4 100644 --- a/app/views/admin/users/_admin_notes.html.haml +++ b/app/views/admin/users/_admin_notes.html.haml @@ -4,4 +4,4 @@ .col-sm-2.col-form-label = f.label :note, s_('AdminNote|Note') .col-sm-10 - = f.text_area :note, class: 'form-control' + = f.text_area :note, class: 'form-control gl-form-input gl-form-textarea' diff --git a/app/views/admin/cohorts/index.html.haml b/app/views/admin/users/_cohorts.html.haml index 03cd392d370..013c6072165 100644 --- a/app/views/admin/cohorts/index.html.haml +++ b/app/views/admin/users/_cohorts.html.haml @@ -1,6 +1,3 @@ -- breadcrumb_title _("Cohorts") -- page_title _("Cohorts") - - if @cohorts = render 'cohorts_table' - else diff --git a/app/views/admin/cohorts/_cohorts_table.html.haml b/app/views/admin/users/_cohorts_table.html.haml index bb6266b38f6..bb6266b38f6 100644 --- a/app/views/admin/cohorts/_cohorts_table.html.haml +++ b/app/views/admin/users/_cohorts_table.html.haml diff --git a/app/views/admin/users/_form.html.haml b/app/views/admin/users/_form.html.haml index 61c31d2d864..40393f0db99 100644 --- a/app/views/admin/users/_form.html.haml +++ b/app/views/admin/users/_form.html.haml @@ -8,19 +8,19 @@ .col-sm-2.col-form-label = f.label :name .col-sm-10 - = f.text_field :name, required: true, autocomplete: 'off', class: 'form-control' + = f.text_field :name, required: true, autocomplete: 'off', class: 'form-control gl-form-input' %span.help-inline * required .form-group.row .col-sm-2.col-form-label = f.label :username .col-sm-10 - = f.text_field :username, required: true, autocomplete: 'off', autocorrect: 'off', autocapitalize: 'off', spellcheck: false, class: 'form-control' + = f.text_field :username, required: true, autocomplete: 'off', autocorrect: 'off', autocapitalize: 'off', spellcheck: false, class: 'form-control gl-form-input' %span.help-inline * required .form-group.row .col-sm-2.col-form-label = f.label :email .col-sm-10 - = f.text_field :email, required: true, autocomplete: 'off', class: 'form-control' + = f.text_field :email, required: true, autocomplete: 'off', class: 'form-control gl-form-input' %span.help-inline * required - if @user.new_record? @@ -41,12 +41,12 @@ .col-sm-2.col-form-label = f.label :password .col-sm-10 - = f.password_field :password, disabled: f.object.force_random_password, class: 'form-control' + = f.password_field :password, disabled: f.object.force_random_password, class: 'form-control gl-form-input' .form-group.row .col-sm-2.col-form-label = f.label :password_confirmation .col-sm-10 - = f.password_field :password_confirmation, disabled: f.object.force_random_password, class: 'form-control' + = f.password_field :password_confirmation, disabled: f.object.force_random_password, class: 'form-control gl-form-input' = render partial: 'access_levels', locals: { f: f } @@ -66,22 +66,22 @@ .col-sm-2.col-form-label = f.label :skype .col-sm-10 - = f.text_field :skype, class: 'form-control' + = f.text_field :skype, class: 'form-control gl-form-input' .form-group.row .col-sm-2.col-form-label = f.label :linkedin .col-sm-10 - = f.text_field :linkedin, class: 'form-control' + = f.text_field :linkedin, class: 'form-control gl-form-input' .form-group.row .col-sm-2.col-form-label = f.label :twitter .col-sm-10 - = f.text_field :twitter, class: 'form-control' + = f.text_field :twitter, class: 'form-control gl-form-input' .form-group.row .col-sm-2.col-form-label = f.label :website_url .col-sm-10 - = f.text_field :website_url, class: 'form-control' + = f.text_field :website_url, class: 'form-control gl-form-input' = render 'admin/users/admin_notes', f: f diff --git a/app/views/admin/users/_head.html.haml b/app/views/admin/users/_head.html.haml index 4abcdef7e27..554f7470694 100644 --- a/app/views/admin/users/_head.html.haml +++ b/app/views/admin/users/_head.html.haml @@ -15,7 +15,7 @@ - if @user.deactivated? %span.cred = s_('AdminUsers|(Deactivated)') - = render_if_exists 'admin/users/audtior_user_badge' + = render_if_exists 'admin/users/auditor_user_badge' .float-right - if impersonation_enabled? && @user != current_user && @user.can?(:log_in) diff --git a/app/views/admin/users/_user_detail.html.haml b/app/views/admin/users/_user_detail.html.haml index 3bafd1cb396..05e387e6479 100644 --- a/app/views/admin/users/_user_detail.html.haml +++ b/app/views/admin/users/_user_detail.html.haml @@ -9,9 +9,10 @@ = render 'admin/users/user_listing_note', user: user - user_badges_in_admin_section(user).each do |badge| - - css_badge = "badge badge-#{badge[:variant]}" if badge[:variant].present? - %span{ class: css_badge } - = badge[:text] + - css_badge = "badge gl-badge sm badge-pill badge-#{badge[:variant]}" if badge[:variant].present? + %span.px-1.py-1 + %span{ class: css_badge } + = badge[:text] .row-second-line.str-truncated-100 = mail_to user.email, user.email, class: 'text-secondary' diff --git a/app/views/admin/users/_users.html.haml b/app/views/admin/users/_users.html.haml new file mode 100644 index 00000000000..57edb9abe90 --- /dev/null +++ b/app/views/admin/users/_users.html.haml @@ -0,0 +1,88 @@ +.top-area.scrolling-tabs-container.inner-page-scroll-tabs + .fade-left + = sprite_icon('chevron-lg-left', size: 12) + .fade-right + = sprite_icon('chevron-lg-right', size: 12) + %ul.nav-links.nav.nav-tabs.scrolling-tabs + = nav_link(html_options: { class: active_when(params[:filter].nil?) }) do + = link_to admin_users_path do + = s_('AdminUsers|Active') + %small.badge.badge-pill= limited_counter_with_delimiter(User.active_without_ghosts) + = nav_link(html_options: { class: active_when(params[:filter] == 'admins') }) do + = link_to admin_users_path(filter: "admins") do + = s_('AdminUsers|Admins') + %small.badge.badge-pill= limited_counter_with_delimiter(User.admins) + = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_enabled')} filter-two-factor-enabled" }) do + = link_to admin_users_path(filter: 'two_factor_enabled') do + = s_('AdminUsers|2FA Enabled') + %small.badge.badge-pill= limited_counter_with_delimiter(User.with_two_factor) + = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_disabled')} filter-two-factor-disabled" }) do + = link_to admin_users_path(filter: 'two_factor_disabled') do + = s_('AdminUsers|2FA Disabled') + %small.badge.badge-pill= limited_counter_with_delimiter(User.without_two_factor) + = nav_link(html_options: { class: active_when(params[:filter] == 'external') }) do + = link_to admin_users_path(filter: 'external') do + = s_('AdminUsers|External') + %small.badge.badge-pill= limited_counter_with_delimiter(User.external) + = nav_link(html_options: { class: active_when(params[:filter] == 'blocked') }) do + = link_to admin_users_path(filter: "blocked") do + = s_('AdminUsers|Blocked') + %small.badge.badge-pill= limited_counter_with_delimiter(User.blocked) + = nav_link(html_options: { class: "#{active_when(params[:filter] == 'blocked_pending_approval')} filter-blocked-pending-approval" }) do + = link_to admin_users_path(filter: "blocked_pending_approval"), data: { qa_selector: 'pending_approval_tab' } do + = s_('AdminUsers|Pending approval') + %small.badge.badge-pill= limited_counter_with_delimiter(User.blocked_pending_approval) + = nav_link(html_options: { class: active_when(params[:filter] == 'deactivated') }) do + = link_to admin_users_path(filter: "deactivated") do + = s_('AdminUsers|Deactivated') + %small.badge.badge-pill= limited_counter_with_delimiter(User.deactivated) + = nav_link(html_options: { class: active_when(params[:filter] == 'wop') }) do + = link_to admin_users_path(filter: "wop") do + = s_('AdminUsers|Without projects') + %small.badge.badge-pill= limited_counter_with_delimiter(User.without_projects) + .nav-controls + = render_if_exists 'admin/users/admin_email_users' + = render_if_exists 'admin/users/admin_export_user_permissions' + = link_to s_('AdminUsers|New user'), new_admin_user_path, class: 'btn gl-button btn-success btn-search float-right' + +.filtered-search-block.row-content-block.border-top-0 + = form_tag admin_users_path, method: :get do + - if params[:filter].present? + = hidden_field_tag "filter", h(params[:filter]) + .search-holder + .search-field-holder.gl-mb-4 + = search_field_tag :search_query, params[:search_query], placeholder: s_('AdminUsers|Search by name, email or username'), class: 'form-control search-text-input js-search-input', spellcheck: false, data: { qa_selector: 'user_search_field' } + - if @sort.present? + = hidden_field_tag :sort, @sort + = sprite_icon('search', css_class: 'search-icon') + = button_tag s_('AdminUsers|Search users') if Rails.env.test? + .dropdown.user-sort-dropdown + = label_tag 'Sort by', nil, class: 'label-bold' + - toggle_text = @sort.present? ? users_sort_options_hash[@sort] : sort_title_name + = dropdown_toggle(toggle_text, { toggle: 'dropdown' }) + %ul.dropdown-menu.dropdown-menu-right + %li.dropdown-header + = s_('AdminUsers|Sort by') + %li + - users_sort_options_hash.each do |value, title| + = link_to admin_users_path(sort: value, filter: params[:filter], search_query: params[:search_query]) do + = title + +- if Feature.enabled?(:vue_admin_users) + #js-admin-users-app{ data: admin_users_data_attributes(@users) } + .gl-spinner-container.gl-my-7 + %span.gl-vertical-align-bottom.gl-spinner.gl-spinner-dark.gl-spinner-lg{ aria: { label: _('Loading') } } +- elsif @users.empty? + .nothing-here-block.border-top-0 + = s_('AdminUsers|No users found') +- else + .table-holder + .thead-white.text-nowrap.gl-responsive-table-row.table-row-header{ role: 'row' } + - user_table_headers.each do |header| + .table-section{ class: header[:section_class_name], role: 'rowheader' }= header[:header_text] + + = render partial: 'admin/users/user', collection: @users + += paginate @users, theme: "gitlab" + += render partial: 'admin/users/modals' diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml index cef16b1881e..8da0c7f4300 100644 --- a/app/views/admin/users/index.html.haml +++ b/app/views/admin/users/index.html.haml @@ -1,92 +1,17 @@ - page_title _("Users") -.top-area.scrolling-tabs-container.inner-page-scroll-tabs - .fade-left - = sprite_icon('chevron-lg-left', size: 12) - .fade-right - = sprite_icon('chevron-lg-right', size: 12) - %ul.nav-links.nav.nav-tabs.scrolling-tabs - = nav_link(html_options: { class: active_when(params[:filter].nil?) }) do - = link_to admin_users_path do - = s_('AdminUsers|Active') - %small.badge.badge-pill= limited_counter_with_delimiter(User.active_without_ghosts) - = nav_link(html_options: { class: active_when(params[:filter] == 'admins') }) do - = link_to admin_users_path(filter: "admins") do - = s_('AdminUsers|Admins') - %small.badge.badge-pill= limited_counter_with_delimiter(User.admins) - = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_enabled')} filter-two-factor-enabled" }) do - = link_to admin_users_path(filter: 'two_factor_enabled') do - = s_('AdminUsers|2FA Enabled') - %small.badge.badge-pill= limited_counter_with_delimiter(User.with_two_factor) - = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_disabled')} filter-two-factor-disabled" }) do - = link_to admin_users_path(filter: 'two_factor_disabled') do - = s_('AdminUsers|2FA Disabled') - %small.badge.badge-pill= limited_counter_with_delimiter(User.without_two_factor) - = nav_link(html_options: { class: active_when(params[:filter] == 'external') }) do - = link_to admin_users_path(filter: 'external') do - = s_('AdminUsers|External') - %small.badge.badge-pill= limited_counter_with_delimiter(User.external) - = nav_link(html_options: { class: active_when(params[:filter] == 'blocked') }) do - = link_to admin_users_path(filter: "blocked") do - = s_('AdminUsers|Blocked') - %small.badge.badge-pill= limited_counter_with_delimiter(User.blocked) - = nav_link(html_options: { class: "#{active_when(params[:filter] == 'blocked_pending_approval')} filter-blocked-pending-approval" }) do - = link_to admin_users_path(filter: "blocked_pending_approval"), data: { qa_selector: 'pending_approval_tab' } do - = s_('AdminUsers|Pending approval') - %small.badge.badge-pill= limited_counter_with_delimiter(User.blocked_pending_approval) - = nav_link(html_options: { class: active_when(params[:filter] == 'deactivated') }) do - = link_to admin_users_path(filter: "deactivated") do - = s_('AdminUsers|Deactivated') - %small.badge.badge-pill= limited_counter_with_delimiter(User.deactivated) - = nav_link(html_options: { class: active_when(params[:filter] == 'wop') }) do - = link_to admin_users_path(filter: "wop") do - = s_('AdminUsers|Without projects') - %small.badge.badge-pill= limited_counter_with_delimiter(User.without_projects) - .nav-controls - = render_if_exists 'admin/users/admin_email_users' - = render_if_exists 'admin/users/admin_export_user_permissions' - = link_to s_('AdminUsers|New user'), new_admin_user_path, class: 'btn gl-button btn-success btn-search float-right' +%ul.nav-links.nav-tabs.nav.js-users-tabs{ role: 'tablist' } + %li.nav-item.js-users-tab-item{ role: 'presentation' } + %a.nav-link{ href: '#users', class: active_when(params[:tab] != 'cohorts'), data: { toggle: 'tab' }, role: 'tab' } + = s_('AdminUsers|Users') + %li.nav-item.js-users-tab-item{ role: 'presentation' } + %a.nav-link{ href: '#cohorts', class: active_when(params[:tab] == 'cohorts'), data: { toggle: 'tab', track: { event: 'i_analytics_cohorts', action: 'click_tab' } }, role: 'tab' } + = s_('AdminUsers|Cohorts') -.filtered-search-block.row-content-block.border-top-0 - = form_tag admin_users_path, method: :get do - - if params[:filter].present? - = hidden_field_tag "filter", h(params[:filter]) - .search-holder - .search-field-holder.gl-mb-4 - = search_field_tag :search_query, params[:search_query], placeholder: s_('AdminUsers|Search by name, email or username'), class: 'form-control search-text-input js-search-input', spellcheck: false, data: { qa_selector: 'user_search_field' } - - if @sort.present? - = hidden_field_tag :sort, @sort - = sprite_icon('search', css_class: 'search-icon') - = button_tag s_('AdminUsers|Search users') if Rails.env.test? - .dropdown.user-sort-dropdown - = label_tag 'Sort by', nil, class: 'label-bold' - - toggle_text = @sort.present? ? users_sort_options_hash[@sort] : sort_title_name - = dropdown_toggle(toggle_text, { toggle: 'dropdown' }) - %ul.dropdown-menu.dropdown-menu-right - %li.dropdown-header - = s_('AdminUsers|Sort by') - %li - - users_sort_options_hash.each do |value, title| - = link_to admin_users_path(sort: value, filter: params[:filter], search_query: params[:search_query]) do - = title +.tab-content + .tab-pane{ id: 'users', class: ('active' if params[:tab] != 'cohorts') } + = render 'users' + .tab-pane{ id: 'cohorts', class: ('active' if params[:tab] == 'cohorts') } + = render 'cohorts' -- if Feature.enabled?(:vue_admin_users) - #js-admin-users-app{ data: admin_users_data_attributes(@users) } - .gl-spinner-container.gl-my-7 - %span.gl-vertical-align-bottom.gl-spinner.gl-spinner-dark.gl-spinner-lg{ aria: { label: _('Loading') } } -- elsif @users.empty? - .nothing-here-block.border-top-0 - = s_('AdminUsers|No users found') -- else - .table-holder - .thead-white.text-nowrap.gl-responsive-table-row.table-row-header{ role: 'row' } - .table-section.section-40{ role: 'rowheader' }= _('Name') - .table-section.section-10{ role: 'rowheader' }= _('Projects') - .table-section.section-15{ role: 'rowheader' }= _('Created on') - .table-section.section-15{ role: 'rowheader' }= _('Last activity') - = render partial: 'admin/users/user', collection: @users - -= paginate @users, theme: "gitlab" - -= render partial: 'admin/users/modals' diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index 26f78ea4d6a..380348f9a98 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -138,10 +138,10 @@ .col-md-6 - unless @user == current_user - if can_force_email_confirmation?(@user) - .card.border-info - .card-header.bg-info.text-white + .gl-card.border-info.gl-mb-5 + .gl-card-header.bg-info.text-white Confirm user - .card-body + .gl-card-body - if @user.unconfirmed_email.present? - email = " (#{@user.unconfirmed_email})" %p This user has an unconfirmed email address#{email}. You may force a confirmation. @@ -152,19 +152,19 @@ - unless @user.internal? - if @user.deactivated? - .card.border-info - .card-header.bg-info.text-white + .gl-card.border-info.gl-mb-5 + .gl-card-header.bg-info.text-white Reactivate this user - .card-body + .gl-card-body = render partial: 'admin/users/user_activation_effects' %br %button.btn.gl-button.btn-info.js-confirm-modal-button{ data: user_activation_data(@user) } = s_('AdminUsers|Activate user') - elsif @user.can_be_deactivated? - .card.border-warning - .card-header.bg-warning.text-white + .gl-card.border-warning.gl-mb-5 + .gl-card-header.bg-warning.text-white Deactivate this user - .card-body + .gl-card-body = user_deactivation_effects %br %button.btn.gl-button.btn-warning.js-confirm-modal-button{ data: user_deactivation_data(@user, s_('AdminUsers|You can always re-activate their account, their data will remain intact.')) } @@ -174,10 +174,10 @@ = render 'admin/users/approve_user', user: @user = render 'admin/users/reject_pending_user', user: @user - else - .card.border-info - .card-header.gl-bg-blue-500.gl-text-white + .gl-card.border-info.gl-mb-5 + .gl-card-header.gl-bg-blue-500.gl-text-white This user is blocked - .card-body + .gl-card-body %p A blocked user cannot: %ul %li Log in @@ -189,7 +189,7 @@ = render 'admin/users/block_user', user: @user - if @user.access_locked? - .card.border-info + .card.border-info.gl-mb-5 .card-header.bg-info.text-white This account has been locked .card-body @@ -197,10 +197,10 @@ %br = link_to 'Unlock user', unlock_admin_user_path(@user), method: :put, class: "btn gl-button btn-info", data: { confirm: 'Are you sure?' } - if !@user.blocked_pending_approval? - .card.border-danger - .card-header.bg-danger.text-white + .gl-card.border-danger.gl-mb-5 + .gl-card-header.bg-danger.text-white = s_('AdminUsers|Delete user') - .card-body + .gl-card-body - if @user.can_be_removed? && can?(current_user, :destroy_user, @user) %p Deleting a user has the following effects: = render 'users/deletion_guidance', user: @user @@ -221,10 +221,10 @@ %p You don't have access to delete this user. - .card.border-danger - .card-header.bg-danger.text-white + .gl-card.border-danger + .gl-card-header.bg-danger.text-white = s_('AdminUsers|Delete user and contributions') - .card-body + .gl-card-body - if can?(current_user, :destroy_user, @user) %p This option deletes the user and any contributions that diff --git a/app/views/authentication/_authenticate.html.haml b/app/views/authentication/_authenticate.html.haml index 17e855dbddd..2d8948ae9aa 100644 --- a/app/views/authentication/_authenticate.html.haml +++ b/app/views/authentication/_authenticate.html.haml @@ -7,7 +7,7 @@ %script#js-authenticate-token-2fa-error{ type: "text/template" } %div %p <%= error_message %> (<%= error_name %>) - %a.btn.btn-block.btn-warning#js-token-2fa-try-again= _("Try again?") + %a.btn.gl-button.btn-block.btn-warning#js-token-2fa-try-again= _("Try again?") %script#js-authenticate-token-2fa-authenticated{ type: "text/template" } %div diff --git a/app/views/authentication/_register.html.haml b/app/views/authentication/_register.html.haml index f1aa76d115a..9b66072869a 100644 --- a/app/views/authentication/_register.html.haml +++ b/app/views/authentication/_register.html.haml @@ -7,13 +7,13 @@ - if current_user.two_factor_otp_enabled? .row.gl-mb-3 .col-md-5 - %button#js-setup-token-2fa-device.btn.btn-info= _("Set up new device") + %button#js-setup-token-2fa-device.gl-button.btn.btn-info= _("Set up new device") .col-md-7 %p= _("Your device needs to be set up. Plug it in (if needed) and click the button on the left.") - else .row.gl-mb-3 .col-md-4 - %button#js-setup-token-2fa-device.btn.btn-info.btn-block{ disabled: true }= _("Set up new device") + %button#js-setup-token-2fa-device.gl-button.btn.btn-info.btn-block{ disabled: true }= _("Set up new device") .col-md-8 %p= _("You need to register a two-factor authentication app before you can set up a device.") @@ -21,7 +21,7 @@ %div %p %span <%= error_message %> (<%= error_name %>) - %a.btn.btn-warning#js-token-2fa-try-again= _("Try again?") + %a.btn.gl-button.btn-warning#js-token-2fa-try-again= _("Try again?") %script#js-register-token-2fa-registered{ type: "text/template" } .row.gl-mb-3 diff --git a/app/views/ci/group_variables/_content.html.haml b/app/views/ci/group_variables/_content.html.haml index db5f1021f57..fe8155cd9f7 100644 --- a/app/views/ci/group_variables/_content.html.haml +++ b/app/views/ci/group_variables/_content.html.haml @@ -1 +1 @@ -= _("These variables are configured in the parent group settings, and will be active in the current project in addition to the project variables.") += _("These variables are inherited from the parent group.") diff --git a/app/views/ci/group_variables/_index.html.haml b/app/views/ci/group_variables/_index.html.haml index 84bcd42e07c..a74dbe793a6 100644 --- a/app/views/ci/group_variables/_index.html.haml +++ b/app/views/ci/group_variables/_index.html.haml @@ -1,13 +1,12 @@ - variables = @project.group.self_and_ancestors.map(&:variables).flatten -.row - .col-lg-12 - .group-variable-list - = render 'ci/group_variables/variable_header' - - variables.each do |variable| - .group-variable-row.d-flex.w-100.border-bottom.pt-2.pb-2 - .table-section.section-40.gl-mr-3.key - = variable.key - .table-section.section-40.gl-mr-3 - %a.group-origin-link{ href: group_settings_ci_cd_path(variable.group) } - = variable.group.name +.ci-variable-table + %table.gl-table.gl-w-full.gl-table-layout-fixed + = render 'ci/group_variables/variable_header' + - variables.each do |variable| + %tr + %td.gl-text-truncate + = variable.key + %td.gl-text-truncate + %a.group-origin-link{ href: group_settings_ci_cd_path(variable.group) } + = variable.group.name diff --git a/app/views/ci/group_variables/_variable_header.html.haml b/app/views/ci/group_variables/_variable_header.html.haml index a8d533da0e0..ec512ab37e7 100644 --- a/app/views/ci/group_variables/_variable_header.html.haml +++ b/app/views/ci/group_variables/_variable_header.html.haml @@ -1,5 +1,5 @@ -.group-variable-keys.d-flex.w-100.align-items-center.pb-2.border-bottom - .bold.table-section.section-40.gl-mr-3 +%tr + %th = s_('Key') - .bold.table-section.section-40.gl-mr-3 - = s_('Origin') + %th + = s_('Group') diff --git a/app/views/ci/runner/_how_to_setup_runner.html.haml b/app/views/ci/runner/_how_to_setup_runner.html.haml index fc3d5360f9b..6c2e4c69d83 100644 --- a/app/views/ci/runner/_how_to_setup_runner.html.haml +++ b/app/views/ci/runner/_how_to_setup_runner.html.haml @@ -21,3 +21,5 @@ = button_to _("Reset registration token"), reset_token_url, method: :put, class: 'gl-button btn btn-default', data: { confirm: _("Are you sure you want to reset the registration token?") } + +#js-install-runner{ data: { project_path: project_path, group_path: group_path } } diff --git a/app/views/ci/variables/_content.html.haml b/app/views/ci/variables/_content.html.haml index bf695d871f8..fd4b546e150 100644 --- a/app/views/ci/variables/_content.html.haml +++ b/app/views/ci/variables/_content.html.haml @@ -1,8 +1,10 @@ -= html_escape(_('Environment variables are applied to environments via the Runner. You can use environment variables for passwords, secret keys, etc. Make variables available to the running application by prepending the variable key with %{code_open}K8S_SECRET_%{code_close}. You can set variables to be:')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } += _('Variables store information, like passwords and secret keys, that you can use in job scripts.') += link_to s_('Learn more.'), help_page_path('ci/variables/README'), target: '_blank', rel: 'noopener noreferrer' +%p + = _('Variables can be:') %ul %li - = html_escape(_('%{code_open}Protected%{code_close} variables are only exposed to protected branches or tags.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } + = html_escape(_('%{code_open}Protected:%{code_close} Only exposed to protected branches or tags.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } %li - = html_escape(_('%{code_open}Masked%{code_close} variables are hidden in job logs (though they must match certain regexp requirements to do so).')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - -= link_to _('More information'), help_page_path('ci/variables/README', anchor: 'custom-environment-variables') + = html_escape(_('%{code_open}Masked:%{code_close} Hidden in job logs. Must match masking requirements.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } + = link_to _('Learn more.'), help_page_path('ci/variables/README', anchor: 'masked-variable-requirements'), target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/ci/variables/_header.html.haml b/app/views/ci/variables/_header.html.haml index f4e2a8584d8..d882a96dd42 100644 --- a/app/views/ci/variables/_header.html.haml +++ b/app/views/ci/variables/_header.html.haml @@ -2,7 +2,6 @@ %h4 = _('Variables') - = link_to sprite_icon('question-o', css_class: 'gl-vertical-align-baseline!'), help_page_path('ci/variables/README', anchor: 'custom-environment-variables'), target: '_blank', rel: 'noopener noreferrer' %button.btn.btn-default.js-settings-toggle{ type: 'button' } = expanded ? _('Collapse') : _('Expand') diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml index 3f6d60c2620..fc0e3488e57 100644 --- a/app/views/ci/variables/_index.html.haml +++ b/app/views/ci/variables/_index.html.haml @@ -3,7 +3,7 @@ - if ci_variable_protected_by_default? %p.settings-message.text-center - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('ci/variables/README', anchor: 'protect-a-custom-variable') } - = 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 } + = 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 } - is_group = !@group.nil? diff --git a/app/views/dashboard/milestones/index.html.haml b/app/views/dashboard/milestones/index.html.haml index 923e78ad360..872099f98fc 100644 --- a/app/views/dashboard/milestones/index.html.haml +++ b/app/views/dashboard/milestones/index.html.haml @@ -17,12 +17,11 @@ .nav-controls = render 'shared/milestones/search_form' -.milestones - %ul.content-list - - if @milestones.blank? - %li - .nothing-here-block No milestones to show - - else +- if @milestones.blank? + = render 'shared/empty_states/milestones' +- else + .milestones + %ul.content-list - @milestones.each do |milestone| = render 'milestone', milestone: milestone - = paginate @milestones, theme: 'gitlab' + = paginate @milestones, theme: 'gitlab' diff --git a/app/views/dashboard/projects/_starred_empty_state.html.haml b/app/views/dashboard/projects/_starred_empty_state.html.haml index bea27f1a456..6db018d72da 100644 --- a/app/views/dashboard/projects/_starred_empty_state.html.haml +++ b/app/views/dashboard/projects/_starred_empty_state.html.haml @@ -3,7 +3,7 @@ .svg-content.svg-250 = image_tag 'illustrations/starred_empty.svg' .text-content - %h4.text-center + %h4.gl-text-center = s_("StarredProjectsEmptyState|You don't have starred projects yet.") - %p.text-secondary + %p.gl-text-gray-500 = s_("StarredProjectsEmptyState|Visit a project page and press on a star icon. Then, you can find the project on this page.") diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml index d458f1d8eba..6f4d53c79a7 100644 --- a/app/views/dashboard/projects/index.html.haml +++ b/app/views/dashboard/projects/index.html.haml @@ -5,7 +5,7 @@ - if show_customize_homepage_banner?(@customize_homepage) = content_for :customize_homepage_banner do - .gl-display-none.gl-display-md-block{ class: "gl-pt-6! gl-pb-2! #{(container_class unless @no_container)} #{@content_class}" } + .gl-display-none.gl-md-display-block{ class: "gl-pt-6! gl-pb-2! #{(container_class unless @no_container)} #{@content_class}" } .js-customize-homepage-banner{ data: { svg_path: image_path('illustrations/monitoring/getting_started.svg'), preferences_behavior_path: profile_preferences_path(anchor: 'behavior'), callouts_path: user_callouts_path, diff --git a/app/views/devise/confirmations/new.html.haml b/app/views/devise/confirmations/new.html.haml index 770a29a629e..ace80ba16dd 100644 --- a/app/views/devise/confirmations/new.html.haml +++ b/app/views/devise/confirmations/new.html.haml @@ -6,7 +6,7 @@ = render "devise/shared/error_messages", resource: resource .form-group = f.label :email - = f.email_field :email, class: "form-control", required: true, title: 'Please provide a valid email address.', value: nil + = f.email_field :email, class: "form-control gl-form-input", required: true, title: 'Please provide a valid email address.', value: nil .clearfix = f.submit "Resend", class: 'gl-button btn btn-success' diff --git a/app/views/devise/passwords/edit.html.haml b/app/views/devise/passwords/edit.html.haml index 42e301d88ae..7876aed2c0a 100644 --- a/app/views/devise/passwords/edit.html.haml +++ b/app/views/devise/passwords/edit.html.haml @@ -7,12 +7,12 @@ = f.hidden_field :reset_password_token .form-group = f.label 'New password', for: "user_password" - = f.password_field :password, class: "form-control top", required: true, title: 'This field is required', data: { qa_selector: 'password_field'} + = f.password_field :password, class: "form-control gl-form-input top", required: true, title: 'This field is required', data: { qa_selector: 'password_field'} .form-group = f.label 'Confirm new password', for: "user_password_confirmation" - = f.password_field :password_confirmation, class: "form-control bottom", title: 'This field is required', data: { qa_selector: 'password_confirmation_field' }, required: true + = f.password_field :password_confirmation, class: "form-control gl-form-input bottom", title: 'This field is required', data: { qa_selector: 'password_confirmation_field' }, required: true .clearfix - = f.submit "Change your password", class: "gl-button btn btn-primary", data: { qa_selector: 'change_password_button' } + = f.submit "Change your password", class: "gl-button btn btn-confirm", data: { qa_selector: 'change_password_button' } .clearfix.prepend-top-20 %p diff --git a/app/views/devise/passwords/new.html.haml b/app/views/devise/passwords/new.html.haml index fe999851605..c4672a5b25e 100644 --- a/app/views/devise/passwords/new.html.haml +++ b/app/views/devise/passwords/new.html.haml @@ -1,4 +1,3 @@ -= render 'devise/shared/tab_single', tab_title: 'Reset Password' .login-box .login-body = form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post, class: 'gl-show-field-errors' }) do |f| @@ -6,9 +5,9 @@ = render "devise/shared/error_messages", resource: resource .form-group = f.label :email - = f.email_field :email, class: "form-control", required: true, value: params[:user_email], autofocus: true, title: 'Please provide a valid email address.' + = f.email_field :email, class: "form-control gl-form-input", required: true, value: params[:user_email], autofocus: true, title: 'Please provide a valid email address.' .clearfix - = f.submit "Reset password", class: "btn-primary btn" + = f.submit "Reset password", class: "gl-button btn-confirm btn" .clearfix.prepend-top-20 = render 'devise/shared/sign_in_link' diff --git a/app/views/devise/sessions/_new_base.html.haml b/app/views/devise/sessions/_new_base.html.haml index a1a1a767847..270652483b7 100644 --- a/app/views/devise/sessions/_new_base.html.haml +++ b/app/views/devise/sessions/_new_base.html.haml @@ -1,10 +1,10 @@ = form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: 'new_user gl-show-field-errors', 'aria-live' => 'assertive'}) do |f| .form-group = f.label _('Username or email'), for: 'user_login', class: 'label-bold' - = f.text_field :login, value: @invite_email, class: 'form-control top', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', required: true, title: _('This field is required.'), data: { qa_selector: 'login_field' } + = f.text_field :login, value: @invite_email, class: 'form-control gl-form-input top', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', required: true, title: _('This field is required.'), data: { qa_selector: 'login_field' } .form-group = f.label :password, class: 'label-bold' - = f.password_field :password, class: 'form-control bottom', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' } + = f.password_field :password, class: 'form-control gl-form-input bottom', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' } - if devise_mapping.rememberable? .remember-me %label{ for: 'user_remember_me' } diff --git a/app/views/devise/sessions/_new_ldap.html.haml b/app/views/devise/sessions/_new_ldap.html.haml index 3fc99b6a47d..8f397de41b7 100644 --- a/app/views/devise/sessions/_new_ldap.html.haml +++ b/app/views/devise/sessions/_new_ldap.html.haml @@ -5,10 +5,10 @@ = form_tag(omniauth_callback_path(:user, server['provider_name']), id: 'new_ldap_user', class: "gl-show-field-errors") do .form-group = label_tag :username, "#{server['label']} Username" - = text_field_tag :username, nil, { class: "form-control top", title: "This field is required.", autofocus: "autofocus", data: { qa_selector: 'username_field' }, required: true } + = text_field_tag :username, nil, { class: "form-control gl-form-input top", title: "This field is required.", autofocus: "autofocus", data: { qa_selector: 'username_field' }, required: true } .form-group = label_tag :password - = password_field_tag :password, nil, { class: "form-control bottom", title: "This field is required.", data: { qa_selector: 'password_field' }, required: true } + = password_field_tag :password, nil, { 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 %label{ for: "remember_me" } @@ -16,4 +16,4 @@ %span Remember me .submit-container.move-submit-down - = submit_tag submit_message, class: "btn-success btn", data: { qa_selector: 'sign_in_button' } + = submit_tag submit_message, class: "gl-button btn-success btn", data: { qa_selector: 'sign_in_button' } diff --git a/app/views/devise/sessions/two_factor.html.haml b/app/views/devise/sessions/two_factor.html.haml index f5f76eb92b1..8704bd16a13 100644 --- a/app/views/devise/sessions/two_factor.html.haml +++ b/app/views/devise/sessions/two_factor.html.haml @@ -8,7 +8,7 @@ = f.hidden_field :remember_me, value: resource_params.fetch(:remember_me, 0) %div = f.label 'Two-Factor Authentication code', name: :otp_attempt - = f.text_field :otp_attempt, class: 'form-control', required: true, autofocus: true, autocomplete: 'off', title: 'This field is required.', data: { qa_selector: 'two_fa_code_field' } + = f.text_field :otp_attempt, class: 'form-control gl-form-input', required: true, autofocus: true, autocomplete: 'off', title: 'This field is required.', data: { qa_selector: 'two_fa_code_field' } %p.form-text.text-muted.hint Enter the code from the two-factor app on your mobile device. If you've lost your device, you may enter one of your recovery codes. .prepend-top-20 = f.submit "Verify code", class: "gl-button btn btn-success", data: { qa_selector: 'verify_code_button' } diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml index acbf3b398b0..aa2224b3ea3 100644 --- a/app/views/devise/shared/_signup_box.html.haml +++ b/app/views/devise/shared/_signup_box.html.haml @@ -15,22 +15,22 @@ .name.form-row .col.form-group = f.label :first_name, _('First name'), for: 'new_user_first_name', class: 'label-bold' - = f.text_field :first_name, class: 'form-control top js-block-emoji js-validate-length', :data => { :max_length => max_first_name_length, :max_length_message => s_('SignUp|First name is too long (maximum is %{max_length} characters).') % { max_length: max_first_name_length }, :qa_selector => 'new_user_first_name_field' }, required: true, title: _('This field is required.') + = f.text_field :first_name, class: 'form-control gl-form-input top js-block-emoji js-validate-length', :data => { :max_length => max_first_name_length, :max_length_message => s_('SignUp|First name is too long (maximum is %{max_length} characters).') % { max_length: max_first_name_length }, :qa_selector => 'new_user_first_name_field' }, required: true, title: _('This field is required.') .col.form-group = f.label :last_name, _('Last name'), for: 'new_user_last_name', class: 'label-bold' - = f.text_field :last_name, class: 'form-control top js-block-emoji js-validate-length', :data => { :max_length => max_last_name_length, :max_length_message => s_('SignUp|Last name is too long (maximum is %{max_length} characters).') % { max_length: max_last_name_length }, :qa_selector => 'new_user_last_name_field' }, required: true, title: _('This field is required.') + = f.text_field :last_name, class: 'form-control gl-form-input top js-block-emoji js-validate-length', :data => { :max_length => max_last_name_length, :max_length_message => s_('SignUp|Last name is too long (maximum is %{max_length} characters).') % { max_length: max_last_name_length }, :qa_selector => 'new_user_last_name_field' }, required: true, title: _('This field is required.') .username.form-group = f.label :username, class: 'label-bold' - = f.text_field :username, class: 'form-control middle js-block-emoji js-validate-length js-validate-username', :data => { :api_path => suggestion_path, :min_length => min_username_length, :min_length_message => s_('SignUp|Username is too short (minimum is %{min_length} characters).') % { min_length: min_username_length }, :max_length => max_username_length, :max_length_message => s_('SignUp|Username is too long (maximum is %{max_length} characters).') % { max_length: max_username_length }, :qa_selector => 'new_user_username_field' }, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: _('Please create a username with only alphanumeric characters.') + = f.text_field :username, class: 'form-control gl-form-input middle js-block-emoji js-validate-length js-validate-username', :data => { :api_path => suggestion_path, :min_length => min_username_length, :min_length_message => s_('SignUp|Username is too short (minimum is %{min_length} characters).') % { min_length: min_username_length }, :max_length => max_username_length, :max_length_message => s_('SignUp|Username is too long (maximum is %{max_length} characters).') % { max_length: max_username_length }, :qa_selector => 'new_user_username_field' }, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: _('Please create a username with only alphanumeric characters.') %p.validation-error.gl-text-red-500.gl-field-error-ignore.gl-mt-2.field-validation.hide= _('Username is already taken.') %p.validation-success.gl-text-green-600.gl-field-error-ignore.gl-mt-2.field-validation.hide= _('Username is available.') %p.validation-pending.gl-field-error-ignore.gl-mt-2.field-validation.hide= _('Checking username availability...') .form-group = f.label :email, class: 'label-bold' - = f.email_field :email, value: @invite_email, class: 'form-control middle', data: { qa_selector: 'new_user_email_field' }, required: true, title: _('Please provide a valid email address.') + = f.email_field :email, value: @invite_email, class: 'form-control gl-form-input middle', data: { qa_selector: 'new_user_email_field' }, required: true, title: _('Please provide a valid email address.') .form-group.gl-mb-5#password-strength = f.label :password, class: 'label-bold' - = f.password_field :password, class: 'form-control bottom', data: { qa_selector: 'new_user_password_field' }, required: true, pattern: ".{#{@minimum_password_length},}", title: s_('SignUp|Minimum length is %{minimum_password_length} characters.') % { minimum_password_length: @minimum_password_length } + = f.password_field :password, class: 'form-control gl-form-input bottom', data: { qa_selector: 'new_user_password_field' }, required: true, pattern: ".{#{@minimum_password_length},}", title: s_('SignUp|Minimum length is %{minimum_password_length} characters.') % { minimum_password_length: @minimum_password_length } %p.gl-field-hint.text-secondary= s_('SignUp|Minimum length is %{minimum_password_length} characters.') % { minimum_password_length: @minimum_password_length } %div - if show_recaptcha_sign_up? diff --git a/app/views/discussions/_notes.html.haml b/app/views/discussions/_notes.html.haml index 7db318f83b1..beac4946fd7 100644 --- a/app/views/discussions/_notes.html.haml +++ b/app/views/discussions/_notes.html.haml @@ -19,8 +19,6 @@ .discussion-reply-holder - if can_create_note? - %a.user-avatar-link.d-none.d-sm-block{ href: user_path(current_user) } - = image_tag avatar_icon_for_user(current_user), alt: current_user.to_reference, class: 'avatar s40' .discussion-with-resolve-btn = link_to_reply_discussion(discussion) - elsif !current_user diff --git a/app/views/doorkeeper/applications/_form.html.haml b/app/views/doorkeeper/applications/_form.html.haml index fbae24410bb..39529e59aee 100644 --- a/app/views/doorkeeper/applications/_form.html.haml +++ b/app/views/doorkeeper/applications/_form.html.haml @@ -3,11 +3,11 @@ .form-group = f.label :name, class: 'label-bold' - = f.text_field :name, class: 'form-control', required: true + = f.text_field :name, class: 'form-control gl-form-input', required: true .form-group = f.label :redirect_uri, class: 'label-bold' - = f.text_area :redirect_uri, class: 'form-control', required: true + = f.text_area :redirect_uri, class: 'form-control gl-form-input gl-form-textarea', required: true %span.form-text.text-muted = _('Use one line per URI') diff --git a/app/views/doorkeeper/applications/show.html.haml b/app/views/doorkeeper/applications/show.html.haml index 0a091aa7586..046d44bc47f 100644 --- a/app/views/doorkeeper/applications/show.html.haml +++ b/app/views/doorkeeper/applications/show.html.haml @@ -43,5 +43,5 @@ = render "shared/tokens/scopes_list", token: @application .form-actions - = link_to _('Edit'), edit_oauth_application_path(@application), class: 'gl-button btn btn-primary wide float-left' - = render 'delete_form', application: @application, submit_btn_css: 'btn btn-danger gl-ml-3' + = link_to _('Edit'), edit_oauth_application_path(@application), class: 'gl-button btn btn-confirm wide float-left' + = render 'delete_form', application: @application, submit_btn_css: 'gl-button btn btn-danger gl-ml-3' diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml index 67f278a06f3..37c4ecc09f3 100644 --- a/app/views/groups/_home_panel.html.haml +++ b/app/views/groups/_home_panel.html.haml @@ -23,7 +23,11 @@ .home-panel-buttons.col-md-12.col-lg-6 - if current_user .gl-display-flex.gl-flex-wrap.gl-lg-justify-content-end.gl-mx-n2{ data: { testid: 'group-buttons' } } - = render 'shared/notifications/new_button', notification_setting: @notification_setting, btn_class: 'btn gl-button gl-sm-w-auto gl-w-full', dropdown_container_class: 'gl-mr-0 gl-px-2 gl-sm-w-auto gl-w-full', emails_disabled: emails_disabled + - if Feature.enabled?(:vue_notification_dropdown, @group, default_enabled: :yaml) + - if @notification_setting + .js-vue-notification-dropdown{ data: { disabled: emails_disabled, dropdown_items: notification_dropdown_items(@notification_setting).to_json, notification_level: @notification_setting.level, help_page_path: help_page_path('user/profile/notifications'), group_id: @group.id, container_class: 'gl-mr-3 gl-mt-3 gl-vertical-align-top' } } + - else + = render 'shared/notifications/new_button', notification_setting: @notification_setting, btn_class: 'btn gl-button gl-sm-w-auto gl-w-full', dropdown_container_class: 'gl-mr-0 gl-px-2 gl-sm-w-auto gl-w-full', emails_disabled: emails_disabled - if can_create_subgroups .gl-px-2.gl-sm-w-auto.gl-w-full = link_to _("New subgroup"), new_group_path(parent_id: @group.id), class: "btn btn-success btn-md gl-button btn-success-secondary gl-mt-3 gl-sm-w-auto gl-w-full", data: { qa_selector: 'new_subgroup_button' } 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 c95e7c16161..83d2e13d345 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 @@ -2,9 +2,18 @@ = form_errors(@group) .gl-border-l-solid.gl-border-r-solid.gl-border-gray-100.gl-border-1.gl-p-5 - %h4 + %h4.gl-display-flex = s_('GroupsNew|Import groups from another instance of GitLab') - %p + %span.badge.badge-info.badge-pill.gl-badge.md.gl-ml-3 + = _('Beta') + .gl-alert.gl-alert-warning{ role: 'alert' } + = sprite_icon('warning', css_class: 'gl-icon s16 gl-alert-icon gl-alert-icon-no-title') + .gl-alert-body + - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index.md') } + - feedback_link_start = '<a href="https://gitlab.com/gitlab-org/gitlab/-/issues/284495" target="_blank" rel="noopener noreferrer">'.html_safe + - link_end = '</a>'.html_safe + = s_('GroupsNew|Not all related objects are migrated, as %{docs_link_start}described here%{docs_link_end}. Please %{feedback_link_start}leave feedback%{feedback_link_end} on this feature.').html_safe % { docs_link_start: docs_link_start, docs_link_end: link_end, feedback_link_start: feedback_link_start, feedback_link_end: link_end } + %p.gl-mt-3 = s_('GroupsNew|Provide credentials for another instance of GitLab to import your groups directly.') .form-group.gl-display-flex.gl-flex-direction-column = f.label :bulk_import_gitlab_url, s_('GroupsNew|GitLab source URL'), for: 'import_gitlab_url' diff --git a/app/views/groups/_invite_members_modal.html.haml b/app/views/groups/_invite_members_modal.html.haml index bd53f73230e..ba6dfcb70ff 100644 --- a/app/views/groups/_invite_members_modal.html.haml +++ b/app/views/groups/_invite_members_modal.html.haml @@ -1,4 +1,4 @@ -- if invite_members_allowed?(group) +- if can_invite_members_for_group?(group) .js-invite-members-modal{ data: { id: group.id, name: group.name, is_project: 'false', diff --git a/app/views/groups/_invite_members_side_nav_link.html.haml b/app/views/groups/_invite_members_side_nav_link.html.haml deleted file mode 100644 index 4f1c06d9fe3..00000000000 --- a/app/views/groups/_invite_members_side_nav_link.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -- if invite_members_allowed?(group) && body_data_page == 'groups:show' - %li - .js-invite-members-trigger{ data: { icon: 'plus', display_text: _('Invite team members') } } diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml index 229e04a371a..d1c4e1a7deb 100644 --- a/app/views/groups/edit.html.haml +++ b/app/views/groups/edit.html.haml @@ -26,6 +26,7 @@ .settings-content = render 'groups/settings/permissions' += render_if_exists 'groups/merge_request_approval_settings', expanded: expanded, group: @group, user: current_user = render_if_exists 'groups/insights', expanded: expanded %section.settings.no-animate#js-badge-settings{ class: ('expanded' if expanded) } diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index ab3998be009..a5257ff20bc 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -14,12 +14,12 @@ = _('Group members') %p = html_escape(_('You can invite a new member to %{strong_start}%{group_name}%{strong_end}.')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } - - if invite_members_allowed?(@group) + - if can_invite_members_for_group?(@group) .gl-w-half.gl-xs-w-full .gl-display-flex.gl-flex-wrap.gl-lg-justify-content-end.gl-mx-n2.gl-mb-3 .js-invite-members-trigger.gl-px-2.gl-sm-w-auto.gl-w-full.gl-mb-4{ data: { classes: 'btn btn-success gl-button gl-mt-3 gl-sm-w-auto gl-w-full', display_text: _('Invite members') } } - = render_if_exists 'groups/invite_members_modal', group: @group - - if can_manage_members && !invite_members_allowed?(@group) + = render 'groups/invite_members_modal', group: @group + - if can_manage_members && !can_invite_members_for_group?(@group) %hr.gl-mt-4 %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' } %li.nav-tab{ role: 'presentation' } @@ -66,7 +66,7 @@ = paginate @members, theme: 'gitlab', params: { invited_members_page: nil, search_invited: nil } - if @group.shared_with_group_links.any? #tab-groups.tab-pane - .js-group-linked-list{ data: linked_groups_list_data_attributes(@group) } + .js-group-group-links-list{ data: group_group_links_list_data_attributes(@group) } .loading .spinner.spinner-md - if show_invited_members diff --git a/app/views/groups/milestones/index.html.haml b/app/views/groups/milestones/index.html.haml index c93b24d14f0..2d5dc4c931d 100644 --- a/app/views/groups/milestones/index.html.haml +++ b/app/views/groups/milestones/index.html.haml @@ -8,17 +8,16 @@ = render 'shared/milestones/search_form' = render 'shared/milestones_sort_dropdown' - if can?(current_user, :admin_milestone, @group) - = link_to "New milestone", new_group_milestone_path(@group), class: "btn gl-button btn-success", data: { qa_selector: "new_group_milestone_link" } + = link_to _('New milestone'), new_group_milestone_path(@group), class: "btn gl-button btn-success", data: { qa_selector: "new_group_milestone_link" } -.milestones - %ul.content-list - - if @milestones.blank? - %li - .nothing-here-block No milestones to show - - else +- if @milestones.blank? + = render 'shared/empty_states/milestones' +- else + .milestones + %ul.content-list - @milestones.each do |milestone| - if milestone.project_milestone? = render 'projects/milestones/milestone', milestone: milestone - else = render 'milestone', milestone: milestone - = paginate @milestones, theme: "gitlab" + = paginate @milestones, theme: "gitlab" diff --git a/app/views/groups/registry/repositories/index.html.haml b/app/views/groups/registry/repositories/index.html.haml index 6d0a3e03019..4f4b6c1089c 100644 --- a/app/views/groups/registry/repositories/index.html.haml +++ b/app/views/groups/registry/repositories/index.html.haml @@ -17,4 +17,7 @@ is_group_page: "true", "group_path": @group.full_path, "gid_prefix": container_repository_gid_prefix, - character_error: @character_error.to_s } } + character_error: @character_error.to_s, + user_callouts_path: user_callouts_path, + user_callout_id: UserCalloutsHelper::UNFINISHED_TAG_CLEANUP_CALLOUT, + show_unfinished_tag_cleanup_callout: show_unfinished_tag_cleanup_callout?.to_s } } diff --git a/app/views/groups/runners/_group_runners.html.haml b/app/views/groups/runners/_group_runners.html.haml index 944ef3435c1..f60cdc9f8da 100644 --- a/app/views/groups/runners/_group_runners.html.haml +++ b/app/views/groups/runners/_group_runners.html.haml @@ -17,5 +17,7 @@ = render partial: 'ci/runner/how_to_setup_runner', locals: { registration_token: @group.runners_token, type: 'group', - reset_token_url: reset_registration_token_group_settings_ci_cd_path } + reset_token_url: reset_registration_token_group_settings_ci_cd_path, + project_path: '', + group_path: @group.path } %br diff --git a/app/views/groups/runners/_runner.html.haml b/app/views/groups/runners/_runner.html.haml index 3fc50cc86d2..80739395713 100644 --- a/app/views/groups/runners/_runner.html.haml +++ b/app/views/groups/runners/_runner.html.haml @@ -77,8 +77,9 @@ = link_to resume_group_runner_path(@group, runner), method: :post, class: 'btn btn-default has-tooltip', title: _('Resume'), ref: 'tooltip', aria: { label: _('Resume') }, data: { placement: 'top', container: 'body'} do = sprite_icon('play') - if runner.belongs_to_more_than_one_project? - .btn-group - .btn.btn-danger.has-tooltip{ 'aria-label' => 'Remove', 'data-container' => 'body', 'data-original-title' => _('Multi-project Runners cannot be removed'), 'data-placement' => 'top', disabled: 'disabled' } + - delete_runner_tooltip = _('Multi-project Runners cannot be removed') + .btn-group.has-tooltip{ data: { container: 'body', placement: 'top' }, title: delete_runner_tooltip } + .btn.btn-danger{ 'aria-label' => delete_runner_tooltip, disabled: 'disabled' } = sprite_icon('close') - else .btn-group diff --git a/app/views/groups/settings/ci_cd/_form.html.haml b/app/views/groups/settings/ci_cd/_form.html.haml index 8fad73f1249..635e3b64e39 100644 --- a/app/views/groups/settings/ci_cd/_form.html.haml +++ b/app/views/groups/settings/ci_cd/_form.html.haml @@ -4,10 +4,10 @@ = form_errors(group) %fieldset.builds-feature .form-group - = f.label :max_artifacts_size, _('Maximum artifacts size (MB)'), class: 'label-bold' + = f.label :max_artifacts_size, _('Maximum artifacts size'), class: 'label-bold' = f.number_field :max_artifacts_size, class: 'form-control' %p.form-text.text-muted - = _("Set the maximum file size for each job's artifacts") + = _("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' = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml index 4a0a92fa91f..1badb7b6ba1 100644 --- a/app/views/groups/settings/ci_cd/show.html.haml +++ b/app/views/groups/settings/ci_cd/show.html.haml @@ -32,7 +32,7 @@ = expanded ? _('Collapse') : _('Expand') %p = _("Runners are processes that pick up and execute CI/CD jobs for GitLab.") - = link_to s_('How do I configure runners?'), help_page_path('ci/runners/README') + = link_to s_('How do I configure runners?'), help_page_path('ci/runners/README'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'groups/runners/index' diff --git a/app/views/groups/settings/packages_and_registries/index.html.haml b/app/views/groups/settings/packages_and_registries/index.html.haml index 33719d56af1..b6bd16d51a6 100644 --- a/app/views/groups/settings/packages_and_registries/index.html.haml +++ b/app/views/groups/settings/packages_and_registries/index.html.haml @@ -2,4 +2,4 @@ - page_title _('Packages & Registries') - @content_class = 'limit-container-width' unless fluid_layout -%section#js-packages-and-registries-settings +%section#js-packages-and-registries-settings{ data: { default_expanded: expanded_by_default?.to_s, group_path: @group.path } } diff --git a/app/views/groups/settings/repository/show.html.haml b/app/views/groups/settings/repository/show.html.haml index a5819320405..869d36d56c5 100644 --- a/app/views/groups/settings/repository/show.html.haml +++ b/app/views/groups/settings/repository/show.html.haml @@ -1,7 +1,7 @@ - breadcrumb_title _('Repository Settings') - page_title _('Repository') -- deploy_token_description = s_('DeployTokens|Group deploy tokens allow access to the packages, repositories, and registry images within the group.') +- deploy_token_description = s_('DeployTokens|Group Deploy Tokens allow access to the packages, repositories, and registry images within the group.') = render "shared/deploy_tokens/index", group_or_project: @group, description: deploy_token_description = render "initial_branch_name", group: @group diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 109e7c3831e..d1787d36cd2 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -16,6 +16,11 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, group_url(@group, rss_url_options), title: "#{@group.name} activity") += content_for :invite_members_sidebar do + - if can_invite_members_for_group?(@group) + %li + .js-invite-members-trigger{ data: { icon: 'plus', classes: 'gl-text-decoration-none! gl-shadow-none!', display_text: _('Invite team members') } } + = render partial: 'flash_messages' = render_if_exists 'trials/banner', namespace: @group @@ -26,7 +31,7 @@ = render_if_exists 'groups/group_activity_analytics', group: @group -= render_if_exists 'groups/invite_members_modal', group: @group += render 'groups/invite_members_modal', group: @group .groups-listing{ data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } } .top-area.group-nav-container.justify-content-between diff --git a/app/views/help/_shortcuts.html.haml b/app/views/help/_shortcuts.html.haml index 6f917e81fb0..9ad87518b1e 100644 --- a/app/views/help/_shortcuts.html.haml +++ b/app/views/help/_shortcuts.html.haml @@ -60,6 +60,12 @@ %kbd p %kbd b %td= _('Toggle the Performance Bar') + - if Gitlab.com? + %tr + %td.shortcut + %kbd g + %kbd x + %td= _('Toggle GitLab Next') %tbody %tr %th diff --git a/app/views/import/bulk_imports/status.html.haml b/app/views/import/bulk_imports/status.html.haml index 6757c32d1e1..17b1169609c 100644 --- a/app/views/import/bulk_imports/status.html.haml +++ b/app/views/import/bulk_imports/status.html.haml @@ -4,9 +4,8 @@ %h1.gl-my-0.gl-py-4.gl-font-size-h1.gl-border-solid.gl-border-gray-200.gl-border-0.gl-border-b-1 = s_('BulkImport|Import groups from GitLab') -%p.gl-my-0.gl-py-5.gl-border-solid.gl-border-gray-200.gl-border-0.gl-border-b-1 - = s_('BulkImport|Importing groups from %{link}').html_safe % { link: external_link(@source_url, @source_url) } #import-groups-mount-element{ data: { status_path: status_import_bulk_imports_path(format: :json), available_namespaces_path: import_available_namespaces_path(format: :json), - create_bulk_import_path: import_bulk_imports_path(format: :json) } } + create_bulk_import_path: import_bulk_imports_path(format: :json), + source_url: @source_url } } diff --git a/app/views/jira_connect/subscriptions/index.html.haml b/app/views/jira_connect/subscriptions/index.html.haml index ed765f80b74..a549ed3540b 100644 --- a/app/views/jira_connect/subscriptions/index.html.haml +++ b/app/views/jira_connect/subscriptions/index.html.haml @@ -3,24 +3,24 @@ .jira-connect-user - if current_user - - user_link = link_to(current_user.to_reference, user_path(current_user), target: '_blank', rel: 'noopener noreferrer') + - user_link = link_to(current_user.to_reference, jira_connect_users_path, target: '_blank', rel: 'noopener noreferrer', class: 'js-jira-connect-sign-in') = _('Signed in to GitLab as %{user_link}').html_safe % { user_link: user_link } - elsif @subscriptions.present? = link_to _('Sign in to GitLab'), jira_connect_users_path, target: '_blank', rel: 'noopener noreferrer', class: 'js-jira-connect-sign-in' .jira-connect-app - if current_user.blank? && @subscriptions.empty? - %h1 - GitLab for Jira Configuration - %h2.heading-with-border Sign in to GitLab.com to get started. + %h2= s_('JiraService|GitLab for Jira Configuration') + %p= s_('JiraService|Sign in to GitLab.com to get started.') - .gl-mt-5 - = external_link _('Sign in to GitLab'), jira_connect_users_path, class: 'ak-button ak-button__appearance-primary js-jira-connect-sign-in' + .gl-mt-7 + - sign_in_button_class = new_jira_connect_ui? ? 'btn gl-button btn-confirm' : 'ak-button ak-button__appearance-primary' + = external_link _('Sign in to GitLab'), jira_connect_users_path, class: "#{sign_in_button_class} js-jira-connect-sign-in" - .gl-mt-5 + .gl-mt-7 %p Note: this integration only works with accounts on GitLab.com (SaaS). - else - .js-jira-connect-app{ data: jira_connect_app_data } + .js-jira-connect-app{ data: jira_connect_app_data(@subscriptions) } - unless new_jira_connect_ui? %form#add-subscription-form.subscription-form{ action: jira_connect_subscriptions_path } @@ -34,7 +34,7 @@ Link namespace to Jira - if @subscriptions.present? - %table.subscriptions + %table.subscriptions.gl-w-full %thead %tr %th Namespace @@ -45,7 +45,7 @@ %tr %td= subscription.namespace.full_path %td= subscription.created_at - %td= link_to 'Remove', jira_connect_subscription_path(subscription), class: 'remove-subscription' + %td= link_to 'Remove', jira_connect_subscription_path(subscription), class: 'js-jira-connect-remove-subscription' - else %h4.empty-subscriptions No linked namespaces @@ -55,10 +55,8 @@ %strong Browser limitations: Adding a namespace currently works only in browsers that allow cross‑site cookies. Please make sure to use %a{ href: 'https://www.mozilla.org/en-US/firefox/', target: '_blank', rel: 'noopener noreferrer' } Firefox - or - %a{ href: 'https://www.google.com/chrome/index.html', target: '_blank', rel: 'noopener noreferrer' } Google Chrome or enable cross‑site cookies in your browser when adding a namespace. - %a{ href: 'https://gitlab.com/gitlab-org/gitlab/-/issues/263509', target: '_blank', rel: 'noopener noreferrer' } Learn more + = link_to _('Learn more'), 'https://gitlab.com/gitlab-org/gitlab/-/issues/284211', target: '_blank', rel: 'noopener noreferrer' = webpack_bundle_tag 'performance_bar' if performance_bar_enabled? = webpack_bundle_tag 'jira_connect_app' diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index 7aa57331c51..8b430f579e9 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -79,6 +79,9 @@ = favicon_link_tag 'touch-icon-ipad-retina.png', rel: 'apple-touch-icon', sizes: '152x152' %link{ rel: 'mask-icon', href: image_path('logo.svg'), color: 'rgb(226, 67, 41)' } + -# OpenSearch + %link{ href: search_opensearch_path(format: :xml), rel: 'search', title: 'Search GitLab', type: 'application/opensearchdescription+xml' } + -# Windows 8 pinned site tile %meta{ name: 'msapplication-TileImage', content: image_path('msapplication-tile.png') } %meta{ name: 'msapplication-TileColor', content: '#30353E' } diff --git a/app/views/layouts/_matomo.html.haml b/app/views/layouts/_matomo.html.haml index fcd3156a162..ef7c3a62902 100644 --- a/app/views/layouts/_matomo.html.haml +++ b/app/views/layouts/_matomo.html.haml @@ -1,9 +1,11 @@ <!-- Matomo --> +- matomo_disable_cookies = extra_config.has_key?('matomo_disable_cookies') && extra_config.matomo_disable_cookies = javascript_tag do :plain var _paq = window._paq = window._paq || []; _paq.push(['trackPageView']); _paq.push(['enableLinkTracking']); + #{matomo_disable_cookies ? '_paq.push(["disableCookies"])' : ""}; (function() { var u="//#{extra_config.matomo_url}/"; _paq.push(['setTrackerUrl', u+'matomo.php']); diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index c552454caa7..1f2fcd1c70b 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -22,6 +22,7 @@ - unless @hide_breadcrumbs = render "layouts/nav/breadcrumbs" %div{ class: "#{(container_class unless @no_container)} #{@content_class}" } - .content{ id: "content-body", **page_itemtype } + %main.content{ id: "content-body", **page_itemtype } = render "layouts/flash", extra_flash_class: 'limit-container-width' + = yield :before_content = yield diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml index d7ca93a296b..ccf62ef043a 100644 --- a/app/views/layouts/header/_current_user_dropdown.html.haml +++ b/app/views/layouts/header/_current_user_dropdown.html.haml @@ -28,7 +28,7 @@ - if current_user_menu?(:start_trial) %li %a.trial-link{ href: trials_link_url } - = s_("CurrentUser|Start a Gold trial") + = s_("CurrentUser|Start an Ultimate trial") = emoji_icon('rocket') - if current_user_menu?(:settings) %li diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index f7e93182ca2..1834e93a079 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -15,7 +15,7 @@ %span.logo-text.d-none.d-lg-block.gl-ml-3 = logo_text - if Gitlab.com_and_canary? - = link_to 'https://next.gitlab.com', class: 'canary-badge bg-transparent', target: :_blank do + = link_to 'https://next.gitlab.com', class: 'canary-badge bg-transparent', target: :_blank, rel: :_noopener do %span.gl-badge.gl-bg-green-500.gl-text-white.gl-rounded-pill.gl-font-weight-bold.gl-py-1 = _('Next') @@ -120,8 +120,7 @@ = sprite_icon('ellipsis_h', size: 12, css_class: 'more-icon js-navbar-toggle-right') = sprite_icon('close', size: 12, css_class: 'close-icon js-navbar-toggle-left') -- if ::Feature.enabled?(:whats_new_drawer, current_user) - #whats-new-app{ data: { storage_key: whats_new_storage_key, versions: whats_new_versions, gitlab_dot_com: Gitlab.dev_env_org_or_com? } } +#whats-new-app{ data: { storage_key: whats_new_storage_key, versions: whats_new_versions, gitlab_dot_com: Gitlab.dev_env_org_or_com? } } - if can?(current_user, :update_user_status, current_user) .js-set-status-modal-wrapper{ data: user_status_data } diff --git a/app/views/layouts/header/_help_dropdown.html.haml b/app/views/layouts/header/_help_dropdown.html.haml index 40bf45db80d..c3769dd2993 100644 --- a/app/views/layouts/header/_help_dropdown.html.haml +++ b/app/views/layouts/header/_help_dropdown.html.haml @@ -1,6 +1,6 @@ %ul - if current_user_menu?(:help) - = render_if_exists 'layouts/header/whats_new_dropdown_item' + = render 'layouts/header/whats_new_dropdown_item' %li = link_to _("Help"), help_path %li diff --git a/app/views/layouts/header/_whats_new_dropdown_item.html.haml b/app/views/layouts/header/_whats_new_dropdown_item.html.haml new file mode 100644 index 00000000000..f79b741ced0 --- /dev/null +++ b/app/views/layouts/header/_whats_new_dropdown_item.html.haml @@ -0,0 +1,5 @@ +%li + %button.gl-justify-content-space-between.gl-align-items-center.js-whats-new-trigger{ type: 'button', data: { storage_key: whats_new_storage_key }, class: 'gl-display-flex!' } + = _("What's new") + %span.js-whats-new-notification-count.whats-new-notification-count + = whats_new_most_recent_release_items_count diff --git a/app/views/layouts/jira_connect.html.haml b/app/views/layouts/jira_connect.html.haml index d996b3387a3..da45d84a83b 100644 --- a/app/views/layouts/jira_connect.html.haml +++ b/app/views/layouts/jira_connect.html.haml @@ -9,7 +9,6 @@ = yield :page_specific_styles = javascript_include_tag 'https://connect-cdn.atl-paas.net/all.js' - = javascript_include_tag 'https://unpkg.com/jquery@3.3.1/dist/jquery.min.js' = Gon::Base.render_data(nonce: content_security_policy_nonce) = yield :head %body diff --git a/app/views/layouts/nav/_breadcrumbs.html.haml b/app/views/layouts/nav/_breadcrumbs.html.haml index dd2c5e2a19e..aeeffb6f4b6 100644 --- a/app/views/layouts/nav/_breadcrumbs.html.haml +++ b/app/views/layouts/nav/_breadcrumbs.html.haml @@ -3,7 +3,7 @@ - unless @skip_current_level_breadcrumb - push_to_schema_breadcrumb(@breadcrumb_title, breadcrumb_title_link) -%nav.breadcrumbs{ role: "navigation", class: [container, @content_class] } +%nav.breadcrumbs{ class: [container, @content_class], 'aria-label': _('Breadcrumbs') } .breadcrumbs-container{ class: ("border-bottom-0" if @no_breadcrumb_border) } - if defined?(@left_sidebar) = button_tag class: 'toggle-mobile-nav', type: 'button' do diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml index da16be707eb..3d4a00b01a2 100644 --- a/app/views/layouts/nav/sidebar/_admin.html.haml +++ b/app/views/layouts/nav/sidebar/_admin.html.haml @@ -1,4 +1,4 @@ -.nav-sidebar.qa-admin-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?) } +%aside.nav-sidebar.qa-admin-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), 'aria-label': _('Admin navigation') } .nav-sidebar-inner-scroll .context-header = link_to admin_root_path, title: _('Admin Overview') do @@ -65,10 +65,6 @@ = link_to admin_dev_ops_report_path, title: _('DevOps Report') do %span = _('DevOps Report') - = nav_link(controller: :cohorts) do - = link_to admin_cohorts_path, title: _('Cohorts') do - %span - = _('Cohorts') - if Feature.enabled?(:instance_statistics, default_enabled: true) = nav_link(controller: :instance_statistics) do = link_to admin_instance_statistics_path, title: _('Usage Trends') do @@ -260,6 +256,9 @@ = link_to general_admin_application_settings_path, title: _('General'), class: 'qa-admin-settings-general-item' do %span = _('General') + + = render_if_exists 'layouts/nav/sidebar/advanced_search', class: 'qa-admin-settings-advanced-search' + - 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 diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml index 473a0d131b8..8401111c86c 100644 --- a/app/views/layouts/nav/sidebar/_group.html.haml +++ b/app/views/layouts/nav/sidebar/_group.html.haml @@ -1,7 +1,9 @@ -- issues_count = group_issues_count(state: 'opened') +- issues_count = group_open_issues_count(@group) - merge_requests_count = group_merge_requests_count(state: 'opened') +- aside_title = @group.subgroup? ? _('Subgroup navigation') : _('Group navigation') +- overview_title = @group.subgroup? ? _('Subgroup overview') : _('Group overview') -.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), **tracking_attrs('groups_side_navigation', 'render', 'groups_side_navigation') } +%aside.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), **tracking_attrs('groups_side_navigation', 'render', 'groups_side_navigation'), 'aria-label': aside_title } .nav-sidebar-inner-scroll .context-header = link_to group_path(@group), title: @group.name do @@ -19,19 +21,13 @@ .nav-icon-container = sprite_icon('home') %span.nav-item-name - - if @group.subgroup? - = _('Subgroup overview') - - else - = _('Group overview') + = overview_title %ul.sidebar-sub-level-items = nav_link(path: ['groups#show', 'groups#details', 'groups#activity', 'groups#subgroups'], html_options: { class: "fly-out-top-item" } ) do = link_to group_path(@group) do %strong.fly-out-top-item-name - - if @group.subgroup? - = _('Subgroup overview') - - else - = _('Group overview') + = overview_title %li.divider.fly-out-top-item = nav_link(path: ['groups#show', 'groups#details', 'groups#subgroups'], html_options: { class: 'home' }) do @@ -54,14 +50,14 @@ = sprite_icon('issues') %span.nav-item-name = _('Issues') - %span.badge.badge-pill.count= number_with_delimiter(issues_count) + %span.badge.badge-pill.count= issues_count %ul.sidebar-sub-level-items{ data: { qa_selector: 'group_issues_sidebar_submenu'} } = nav_link(path: ['groups#issues', 'labels#index', 'milestones#index', 'iterations#index'], html_options: { class: "fly-out-top-item" } ) do = link_to issues_group_path(@group) do %strong.fly-out-top-item-name = _('Issues') - %span.badge.badge-pill.count.issue_counter.fly-out-badge= number_with_delimiter(issues_count) + %span.badge.badge-pill.count.issue_counter.fly-out-badge= issues_count %li.divider.fly-out-top-item = nav_link(path: 'groups#issues', html_options: { class: 'home' }) do @@ -141,7 +137,7 @@ %strong.fly-out-top-item-name = _('Members') - = render_if_exists 'groups/invite_members_side_nav_link', group: @group + = content_for :invite_members_sidebar - if group_sidebar_link?(:settings) = nav_link(path: group_settings_nav_link_paths) do diff --git a/app/views/layouts/nav/sidebar/_profile.html.haml b/app/views/layouts/nav/sidebar/_profile.html.haml index dadab554c02..a66110f28e8 100644 --- a/app/views/layouts/nav/sidebar/_profile.html.haml +++ b/app/views/layouts/nav/sidebar/_profile.html.haml @@ -1,4 +1,4 @@ -.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), **tracking_attrs('user_side_navigation', 'render', 'user_side_navigation') } +%aside.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), **tracking_attrs('user_side_navigation', 'render', 'user_side_navigation'), 'aria-label': _('User settings') } .nav-sidebar-inner-scroll .context-header = link_to profile_path, title: _('Profile Settings') do diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index e02b8333c60..f383674eb6c 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -1,4 +1,4 @@ -.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), **tracking_attrs('projects_side_navigation', 'render', 'projects_side_navigation') } +%aside.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), **tracking_attrs('projects_side_navigation', 'render', 'projects_side_navigation'), 'aria-label': _('Project navigation') } .nav-sidebar-inner-scroll .context-header = link_to project_path(@project), title: @project.name do @@ -212,7 +212,8 @@ = render_if_exists "layouts/nav/test_cases_link", project: @project - = render_if_exists 'layouts/nav/sidebar/project_security_link' # EE-specific + - if project_nav_tab? :security_and_compliance + = render_if_exists 'layouts/nav/sidebar/project_security_link' # EE-specific - if project_nav_tab? :operations = nav_link(controller: sidebar_operations_paths) do @@ -383,7 +384,7 @@ %strong.fly-out-top-item-name = _('Members') - = render_if_exists 'projects/invite_members_side_nav_link', project: @project + = content_for :invite_members_sidebar - if project_nav_tab? :settings = nav_link(path: sidebar_settings_paths) do diff --git a/app/views/layouts/nav/sidebar/_project_security_link.html.haml b/app/views/layouts/nav/sidebar/_project_security_link.html.haml new file mode 100644 index 00000000000..426845639e3 --- /dev/null +++ b/app/views/layouts/nav/sidebar/_project_security_link.html.haml @@ -0,0 +1,21 @@ +- top_level_link = project_security_configuration_path(@project) +- top_level_qa_selector = 'security_configuration_link' +- if any_project_nav_tab?([:security_configuration]) + = nav_link(path: sidebar_security_paths) do + = link_to top_level_link, data: { qa_selector: top_level_qa_selector } do + .nav-icon-container + = sprite_icon('shield') + %span.nav-item-name + = _('Security & Compliance') + + %ul.sidebar-sub-level-items + = nav_link(path: sidebar_security_paths, html_options: { class: "fly-out-top-item" } ) do + = link_to top_level_link do + %strong.fly-out-top-item-name + = _('Security & Compliance') + + %li.divider.fly-out-top-item + - if project_nav_tab?(:security_configuration) + = nav_link(path: sidebar_security_configuration_paths) do + = link_to project_security_configuration_path(@project), title: _('Configuration'), data: { qa_selector: 'security_configuration_link'} do + %span= _('Configuration') diff --git a/app/views/layouts/welcome.html.haml b/app/views/layouts/welcome.html.haml index 48921e9ff89..944f524d692 100644 --- a/app/views/layouts/welcome.html.haml +++ b/app/views/layouts/welcome.html.haml @@ -1,8 +1,8 @@ !!! 5 %html.subscriptions-layout-html{ lang: 'en' } = render 'layouts/head' - %body.ui-indigo.d-flex.vh-100.gl-bg-gray-10 + %body.ui-indigo.gl-display-flex.vh-100 = render "layouts/header/logo_with_title" = render "layouts/broadcast" - .container.d-flex.flex-grow-1.m-0 + .container.gl-display-flex.gl-flex-grow-1 = yield diff --git a/app/views/notify/in_product_marketing_email.html.haml b/app/views/notify/in_product_marketing_email.html.haml new file mode 100644 index 00000000000..024cfa97abc --- /dev/null +++ b/app/views/notify/in_product_marketing_email.html.haml @@ -0,0 +1,204 @@ +!!! +%html{ lang: "en" } + %head + %meta{ content: "text/html; charset=utf-8", "http-equiv" => "Content-Type" } + %meta{ content: "width=device-width, initial-scale=1", name: "viewport" } + %link{ href: "https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,600", rel: "stylesheet", type: "text/css" } + %title= message.subject + :css + /* CLIENT-SPECIFIC STYLES */ + body, + table, + td, + a { + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; + } + + table, + td { + mso-table-lspace: 0pt; + mso-table-rspace: 0pt; + } + + img { + -ms-interpolation-mode: bicubic; + } + + /* RESET STYLES */ + img { + border: 0; + height: auto; + line-height: 100%; + outline: none; + text-decoration: none; + } + + table { + border-collapse: collapse !important; + } + + body { + height: 100% !important; + margin: 0 !important; + padding: 0 !important; + width: 100% !important; + background-color: #ffffff; + color: #424242; + } + + a { + color: #6b4fbb; + text-decoration: underline; + } + + .cta_link a { + font-size: 24px; + font-family: 'Source Sans Pro', helvetica, arial, sans-serif; + color: #ffffff; + text-decoration: none; + border-radius: 5px; + -webkit-border-radius: 5px; + background-color: #6e49cb; + border-top: 15px solid #6e49cb; + border-bottom: 15px solid #6e49cb; + border-right: 40px solid #6e49cb; + border-left: 40px solid #6e49cb; + display: inline-block; + } + + .footernav { + display: inline !important; + } + + .footernav a { + color: #6e49cb; + } + + .address { + margin: 0; + font-size: 16px; + line-height: 26px; + } + + :css + /* iOS BLUE LINKS */ + a[x-apple-data-detectors] { + color: inherit !important; + text-decoration: none !important; + font-size: inherit !important; + font-family: inherit !important; + font-weight: inherit !important; + line-height: inherit !important; + } + /[if gte mso 9] + <xml> + <o:OfficeDocumentSettings> + <o:AllowPNG/> + <o:PixelsPerInch>96</o:PixelsPerInch> + </o:OfficeDocumentSettings> + </xml> + /[if (mso)|(mso 16)] + <style type="text/css"> + body, table, td, a, span { font-family: Arial, Helvetica, sans-serif !important; } + </style> + :css + @media only screen and (max-width: 595px) { + + .wrapper { + width: 100% !important; + margin: 0 auto !important; + padding: 0 !important; + } + + p, + li { + font-size: 18px !important; + line-height: 26px !important; + } + + .stack { + width: 100% !important; + } + + .stack-mobile-padding { + width: 100% !important; + margin-top: 20px !important; + } + + .callout { + padding-bottom: 20px !important; + } + + .redbutton { + text-align: center; + } + + .stack33 { + display: block !important; + width: 100% !important; + max-width: 100% !important; + direction: ltr !important; + text-align: center !important; + } + } + + @media only screen and (max-width: 480px) { + u~div { + width: 100vw !important; + } + + div>u~div { + width: 100% !important; + } + } + %body#body{ width: "100%" } + %table{ border: "0", cellpadding: "0", cellspacing: "0", role: "presentation", width: "100%" } + %tr + %td{ align: "center", style: "padding: 0px;" } + %table.wrapper{ border: "0", cellpadding: "0", cellspacing: "0", role: "presentation", width: "600" } + %tr + %td{ style: "padding: 0px;" } + #main-story.mktEditable{ mktoname: "main-story" } + %table{ border: "0", cellpadding: "0", cellspacing: "0", role: "presentation", width: "100%" } + %tr + %td{ align: "left", style: "padding: 0px;" } + = about_link('mailers/in_product_marketing', 'gitlab-logo-gray-rgb.png', 200) + %tr + %td{ "aria-hidden" => "true", height: "30", style: "font-size: 0; line-height: 0;" } + %tr + %td{ bgcolor: "#ffffff", height: "auto", style: "max-width: 600px; width: 100%; text-align: center; height: 200px; padding: 25px 15px; mso-line-height-rule: exactly; min-height: 40px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;", valign: "middle", width: "100%" } + = in_product_marketing_logo(@track, @series) + %h1{ style: "font-size: 40px; line-height: 46x; color: #000000; padding: 20px 0 0 0; font-weight: normal;" } + = in_product_marketing_title(@track, @series) + %h2{ style: "font-size: 28px; line-height: 34px; color: #000000; padding: 0; font-weight: 400;" } + = in_product_marketing_subtitle(@track, @series) + %tr + %td{ style: "padding: 10px 20px 30px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; color:#000000; font-size: 18px; line-height: 24px;" } + %p{ style: "margin: 0 0 20px 0;" } + = in_product_marketing_body_line1(@track, @series, format: :html).html_safe + %p{ style: "margin: 0 0 20px 0;" } + = in_product_marketing_body_line2(@track, @series, format: :html).html_safe + %tr + %td{ align: "center", style: "padding: 10px 20px 80px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" } + .cta_link= cta_link(@track, @series, @group, format: :html) + %tr{ style: "background-color: #ffffff;" } + %td{ style: "color: #424242; padding: 10px 30px; text-align: center; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;font-size: 16px; line-height: 22px; border: 1px solid #dddddd" } + %p + = in_product_marketing_progress(@track, @series) + %tr{ style: "background-color: #ffffff;" } + %td{ align: "center", style: "padding:50px 20px 0 20px;" } + = about_link('', 'gitlab_logo.png', 80) + %tr{ style: "background-color: #ffffff;" } + %td{ align: "center", style: "padding:0px ;" } + %tr{ style: "background-color: #ffffff;" } + %td{ align: "center", style: "padding:0px 10px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; " } + %span.footernav{ style: "color: #6e49cb; font-size: 16px; line-height: 26px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" } + = footer_links(format: :html).join(' ' * 3 + '|' + ' ' * 4).html_safe + %tr{ style: "background-color:#ffffff;" } + %td{ align: "center", style: "padding: 40px 30px 20px 30px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" } + .address= address(format: :html) + %tr{ style: "background-color: #ffffff;" } + %td{ align: "left", style: "padding:20px 30px 20px 30px;" } + %span.footernav{ style: "color: #6e49cb; font-size: 14px; line-height: 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; color:#424242;" } + = unsubscribe(format: :html).html_safe diff --git a/app/views/notify/in_product_marketing_email.text.erb b/app/views/notify/in_product_marketing_email.text.erb new file mode 100644 index 00000000000..ecc4c565b73 --- /dev/null +++ b/app/views/notify/in_product_marketing_email.text.erb @@ -0,0 +1,23 @@ +<%= in_product_marketing_tagline(@track, @series) %> + +<%= in_product_marketing_title(@track, @series) %> +<%= in_product_marketing_subtitle(@track, @series) %> + + +<%= in_product_marketing_body_line1(@track, @series) %> + +<%= in_product_marketing_body_line2(@track, @series) %> + +<%= cta_link(@track, @series, @group) %> + + + + + + + +<%= footer_links %> + +<%= address %> + +<%= unsubscribe %> diff --git a/app/views/notify/member_invited_email.html.haml b/app/views/notify/member_invited_email.html.haml index 5ff1e2393c9..55251fe88de 100644 --- a/app/views/notify/member_invited_email.html.haml +++ b/app/views/notify/member_invited_email.html.haml @@ -1,12 +1,23 @@ - placeholders = { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, project_or_group_name: member_source.human_name, project_or_group: member_source.model_name.singular, br_tag: '<br/>'.html_safe, role: member.human_access.downcase } -%tr - %td.text-content - %h2.invite-header - = s_('InviteEmail|You are invited!') - %p - - if member.created_by - = html_escape(s_("InviteEmail|%{inviter} invited you to join the %{strong_start}%{project_or_group_name}%{strong_end}%{br_tag}%{project_or_group} as a %{role}")) % placeholders.merge({ inviter: (link_to member.created_by.name, user_url(member.created_by)).html_safe }) - - else - = html_escape(s_("InviteEmail|You are invited to join the %{strong_start}%{project_or_group_name}%{strong_end}%{br_tag}%{project_or_group} as a %{role}")) % placeholders - %p.invite-actions - = link_to s_('InviteEmail|Join now'), invite_url(@token), class: 'invite-btn-join' + +- experiment('members/invite_email', actor: member) do |e| + - e.use do + %tr + %td.text-content + %h2.invite-header + = s_('InviteEmail|You are invited!') + %p + - if member.created_by + = html_escape(s_("InviteEmail|%{inviter} invited you to join the %{strong_start}%{project_or_group_name}%{strong_end}%{br_tag}%{project_or_group} as a %{role}")) % placeholders.merge({ inviter: (link_to member.created_by.name, user_url(member.created_by)).html_safe }) + - else + = html_escape(s_("InviteEmail|You are invited to join the %{strong_start}%{project_or_group_name}%{strong_end}%{br_tag}%{project_or_group} as a %{role}")) % placeholders + %p.invite-actions + = link_to s_('InviteEmail|Join now'), invite_url(@token, invite_type: Members::InviteEmailExperiment::INVITE_TYPE), class: 'invite-btn-join' + - e.try(:avatar) do + %tr + %td.text-content + %img.avatar{ height: "60", src: avatar_icon_for_user(member.created_by, 60, only_path: false), style: "display: block; border-radius: 30px; margin: -2px 0;", width: "60", alt: "" } + %p + = html_escape(s_("InviteEmail|%{inviter} invited you to join the %{strong_start}%{project_or_group_name}%{strong_end}%{br_tag}%{project_or_group} as a %{role}")) % placeholders.merge({ inviter: (link_to member.created_by.name, user_url(member.created_by)).html_safe }) + %p.invite-actions + = link_to s_('InviteEmail|Join now'), invite_url(@token, invite_type: Members::InviteEmailExperiment::INVITE_TYPE), class: 'invite-btn-join' diff --git a/app/views/notify/provisioned_member_access_granted_email.haml b/app/views/notify/provisioned_member_access_granted_email.html.haml index 2f2fd33145a..515254d1454 100644 --- a/app/views/notify/provisioned_member_access_granted_email.haml +++ b/app/views/notify/provisioned_member_access_granted_email.html.haml @@ -16,7 +16,8 @@ %td.text-content %p = _('By authenticating with an account tied to an Enterprise e-mail address, it is understood that this account is an Enterprise User. ') - = _('To ensure no loss of personal content, an Individual User should create a separate account under their own personal email address, not tied to the Enterprise email domain or name-space.') + = _('To ensure no loss of personal content, this account should only be used for matters related to %{group_name}.') % { group_name: member_source.human_name } + = _('For individual use, create a separate account under your personal email address, not tied to the Enterprise email domain or group.') - unless @user.confirmed? %p = _('To get started, click the link below to confirm your account.') diff --git a/app/views/notify/provisioned_member_access_granted_email.erb b/app/views/notify/provisioned_member_access_granted_email.text.erb index 485ee5a5242..b143b8d6f54 100644 --- a/app/views/notify/provisioned_member_access_granted_email.erb +++ b/app/views/notify/provisioned_member_access_granted_email.text.erb @@ -7,7 +7,8 @@ <%= _('By authenticating with an account tied to an Enterprise e-mail address, it is understood that this account is an Enterprise User. ') %> -<%= _('To ensure no loss of personal content, an Individual User should create a separate account under their own personal email address, not tied to the Enterprise email domain or name-space.') %> +<%= _('To ensure no loss of personal content, this account should only be used for matters related to %{group_name}.') % { group_name: member_source.human_name } %> +<%= _('For individual use, create a separate account under your personal email address, not tied to the Enterprise email domain or group.') %> <%- unless @user.confirmed? %> <%= _('To get started, click the link below to confirm your account.') %> <%= confirmation_url(@user, confirmation_token: @user.confirmation_token) %> diff --git a/app/views/notify/request_review_merge_request_email.html.haml b/app/views/notify/request_review_merge_request_email.html.haml new file mode 100644 index 00000000000..d1f72f6529a --- /dev/null +++ b/app/views/notify/request_review_merge_request_email.html.haml @@ -0,0 +1,2 @@ +%p + #{sanitize_name(@updated_by.name)} requested a new review on #{merge_request_reference_link(@merge_request)}. diff --git a/app/views/notify/request_review_merge_request_email.text.erb b/app/views/notify/request_review_merge_request_email.text.erb new file mode 100644 index 00000000000..9ab15332c51 --- /dev/null +++ b/app/views/notify/request_review_merge_request_email.text.erb @@ -0,0 +1 @@ +<%= sanitize_name(@updated_by.name) %> requested a new review on <%= merge_request_reference_link(@merge_request) %>. diff --git a/app/views/profiles/accounts/_providers.html.haml b/app/views/profiles/accounts/_providers.html.haml index f7368c5e921..5c0044ed825 100644 --- a/app/views/profiles/accounts/_providers.html.haml +++ b/app/views/profiles/accounts/_providers.html.haml @@ -1,3 +1,5 @@ +- button_class = 'btn btn-default gl-button gl-mb-3 gl-mr-3' + %label.label-bold = s_('Profiles|Connected Accounts') %p= s_('Profiles|Click on icon to activate signin with one of the following services') @@ -5,17 +7,19 @@ - unlink_allowed = unlink_provider_allowed?(provider) - link_allowed = link_provider_allowed?(provider) - if unlink_allowed || link_allowed - .provider-btn-group - .provider-btn-image - = provider_image_tag(provider) - - if auth_active?(provider) - - if unlink_allowed - = link_to unlink_profile_account_path(provider: provider), method: :delete, class: 'provider-btn' do + - if auth_active?(provider) + - if unlink_allowed + = link_to unlink_profile_account_path(provider: provider), method: :delete, class: button_class do + .social-provider-btn-image.gl-button-icon= provider_image_tag(provider) + .gl-button-text = s_('Profiles|Disconnect %{provider}') % { provider: label_for_provider(provider) } - - else - %a.provider-btn + - else + %a{ class: button_class } + .gl-button-text = s_('Profiles|%{provider} Active') % { provider: label_for_provider(provider) } - - elsif link_allowed - = link_to omniauth_authorize_path(:user, provider), method: :post, class: 'provider-btn gl-text-blue-500' do + - elsif link_allowed + = link_to omniauth_authorize_path(:user, provider), method: :post, class: button_class do + .social-provider-btn-image.gl-button-icon= provider_image_tag(provider) + .gl-button-text = s_('Profiles|Connect %{provider}') % { provider: label_for_provider(provider) } = render_if_exists 'profiles/accounts/group_saml_unlink_buttons', group_saml_identities: group_saml_identities diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index ca64c5f57b3..e8b2c5db4e6 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -15,7 +15,7 @@ .gl-alert-body = _('Congratulations! You have enabled Two-factor Authentication!') -.row.gl-mt-3 +.row.gl-mt-3.js-search-settings-section .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 = s_('Profiles|Two-Factor Authentication') @@ -29,10 +29,11 @@ - else .gl-mb-3 = link_to _('Enable two-factor authentication'), profile_two_factor_auth_path, class: 'gl-button btn btn-success', data: { qa_selector: 'enable_2fa_button' } + .col-lg-12 + %hr -%hr - if display_providers_on_profile? - .row.gl-mt-3 + .row.gl-mt-3.js-search-settings-section .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 = s_('Profiles|Social sign-in') @@ -40,9 +41,10 @@ = s_('Profiles|Activate signin with one of the following services') .col-lg-8 = render 'providers', providers: button_based_providers, group_saml_identities: local_assigns[:group_saml_identities] - %hr + .col-lg-12 + %hr - if current_user.can_change_username? - .row.gl-mt-3 + .row.gl-mt-3.js-search-settings-section .col-lg-4.profile-settings-sidebar %h4.gl-mt-0.warning-title = s_('Profiles|Change username') @@ -53,9 +55,10 @@ .col-lg-8 - data = { initial_username: current_user.username, root_url: root_url, action_url: update_username_profile_path(format: :json) } #update-username{ data: data } - %hr + .col-lg-12 + %hr -.row.gl-mt-3 +.row.gl-mt-3.js-search-settings-section .col-lg-4.profile-settings-sidebar %h4.gl-mt-0.danger-title = s_('Profiles|Delete account') @@ -81,9 +84,9 @@ = s_('Profiles|You must transfer ownership or delete these groups before you can delete your account.') - elsif !current_user.can_remove_self? %p - = s_('Profiles|GitLab is unable to verify your identity automatically.') + = s_('Profiles|GitLab is unable to verify your identity automatically. For security purposes, you must set a password by %{openingTag}resetting your password%{closingTag} to delete your account.').html_safe % { openingTag: "<a href='#{reset_profile_password_path}' rel=\"nofollow\" data-method=\"put\">".html_safe, closingTag: '</a>'.html_safe} %p - = s_('Profiles|Please email %{data_request} to begin the account deletion process.').html_safe % { data_request: mail_to('personal-data-request@gitlab.com') } + = s_('Profiles|If after setting a password, the option to delete your account is still not available, please email %{data_request} to begin the account deletion process.').html_safe % { data_request: mail_to('personal-data-request@gitlab.com') } - else %p = s_("Profiles|You don't have access to delete this user.") diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml index 0c6dc1a05d8..89198b0a65b 100644 --- a/app/views/profiles/emails/index.html.haml +++ b/app/views/profiles/emails/index.html.haml @@ -13,7 +13,7 @@ = 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', data: { qa_selector: 'email_address_field' } + = 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-success', data: { qa_selector: 'add_email_address_button' } %hr @@ -37,23 +37,23 @@ %li = render partial: 'shared/email_with_badge', locals: { email: @primary_email, verified: current_user.confirmed? } %span.float-right - %span.badge.badge-success= s_('Profiles|Primary email') + %span.badge.badge-muted.badge-pill.gl-badge.badge-success= s_('Profiles|Primary email') - if @primary_email === current_user.commit_email - %span.badge.badge-info= s_('Profiles|Commit email') + %span.badge.badge-muted.badge-pill.gl-badge.badge-info= s_('Profiles|Commit email') - if @primary_email === current_user.public_email - %span.badge.badge-info= s_('Profiles|Public email') + %span.badge.badge-muted.badge-pill.gl-badge.badge-info= s_('Profiles|Public email') - if @primary_email === current_user.notification_email - %span.badge.badge-info= s_('Profiles|Default notification email') + %span.badge.badge-muted.badge-pill.gl-badge.badge-info= s_('Profiles|Default notification email') - @emails.each do |email| %li{ data: { qa_selector: 'email_row_content' } } = render partial: 'shared/email_with_badge', locals: { email: email.email, verified: email.confirmed? } %span.float-right - if email.email === current_user.commit_email - %span.badge.badge-info= s_('Profiles|Commit email') + %span.badge.badge-muted.badge-pill.gl-badge.badge-info= s_('Profiles|Commit email') - if email.email === current_user.public_email - %span.badge.badge-info= s_('Profiles|Public email') + %span.badge.badge-muted.badge-pill.gl-badge.badge-info= s_('Profiles|Public email') - if email.email === current_user.notification_email - %span.badge.badge-info= s_('Profiles|Notification email') + %span.badge.badge-muted.badge-pill.gl-badge.badge-info= s_('Profiles|Notification email') - unless email.confirmed? - confirm_title = "#{email.confirmation_sent_at ? _('Resend confirmation email') : _('Send confirmation email')}" = link_to confirm_title, resend_confirmation_instructions_profile_email_path(email), method: :put, class: 'gl-button btn btn-sm btn-warning gl-ml-3' diff --git a/app/views/profiles/keys/_key.html.haml b/app/views/profiles/keys/_key.html.haml index eaf00ce6709..38fea521578 100644 --- a/app/views/profiles/keys/_key.html.haml +++ b/app/views/profiles/keys/_key.html.haml @@ -27,6 +27,4 @@ = s_('Profiles|Created%{time_ago}'.html_safe) % { time_ago: time_ago_with_tooltip(key.created_at, html_class: 'gl-ml-2')} - if key.can_delete? .gl-ml-3 - = button_to '#', class: "btn btn-default gl-button btn-default-tertiary js-confirm-modal-button", data: ssh_key_delete_modal_data(key, path_to_key(key, is_admin)) do - %span.sr-only= _('Delete') - = sprite_icon('remove') + = render 'shared/ssh_keys/key_delete', html_class: "btn btn-default gl-button btn-default-tertiary js-confirm-modal-button", button_data: ssh_key_delete_modal_data(key, path_to_key(key, is_admin)) diff --git a/app/views/profiles/keys/_key_details.html.haml b/app/views/profiles/keys/_key_details.html.haml index 22d795ca831..8016d989ff1 100644 --- a/app/views/profiles/keys/_key_details.html.haml +++ b/app/views/profiles/keys/_key_details.html.haml @@ -38,4 +38,4 @@ .col-md-12 .float-right - if @key.can_delete? - = button_to _('Delete'), '#', class: "btn btn-danger gl-button delete-key js-confirm-modal-button", data: ssh_key_delete_modal_data(@key, path_to_key(@key, is_admin)) + = render 'shared/ssh_keys/key_delete', text: _('Delete'), html_class: "btn btn-danger gl-button delete-key js-confirm-modal-button", button_data: ssh_key_delete_modal_data(@key, path_to_key(@key, is_admin)) diff --git a/app/views/profiles/notifications/_group_settings.html.haml b/app/views/profiles/notifications/_group_settings.html.haml index b1578886098..abbfbd995b6 100644 --- a/app/views/profiles/notifications/_group_settings.html.haml +++ b/app/views/profiles/notifications/_group_settings.html.haml @@ -9,7 +9,11 @@ = link_to group.name, group_path(group) .table-section.section-30.text-right - = render 'shared/notifications/button', notification_setting: setting, emails_disabled: emails_disabled + - if Feature.enabled?(:vue_notification_dropdown, default_enabled: :yaml) + - if setting + .js-vue-notification-dropdown{ data: { disabled: emails_disabled, dropdown_items: notification_dropdown_items(setting).to_json, notification_level: setting.level, group_id: group.id, container_class: 'gl-mr-3', show_label: "true" } } + - else + = render 'shared/notifications/button', notification_setting: setting, emails_disabled: emails_disabled .table-section.section-30 = form_for setting, url: profile_notifications_group_path(group), method: :put, html: { class: 'update-notifications gl-display-flex' } do |f| diff --git a/app/views/profiles/notifications/_project_settings.html.haml b/app/views/profiles/notifications/_project_settings.html.haml index 6e81d585f24..8cd552caa3d 100644 --- a/app/views/profiles/notifications/_project_settings.html.haml +++ b/app/views/profiles/notifications/_project_settings.html.haml @@ -8,4 +8,8 @@ = link_to_project(project) .float-right - = render 'shared/notifications/button', notification_setting: setting, emails_disabled: emails_disabled + - if Feature.enabled?(:vue_notification_dropdown, default_enabled: :yaml) + - if setting + .js-vue-notification-dropdown{ data: { disabled: emails_disabled, dropdown_items: notification_dropdown_items(setting).to_json, notification_level: setting.level, project_id: project.id, container_class: 'gl-mr-3', show_label: "true" } } + - else + = render 'shared/notifications/button', notification_setting: setting, emails_disabled: emails_disabled diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml index e1345a94fb1..cb0ada414ed 100644 --- a/app/views/profiles/notifications/show.html.haml +++ b/app/views/profiles/notifications/show.html.haml @@ -32,7 +32,11 @@ %br .clearfix .form-group.float-left.global-notification-setting - = render 'shared/notifications/button', notification_setting: @global_notification_setting + - if Feature.enabled?(:vue_notification_dropdown, default_enabled: :yaml) + - if @global_notification_setting + .js-vue-notification-dropdown{ data: { dropdown_items: notification_dropdown_items(@global_notification_setting).to_json, notification_level: @global_notification_setting.level, help_page_path: help_page_path('user/profile/notifications'), show_label: 'true' } } + - else + = render 'shared/notifications/button', notification_setting: @global_notification_setting .clearfix diff --git a/app/views/profiles/passwords/edit.html.haml b/app/views/profiles/passwords/edit.html.haml index 1ee5f52e407..b281dbb4367 100644 --- a/app/views/profiles/passwords/edit.html.haml +++ b/app/views/profiles/passwords/edit.html.haml @@ -20,15 +20,15 @@ - unless @user.password_automatically_set? .form-group = f.label :current_password, _('Current password'), class: 'label-bold' - = f.password_field :current_password, required: true, class: 'form-control', data: { qa_selector: 'current_password_field' } + = f.password_field :current_password, required: true, class: 'form-control gl-form-input', data: { qa_selector: 'current_password_field' } %p.form-text.text-muted = _('You must provide your current password in order to change it.') .form-group = f.label :password, _('New password'), class: 'label-bold' - = f.password_field :password, required: true, class: 'form-control', data: { qa_selector: 'new_password_field' } + = f.password_field :password, required: true, class: 'form-control gl-form-input', data: { qa_selector: 'new_password_field' } .form-group = f.label :password_confirmation, _('Password confirmation'), class: 'label-bold' - = f.password_field :password_confirmation, required: true, class: 'form-control', data: { qa_selector: 'confirm_password_field' } + = f.password_field :password_confirmation, required: true, 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-success gl-mr-3", data: { qa_selector: 'save_password_button' } - unless @user.password_automatically_set? diff --git a/app/views/profiles/passwords/new.html.haml b/app/views/profiles/passwords/new.html.haml index f6783528243..ffec6baa20e 100644 --- a/app/views/profiles/passwords/new.html.haml +++ b/app/views/profiles/passwords/new.html.haml @@ -16,16 +16,16 @@ .col-sm-2.col-form-label = f.label :current_password, _('Current password') .col-sm-10 - = f.password_field :current_password, required: true, class: 'form-control' + = f.password_field :current_password, required: true, class: 'form-control gl-form-input' .form-group.row .col-sm-2.col-form-label = f.label :password, _('New password') .col-sm-10 - = f.password_field :password, required: true, class: 'form-control' + = f.password_field :password, required: true, class: 'form-control gl-form-input' .form-group.row .col-sm-2.col-form-label = f.label :password_confirmation, _('Password confirmation') .col-sm-10 - = f.password_field :password_confirmation, required: true, class: 'form-control' + = f.password_field :password_confirmation, required: true, class: 'form-control gl-form-input' .form-actions = f.submit _('Set new password'), class: 'gl-button btn btn-success' diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 577b64ba17a..fdc760e671e 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -4,7 +4,7 @@ - type_plural = _('personal 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.profile-settings-sidebar %h4.gl-mt-0 = page_title @@ -33,8 +33,9 @@ revoke_route_helper: ->(token) { revoke_profile_personal_access_token_path(token) } - unless Gitlab::CurrentSettings.disable_feed_token - %hr - .row.gl-mt-3 + .col-lg-12 + %hr + .row.gl-mt-3.js-search-settings-section .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 = s_('AccessTokens|Feed token') @@ -51,8 +52,9 @@ = reset_message.html_safe - if incoming_email_token_enabled? - %hr - .row.gl-mt-3 + .col-lg-12 + %hr + .row.gl-mt-3.js-search-settings-section .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 = s_('AccessTokens|Incoming email token') @@ -69,8 +71,9 @@ = reset_message.html_safe - if static_objects_external_storage_enabled? - %hr - .row.gl-mt-3 + .col-lg-12 + %hr + .row.gl-mt-3.js-search-settings-section .col-lg-4 %h4.gl-mt-0 = s_('AccessTokens|Static object token') diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index aeecb0c0d72..169eef1fa9b 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -8,7 +8,7 @@ = stylesheet_link_tag "themes/#{theme.css_filename}" if theme.css_filename = form_for @user, url: profile_preferences_path, remote: true, method: :put do |f| - .row.gl-mt-3.js-preferences-form + .row.gl-mt-3.js-preferences-form.js-search-settings-section .col-lg-4.application-theme#navigation-theme %h4.gl-mt-0 = s_('Preferences|Navigation theme') @@ -25,6 +25,7 @@ .col-sm-12 %hr + .row.js-preferences-form.js-search-settings-section .col-lg-4.profile-settings-sidebar#syntax-highlighting-theme %h4.gl-mt-0 = s_('Preferences|Syntax highlighting theme') @@ -42,6 +43,7 @@ .col-sm-12 %hr + .row.js-preferences-form.js-search-settings-section .col-lg-4.profile-settings-sidebar#behavior %h4.gl-mt-0 = s_('Preferences|Behavior') @@ -97,7 +99,7 @@ .col-sm-12 %hr - + .row.js-preferences-form.js-search-settings-section .col-lg-4.profile-settings-sidebar#localization %h4.gl-mt-0 = _('Localization') diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 41699d6f01f..b1f4966f731 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -8,7 +8,7 @@ = bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user gl-mt-3 js-quick-submit gl-show-field-errors' }, authenticity_token: true do |f| = form_errors(@user) - .row + .row.js-search-settings-section .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 = s_("Profiles|Public Avatar") @@ -32,16 +32,16 @@ = image_tag avatar_icon_for_user(@user, 160), alt: '', class: 'avatar s160' %h5.gl-mt-0= s_("Profiles|Upload new avatar") .gl-mt-2.gl-mb-3 - %button.btn.js-choose-user-avatar-button{ type: 'button' }= s_("Profiles|Choose file...") + %button.gl-button.btn.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") = f.file_field_without_bootstrap :avatar, class: 'js-user-avatar-input hidden', accept: 'image/*' .form-text.text-muted= s_("Profiles|The maximum file size allowed is 200KB.") - if @user.avatar? %hr = 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 btn-inverted' - - %hr - .row + .col-lg-12 + %hr + .row.js-search-settings-section .col-lg-4.profile-settings-sidebar %h4.gl-mt-0= s_("Profiles|Current status") %p= s_("Profiles|This emoji and message will appear on your profile and throughout the interface.") @@ -74,21 +74,22 @@ .checkbox-icon-inline-wrapper = status_form.check_box :availability, { data: { testid: "user-availability-checkbox" }, label: s_("Profiles|Busy"), wrapper_class: 'gl-mr-0 gl-font-weight-bold' }, availability["busy"], availability["not_set"] .gl-text-gray-600.gl-ml-5= s_('Profiles|"Busy" will be shown next to your name') - - if Feature.enabled?(:user_time_settings) - %hr - .row.user-time-preferences - .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0= s_("Profiles|Time settings") - %p= s_("Profiles|You can set your current timezone here") - .col-lg-8 - -# TODO: might need an entry in user/profile.md to describe some of these settings - -# https://gitlab.com/gitlab-org/gitlab-foss/issues/60070 - %h5= ("Time zone") - = dropdown_tag(_("Select a timezone"), options: { toggle_class: 'gl-button btn js-timezone-dropdown input-lg', title: _("Select a timezone"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: timezone_data } } ) - %input.hidden{ :type => 'hidden', :id => 'user_timezone', :name => 'user[timezone]', value: @user.timezone } - - %hr - .row + - if Feature.enabled?(:user_time_settings) + .col-lg-12 + %hr + .row.user-time-preferences.js-search-settings-section + .col-lg-4.profile-settings-sidebar + %h4.gl-mt-0= s_("Profiles|Time settings") + %p= s_("Profiles|You can set your current timezone here") + .col-lg-8 + -# TODO: might need an entry in user/profile.md to describe some of these settings + -# https://gitlab.com/gitlab-org/gitlab-foss/issues/60070 + %h5= ("Time zone") + = dropdown_tag(_("Select a timezone"), options: { toggle_class: 'gl-button btn js-timezone-dropdown input-lg', title: _("Select a timezone"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: timezone_data } } ) + %input.hidden{ :type => 'hidden', :id => 'user_timezone', :name => 'user[timezone]', value: @user.timezone } + .col-lg-12 + %hr + .row.js-search-settings-section .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 = s_("Profiles|Main settings") @@ -124,9 +125,10 @@ = f.check_box :include_private_contributions, label: s_('Profiles|Include private contributions on my profile'), wrapper_class: 'mb-2', inline: true .help-block = s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information") - .gl-mt-3.gl-mb-3 - = f.submit s_("Profiles|Update profile settings"), class: 'gl-button btn btn-success' - = link_to _("Cancel"), user_path(current_user), class: 'gl-button btn btn-cancel' + .row.gl-mt-3.gl-mb-3.gl-justify-content-end + .col-lg-8 + = f.submit s_("Profiles|Update profile settings"), class: 'gl-button btn btn-success' + = link_to _("Cancel"), user_path(current_user), class: 'gl-button btn btn-cancel' .modal.modal-profile-crop{ data: { cropper_css_path: ActionController::Base.helpers.stylesheet_path('lazy_bundles/cropper.css') } } .modal-dialog @@ -141,12 +143,12 @@ %img.modal-profile-crop-image{ alt: s_("Profiles|Avatar cropper") } .crop-controls .btn-group - %button.btn.btn-primary{ data: { method: 'zoom', option: '-0.1' } } + %button.btn.gl-button.btn-confirm{ data: { method: 'zoom', option: '-0.1' } } %span = sprite_icon('search-minus') - %button.btn.btn-primary{ data: { method: 'zoom', option: '0.1' } } + %button.btn.gl-button.btn-confirm{ data: { method: 'zoom', option: '0.1' } } %span = sprite_icon('search-plus') .modal-footer - %button.btn.btn-primary.js-upload-user-avatar{ type: 'button' } + %button.btn.gl-button.btn-confirm.js-upload-user-avatar{ type: 'button' } = s_("Profiles|Set new profile picture") diff --git a/app/views/projects/_activity.html.haml b/app/views/projects/_activity.html.haml index c47ca81c431..db0f13843dd 100644 --- a/app/views/projects/_activity.html.haml +++ b/app/views/projects/_activity.html.haml @@ -1,15 +1,14 @@ - is_project_overview = local_assigns.fetch(:is_project_overview, false) -%div{ class: container_class } - .nav-block.d-none.d-sm-flex.activities.gl-static - = render 'shared/event_filter' - .controls.gl-display-flex - = link_to project_path(@project, rss_url_options), title: s_("ProjectActivityRSS|Subscribe"), class: 'btn gl-button btn-default btn-icon d-none d-sm-inline-flex has-tooltip' do - = sprite_icon('rss', css_class: 'qa-rss-icon gl-icon') - - if is_project_overview && can?(current_user, :download_code, @project) - .project-clone-holder.d-none.d-md-inline-flex.gl-ml-2 - = render "projects/buttons/clone", dropdown_class: 'dropdown-menu-right' +.nav-block.d-none.d-sm-flex.activities.gl-static + = render 'shared/event_filter' + .controls.gl-display-flex + = link_to project_path(@project, rss_url_options), title: s_("ProjectActivityRSS|Subscribe"), class: 'btn gl-button btn-default btn-icon d-none d-sm-inline-flex has-tooltip' do + = sprite_icon('rss', css_class: 'qa-rss-icon gl-icon') + - if is_project_overview && can?(current_user, :download_code, @project) + .project-clone-holder.d-none.d-md-inline-flex.gl-ml-2 + = render "projects/buttons/clone", dropdown_class: 'dropdown-menu-right' - .content_list.project-activity{ :"data-href" => activity_project_path(@project) } - .loading - .spinner.spinner-md +.content_list.project-activity{ :"data-href" => activity_project_path(@project) } +.loading + .spinner.spinner-md diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml index 86dfcda6d1b..f095f96779d 100644 --- a/app/views/projects/_export.html.haml +++ b/app/views/projects/_export.html.haml @@ -4,22 +4,20 @@ .sub-section{ data: { qa_selector: 'export_project_content' } } %h4= _('Export project') - %p= _('Export this project with all its related data in order to move your project to a new GitLab instance. Once the export is finished, you can import the file from the "New Project" page.') - - .bs-callout.bs-callout-info - %p.gl-mb-0 - %p= _('The following items will be exported:') - %ul - - project_export_descriptions.each do |desc| - %li= desc - %p= _('The following items will NOT be exported:') - %ul - %li= _('Job logs and artifacts') - %li= _('Container registry images') - %li= _('CI variables') - %li= _('Webhooks') - %li= _('Any encrypted tokens') - %p= _('Once the exported file is ready, you will receive a notification email with a download link, or you can download it from this page.') + - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/import_export') } + %p= _('Export this project with all its related data in order to move it to a new GitLab instance. When the exported file is ready, you can download it from this page or from the download link in the email notification you will receive. You can then import it when creating a new project. %{link_start}Learn more.%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } + %p.gl-mb-0 + %p= _('The following items will be exported:') + %ul + - project_export_descriptions.each do |desc| + %li= desc + %p= _('The following items will NOT be exported:') + %ul + %li= _('Job logs and artifacts') + %li= _('Container registry images') + %li= _('CI variables') + %li= _('Webhooks') + %li= _('Any encrypted tokens') - if project.export_status == :finished = link_to _('Download export'), download_export_project_path(project), rel: 'nofollow', download: '', method: :get, class: "btn btn-default", data: { qa_selector: 'download_export_link' } diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index 88dcc74a465..30d885964b5 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -11,7 +11,7 @@ = render 'projects/tree/tree_header', tree: @tree #js-last-commit - .info-well.gl-display-none.gl-display-sm-flex.project-last-commit + .info-well.gl-display-none.gl-sm-display-flex.project-last-commit .gl-spinner-container.m-auto = loading_icon(size: 'md', color: 'dark', css_class: 'align-text-bottom') diff --git a/app/views/projects/_find_file_link.html.haml b/app/views/projects/_find_file_link.html.haml index c3b4a61c28a..a4bf72edf12 100644 --- a/app/views/projects/_find_file_link.html.haml +++ b/app/views/projects/_find_file_link.html.haml @@ -1,2 +1,2 @@ -= link_to project_find_file_path(@project, @ref), class: 'gl-button btn shortcuts-find-file', rel: 'nofollow' do += link_to project_find_file_path(@project, @ref), class: 'gl-button btn btn-default shortcuts-find-file', rel: 'nofollow' do = _('Find file') diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 3e1d08e646e..9414e9b32d5 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -3,7 +3,7 @@ - max_project_topic_length = 15 - emails_disabled = @project.emails_disabled? -= render_if_exists 'projects/invite_members_modal', project: @project += render 'projects/invite_members_modal', project: @project .project-home-panel.js-show-on-project-root.gl-my-5{ class: [("empty-project" if empty_repo)] } .row.gl-mb-3 @@ -46,7 +46,11 @@ .project-repo-buttons.col-md-12.col-lg-6.d-inline-flex.flex-wrap.justify-content-lg-end - if current_user .d-inline-flex - = render 'shared/notifications/new_button', notification_setting: @notification_setting, btn_class: 'btn-xs', dropdown_container_class: 'gl-mr-3', emails_disabled: emails_disabled + - if Feature.enabled?(:vue_notification_dropdown, @project, default_enabled: :yaml) + - if @notification_setting + .js-vue-notification-dropdown{ data: { button_size: "small", disabled: emails_disabled, dropdown_items: notification_dropdown_items(@notification_setting).to_json, notification_level: @notification_setting.level, help_page_path: help_page_path('user/profile/notifications'), project_id: @project.id, container_class: 'gl-mr-3 gl-mt-5 gl-vertical-align-top' } } + - else + = render 'shared/notifications/new_button', notification_setting: @notification_setting, btn_class: 'btn-xs', dropdown_container_class: 'gl-mr-3', emails_disabled: emails_disabled .count-buttons.d-inline-flex = render 'projects/buttons/star' diff --git a/app/views/projects/_invite_members_link.html.haml b/app/views/projects/_invite_members_link.html.haml new file mode 100644 index 00000000000..95cfc75d955 --- /dev/null +++ b/app/views/projects/_invite_members_link.html.haml @@ -0,0 +1,4 @@ +- return unless can_invite_members_for_project?(@project) + +%li + .js-invite-members-trigger{ data: { icon: 'plus', classes: 'gl-text-decoration-none! gl-shadow-none!', display_text: _('Invite team members') } } diff --git a/app/views/projects/_invite_members_modal.html.haml b/app/views/projects/_invite_members_modal.html.haml index e8f61336882..b1bba5b59ca 100644 --- a/app/views/projects/_invite_members_modal.html.haml +++ b/app/views/projects/_invite_members_modal.html.haml @@ -1,4 +1,4 @@ -- if invite_members_allowed?(project.group) +- if can_invite_members_for_project?(project) .js-invite-members-modal{ data: { id: project.id, name: project.name, is_project: 'true', diff --git a/app/views/projects/_invite_members_side_nav_link.html.haml b/app/views/projects/_invite_members_side_nav_link.html.haml deleted file mode 100644 index 15e0b75cf57..00000000000 --- a/app/views/projects/_invite_members_side_nav_link.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -- if invite_members_allowed?(project.group) && body_data_page == 'projects:show' - %li - .js-invite-members-trigger{ data: { icon: 'plus', display_text: _('Invite team members') } } diff --git a/app/views/projects/_merge_request_merge_options_settings.html.haml b/app/views/projects/_merge_request_merge_options_settings.html.haml index 8951f2ed22f..80dabeceeb0 100644 --- a/app/views/projects/_merge_request_merge_options_settings.html.haml +++ b/app/views/projects/_merge_request_merge_options_settings.html.haml @@ -1,6 +1,6 @@ - form = local_assigns.fetch(:form) -.form-group +.form-group#project-merge-options{ data: { project_full_path: @project.full_path } } %b= s_('ProjectSettings|Merge options') %p.text-secondary= s_('ProjectSettings|Additional merge request capabilities that influence how and when merges will be performed') = render_if_exists 'projects/merge_pipelines_settings', form: form diff --git a/app/views/projects/_merge_request_merge_suggestions_settings.html.haml b/app/views/projects/_merge_request_merge_suggestions_settings.html.haml index 258cf86ab05..31a85a204be 100644 --- a/app/views/projects/_merge_request_merge_suggestions_settings.html.haml +++ b/app/views/projects/_merge_request_merge_suggestions_settings.html.haml @@ -9,7 +9,7 @@ anchor: 'configure-the-commit-message-for-applied-suggestions'), target: '_blank' .mb-2 - = form.text_field :suggestion_commit_message, class: 'form-control mb-2', placeholder: Gitlab::Suggestions::CommitMessage::DEFAULT_SUGGESTION_COMMIT_MESSAGE + = form.text_field :suggestion_commit_message, class: 'form-control gl-form-input mb-2', placeholder: Gitlab::Suggestions::CommitMessage::DEFAULT_SUGGESTION_COMMIT_MESSAGE %p.form-text.text-muted = s_('ProjectSettings|The variables GitLab supports:') - Gitlab::Suggestions::CommitMessage::PLACEHOLDERS.keys.each do |placeholder| diff --git a/app/views/projects/_merge_request_settings.html.haml b/app/views/projects/_merge_request_settings.html.haml index e69972e8163..a54eb2dddac 100644 --- a/app/views/projects/_merge_request_settings.html.haml +++ b/app/views/projects/_merge_request_settings.html.haml @@ -4,8 +4,7 @@ = render 'projects/merge_request_merge_options_settings', project: @project, form: form -- if Feature.enabled?(:squash_options, @project, default_enabled: true) - = render 'projects/merge_request_squash_options_settings', form: form += render 'projects/merge_request_squash_options_settings', form: form = render 'projects/merge_request_merge_checks_settings', project: @project, form: form diff --git a/app/views/projects/_remove.html.haml b/app/views/projects/_remove.html.haml index c246c45d0f7..e991a9b0ec7 100644 --- a/app/views/projects/_remove.html.haml +++ b/app/views/projects/_remove.html.haml @@ -3,7 +3,8 @@ .sub-section %h4.danger-title= _('Delete project') %p - %strong= _('Deleting the project will delete its repository and all related resources including issues, merge requests etc.') + %strong= _('Deleting the project will delete its repository and all related resources including issues, merge requests, etc.') + = link_to _('Learn more.'), help_page_path('user/project/settings/index', anchor: 'removing-a-fork-relationship'), target: '_blank', rel: 'noopener noreferrer' %p %strong= _('Deleted projects cannot be restored!') #js-project-delete-button{ data: { form_path: project_path(project), confirm_phrase: project.path } } diff --git a/app/views/projects/_remove_fork.html.haml b/app/views/projects/_remove_fork.html.haml index 2a7453902a8..8fa21966683 100644 --- a/app/views/projects/_remove_fork.html.haml +++ b/app/views/projects/_remove_fork.html.haml @@ -7,4 +7,5 @@ = form_for @project, url: remove_fork_project_path(@project), method: :delete, remote: true, html: { class: 'transfer-project' } do |f| %p %strong= _('Once removed, the fork relationship cannot be restored. This project will no longer be able to receive or send merge requests to the source project or other forks.') + = link_to _('Learn more.'), help_page_path('user/project/settings/index', anchor: 'removing-a-fork-relationship'), target: '_blank', rel: 'noopener noreferrer' = button_to _('Remove fork relationship'), '#', class: "gl-button btn btn-danger js-confirm-danger", data: { "confirm-danger-message" => remove_fork_project_warning_message(@project) } diff --git a/app/views/projects/_transfer.html.haml b/app/views/projects/_transfer.html.haml index eb7feb7bd3b..ee717c2deca 100644 --- a/app/views/projects/_transfer.html.haml +++ b/app/views/projects/_transfer.html.haml @@ -4,13 +4,14 @@ %h4.danger-title= _('Transfer project') = form_for @project, url: transfer_project_path(@project), method: :put, remote: true, html: { class: 'js-project-transfer-form' } do |f| .form-group - = label_tag :new_namespace_id, nil, class: 'label-bold' do - %span= _('Select a new namespace') - .form-group - = select_tag :new_namespace_id, namespaces_options(nil), include_blank: true, class: 'select2' + - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'transferring-an-existing-project-into-another-namespace') } + %p= _("Transfer your project into another namespace. %{link_start}Learn more.%{link_end}").html_safe % { link_start: link_start, link_end: '</a>'.html_safe } %ul %li= _("Be careful. Changing the project's namespace can have unintended side effects.") %li= _('You can only transfer the project to namespaces you manage.') %li= _('You will need to update your local repositories to point to the new location.') %li= _('Project visibility level will be changed to match namespace rules when transferring to a group.') + = label_tag :new_namespace_id, _('Select a new namespace'), class: 'gl-font-weight-bold' + .form-group + = select_tag :new_namespace_id, namespaces_options(nil), include_blank: true, class: 'select2' = f.submit 'Transfer project', class: "gl-button btn btn-danger js-confirm-danger qa-transfer-button", data: { "confirm-danger-message" => transfer_project_message(@project) } diff --git a/app/views/projects/artifacts/_artifact.html.haml b/app/views/projects/artifacts/_artifact.html.haml index 233a41a37b5..6c9b6ec164e 100644 --- a/app/views/projects/artifacts/_artifact.html.haml +++ b/app/views/projects/artifacts/_artifact.html.haml @@ -50,10 +50,10 @@ .table-action-buttons .btn-group - if can?(current_user, :read_build, @project) - = link_to download_project_job_artifacts_path(@project, artifact.job), rel: 'nofollow', download: '', title: _('Download artifacts'), data: { placement: 'top', container: 'body' }, ref: 'tooltip', aria: { label: _('Download artifacts') }, class: 'gl-button btn btn-build has-tooltip ml-0' do + = link_to download_project_job_artifacts_path(@project, artifact.job), rel: 'nofollow', download: '', title: _('Download artifacts'), data: { placement: 'top', container: 'body' }, ref: 'tooltip', aria: { label: _('Download artifacts') }, class: 'gl-button btn btn-default btn-build has-tooltip ml-0' do = sprite_icon('download') - = link_to browse_project_job_artifacts_path(@project, artifact.job), rel: 'nofollow', title: _('Browse artifacts'), data: { placement: 'top', container: 'body' }, ref: 'tooltip', aria: { label: _('Browse artifacts') }, class: 'gl-button btn btn-build has-tooltip' do + = link_to browse_project_job_artifacts_path(@project, artifact.job), rel: 'nofollow', title: _('Browse artifacts'), data: { placement: 'top', container: 'body' }, ref: 'tooltip', aria: { label: _('Browse artifacts') }, class: 'gl-button btn btn-default btn-build has-tooltip' do = sprite_icon('folder-open') - if can?(current_user, :destroy_artifacts, @project) diff --git a/app/views/projects/blob/_breadcrumb.html.haml b/app/views/projects/blob/_breadcrumb.html.haml index 710417f90e3..e666bb237bd 100644 --- a/app/views/projects/blob/_breadcrumb.html.haml +++ b/app/views/projects/blob/_breadcrumb.html.haml @@ -23,13 +23,13 @@ - if blob.readable_text? - if blame = link_to 'Normal view', project_blob_path(@project, @id), - class: 'gl-button btn' + class: 'gl-button btn btn-default' - else = link_to 'Blame', project_blame_path(@project, @id), - class: 'gl-button btn js-blob-blame-link' unless blob.empty? + class: 'gl-button btn btn-default js-blob-blame-link' unless blob.empty? = link_to 'History', project_commits_path(@project, @id), - class: 'gl-button btn' + class: 'gl-button btn btn-default' = link_to 'Permalink', project_blob_path(@project, - tree_join(@commit.sha, @path)), class: 'gl-button btn js-data-file-blob-permalink-url' + tree_join(@commit.sha, @path)), class: 'gl-button btn btn-default js-data-file-blob-permalink-url' diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml index b0317d84cdc..f9d11ec33d2 100644 --- a/app/views/projects/blob/_editor.html.haml +++ b/app/views/projects/blob/_editor.html.haml @@ -10,14 +10,14 @@ - if current_action?(:edit) || current_action?(:update) %span.float-left.gl-mr-3 = text_field_tag 'file_path', (params[:file_path] || @path), - class: 'form-control new-file-path js-file-path-name-input' + class: 'form-control gl-form-input new-file-path js-file-path-name-input' = render 'template_selectors' - if current_action?(:new) || current_action?(:create) %span.float-left.gl-mr-3 \/ = text_field_tag 'file_name', params[:file_name], placeholder: "File name", - required: true, class: 'form-control new-file-name js-file-path-name-input', value: params[:file_name] || (should_suggest_gitlab_ci_yml? ? '.gitlab-ci.yml' : '') + required: true, class: 'form-control gl-form-input new-file-name js-file-path-name-input', value: params[:file_name] || (should_suggest_gitlab_ci_yml? ? '.gitlab-ci.yml' : '') = render 'template_selectors' - if should_suggest_gitlab_ci_yml? .js-suggest-gitlab-ci-yml{ data: { target: '#gitlab-ci-yml-selector', diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml index dc4172e2f09..fcf073e1e09 100644 --- a/app/views/projects/branches/_branch.html.haml +++ b/app/views/projects/branches/_branch.html.haml @@ -8,13 +8,13 @@ = link_to project_tree_path(@project, branch.name), class: 'item-title str-truncated-100 ref-name gl-ml-3 qa-branch-name' do = branch.name - if branch.name == @repository.root_ref - %span.badge.badge-primary.gl-ml-2 default + %span.badge.gl-badge.sm.badge-pill.badge-primary.gl-ml-2 default - elsif merged - %span.badge.badge-info.has-tooltip.gl-ml-2{ title: s_('Branches|Merged into %{default_branch}') % { default_branch: @repository.root_ref } } + %span.badge.gl-badge.sm.badge-pill.badge-info.has-tooltip.gl-ml-2{ title: s_('Branches|Merged into %{default_branch}') % { default_branch: @repository.root_ref } } = s_('Branches|merged') - if protected_branch?(@project, branch) - %span.badge.badge-success.gl-ml-2 + %span.badge.gl-badge.sm.badge-pill.badge-success.gl-ml-2 = s_('Branches|protected') = render_if_exists 'projects/branches/diverged_from_upstream', branch: branch @@ -50,10 +50,10 @@ - if can?(current_user, :push_code, @project) - if branch.name == @project.repository.root_ref - %button{ class: "gl-button btn btn-danger remove-row has-tooltip disabled", - disabled: true, - title: s_('Branches|The default branch cannot be deleted') } - = sprite_icon("remove") + - delete_default_branch_tooltip = s_('Branches|The default branch cannot be deleted') + %span.has-tooltip{ title: delete_default_branch_tooltip } + %button{ class: "gl-button btn btn-danger remove-row disabled", disabled: true, 'aria-label' => delete_default_branch_tooltip } + = sprite_icon("remove") - elsif protected_branch?(@project, branch) - if can?(current_user, :push_to_delete_protected_branch, @project) %button{ class: "gl-button btn btn-danger remove-row has-tooltip", @@ -65,10 +65,10 @@ is_merged: ("true" if merged) } } = sprite_icon("remove") - else - %button{ class: "gl-button btn btn-danger remove-row has-tooltip disabled", - disabled: true, - title: s_('Branches|Only a project maintainer or owner can delete a protected branch') } - = sprite_icon("remove") + - delete_protected_branch_tooltip = s_('Branches|Only a project maintainer or owner can delete a protected branch') + %span.has-tooltip{ title: delete_protected_branch_tooltip } + %button{ class: "gl-button btn btn-danger remove-row disabled", disabled: true, 'aria-label' => delete_protected_branch_tooltip } + = sprite_icon("remove") - else = link_to project_branch_path(@project, branch.name), class: "gl-button btn btn-danger remove-row qa-remove-btn js-ajax-loading-spinner has-tooltip", diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml index 46cce59f67a..78f7d1af60f 100644 --- a/app/views/projects/branches/index.html.haml +++ b/app/views/projects/branches/index.html.haml @@ -60,7 +60,10 @@ %ul.content-list.all-branches - @branches.each do |branch| = render "projects/branches/branch", branch: branch, merged: @merged_branch_names.include?(branch.name), commit_status: @branch_pipeline_statuses[branch.name], show_commit_status: @branch_pipeline_statuses.any? - = paginate @branches, theme: 'gitlab' + - if Feature.enabled?(:branches_pagination_without_count, @project, default_enabled: true) + = paginate_without_count @branches + - else + = paginate @branches, theme: 'gitlab' - else .nothing-here-block = s_('Branches|No branches to show') diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml index 0fcbf2ca1eb..3071e5ea5f8 100644 --- a/app/views/projects/buttons/_download.html.haml +++ b/app/views/projects/buttons/_download.html.haml @@ -3,7 +3,7 @@ - if !project.empty_repo? && can?(current_user, :download_code, project) - archive_prefix = "#{project.path}-#{ref.tr('/', '-')}" .project-action-button.dropdown.inline> - %button.gl-button.btn.has-tooltip{ title: s_('DownloadSource|Download'), 'data-toggle' => 'dropdown', 'aria-label' => s_('DownloadSource|Download'), 'data-display' => 'static', data: { qa_selector: 'download_source_code_button' } } + %button.gl-button.btn.btn-default.has-tooltip{ title: s_('DownloadSource|Download'), 'data-toggle' => 'dropdown', 'aria-label' => s_('DownloadSource|Download'), 'data-display' => 'static', data: { qa_selector: 'download_source_code_button' } } = sprite_icon('download') %span.sr-only= _('Select Archive Format') = sprite_icon("chevron-down") diff --git a/app/views/projects/buttons/_download_links.html.haml b/app/views/projects/buttons/_download_links.html.haml index c997df578c0..f6084cfcde8 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-xs #{"btn-primary" if index == 0}" + = 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 dbe0bf35b98..fad168da71e 100644 --- a/app/views/projects/buttons/_fork.html.haml +++ b/app/views/projects/buttons/_fork.html.haml @@ -7,11 +7,11 @@ %span= s_('ProjectOverview|Fork') - else - can_create_fork = current_user.can?(:create_fork) - = link_to new_project_fork_path(@project), - class: "btn btn-default btn-xs has-tooltip count-badge-button d-flex align-items-center fork-btn #{'has-tooltip disabled' unless can_create_fork}", - title: (s_('ProjectOverview|You have reached your project limit') unless can_create_fork) do - = sprite_icon('fork', css_class: 'icon') - %span= s_('ProjectOverview|Fork') + - disabled_fork_tooltip = s_('ProjectOverview|You have reached your project limit') + %span.has-tooltip{ title: (disabled_fork_tooltip unless can_create_fork) } + = link_to new_project_fork_path(@project), class: "btn btn-default btn-xs count-badge-button d-flex align-items-center fork-btn #{' disabled' unless can_create_fork }", 'aria-label' => (disabled_fork_tooltip unless can_create_fork) do + = sprite_icon('fork', css_class: 'icon') + %span= s_('ProjectOverview|Fork') %span.fork-count.count-badge-count.d-flex.align-items-center = link_to project_forks_path(@project), title: n_(s_('ProjectOverview|Fork'), s_('ProjectOverview|Forks'), @project.forks_count), class: 'count' do = @project.forks_count diff --git a/app/views/projects/buttons/_remove_tag.html.haml b/app/views/projects/buttons/_remove_tag.html.haml index ae776e93203..68a9d715674 100644 --- a/app/views/projects/buttons/_remove_tag.html.haml +++ b/app/views/projects/buttons/_remove_tag.html.haml @@ -2,5 +2,5 @@ - tag = local_assigns.fetch(:tag, nil) - return unless project && tag -%button{ type: "button", class: "js-remove-tag js-confirm-modal-button gl-button btn btn-danger remove-row has-tooltip gl-ml-3 #{protected_tag?(project, tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), data: { container: 'body', path: project_tag_path(@project, tag.name), modal_attributes: delete_tag_modal_attributes(tag.name) } } +%button{ type: "button", class: "js-remove-tag js-confirm-modal-button gl-button btn btn-danger btn-icon remove-row has-tooltip gl-ml-3 #{protected_tag?(project, tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), data: { container: 'body', path: project_tag_path(@project, tag.name), modal_attributes: delete_tag_modal_attributes(tag.name) } } = sprite_icon("remove") diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index 017c804ced0..b7a265c0b17 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -99,11 +99,11 @@ %td .gl-display-flex - if can?(current_user, :read_job_artifacts, job) && job.artifacts? - = link_to download_project_job_artifacts_path(job.project, job), rel: 'nofollow', download: '', title: _('Download artifacts'), class: 'btn btn-build gl-button btn-icon btn-svg' do + = link_to download_project_job_artifacts_path(job.project, job), rel: 'nofollow', download: '', title: _('Download artifacts'), class: 'btn btn-build btn-default gl-button btn-icon btn-svg' do = sprite_icon('download') - if can?(current_user, :update_build, job) - if job.active? - = link_to cancel_project_job_path(job.project, job, continue: { to: request.fullpath }), method: :post, title: _('Cancel'), class: 'btn gl-button btn-build' do + = link_to cancel_project_job_path(job.project, job, continue: { to: request.fullpath }), method: :post, title: _('Cancel'), class: 'btn gl-button btn-build btn-default' do = sprite_icon('close') - elsif job.scheduled? .btn-group @@ -125,7 +125,7 @@ = sprite_icon('time-out') - elsif allow_retry - if job.playable? && !admin && can?(current_user, :update_build, job) - = link_to play_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Play'), class: 'btn gl-button btn-build' do + = link_to play_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Play'), class: 'btn gl-button btn-build btn-default' do = custom_icon('icon_play') - elsif job.retryable? = link_to retry_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Retry'), class: 'btn btn-build gl-button btn-icon btn-default' do diff --git a/app/views/projects/cleanup/_show.html.haml b/app/views/projects/cleanup/_show.html.haml index 37ec63cc871..e693082461f 100644 --- a/app/views/projects/cleanup/_show.html.haml +++ b/app/views/projects/cleanup/_show.html.haml @@ -6,7 +6,10 @@ %button.btn.js-settings-toggle = expanded ? _('Collapse') : _('Expand') %p - = _("Clean up after running %{filter_repo} on the repository" % { filter_repo: link_to_filter_repo }).html_safe + - link_url = 'https://github.com/newren/git-filter-repo' + - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: link_url } + - link_end = '</a>'.html_safe + = _("Clean up after running %{link_start}git filter-repo%{link_end} on the repository.").html_safe % { link_start: link_start, link_end: link_end } = link_to sprite_icon('question-o'), help_page_path('user/project/repository/reducing_the_repo_size_using_git.md'), target: '_blank', rel: 'noopener noreferrer' @@ -24,6 +27,6 @@ = _("No file selected") = f.file_field :bfg_object_map, class: "hidden js-object-map-input", required: true .form-text.text-muted - = _("The maximum file size allowed is %{size}.") % { size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes) } + = _("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-success' diff --git a/app/views/projects/commit/_change.html.haml b/app/views/projects/commit/_change.html.haml index 69b20fbc6d0..3c5f291b157 100644 --- a/app/views/projects/commit/_change.html.haml +++ b/app/views/projects/commit/_change.html.haml @@ -22,4 +22,14 @@ - label = s_('ChangeTypeAction|Cherry-pick') - branch_label = s_('ChangeTypeActionLabel|Pick into branch') - title = commit.merged_merge_request(current_user) ? _('Cherry-pick this merge request') : _('Cherry-pick this commit') - = render "projects/commit/commit_modal", title: title, type: type, commit: commit, branch_label: branch_label, description: description, label: label + + - if defined?(pajamas) + .js-cherry-pick-commit-modal{ data: { title: title, + endpoint: cherry_pick_namespace_project_commit_path(commit, namespace_id: @project.namespace.full_path, project_id: @project), + branch: @project.default_branch, + push_code: can?(current_user, :push_code, @project).to_s, + branch_collaboration: @project.branch_allows_collaboration?(current_user, selected_branch).to_s, + existing_branch: ERB::Util.html_escape(selected_branch), + branches_endpoint: project_branches_path(@project) } } + - else + = render "projects/commit/commit_modal", title: title, type: type, commit: commit, branch_label: branch_label, description: description, label: label diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index e8d524daced..ad6a8c10f6d 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -37,10 +37,10 @@ #{ _('Browse Files') } - if can_collaborate && !@commit.has_been_reverted?(current_user) %li.clearfix - = revert_commit_link(@commit, project_commit_path(@project, @commit.id), pajamas: true) + = revert_commit_link - if can_collaborate %li.clearfix - = cherry_pick_commit_link(@commit, project_commit_path(@project, @commit.id), has_tooltip: false) + = cherry_pick_commit_link - if can?(current_user, :push_code, @project) %li.clearfix = link_to s_('CreateTag|Tag'), new_project_tag_path(@project, ref: @commit) @@ -48,8 +48,8 @@ %li.dropdown-header #{ _('Download') } - unless @commit.parents.length > 1 - %li= link_to s_('DownloadCommit|Email Patches'), project_commit_path(@project, @commit, format: :patch), class: "qa-email-patches" - %li= link_to s_('DownloadCommit|Plain Diff'), project_commit_path(@project, @commit, format: :diff), class: "qa-plain-diff" + %li= link_to s_('DownloadCommit|Email Patches'), project_commit_path(@project, @commit, format: :patch), class: "qa-email-patches", rel: 'nofollow', download: '' + %li= link_to s_('DownloadCommit|Plain Diff'), project_commit_path(@project, @commit, format: :diff), class: "qa-plain-diff", rel: 'nofollow', download: '' .commit-box{ data: { project_path: project_path(@project) } } %h3.commit-title diff --git a/app/views/projects/commit/pipelines.html.haml b/app/views/projects/commit/pipelines.html.haml index 0dbd6e53212..b4c59c73e3b 100644 --- a/app/views/projects/commit/pipelines.html.haml +++ b/app/views/projects/commit/pipelines.html.haml @@ -4,3 +4,7 @@ = render 'commit_box' = render 'ci_menu' = render 'projects/commit/pipelines_list', endpoint: pipelines_project_commit_path(@project, @commit.id) + +- if can_collaborate_with_project?(@project) + = render "projects/commit/change", type: 'revert', commit: @commit, pajamas: true + = render "projects/commit/change", type: 'cherry-pick', commit: @commit, pajamas: true diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml index e7b2e757ce4..5e3bc270d5f 100644 --- a/app/views/projects/commit/show.html.haml +++ b/app/views/projects/commit/show.html.haml @@ -18,4 +18,4 @@ = render "shared/notes/notes_with_form", :autocomplete => true - if can_collaborate_with_project?(@project) = render "projects/commit/change", type: 'revert', commit: @commit, pajamas: true - = render "projects/commit/change", type: 'cherry-pick', commit: @commit, title: @commit.title + = render "projects/commit/change", type: 'cherry-pick', commit: @commit, pajamas: true diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml index a14f75259ec..802df664241 100644 --- a/app/views/projects/commits/show.html.haml +++ b/app/views/projects/commits/show.html.haml @@ -24,9 +24,9 @@ .control = form_tag(project_commits_path(@project, @id), method: :get, class: 'commits-search-form js-signature-container', data: { 'signatures-path' => namespace_project_signatures_path }) do - = search_field_tag :search, params[:search], { placeholder: _('Search by message'), id: 'commits-search', class: 'form-control gl-form-input input-short gl-mt-3 gl-sm-mt-0 gl-min-w-full gl-inset-border-1-gray-200!', spellcheck: false } + = search_field_tag :search, params[:search], { placeholder: _('Search by message'), id: 'commits-search', class: 'form-control gl-form-input input-short gl-mt-3 gl-sm-mt-0 gl-min-w-full', spellcheck: false } .control.d-none.d-md-block - = link_to project_commits_path(@project, @ref, rss_url_options), title: _("Commits feed"), class: 'btn gl-button btn-svg' do + = link_to project_commits_path(@project, @ref, rss_url_options), title: _("Commits feed"), class: 'btn gl-button btn-default btn-icon' do = sprite_icon('rss', css_class: 'qa-rss-icon') = render_if_exists 'projects/commits/mirror_status' diff --git a/app/views/projects/compare/_form.html.haml b/app/views/projects/compare/_form.html.haml index 0c0530110c5..17134613b17 100644 --- a/app/views/projects/compare/_form.html.haml +++ b/app/views/projects/compare/_form.html.haml @@ -1,7 +1,4 @@ = form_tag project_compare_index_path(@project), method: :post, class: 'form-inline js-requires-input js-signature-container', data: { 'signatures-path' => signatures_namespace_project_compare_index_path } do - - if params[:to] && params[:from] - .compare-switch-container - = link_to sprite_icon('substitute'), { from: params[:to], to: params[:from] }, class: 'commits-compare-switch has-tooltip btn gl-button btn-white', title: 'Swap revisions' .form-group.dropdown.compare-form-group.to.js-compare-to-dropdown .input-group.inline-input-group %span.input-group-prepend diff --git a/app/views/projects/compare/index.html.haml b/app/views/projects/compare/index.html.haml index e45ea209e8c..3f9aa24a569 100644 --- a/app/views/projects/compare/index.html.haml +++ b/app/views/projects/compare/index.html.haml @@ -13,4 +13,8 @@ = html_escape(_("Changes are shown as if the %{b_open}source%{b_close} revision was being merged into the %{b_open}target%{b_close} revision.")) % { b_open: '<b>'.html_safe, b_close: '</b>'.html_safe } .prepend-top-20 - = render "form" + #js-compare-selector{ data: { project_compare_index_path: project_compare_index_path(@project), + refs_project_path: refs_project_path(@project), + params_from: params[:from], params_to: params[:to], + project_merge_request_path: @merge_request.present? ? project_merge_request_path(@project, @merge_request) : '', + create_mr_path: create_mr_button? ? create_mr_path : '' } } diff --git a/app/views/projects/default_branch/_show.html.haml b/app/views/projects/default_branch/_show.html.haml index a1c7f5027c5..4e504f4c319 100644 --- a/app/views/projects/default_branch/_show.html.haml +++ b/app/views/projects/default_branch/_show.html.haml @@ -6,7 +6,7 @@ %button.btn.js-settings-toggle = expanded ? _('Collapse') : _('Expand') %p - = _('Select the branch you want to set as the default for this project. All merge requests and commits will automatically be made against this branch unless you specify a different one.') + = _('Set the default branch for this project. All merge requests and commits are made against this branch unless you specify a different one.') .settings-content = form_for @project, remote: true, html: { multipart: true, anchor: 'default-branch-settings' }, authenticity_token: true do |f| @@ -25,7 +25,7 @@ = f.label :autoclose_referenced_issues, class: 'form-check-label' do %strong= _("Auto-close referenced issues on default branch") .form-text.text-muted - = _("Issues referenced by merge requests and commits within the default branch will be closed automatically") + = _("When merge requests and commits in the default branch close, any issues they reference also close.") = link_to sprite_icon('question-o'), help_page_path('user/project/issues/managing_issues.md', anchor: 'disabling-automatic-issue-closing'), target: '_blank' = f.submit _('Save changes'), class: "gl-button btn btn-success" diff --git a/app/views/projects/diffs/viewers/_collapsed.html.haml b/app/views/projects/diffs/viewers/_collapsed.html.haml index 94dcda38bd6..02f499144c0 100644 --- a/app/views/projects/diffs/viewers/_collapsed.html.haml +++ b/app/views/projects/diffs/viewers/_collapsed.html.haml @@ -1,5 +1,3 @@ -- diff_file = viewer.diff_file -- url = url_for(safe_params.merge(action: :diff_for_path, old_path: diff_file.old_path, new_path: diff_file.new_path, file_identifier: diff_file.file_identifier)) -.nothing-here-block.diff-collapsed{ data: { diff_for_path: url } } +.nothing-here-block.diff-collapsed{ data: { diff_for_path: collapsed_diff_url(viewer.diff_file) } } = _("This diff is collapsed.") %button.click-to-expand.btn.btn-link= _("Click to expand it.") diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index cde8a5f69dd..962e1158118 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -3,7 +3,7 @@ - @content_class = "limit-container-width" unless fluid_layout - expanded = expanded_by_default? -= render "shared/search_settings" +- enable_search_settings %section.settings.general-settings.no-animate.expanded#js-general-settings .settings-header @@ -16,7 +16,7 @@ .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Visibility, project features, permissions') %button.gl-button.btn.btn-default.js-settings-toggle{ type: 'button' }= expanded ? _('Collapse') : _('Expand') - %p= _('Choose visibility level, enable/disable project features (issues, repository, wiki, snippets) and set permissions.') + %p= _('Choose visibility level, enable/disable project features and their permissions, disable email notifications, and show default award emoji.') .settings-content = form_for @project, remote: true, html: { multipart: true, class: "sharing-permissions-form" }, authenticity_token: true do |f| @@ -25,7 +25,7 @@ .js-project-permissions-form - if show_visibility_confirm_modal?(@project) = render "visibility_modal" - = f.submit _('Save changes'), class: "btn btn-success #{('js-confirm-danger' if show_visibility_confirm_modal?(@project))}", data: { qa_selector: 'visibility_features_permissions_save_button', check_field_name: ("project[visibility_level]" if show_visibility_confirm_modal?(@project)), check_compare_value: @project.visibility_level } + = f.submit _('Save changes'), class: "gl-button gl-button btn btn-success #{('js-confirm-danger' if show_visibility_confirm_modal?(@project))}", data: { qa_selector: 'visibility_features_permissions_save_button', check_field_name: ("project[visibility_level]" if show_visibility_confirm_modal?(@project)), check_compare_value: @project.visibility_level } %section.qa-merge-request-settings.rspec-merge-request-settings.settings.merge-requests-feature.no-animate#js-merge-request-settings{ class: [('expanded' if expanded), ('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)] } .settings-header @@ -39,7 +39,7 @@ = form_for @project, remote: true, 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 btn-succes qa-save-merge-request-changes rspec-save-merge-request-changes" + = f.submit _('Save changes'), class: "gl-button btn btn-succes qa-save-merge-request-changes rspec-save-merge-request-changes" = render_if_exists 'projects/merge_request_approvals_settings', expanded: expanded @@ -68,9 +68,11 @@ .settings-content .sub-section %h4= _('Housekeeping') - %p= _('Runs a number of housekeeping tasks within the current repository, such as compressing file revisions and removing unreachable objects.') + %p + = _('Runs a number of housekeeping tasks within the current repository, such as compressing file revisions and removing unreachable objects.') + = link_to _('Learn more.'), help_page_path('administration/housekeeping'), target: '_blank', rel: 'noopener noreferrer' = link_to _('Run housekeeping'), housekeeping_project_path(@project), - method: :post, class: "btn btn-default" + method: :post, class: "gl-button btn btn-default" = render 'export', project: @project @@ -80,6 +82,13 @@ = render 'projects/errors' = form_for @project do |f| .form-group + - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'renaming-a-repository') } + %p= _("A project’s repository name defines its URL (the one you use to access the project via a browser) and its place on the file disk where GitLab is installed. %{link_start}Learn more.%{link_end}").html_safe % { link_start: link_start, link_end: '</a>'.html_safe } + %ul + %li= _("Be careful. Renaming a project's repository can have unintended side effects.") + %li= _('You will need to update your local repositories to point to the new location.') + - if @project.deployment_platform.present? + %li= _('Your deployment services will be broken, you will need to manually fix the services after renaming.') = f.label :path, _('Path'), class: 'label-bold' .form-group .input-group @@ -87,12 +96,7 @@ .input-group-text #{Gitlab::Utils.append_path(root_url, @project.namespace.full_path)}/ = f.text_field :path, class: 'form-control qa-project-path-field h-auto' - %ul - %li= _("Be careful. Renaming a project's repository can have unintended side effects.") - %li= _('You will need to update your local repositories to point to the new location.') - - if @project.deployment_platform.present? - %li= _('Your deployment services will be broken, you will need to manually fix the services after renaming.') - = f.submit _('Change path'), class: "btn btn-warning qa-change-path-button" + = f.submit _('Change path'), class: "gl-button btn btn-warning qa-change-path-button" = render 'transfer', project: @project diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml index 2936eff45df..2c245c1a914 100644 --- a/app/views/projects/empty.html.haml +++ b/app/views/projects/empty.html.haml @@ -2,6 +2,9 @@ - default_branch_name = @project.default_branch || "master" - @skip_current_level_breadcrumb = true += content_for :invite_members_sidebar do + = render partial: 'projects/invite_members_link' + = render partial: 'flash_messages', locals: { project: @project } = render "home_panel" diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml index 067c987e721..5da9c25b780 100644 --- a/app/views/projects/environments/index.html.haml +++ b/app/views/projects/environments/index.html.haml @@ -6,5 +6,4 @@ "can-create-environment" => can?(current_user, :create_environment, @project).to_s, "new-environment-path" => new_project_environment_path(@project), "help-page-path" => help_page_path("ci/environments/index.md"), - "deploy-boards-help-path" => help_page_path("user/project/deploy_boards", anchor: "enabling-deploy-boards"), "project-path" => @project.full_path } } diff --git a/app/views/projects/graphs/charts.html.haml b/app/views/projects/graphs/charts.html.haml index a92b02701c5..40280e0787f 100644 --- a/app/views/projects/graphs/charts.html.haml +++ b/app/views/projects/graphs/charts.html.haml @@ -22,10 +22,10 @@ #{@daily_coverage_options[:base_params][:start_date].strftime('%b %d')} - end_date = capture do #{@daily_coverage_options[:base_params][:end_date].strftime('%b %d')} - = (_("Code coverage statistics for master %{start_date} - %{end_date}") % {start_date: start_date, end_date: end_date}) + = (_("Code coverage statistics for %{ref} %{start_date} - %{end_date}") % { ref: "<strong>#{h @ref}</strong>", start_date: start_date, end_date: end_date }).html_safe - download_path = capture do #{@daily_coverage_options[:download_path]} - %a.btn.gl-button.btn-sm{ href: "#{download_path}?#{@daily_coverage_options[:base_params].to_query}" } + %a.btn.gl-button.btn-default.btn-sm{ href: "#{download_path}?#{@daily_coverage_options[:base_params].to_query}" } %small = _("Download raw data (.csv)") #js-code-coverage-chart{ data: { graph_endpoint: "#{@daily_coverage_options[:graph_api_path]}?#{@daily_coverage_options[:base_params].to_query}" } } diff --git a/app/views/projects/graphs/show.html.haml b/app/views/projects/graphs/show.html.haml index c7508ef4d47..c62d4a35973 100644 --- a/app/views/projects/graphs/show.html.haml +++ b/app/views/projects/graphs/show.html.haml @@ -3,6 +3,6 @@ .sub-header-block.bg-gray-light.gl-p-5 .tree-ref-holder.inline.vertical-align-middle = render 'shared/ref_switcher', destination: 'graphs' - = link_to s_('Commits|History'), project_commits_path(@project, current_ref), class: 'btn gl-button' + = link_to s_('Commits|History'), project_commits_path(@project, current_ref), class: 'btn gl-button btn-default' .js-contributors-graph{ class: container_class, 'data-project-graph-path': project_graph_path(@project, current_ref, format: :json),'data-project-branch': current_ref } 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 3a8629b3b6e..5fa8f908122 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 @@ -3,7 +3,7 @@ - button_class = "btn gl-button #{!@merge_request.closed? && 'js-draft-toggle-button'}" - toggle_class = "btn gl-button dropdown-toggle" -.float-left.btn-group.gl-ml-3.gl-display-none.gl-display-md-flex +.float-left.btn-group.gl-ml-3.gl-display-none.gl-md-display-flex = link_to @merge_request.closed? ? reopen_issuable_path(@merge_request) : toggle_draft_merge_request_path(@merge_request), method: :put, class: "#{button_class} #{button_action_class}" do - if @merge_request.closed? = _('Reopen') diff --git a/app/views/projects/merge_requests/_commits.html.haml b/app/views/projects/merge_requests/_commits.html.haml index 178e57b08b3..ecf5df5d3b4 100644 --- a/app/views/projects/merge_requests/_commits.html.haml +++ b/app/views/projects/merge_requests/_commits.html.haml @@ -8,7 +8,7 @@ - if @project&.context_commits_enabled? && can_update_merge_request %p = _('Push commits to the source branch or add previously merged commits to review them.') - %button.btn.btn-primary.add-review-item-modal-trigger{ type: "button", data: { commits_empty: 'true', context_commits_empty: 'true' } } + %button.btn.gl-button.btn-confirm.add-review-item-modal-trigger{ type: "button", data: { commits_empty: 'true', context_commits_empty: 'true' } } = _('Add previously merged commits') - else %ol#commits-list.list-unstyled diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml index 6a42f33db7d..61747fe2c8d 100644 --- a/app/views/projects/merge_requests/_mr_title.html.haml +++ b/app/views/projects/merge_requests/_mr_title.html.haml @@ -13,8 +13,8 @@ .detail-page-header.border-bottom-0.pt-0.pb-0 .detail-page-header-body .issuable-status-box.status-box.js-mr-status-box{ class: status_box_class(@merge_request), data: { state: @merge_request.state } } - = sprite_icon(state_icon_name, css_class: 'gl-display-block gl-display-sm-none!') - %span.gl-display-none.gl-display-sm-block + = sprite_icon(state_icon_name, css_class: 'gl-display-block gl-sm-display-none!') + %span.gl-display-none.gl-sm-display-block = state_human_name .issuable-meta @@ -26,7 +26,7 @@ .detail-page-header-actions.js-issuable-actions .clearfix.dropdown - %button.gl-button.btn.btn-default.float-left.gl-display-md-none.gl-w-full{ type: "button", data: { toggle: "dropdown" } } + %button.gl-button.btn.btn-default.float-left.gl-md-display-none.gl-w-full{ type: "button", data: { toggle: "dropdown" } } Options = sprite_icon('chevron-down', css_class: 'gl-text-gray-500') .dropdown-menu.dropdown-menu-right @@ -45,9 +45,9 @@ %li= link_to 'Report abuse', new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request)) - if can_update_merge_request - = link_to 'Edit', edit_project_merge_request_path(@project, @merge_request), class: "d-none d-md-block btn gl-button btn-grouped js-issuable-edit qa-edit-button" + = link_to 'Edit', edit_project_merge_request_path(@project, @merge_request), class: "d-none d-md-block btn gl-button btn-default btn-grouped js-issuable-edit qa-edit-button" - if can_update_merge_request && !are_close_and_open_buttons_hidden = render 'projects/merge_requests/close_reopen_draft_report_toggle' - elsif !@merge_request.merged? - = link_to _('Report abuse'), new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request)), class: 'gl-display-none gl-display-md-block gl-button btn btn-warning-secondary float-right gl-ml-3', title: _('Report abuse') + = link_to _('Report abuse'), new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request)), class: 'gl-display-none gl-md-display-block gl-button btn btn-warning-secondary float-right gl-ml-3', title: _('Report abuse') diff --git a/app/views/projects/merge_requests/conflicts/_submit_form.html.haml b/app/views/projects/merge_requests/conflicts/_submit_form.html.haml index 15655e2b162..87356f33b1e 100644 --- a/app/views/projects/merge_requests/conflicts/_submit_form.html.haml +++ b/app/views/projects/merge_requests/conflicts/_submit_form.html.haml @@ -21,4 +21,4 @@ %button.btn.gl-button.btn-success.js-submit-button{ type: "button", "@click" => "commit()", ":disabled" => "!readyToCommit" } %span {{commitButtonText}} .col-6.text-right - = link_to "Cancel", project_merge_request_path(@merge_request.project, @merge_request), class: "gl-button btn btn-cancel" + = link_to "Cancel", project_merge_request_path(@merge_request.project, @merge_request), class: "gl-button btn btn-default" diff --git a/app/views/projects/merge_requests/conflicts/components/_diff_file_editor.html.haml b/app/views/projects/merge_requests/conflicts/components/_diff_file_editor.html.haml index 7294c5d321a..4ba5ec5795a 100644 --- a/app/views/projects/merge_requests/conflicts/components/_diff_file_editor.html.haml +++ b/app/views/projects/merge_requests/conflicts/components/_diff_file_editor.html.haml @@ -4,7 +4,7 @@ .discard-changes-alert Are you sure you want to discard your changes? .discard-actions - %button.btn.btn-sm.btn-close{ "@click" => "acceptDiscardConfirmation(file)" } Discard changes - %button.btn.btn-sm{ "@click" => "cancelDiscardConfirmation(file)" } Cancel + %button.btn.btn-sm.btn-danger-secondary.gl-button{ "@click" => "acceptDiscardConfirmation(file)" } Discard changes + %button.btn.btn-default.btn-sm.gl-button{ "@click" => "cancelDiscardConfirmation(file)" } Cancel .editor-wrap{ ":class" => "classObject" } .editor{ "style" => "height: 350px", data: { 'editor-loading': true } } diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index 849cfac825f..f20a4094f8f 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -56,10 +56,13 @@ = render "projects/merge_requests/widget" = render "projects/merge_requests/awards_block" - if mr_action === "show" - - add_page_startup_api_call discussions_path(@merge_request) + - if Feature.enabled?(:paginated_notes, @project) + - add_page_startup_api_call notes_url + - else + - add_page_startup_api_call discussions_path(@merge_request) - add_page_startup_api_call widget_project_json_merge_request_path(@project, @merge_request, format: :json) - add_page_startup_api_call cached_widget_project_json_merge_request_path(@project, @merge_request, format: :json) - #js-vue-mr-discussions{ data: { notes_data: notes_data(@merge_request).to_json, + #js-vue-mr-discussions{ data: { notes_data: notes_data(@merge_request, Feature.enabled?(:paginated_notes, @project)).to_json, noteable_data: serialize_issuable(@merge_request, serializer: 'noteable'), noteable_type: 'MergeRequest', target_type: 'merge_request', @@ -100,7 +103,7 @@ = render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @merge_request.assignees, reviewers: @merge_request.reviewers, source_branch: @merge_request.source_branch - if @merge_request.can_be_reverted?(current_user) - = render "projects/commit/change", type: 'revert', commit: @merge_request.merge_commit, title: @merge_request.title + = render "projects/commit/change", type: 'revert', commit: @merge_request.merge_commit, pajamas: true - if @merge_request.can_be_cherry_picked? = render "projects/commit/change", type: 'cherry-pick', commit: @merge_request.merge_commit, title: @merge_request.title diff --git a/app/views/projects/merge_requests/widget/_commit_change_content.html.haml b/app/views/projects/merge_requests/widget/_commit_change_content.html.haml index ad0ce7bf501..8684d2c860d 100644 --- a/app/views/projects/merge_requests/widget/_commit_change_content.html.haml +++ b/app/views/projects/merge_requests/widget/_commit_change_content.html.haml @@ -1,4 +1,4 @@ - if @merge_request.can_be_reverted?(current_user) - = render "projects/commit/change", type: 'revert', commit: @merge_request.merge_commit, title: @merge_request.title + = render "projects/commit/change", type: 'revert', commit: @merge_request.merge_commit, pajamas: true - if @merge_request.can_be_cherry_picked? = render "projects/commit/change", type: 'cherry-pick', commit: @merge_request.merge_commit, title: @merge_request.title diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml index a21f519da0e..f6e4442d4fb 100644 --- a/app/views/projects/milestones/_form.html.haml +++ b/app/views/projects/milestones/_form.html.haml @@ -21,8 +21,8 @@ .form-actions - if @milestone.new_record? - = f.submit _('Create milestone'), class: 'btn-success btn', data: { qa_selector: 'create_milestone_button' } + = f.submit _('Create milestone'), class: 'gl-button btn-success btn', data: { qa_selector: 'create_milestone_button' } = link_to _('Cancel'), project_milestones_path(@project), class: 'gl-button btn btn-cancel' - else - = f.submit _('Save changes'), class: 'btn-success btn' + = f.submit _('Save changes'), class: 'gl-button btn-success btn' = link_to _('Cancel'), project_milestone_path(@project, @milestone), class: 'gl-button btn btn-cancel' diff --git a/app/views/projects/milestones/index.html.haml b/app/views/projects/milestones/index.html.haml index b964c8b1a93..c0df986e1ca 100644 --- a/app/views/projects/milestones/index.html.haml +++ b/app/views/projects/milestones/index.html.haml @@ -11,15 +11,14 @@ = link_to new_project_milestone_path(@project), class: 'gl-button btn btn-success', data: { qa_selector: "new_project_milestone_link" }, title: _('New milestone') do = _('New milestone') -.milestones - #js-delete-milestone-modal - #promote-milestone-modal +- if @milestones.blank? + = render 'shared/empty_states/milestones' +- else + .milestones + #js-delete-milestone-modal + #promote-milestone-modal - %ul.content-list - = render @milestones + %ul.content-list + = render @milestones - - if @milestones.blank? - %li - .nothing-here-block= _('No milestones to show') - - = paginate @milestones, theme: 'gitlab' + = paginate @milestones, theme: 'gitlab' diff --git a/app/views/projects/mirrors/_authentication_method.html.haml b/app/views/projects/mirrors/_authentication_method.html.haml index 88bd7da7fef..94f8703657b 100644 --- a/app/views/projects/mirrors/_authentication_method.html.haml +++ b/app/views/projects/mirrors/_authentication_method.html.haml @@ -6,11 +6,11 @@ .select-wrapper = f.select :auth_method, options_for_select(auth_options, mirror.auth_method), - {}, { class: "form-control select-control js-mirror-auth-type qa-authentication-method" } + {}, { class: "form-control gl-form-input 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" .form-group .well-password-auth.collapse.js-well-password-auth = f.label :password, _("Password"), class: "label-bold" - = f.password_field :password, value: mirror.password, class: 'form-control qa-password', autocomplete: 'new-password' + = f.password_field :password, value: mirror.password, class: 'form-control gl-form-input qa-password', autocomplete: 'new-password' diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml index 98d35845b31..d6ad6147e6e 100644 --- a/app/views/projects/mirrors/_mirror_repos.html.haml +++ b/app/views/projects/mirrors/_mirror_repos.html.haml @@ -21,7 +21,7 @@ .form-group.has-feedback = label_tag :url, _('Git repository URL'), class: 'label-light' - = text_field_tag :url, nil, class: 'form-control js-mirror-url js-repo-url qa-mirror-repository-url-input', placeholder: _('Input the remote repository URL'), required: true, pattern: "(#{protocols}):\/\/.+", autocomplete: 'new-password' + = text_field_tag :url, nil, class: 'form-control gl-form-input js-mirror-url js-repo-url qa-mirror-repository-url-input', placeholder: _('Input the remote repository URL'), required: true, pattern: "(#{protocols}):\/\/.+", autocomplete: 'new-password' = render 'projects/mirrors/instructions' diff --git a/app/views/projects/mirrors/_mirror_repos_form.html.haml b/app/views/projects/mirrors/_mirror_repos_form.html.haml index 215d0a59d1b..dca01ebbe90 100644 --- a/app/views/projects/mirrors/_mirror_repos_form.html.haml +++ b/app/views/projects/mirrors/_mirror_repos_form.html.haml @@ -1,7 +1,7 @@ .form-group = label_tag :mirror_direction, _('Mirror direction'), class: 'label-light' .select-wrapper - = select_tag :mirror_direction, options_for_select([[_('Push'), 'push']]), class: 'form-control select-control js-mirror-direction qa-mirror-direction', disabled: true + = select_tag :mirror_direction, options_for_select([[_('Push'), 'push']]), class: 'form-control gl-form-input select-control js-mirror-direction qa-mirror-direction', disabled: true = sprite_icon('chevron-down', css_class: "gl-icon gl-absolute gl-top-3 gl-right-3 gl-text-gray-200") = render partial: "projects/mirrors/mirror_repos_push", locals: { f: f } diff --git a/app/views/projects/network/show.html.haml b/app/views/projects/network/show.html.haml index 30ba22ba53c..99672ded6db 100644 --- a/app/views/projects/network/show.html.haml +++ b/app/views/projects/network/show.html.haml @@ -5,8 +5,8 @@ .project-network.gl-border-1.gl-border-solid.gl-border-gray-300 .controls.gl-bg-gray-50.gl-p-2.gl-font-base.gl-text-gray-400.gl-border-b-1.gl-border-b-solid.gl-border-b-gray-300 = form_tag project_network_path(@project, @id), method: :get, class: 'form-inline network-form' do |f| - = text_field_tag :extended_sha1, @options[:extended_sha1], placeholder: _("Git revision"), class: 'search-input form-control input-mx-250 search-sha' - = button_tag class: 'btn btn-success' do + = text_field_tag :extended_sha1, @options[:extended_sha1], placeholder: _("Git revision"), class: 'search-input form-control gl-form-input input-mx-250 search-sha gl-mr-2' + = button_tag class: 'btn gl-button btn-success btn-icon' do = sprite_icon('search') .inline.gl-ml-5 .form-check.light diff --git a/app/views/projects/no_repo.html.haml b/app/views/projects/no_repo.html.haml index d7853c1b466..ea14e2d6ca5 100644 --- a/app/views/projects/no_repo.html.haml +++ b/app/views/projects/no_repo.html.haml @@ -12,7 +12,7 @@ #{ _('This means you can not push code until you create an empty repository or import existing one.') } %hr -= render_if_exists 'projects/invite_members_modal', project: @project += render 'projects/invite_members_modal', project: @project .no-repo-actions = link_to project_repository_path(@project), method: :post, class: 'btn btn-primary' do diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml index a785e36fad5..d11b61466e2 100644 --- a/app/views/projects/notes/_actions.html.haml +++ b/app/views/projects/notes/_actions.html.haml @@ -39,15 +39,15 @@ - if can?(current_user, :award_emoji, note) - if note.emoji_awardable? .note-actions-item - = button_tag title: 'Add reaction', class: "note-action-button note-emoji-button js-add-award js-note-emoji has-tooltip btn btn-transparent", data: { position: 'right', container: 'body' } do - %span{ class: 'link-highlight award-control-icon-neutral' }= sprite_icon('slight-smile') - %span{ class: 'link-highlight award-control-icon-positive' }= sprite_icon('smiley') - %span{ class: 'link-highlight award-control-icon-super-positive' }= sprite_icon('smile') + = button_tag title: 'Add reaction', class: "note-action-button note-emoji-button js-add-award js-note-emoji has-tooltip btn gl-button btn-icon btn-default-tertiary btn-transparent", data: { position: 'right', container: 'body' } do + = sprite_icon('slight-smile', css_class: 'link-highlight award-control-icon-neutral gl-button-icon gl-icon gl-text-gray-400') + = sprite_icon('smiley', css_class: 'link-highlight award-control-icon-positive gl-button-icon gl-icon gl-left-3!') + = sprite_icon('smile', css_class: 'link-highlight award-control-icon-super-positive gl-button-icon gl-icon gl-left-3! ') - if note_editable - .note-actions-item - = button_tag title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip btn btn-transparent', data: { container: 'body', qa_selector: 'edit_comment_button' } do + .note-actions-item.gl-ml-0 + = button_tag title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip btn gl-button btn-default-tertiary btn-transparent gl-px-2!', data: { container: 'body', qa_selector: 'edit_comment_button' } do %span.link-highlight - = custom_icon('icon_pencil') + = sprite_icon('pencil', css_class: 'gl-button-icon gl-icon gl-text-gray-400 s16') = render 'projects/notes/more_actions_dropdown', note: note, note_editable: note_editable diff --git a/app/views/projects/notes/_more_actions_dropdown.html.haml b/app/views/projects/notes/_more_actions_dropdown.html.haml index 8cf1b6b9294..c81a3683e90 100644 --- a/app/views/projects/notes/_more_actions_dropdown.html.haml +++ b/app/views/projects/notes/_more_actions_dropdown.html.haml @@ -1,10 +1,9 @@ - is_current_user = current_user == note.author - if note_editable || !is_current_user - .dropdown.more-actions.note-actions-item - = button_tag title: 'More actions', class: 'note-action-button more-actions-toggle has-tooltip btn btn-transparent', data: { toggle: 'dropdown', container: 'body', qa_selector: 'more_actions_dropdown' } do - %span.icon - = custom_icon('ellipsis_v') + %div{ class: "dropdown more-actions note-actions-item gl-ml-0!" } + = button_tag title: 'More actions', class: 'note-action-button more-actions-toggle has-tooltip btn gl-button btn-default-tertiary btn-transparent gl-pl-2! gl-pr-0!', data: { toggle: 'dropdown', container: 'body', qa_selector: 'more_actions_dropdown' } do + = sprite_icon('ellipsis_v', css_class: 'gl-button-icon gl-icon gl-text-gray-400') %ul.dropdown-menu.more-actions-dropdown.dropdown-open-left %li = clipboard_button(text: noteable_note_url(note), title: _('Copy reference'), button_text: _('Copy link'), class: 'btn-clipboard', hide_tooltip: true, hide_button_icon: true) diff --git a/app/views/projects/pages/_use.html.haml b/app/views/projects/pages/_use.html.haml index e9ace8c72f1..ec3fc27dc20 100644 --- a/app/views/projects/pages/_use.html.haml +++ b/app/views/projects/pages/_use.html.haml @@ -4,7 +4,8 @@ = s_('GitLabPages|Configure pages') .card-body %p.gl-mb-0 - - link_start = "<a href='#{help_page_path('user/project/pages/index.md')}' target='_blank' rel='noopener noreferrer'>".html_safe + - docs_link_start = "<a href='#{help_page_path('user/project/pages/index.md')}' target='_blank' rel='noopener noreferrer'>".html_safe + - samples_link_start = "<a href='https://gitlab.com/pages' target='_blank' rel='noopener noreferrer'>".html_safe + - templates_link_start = "<a href='https://gitlab.com/gitlab-org/project-templates' target='_blank' rel='noopener noreferrer'>".html_safe - link_end = '</a>'.html_safe - = s_('GitLabPages|Learn how to upload your static site and have it served by GitLab by following the %{link_start}documentation on GitLab Pages%{link_end}.').html_safe % { link_start: link_start, - link_end: link_end } + = s_('GitLabPages|See the %{docs_link_start}GitLab Pages documentation%{link_end} to learn how to upload your static site and have GitLab serve it. You can also follow a %{samples_link_start}sample project%{link_end} or use a %{templates_link_start}GitLab CI template%{link_end}.').html_safe % { docs_link_start: docs_link_start, samples_link_start: samples_link_start, templates_link_start: templates_link_start, link_end: link_end } diff --git a/app/views/projects/pipeline_schedules/_form.html.haml b/app/views/projects/pipeline_schedules/_form.html.haml index ee0fe43e79c..8a369202555 100644 --- a/app/views/projects/pipeline_schedules/_form.html.haml +++ b/app/views/projects/pipeline_schedules/_form.html.haml @@ -39,5 +39,5 @@ = f.check_box :active, required: false, value: @schedule.active? = f.label :active, _('Active'), class: 'gl-font-weight-normal' .footer-block.row-content-block - = f.submit _('Save pipeline schedule'), class: 'btn btn-success' - = link_to _('Cancel'), pipeline_schedules_path(@project), class: 'btn btn-cancel' + = f.submit _('Save pipeline schedule'), class: 'btn gl-button btn-success' + = 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 45aaf2b64bf..e17c905e092 100644 --- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml +++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml @@ -27,14 +27,14 @@ %td .float-right.btn-group - if can?(current_user, :play_pipeline_schedule, pipeline_schedule) - = link_to play_pipeline_schedule_path(pipeline_schedule), method: :post, title: s_('Play'), class: 'btn btn-svg gl-display-flex gl-align-items-center gl-justify-content-center' do + = link_to play_pipeline_schedule_path(pipeline_schedule), method: :post, title: s_('Play'), class: 'btn gl-button btn-default btn-svg' do = sprite_icon('play') - if can?(current_user, :take_ownership_pipeline_schedule, pipeline_schedule) - = link_to take_ownership_pipeline_schedule_path(pipeline_schedule), method: :post, title: s_('PipelineSchedules|Take ownership'), class: 'btn' do + = link_to take_ownership_pipeline_schedule_path(pipeline_schedule), method: :post, title: s_('PipelineSchedules|Take ownership'), class: 'btn gl-button btn-default' do = s_('PipelineSchedules|Take ownership') - if can?(current_user, :update_pipeline_schedule, pipeline_schedule) - = link_to edit_pipeline_schedule_path(pipeline_schedule), title: _('Edit'), class: 'btn gl-display-flex' do + = link_to edit_pipeline_schedule_path(pipeline_schedule), title: _('Edit'), class: 'btn gl-button btn-default' do = sprite_icon('pencil') - if can?(current_user, :admin_pipeline_schedule, pipeline_schedule) - = link_to pipeline_schedule_path(pipeline_schedule), title: _('Delete'), method: :delete, class: 'gl-button btn btn-danger', data: { confirm: _("Are you sure you want to delete this pipeline schedule?") } do + = link_to pipeline_schedule_path(pipeline_schedule), title: _('Delete'), method: :delete, class: 'btn gl-button btn-danger', data: { confirm: _("Are you sure you want to delete this pipeline schedule?") } do = sprite_icon('remove') diff --git a/app/views/projects/pipeline_schedules/index.html.haml b/app/views/projects/pipeline_schedules/index.html.haml index 90417a852d5..558c12c04e4 100644 --- a/app/views/projects/pipeline_schedules/index.html.haml +++ b/app/views/projects/pipeline_schedules/index.html.haml @@ -9,7 +9,7 @@ - if can?(current_user, :create_pipeline_schedule, @project) .nav-controls - = link_to new_project_pipeline_schedule_path(@project), class: 'btn btn-success' do + = link_to new_project_pipeline_schedule_path(@project), class: 'btn gl-button btn-success' do %span= _('New schedule') - if @schedules.present? diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index 77aa537dfdb..4a10f6aee1c 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -28,6 +28,9 @@ - if @pipeline.latest? %span.js-pipeline-url-latest.badge.badge-pill.gl-badge.sm.badge-success.has-tooltip{ title: _("Latest pipeline for the most recent commit on this branch") } latest + - if @pipeline.merge_train_pipeline? + %span.js-pipeline-url-train.badge.badge-pill.gl-badge.sm.badge-info.has-tooltip{ title: _("This is a merge train pipeline") } + train - if @pipeline.has_yaml_errors? %span.js-pipeline-url-yaml.badge.badge-pill.gl-badge.sm.badge-danger.has-tooltip{ title: @pipeline.yaml_errors } yaml invalid diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index b41c3f4fc27..5f99396d744 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -1,15 +1,13 @@ - return if pipeline_has_errors -- dag_pipeline_tab_enabled = Feature.enabled?(:dag_pipeline_tab, @project, default_enabled: true) .tabs-holder %ul.pipelines-tabs.nav-links.no-top.no-bottom.mobile-separator.nav.nav-tabs %li.js-pipeline-tab-link = link_to project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-pipeline', action: 'pipelines', toggle: 'tab' }, class: 'pipeline-tab' do = _('Pipeline') - - if dag_pipeline_tab_enabled - %li.js-dag-tab-link - = link_to dag_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-dag', action: 'dag', toggle: 'tab' }, class: 'dag-tab' do - = _('Needs') + %li.js-dag-tab-link + = link_to dag_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-dag', action: 'dag', toggle: 'tab' }, class: 'dag-tab' do + = _('Needs') %li.js-builds-tab-link = link_to builds_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-builds', action: 'builds', toggle: 'tab' }, class: 'builds-tab' do = _('Jobs') @@ -79,11 +77,10 @@ %code.bash.js-build-output = build_summary(build) - - if dag_pipeline_tab_enabled - #js-tab-dag.tab-pane - #js-pipeline-dag-vue{ data: { pipeline_project_path: @project.full_path, pipeline_iid: @pipeline.iid, empty_svg_path: image_path('illustrations/empty-state/empty-dag-md.svg'), about_dag_doc_path: help_page_path('ci/directed_acyclic_graph/index.md'), dag_doc_path: help_page_path('ci/yaml/README.md', anchor: 'needs')} } + #js-tab-dag.tab-pane + #js-pipeline-dag-vue{ data: { pipeline_project_path: @project.full_path, pipeline_iid: @pipeline.iid, empty_svg_path: image_path('illustrations/empty-state/empty-dag-md.svg'), about_dag_doc_path: help_page_path('ci/directed_acyclic_graph/index.md'), dag_doc_path: help_page_path('ci/yaml/README.md', anchor: 'needs')} } #js-tab-tests.tab-pane #js-pipeline-tests-detail{ data: { summary_endpoint: summary_project_pipeline_tests_path(@project, @pipeline, format: :json), - suite_endpoint: project_pipeline_test_path(@project, @pipeline, suite_name: ':suite_name', format: :json) } } + suite_endpoint: project_pipeline_test_path(@project, @pipeline, suite_name: 'suite', format: :json) } } = render_if_exists "projects/pipelines/tabs_content", pipeline: @pipeline, project: @project diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml index b3ad210aa47..b431ef202b3 100644 --- a/app/views/projects/pipelines/show.html.haml +++ b/app/views/projects/pipelines/show.html.haml @@ -6,7 +6,7 @@ - add_page_specific_style 'page_bundles/reports' - add_page_specific_style 'page_bundles/ci_status' -- if Feature.enabled?(:graphql_pipeline_details, @project) +- if Feature.enabled?(:graphql_pipeline_details, @project, default_enabled: :yaml) || Feature.enabled?(:graphql_pipeline_details_users, @current_user, default_enabled: :yaml) - add_page_startup_graphql_call('pipelines/get_pipeline_details', { projectPath: @project.full_path, iid: @pipeline.iid }) .js-pipeline-container{ data: { controller_action: "#{controller.action_name}" } } diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index cf39ac4dd56..b3c209d564b 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -1,10 +1,11 @@ - page_title _("Members") - group = @project.group +- vue_project_members_list_enabled = Feature.enabled?(:vue_project_members_list, @project) .js-remove-member-modal .row.gl-mt-3 .col-lg-12 - - if invite_members_allowed?(group) + - if can_invite_members_for_project?(@project) .row .col-md-12.col-lg-6.gl-display-flex .gl-flex-direction-column.gl-flex-wrap.align-items-baseline @@ -19,7 +20,7 @@ .col-md-12.col-lg-6 .gl-display-flex.gl-flex-wrap.gl-lg-justify-content-end.gl-mx-n2.gl-mb-3 .js-invite-members-trigger.gl-px-2.gl-sm-w-auto.gl-w-full.gl-mb-4{ data: { classes: 'btn btn-success gl-button gl-mt-3 gl-sm-w-auto gl-w-full', display_text: _('Invite members') } } - = render_if_exists 'projects/invite_members_modal', project: @project + = render 'projects/invite_members_modal', project: @project - else - if project_can_be_shared? @@ -31,7 +32,7 @@ %p = html_escape(_("Members can be added by project %{i_open}Maintainers%{i_close} or %{i_open}Owners%{i_close}")) % { i_open: '<i>'.html_safe, i_close: '</i>'.html_safe } - - if !invite_members_allowed?(group) && can_manage_project_members?(@project) && project_can_be_shared? + - if !can_invite_members_for_project?(@project) && can_manage_project_members?(@project) && project_can_be_shared? - if !membership_locked? && @project.allowed_to_share_with_group? %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' } %li.nav-tab{ role: 'presentation' } @@ -74,24 +75,44 @@ %span.badge.badge-pill= @requesters.count .tab-content #tab-members.tab-pane{ class: ('active' unless groups_tab_active?) } - = render 'projects/project_members/team', project: @project, group: group, members: @project_members, current_user_is_group_owner: current_user_is_group_owner?(@project) + - if vue_project_members_list_enabled + .js-project-members-list{ data: project_members_list_data_attributes(@project, @project_members) } + .loading + .spinner.spinner-md + - else + = render 'projects/project_members/team', project: @project, group: group, members: @project_members, current_user_is_group_owner: current_user_is_group_owner?(@project) = paginate @project_members, theme: "gitlab", params: { search_groups: nil } - if show_groups?(@group_links) #tab-groups.tab-pane{ class: ('active' if groups_tab_active?) } - = render 'projects/project_members/groups', group_links: @group_links + - if vue_project_members_list_enabled + .js-project-group-links-list{ data: project_group_links_list_data_attributes(@project, @group_links) } + .loading + .spinner.spinner-md + - else + = render 'projects/project_members/groups', group_links: @group_links - if show_invited_members?(@project, @invited_members) #tab-invited-members.tab-pane - .card.card-without-border - = render 'shared/members/tab_pane/header' do - = render 'shared/members/tab_pane/title' do - = html_escape(_('Members invited to %{strong_start}%{project_name}%{strong_end}')) % { project_name: @project.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } - %ul.content-list.members-list - = render partial: 'shared/members/member', collection: @invited_members, as: :member, locals: { membership_source: @project, group: group, current_user_is_group_owner: current_user_is_group_owner?(@project) } + - if vue_project_members_list_enabled + .js-project-invited-members-list{ data: project_members_list_data_attributes(@project, @invited_members) } + .loading + .spinner.spinner-md + - else + .card.card-without-border + = render 'shared/members/tab_pane/header' do + = render 'shared/members/tab_pane/title' do + = html_escape(_('Members invited to %{strong_start}%{project_name}%{strong_end}')) % { project_name: @project.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } + %ul.content-list.members-list + = render partial: 'shared/members/member', collection: @invited_members, as: :member, locals: { membership_source: @project, group: group, current_user_is_group_owner: current_user_is_group_owner?(@project) } - if show_access_requests?(@project, @requesters) #tab-access-requests.tab-pane - .card.card-without-border - = render 'shared/members/tab_pane/header' do - = render 'shared/members/tab_pane/title' do - = html_escape(_('Users requesting access to %{strong_start}%{project_name}%{strong_end}')) % { project_name: @project.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } - %ul.content-list.members-list - = render partial: 'shared/members/member', collection: @requesters, as: :member, locals: { membership_source: @project, group: group } + - if vue_project_members_list_enabled + .js-project-access-requests-list{ data: project_members_list_data_attributes(@project, @requesters) } + .loading + .spinner.spinner-md + - else + .card.card-without-border + = render 'shared/members/tab_pane/header' do + = render 'shared/members/tab_pane/title' do + = html_escape(_('Users requesting access to %{strong_start}%{project_name}%{strong_end}')) % { project_name: @project.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } + %ul.content-list.members-list + = render partial: 'shared/members/member', collection: @requesters, as: :member, locals: { membership_source: @project, group: group } diff --git a/app/views/projects/protected_branches/shared/_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_protected_branch.html.haml index d62e9513d56..02ec778b97c 100644 --- a/app/views/projects/protected_branches/shared/_protected_branch.html.haml +++ b/app/views/projects/protected_branches/shared/_protected_branch.html.haml @@ -5,7 +5,7 @@ %span.ref-name= protected_branch.name - if @project.root_ref?(protected_branch.name) - %span.badge.badge-info.d-inline default + %span.badge.gl-badge.badge-pill.badge-info.d-inline default %div - if protected_branch.wildcard? @@ -20,4 +20,4 @@ - if can_admin_project %td - = link_to 'Unprotect', [@project, protected_branch, { update_section: 'js-protected-branches-settings' }], disabled: local_assigns[:disabled], data: { confirm: 'Branch will be writable for developers. Are you sure?' }, method: :delete, class: "btn btn-warning" + = link_to 'Unprotect', [@project, protected_branch, { update_section: 'js-protected-branches-settings' }], disabled: local_assigns[:disabled], data: { confirm: 'Branch will be writable for developers. Are you sure?' }, method: :delete, class: "btn gl-button btn-warning" diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml index 97bc366544f..93e94928110 100644 --- a/app/views/projects/registry/repositories/index.html.haml +++ b/app/views/projects/registry/repositories/index.html.haml @@ -19,4 +19,7 @@ "project_path": @project.full_path, "gid_prefix": container_repository_gid_prefix, "is_admin": current_user&.admin.to_s, - character_error: @character_error.to_s } } + character_error: @character_error.to_s, + user_callouts_path: user_callouts_path, + user_callout_id: UserCalloutsHelper::UNFINISHED_TAG_CLEANUP_CALLOUT, + show_unfinished_tag_cleanup_callout: show_unfinished_tag_cleanup_callout?.to_s, } } diff --git a/app/views/projects/runners/_group_runners.html.haml b/app/views/projects/runners/_group_runners.html.haml index 9415516d6f6..6e46423cde0 100644 --- a/app/views/projects/runners/_group_runners.html.haml +++ b/app/views/projects/runners/_group_runners.html.haml @@ -13,10 +13,10 @@ %br %br - if @project.group_runners_enabled? - = link_to toggle_group_runners_project_runners_path(@project), class: 'btn btn-close', method: :post do + = link_to toggle_group_runners_project_runners_path(@project), class: 'btn gl-button btn-warning-secondary', method: :post do = _('Disable group runners') - else - = link_to toggle_group_runners_project_runners_path(@project), class: 'btn btn-success btn-inverted', method: :post do + = link_to toggle_group_runners_project_runners_path(@project), class: 'btn gl-button btn-success btn-inverted', method: :post do = _('Enable group runners') = _('for this project') diff --git a/app/views/projects/runners/_runner.html.haml b/app/views/projects/runners/_runner.html.haml index 85bd0335b92..41159df1435 100644 --- a/app/views/projects/runners/_runner.html.haml +++ b/app/views/projects/runners/_runner.html.haml @@ -10,8 +10,8 @@ = sprite_icon('lock') %small.edit-runner - = link_to edit_project_runner_path(@project, runner), class: 'btn btn-edit' do - = sprite_icon('pencil') + = link_to edit_project_runner_path(@project, runner), class: 'btn gl-button btn-edit' do + = sprite_icon('pencil', css_class: 'gl-my-2') - else %span.commit-sha = runner.short_sha @@ -19,18 +19,18 @@ .float-right - if @project_runners.include?(runner) - if runner.active? - = link_to _('Pause'), pause_project_runner_path(@project, runner), method: :post, class: 'btn btn-sm btn-danger', data: { confirm: _("Are you sure?") } + = link_to _('Pause'), pause_project_runner_path(@project, runner), method: :post, class: 'btn gl-button btn-sm btn-danger', data: { confirm: _("Are you sure?") } - else - = link_to _('Resume'), resume_project_runner_path(@project, runner), method: :post, class: 'btn btn-success btn-sm' + = link_to _('Resume'), resume_project_runner_path(@project, runner), method: :post, class: 'btn gl-button btn-success btn-sm' - if runner.belongs_to_one_project? - = link_to _('Remove runner'), project_runner_path(@project, runner), data: { confirm: _("Are you sure?") }, method: :delete, class: 'btn btn-danger btn-sm' + = link_to _('Remove runner'), project_runner_path(@project, runner), data: { confirm: _("Are you sure?") }, method: :delete, class: 'btn gl-button btn-danger btn-sm' - else - runner_project = @project.runner_projects.find_by(runner_id: runner) # rubocop: disable CodeReuse/ActiveRecord - = link_to _('Disable for this project'), project_runner_project_path(@project, runner_project), data: { confirm: _("Are you sure?") }, method: :delete, class: 'btn btn-danger btn-sm' + = link_to _('Disable for this project'), project_runner_project_path(@project, runner_project), data: { confirm: _("Are you sure?") }, method: :delete, class: 'btn gl-button btn-danger btn-sm' - elsif runner.project_type? = form_for [@project, @project.runner_projects.new] do |f| = f.hidden_field :runner_id, value: runner.id - = f.submit _('Enable for this project'), class: 'btn btn-sm' + = f.submit _('Enable for this project'), class: 'btn gl-button btn-sm' .float-right %small.light \##{runner.id} diff --git a/app/views/projects/runners/_shared_runners.html.haml b/app/views/projects/runners/_shared_runners.html.haml index fd8b4eb0d39..484d8f8a40c 100644 --- a/app/views/projects/runners/_shared_runners.html.haml +++ b/app/views/projects/runners/_shared_runners.html.haml @@ -9,10 +9,10 @@ = _('Shared runners disabled on group level') - else - if @project.shared_runners_enabled? - = link_to toggle_shared_runners_project_runners_path(@project), class: 'btn btn-close', method: :post do + = link_to toggle_shared_runners_project_runners_path(@project), class: 'btn gl-button btn-warning-secondary', method: :post do = _('Disable shared runners') - else - = link_to toggle_shared_runners_project_runners_path(@project), class: 'btn btn-success', method: :post do + = link_to toggle_shared_runners_project_runners_path(@project), class: 'btn gl-button btn-success', method: :post do = _('Enable shared runners') for this project diff --git a/app/views/projects/runners/_specific_runners.html.haml b/app/views/projects/runners/_specific_runners.html.haml index 3e325b80efd..88895634990 100644 --- a/app/views/projects/runners/_specific_runners.html.haml +++ b/app/views/projects/runners/_specific_runners.html.haml @@ -9,9 +9,11 @@ clusters_path: project_clusters_path(@project) } %hr = render partial: 'ci/runner/how_to_setup_runner', - locals: { registration_token: @project.runners_token, - type: 'specific', - reset_token_url: reset_registration_token_namespace_project_settings_ci_cd_path } + locals: { registration_token: @project.runners_token, + type: 'specific', + reset_token_url: reset_registration_token_namespace_project_settings_ci_cd_path, + project_path: @project.path_with_namespace, + group_path: '' } %hr diff --git a/app/views/projects/security/configuration/show.html.haml b/app/views/projects/security/configuration/show.html.haml new file mode 100644 index 00000000000..1a371955be8 --- /dev/null +++ b/app/views/projects/security/configuration/show.html.haml @@ -0,0 +1,4 @@ +- breadcrumb_title _("Security Configuration") +- page_title _("Security Configuration") + +#js-security-configuration-static diff --git a/app/views/projects/services/_form.html.haml b/app/views/projects/services/_form.html.haml index 59b3afa476f..3c99b4c5e68 100644 --- a/app/views/projects/services/_form.html.haml +++ b/app/views/projects/services/_form.html.haml @@ -1,19 +1,14 @@ - if lookup_context.template_exists?('top', "projects/services/#{@service.to_param}", true) = render "projects/services/#{@service.to_param}/top" -.row.gl-mt-3.gl-mb-3 - .col-lg-4 - %h3.page-title.gl-mt-0 - = @service.title - - if @service.operating? - = sprite_icon('check', css_class: 'gl-text-green-500') +%h3.page-title + = @service.title + - if @service.operating? + = sprite_icon('check', css_class: 'gl-text-green-500') - - if @service.respond_to?(:detailed_description) - %p= @service.detailed_description - .col-lg-8 - = form_for(@service, as: :service, url: scoped_integration_path(@service), method: :put, html: { class: 'gl-show-field-errors integration-settings-form js-integration-settings-form', data: { 'test-url' => test_project_service_path(@project, @service) } }) do |form| - = render 'shared/service_settings', form: form, integration: @service - %input{ id: 'services_redirect_to', type: 'hidden', name: 'redirect_to', value: request.referrer } += form_for(@service, as: :service, url: scoped_integration_path(@service), method: :put, html: { class: 'gl-show-field-errors integration-settings-form js-integration-settings-form', data: { 'test-url' => test_project_service_path(@project, @service) } }) do |form| + = render 'shared/service_settings', form: form, integration: @service + %input{ id: 'services_redirect_to', type: 'hidden', name: 'redirect_to', value: request.referrer } - if lookup_context.template_exists?('show', "projects/services/#{@service.to_param}", true) %hr diff --git a/app/views/projects/services/prometheus/_top.html.haml b/app/views/projects/services/prometheus/_top.html.haml index 0238a45b75f..db02ea85865 100644 --- a/app/views/projects/services/prometheus/_top.html.haml +++ b/app/views/projects/services/prometheus/_top.html.haml @@ -7,4 +7,4 @@ .gl-alert-body = s_('AlertSettings|You can now set up alert endpoints for manually configured Prometheus instances in the Alerts section on the Operations settings page. Alert endpoint fields on this page have been deprecated.') .gl-alert-actions - = link_to _('Visit settings page'), project_settings_operations_path(@project, anchor: 'js-alert-management-settings'), class: 'btn gl-alert-action btn-info gl-button' + = link_to _('Visit settings page'), project_settings_operations_path(@project, anchor: 'js-alert-management-settings'), class: 'gl-button btn gl-alert-action btn-info' diff --git a/app/views/projects/settings/_archive.html.haml b/app/views/projects/settings/_archive.html.haml index 4133129fde2..4300ebb4852 100644 --- a/app/views/projects/settings/_archive.html.haml +++ b/app/views/projects/settings/_archive.html.haml @@ -7,12 +7,14 @@ - else = _('Archive project') - if @project.archived? - %p= _("Unarchiving the project will restore people's ability to make changes to it. The repository can be committed to, and issues, comments, and other entities can be created. %{strong_start}Once active, this project shows up in the search and on the dashboard.%{strong_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } + - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'unarchiving-a-project') } + %p= _("Unarchiving the project will restore its members' ability to make changes to it. The repository can be committed to, and issues, comments, and other entities can be created. %{strong_start}Once active, this project shows up in the search and on the dashboard.%{strong_end} %{link_start}Learn more.%{link_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, link_start: link_start, link_end: '</a>'.html_safe } = link_to _('Unarchive project'), unarchive_project_path(@project), data: { confirm: _("Are you sure that you want to unarchive this project?"), qa_selector: 'unarchive_project_link' }, - method: :post, class: "btn btn-success" + method: :post, class: "gl-button btn btn-success" - else - %p= _("Archiving the project will make it entirely read only. It is hidden from the dashboard and doesn't show up in searches. %{strong_start}The repository cannot be committed to, and no issues, comments, or other entities can be created.%{strong_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } + - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'archiving-a-project') } + %p= _("Archiving the project will make it entirely read only. It is hidden from the dashboard and doesn't show up in searches. %{strong_start}The repository cannot be committed to, and no issues, comments, or other entities can be created.%{strong_end} %{link_start}Learn more.%{link_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, link_start: link_start, link_end: '</a>'.html_safe } = link_to _('Archive project'), archive_project_path(@project), data: { confirm: _("Are you sure that you want to archive this project?"), qa_selector: 'archive_project_link' }, - method: :post, class: "btn btn-warning" + method: :post, class: "gl-button btn btn-warning" diff --git a/app/views/projects/settings/_general.html.haml b/app/views/projects/settings/_general.html.haml index 5d5f1d54439..3b03e213983 100644 --- a/app/views/projects/settings/_general.html.haml +++ b/app/views/projects/settings/_general.html.haml @@ -7,17 +7,17 @@ .form-group.col-md-5 = f.label :name, class: 'label-bold', for: 'project_name_edit' do = _('Project name') - = f.text_field :name, class: 'form-control qa-project-name-field', id: "project_name_edit" + = f.text_field :name, class: 'form-control gl-form-input qa-project-name-field', id: "project_name_edit" .form-group.col-md-7 = f.label :id, class: 'label-bold' do = _('Project ID') - = f.text_field :id, class: 'form-control w-auto', readonly: true + = f.text_field :id, class: 'form-control gl-form-input w-auto', readonly: true .row .form-group.col-md-9 = f.label :tag_list, _('Topics (optional)'), class: 'label-bold' - = f.text_field :tag_list, value: @project.tag_list.join(', '), maxlength: 2000, class: "form-control" + = f.text_field :tag_list, value: @project.tag_list.join(', '), maxlength: 2000, class: "form-control gl-form-input" %p.form-text.text-muted= _('Separate topics with commas.') = render_if_exists 'compliance_management/compliance_framework/project_settings', f: f @@ -25,14 +25,14 @@ .row .form-group.col-md-9 = f.label :description, _('Project description (optional)'), class: 'label-bold' - = f.text_area :description, class: 'form-control', rows: 3, maxlength: 250 + = f.text_area :description, class: 'form-control gl-form-input', rows: 3, maxlength: 250 .row= render_if_exists 'projects/classification_policy_settings', f: f = render_if_exists 'shared/repository_size_limit_setting', form: f, type: :project .form-group.gl-mt-3.gl-mb-3 - .avatar-container.s90 + .avatar-container.rect-avatar.s90 = project_icon(@project, alt: _('Project avatar'), class: 'avatar project-avatar s90') = f.label :avatar, _('Project avatar'), class: 'label-bold d-block' = render 'shared/choose_avatar_button', f: f @@ -40,4 +40,4 @@ %hr = link_to _('Remove avatar'), project_avatar_path(@project), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'btn btn-link' - = f.submit _('Save changes'), class: "btn btn-success mt-4 qa-save-naming-topics-avatar-button" + = f.submit _('Save changes'), class: "gl-button btn btn-success gl-mt-6 qa-save-naming-topics-avatar-button" diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml index d247e73a5b4..e0c4a3d624e 100644 --- a/app/views/projects/settings/ci_cd/_form.html.haml +++ b/app/views/projects/settings/ci_cd/_form.html.haml @@ -4,10 +4,46 @@ = form_errors(@project) %fieldset.builds-feature .form-group + .form-check + = f.check_box :public_builds, { class: 'form-check-input' } + = f.label :public_builds, class: 'form-check-label' do + %strong= _("Public pipelines") + .form-text.text-muted + = _("Allow public access to pipelines and job details, including output logs and artifacts.") + = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'visibility-of-pipelines'), target: '_blank' + + .form-group + .form-check + = f.check_box :auto_cancel_pending_pipelines, { class: 'form-check-input' }, 'enabled', 'disabled' + = f.label :auto_cancel_pending_pipelines, class: 'form-check-label' do + %strong= _("Auto-cancel redundant pipelines") + .form-text.text-muted + = _("New pipelines cause older pending pipelines on the same branch to be cancelled.") + = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'auto-cancel-redundant-pipelines'), target: '_blank' + + .form-group + .form-check + = f.fields_for :ci_cd_settings_attributes, @project.ci_cd_settings do |form| + = form.check_box :forward_deployment_enabled, { class: 'form-check-input' } + = form.label :forward_deployment_enabled, class: 'form-check-label' do + %strong= _("Skip outdated deployment jobs") + .form-text.text-muted + = _("When a deployment job is successful, skip older deployment jobs that are still pending.") + = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'skip-outdated-deployment-jobs'), target: '_blank' + + .form-group + = f.label :ci_config_path, _('CI/CD configuration file'), class: 'label-bold' + = f.text_field :ci_config_path, class: 'form-control', placeholder: '.gitlab-ci.yml' + %p.form-text.text-muted + = html_escape(_("The name of the CI/CD configuration file. A path relative to the root directory is optional (for example %{code_open}my/path/.myfile.yml%{code_close}).")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } + = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'custom-cicd-configuration-path'), target: '_blank' + + %hr + .form-group %h5.gl-mt-0 - = _("Git strategy for pipelines") + = _("Git strategy") %p - = html_escape(_("Choose between %{code_open}clone%{code_close} or %{code_open}fetch%{code_close} to get the recent application code")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } + = _("Choose which Git strategy to use when fetching the project.") = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'git-strategy'), target: '_blank' .form-check = f.radio_button :build_allow_git_fetch, 'false', { class: 'form-check-input' } @@ -15,137 +51,50 @@ %strong git clone %br %span - = _("Slower but makes sure the project workspace is pristine as it clones the repository from scratch for every job") + = _("For each job, clone the repository.") .form-check = f.radio_button :build_allow_git_fetch, 'true', { class: 'form-check-input' } = f.label :build_allow_git_fetch_true, class: 'form-check-label' do %strong git fetch %br %span - = _("Faster as it re-uses the project workspace (falling back to clone if it doesn't exist)") + = html_escape(_("For each job, re-use the project workspace. If the workspace doesn't exist, use %{code_open}git clone%{code_close}.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - %hr .form-group = f.fields_for :ci_cd_settings_attributes, @project.ci_cd_settings do |form| = form.label :default_git_depth, _('Git shallow clone'), class: 'label-bold' - = form.number_field :default_git_depth, { class: 'form-control', min: 0, max: 1000 } + = form.number_field :default_git_depth, { class: 'form-control gl-form-input', min: 0, max: 1000 } %p.form-text.text-muted - = _('The number of changes to be fetched from GitLab when cloning a repository. This can speed up Pipelines execution. Keep empty or set to 0 to disable shallow clone by default and make GitLab CI fetch all branches and tags each time.') + = html_escape(_('The number of changes to fetch from GitLab when cloning a repository. Lower values can speed up pipeline execution. Set to %{code_open}0%{code_close} or blank to fetch all branches and tags for each job')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } + = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'git-shallow-clone'), target: '_blank' %hr .form-group = f.label :build_timeout_human_readable, _('Timeout'), class: 'label-bold' - = f.text_field :build_timeout_human_readable, class: 'form-control' + = f.text_field :build_timeout_human_readable, class: 'form-control gl-form-input' %p.form-text.text-muted - = _('If any job surpasses this timeout threshold, it will be marked as failed. Human readable time input language is accepted like "1 hour". Values without specification represent seconds.') + = html_escape(_('Jobs fail if they run longer than the timeout time. Input value is in seconds by default. Human readable input is also accepted, for example %{code_open}1 hour%{code_close}.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'timeout'), target: '_blank' - if can?(current_user, :update_max_artifacts_size, @project) - %hr .form-group - = f.label :max_artifacts_size, _('Maximum artifacts size (MB)'), class: 'label-bold' - = f.number_field :max_artifacts_size, class: 'form-control' + = f.label :max_artifacts_size, _('Maximum artifacts size'), class: 'label-bold' + = f.number_field :max_artifacts_size, class: 'form-control gl-form-input' %p.form-text.text-muted - = _("Set the maximum file size for each job's artifacts") + = _("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' - %hr - .form-group - = f.label :ci_config_path, _('Custom CI configuration path'), class: 'label-bold' - = f.text_field :ci_config_path, class: 'form-control', placeholder: '.gitlab-ci.yml' - %p.form-text.text-muted - = html_escape(_("The path to the CI configuration file. Defaults to %{code_open}.gitlab-ci.yml%{code_close}")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'custom-ci-configuration-path'), target: '_blank' - - %hr - .form-group - .form-check - = f.check_box :public_builds, { class: 'form-check-input' } - = f.label :public_builds, class: 'form-check-label' do - %strong= _("Public pipelines") - .form-text.text-muted - = _("Allow public access to pipelines and job details, including output logs and artifacts") - = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'visibility-of-pipelines'), target: '_blank' - .bs-callout.bs-callout-info - %p #{_("If enabled")}: - %ul - %li - = _("For public projects, anyone can view pipelines and access job details (output logs and artifacts)") - %li - = _("For internal projects, any logged in user except external users can view pipelines and access job details (output logs and artifacts)") - %li - = _("For private projects, any member (guest or higher) can view pipelines and access job details (output logs and artifacts)") - %p - = _("If disabled, the access level will depend on the user's permissions in the project.") - - %hr - .form-group - .form-check - = f.check_box :auto_cancel_pending_pipelines, { class: 'form-check-input' }, 'enabled', 'disabled' - = f.label :auto_cancel_pending_pipelines, class: 'form-check-label' do - %strong= _("Auto-cancel redundant, pending pipelines") - .form-text.text-muted - = _("New pipelines will cancel older, pending pipelines on the same branch") - = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'auto-cancel-pending-pipelines'), target: '_blank' - - .form-group - .form-check - = f.fields_for :ci_cd_settings_attributes, @project.ci_cd_settings do |form| - = form.check_box :forward_deployment_enabled, { class: 'form-check-input' } - = form.label :forward_deployment_enabled, class: 'form-check-label' do - %strong= _("Skip outdated deployment jobs") - .form-text.text-muted - = _("When a deployment job is successful, skip older deployment jobs that are still pending") - = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'skip-outdated-deployment-jobs'), target: '_blank' - - %hr .form-group = f.label :build_coverage_regex, _("Test coverage parsing"), class: 'label-bold' .input-group %span.input-group-prepend .input-group-text / - = f.text_field :build_coverage_regex, class: 'form-control', placeholder: 'Regular expression', data: { qa_selector: 'build_coverage_regex_field' } + = f.text_field :build_coverage_regex, class: 'form-control gl-form-input', placeholder: 'Regular expression', data: { qa_selector: 'build_coverage_regex_field' } %span.input-group-append .input-group-text / %p.form-text.text-muted - = _("A regular expression that will be used to find the test coverage output in the job log. Leave blank to disable") + = html_escape(_('The regular expression used to find test coverage output in the job log. For example, use %{regex} for Simplecov (Ruby). Leave blank to disable.')) % { regex: '<code>\(\d+.\d+%\)</code>'.html_safe } = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'test-coverage-parsing'), target: '_blank' - .bs-callout.bs-callout-info - %p= _("Below are examples of regex for existing tools:") - %ul - %li - Simplecov (Ruby) - - %code \(\d+.\d+\%\) covered - %li - pytest-cov (Python) - - %code ^TOTAL.+?(\d+\%)$ - %li - Scoverage (Scala) - - %code Statement coverage[A-Za-z\.*]\s*:\s*([^%]+) - %li - phpunit --coverage-text --colors=never (PHP) - - %code ^\s*Lines:\s*\d+.\d+\% - %li - gcovr (C/C++) - - %code ^TOTAL.*\s+(\d+\%)$ - %li - tap --coverage-report=text-summary (NodeJS) - - %code ^Statements\s*:\s*([^%]+) - %li - nyc npm test (NodeJS) - - %code All files[^|]*\|[^|]*\s+([\d\.]+) - %li - excoveralls (Elixir) - - %code \[TOTAL\]\s+(\d+\.\d+)% - %li - mix test --cover (Elixir) - - %code \d+.\d+\%\s+\|\s+Total - %li - JaCoCo (Java/Kotlin) - %code Total.*?([0-9]{1,3})% - %li - go test -cover (Go) - %code coverage: \d+.\d+% of statements = f.submit _('Save changes'), class: "btn btn-success", data: { qa_selector: 'save_general_pipelines_changes_button' } diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index 55b6cf372fb..baf80c0a103 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -12,7 +12,7 @@ %button.btn.js-settings-toggle{ type: 'button' } = expanded ? _('Collapse') : _('Expand') %p - = _("Customize your pipeline configuration, view your pipeline status and coverage report.") + = _("Customize your pipeline configuration and coverage report.") .settings-content = render 'form' @@ -41,7 +41,7 @@ = expanded ? _('Collapse') : _('Expand') %p = _("Runners are processes that pick up and execute CI/CD jobs for GitLab.") - = link_to s_('How do I configure runners?'), help_page_path('ci/runners/README') + = link_to s_('How do I configure runners?'), help_page_path('ci/runners/README'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'projects/runners/index' @@ -69,7 +69,8 @@ %button.btn.js-settings-toggle{ type: 'button' } = expanded ? _('Collapse') : _('Expand') %p - = _("Triggers can force a specific branch or tag to get rebuilt with an API call. These tokens will impersonate their associated user including their access to projects and their project permissions.") + = _("Trigger a pipeline for a branch or tag by generating a trigger token and using it with an API call. The token impersonates a user's project access and permissions.") + = link_to _('Learn more.'), help_page_path('ci/triggers/README'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'projects/triggers/index' @@ -82,7 +83,7 @@ = expanded ? _('Collapse') : _('Expand') %p = _("Save space and find images in the Container Registry. Remove unneeded tags and keep only the ones you want.") - = link_to _('How does cleanup work?'), help_page_path('user/packages/container_registry/index', anchor: 'cleanup-policy', target: '_blank', rel: 'noopener noreferrer') + = link_to _('How does cleanup work?'), help_page_path('user/packages/container_registry/index', anchor: 'cleanup-policy'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'projects/registry/settings/index' @@ -98,11 +99,11 @@ %p - freeze_period_docs = help_page_path('user/project/releases/index', anchor: 'prevent-unintentional-releases-by-setting-a-deploy-freeze') - freeze_period_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: freeze_period_docs } - = html_escape(s_('DeployFreeze|Specify times when deployments are not allowed for an environment. The %{filename} file must be updated to make deployment jobs aware of the %{freeze_period_link_start}freeze period%{freeze_period_link_end}.')) % { freeze_period_link_start: freeze_period_link_start, freeze_period_link_end: '</a>'.html_safe, filename: tag.code('gitlab-ci.yml') } + = html_escape(s_('DeployFreeze|Add a freeze period to prevent unintended releases during a period of time for a given environment. You must update the deployment jobs in %{filename} according to the deploy freezes added here. %{freeze_period_link_start}Learn more.%{freeze_period_link_end}')) % { freeze_period_link_start: freeze_period_link_start, freeze_period_link_end: '</a>'.html_safe, filename: tag.code('.gitlab-ci.yml') } - cron_syntax_url = 'https://crontab.guru/' - cron_syntax_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: cron_syntax_url } - = s_('DeployFreeze|You can specify deploy freezes using only %{cron_syntax_link_start}cron syntax%{cron_syntax_link_end}.').html_safe % { cron_syntax_link_start: cron_syntax_link_start, cron_syntax_link_end: "</a>".html_safe } + = s_('DeployFreeze|Specify deploy freezes using %{cron_syntax_link_start}cron syntax%{cron_syntax_link_end}.').html_safe % { cron_syntax_link_start: cron_syntax_link_start, cron_syntax_link_end: "</a>".html_safe } .settings-content = render 'ci/deploy_freeze/index' diff --git a/app/views/projects/settings/integrations/show.html.haml b/app/views/projects/settings/integrations/show.html.haml index 18c6cb31874..3f5fd765b11 100644 --- a/app/views/projects/settings/integrations/show.html.haml +++ b/app/views/projects/settings/integrations/show.html.haml @@ -10,7 +10,7 @@ .gl-alert-body = _('Webhooks have moved. They can now be found under the Settings menu.') .gl-alert-actions - = link_to _('Go to Webhooks'), project_hooks_path(@project), class: 'btn gl-alert-action btn-info new-gl-button' + = link_to _('Go to Webhooks'), project_hooks_path(@project), class: 'gl-button btn gl-alert-action btn-info' %h4= _('Integrations') - integrations_link_start = '<a href="%{url}">'.html_safe % { url: help_page_url('user/project/integrations/overview') } diff --git a/app/views/projects/settings/operations/_alert_management.html.haml b/app/views/projects/settings/operations/_alert_management.html.haml index 46f45df00df..079812f7077 100644 --- a/app/views/projects/settings/operations/_alert_management.html.haml +++ b/app/views/projects/settings/operations/_alert_management.html.haml @@ -5,7 +5,7 @@ %section.settings.no-animate#js-alert-management-settings{ class: ('expanded' if expanded) } .settings-header - %h3{ :class => "h4" } + %h4 = _('Alerts') %button.btn.js-settings-toggle{ type: 'button' } = _('Expand') diff --git a/app/views/projects/settings/operations/_error_tracking.html.haml b/app/views/projects/settings/operations/_error_tracking.html.haml index 6ab8beff99f..fe302978da6 100644 --- a/app/views/projects/settings/operations/_error_tracking.html.haml +++ b/app/views/projects/settings/operations/_error_tracking.html.haml @@ -4,7 +4,7 @@ %section.settings.no-animate.js-error-tracking-settings .settings-header - %h3{ :class => "h4" } + %h4 = _('Error tracking') %button.btn.js-settings-toggle{ type: 'button' } = _('Expand') diff --git a/app/views/projects/settings/operations/_tracing.html.haml b/app/views/projects/settings/operations/_tracing.html.haml index f654c723e36..03970dfe0b9 100644 --- a/app/views/projects/settings/operations/_tracing.html.haml +++ b/app/views/projects/settings/operations/_tracing.html.haml @@ -3,7 +3,7 @@ %section.settings.border-0.no-animate .settings-header{ :class => "border-top" } - %h3{ :class => "h4" } + %h4 = _("Jaeger tracing") %button.btn.gl-button.js-settings-toggle{ type: 'button' } = _('Expand') @@ -24,7 +24,7 @@ .form-group = f.fields_for :tracing_setting_attributes, setting do |form| = form.label :external_url, _('Jaeger URL'), class: 'label-bold' - = form.url_field :external_url, class: 'form-control', placeholder: 'e.g. https://jaeger.mycompany.com' + = form.url_field :external_url, class: 'form-control gl-form-input', placeholder: 'e.g. https://jaeger.mycompany.com' %p.form-text.text-muted - jaeger_help_url = "https://www.jaegertracing.io/docs/1.7/getting-started/" - link_start_tag = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: jaeger_help_url } diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml index 24fc137fd29..8ac22e1c325 100644 --- a/app/views/projects/settings/repository/show.html.haml +++ b/app/views/projects/settings/repository/show.html.haml @@ -1,7 +1,7 @@ - breadcrumb_title _("Repository Settings") - page_title _("Repository") - @content_class = "limit-container-width" unless fluid_layout -- deploy_token_description = s_('DeployTokens|Deploy tokens allow access to packages, your repository, and registry images.') +- deploy_token_description = s_('DeployTokens|Deploy Tokens allow access to packages, your repository, and registry images.') = render "projects/default_branch/show" = render_if_exists "projects/push_rules/index" diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index 40faf91eadf..e1774c955bc 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -6,6 +6,9 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, project_path(@project, rss_url_options), title: "#{@project.name} activity") += content_for :invite_members_sidebar do + = render partial: 'projects/invite_members_link' + = render partial: 'flash_messages', locals: { project: @project } = render "projects/last_push" diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml index 9d4e5d629f4..61b357831fd 100644 --- a/app/views/projects/tags/_tag.html.haml +++ b/app/views/projects/tags/_tag.html.haml @@ -41,6 +41,6 @@ = render 'projects/buttons/download', project: @project, ref: tag.name, pipeline: @tags_pipelines[tag.name] - if can?(current_user, :admin_tag, @project) - = link_to edit_project_tag_release_path(@project, tag.name), class: 'btn btn-edit has-tooltip', title: s_('TagsPage|Edit release notes'), data: { container: "body" } do + = link_to edit_project_tag_release_path(@project, tag.name), class: 'btn gl-button btn-default btn-icon btn-edit has-tooltip', title: s_('TagsPage|Edit release notes'), data: { container: "body" } do = sprite_icon("pencil") = render 'projects/buttons/remove_tag', project: @project, tag: tag diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml index 2fe5c5888f5..04d8c1f42bc 100644 --- a/app/views/projects/tags/index.html.haml +++ b/app/views/projects/tags/index.html.haml @@ -24,9 +24,9 @@ %li = link_to title, filter_tags_path(sort: value), class: ("is-active" if @sort == value) - if can?(current_user, :admin_tag, @project) - = link_to new_project_tag_path(@project), class: 'btn btn-success new-tag-btn', data: { qa_selector: "new_tag_button" } do + = link_to new_project_tag_path(@project), class: 'btn gl-button btn-success', data: { qa_selector: "new_tag_button" } do = s_('TagsPage|New tag') - = link_to project_tags_path(@project, rss_url_options), title: _("Tags feed"), class: 'btn btn-svg d-none d-sm-inline-block has-tooltip' do + = link_to project_tags_path(@project, rss_url_options), title: _("Tags feed"), class: 'btn gl-button btn-default btn-icon d-none d-sm-inline-block has-tooltip' do = sprite_icon('rss', css_class: 'qa-rss-icon') = render_if_exists 'projects/commits/mirror_status' diff --git a/app/views/projects/tags/releases/edit.html.haml b/app/views/projects/tags/releases/edit.html.haml index 896dbe454e6..d82c89a3f9f 100644 --- a/app/views/projects/tags/releases/edit.html.haml +++ b/app/views/projects/tags/releases/edit.html.haml @@ -15,5 +15,5 @@ = render 'shared/notes/hints' .error-alert .gl-mt-3 - = f.submit 'Save changes', class: 'btn btn-success' - = link_to "Cancel", project_tag_path(@project, @tag.name), class: "btn btn-default btn-cancel" + = f.submit 'Save changes', class: 'btn gl-button btn-success' + = link_to "Cancel", project_tag_path(@project, @tag.name), class: "btn gl-button btn-default btn-cancel" diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml index d726d2ab233..b3a75494ccc 100644 --- a/app/views/projects/tags/show.html.haml +++ b/app/views/projects/tags/show.html.haml @@ -42,13 +42,13 @@ - if @tag.has_signature? = render partial: 'projects/commit/signature', object: @tag.signature - if can?(current_user, :admin_tag, @project) - = link_to edit_project_tag_release_path(@project, @tag.name), class: 'btn btn-icon btn-edit gl-button controls-item has-tooltip', title: s_('TagsPage|Edit release notes') do + = link_to edit_project_tag_release_path(@project, @tag.name), class: 'btn btn-icon btn-edit gl-button btn-default controls-item has-tooltip', title: s_('TagsPage|Edit release notes') do = sprite_icon("pencil", css_class: 'gl-icon') - = link_to project_tree_path(@project, @tag.name), class: 'btn btn-icon gl-button controls-item has-tooltip', title: s_('TagsPage|Browse files') do + = link_to project_tree_path(@project, @tag.name), class: 'btn btn-icon gl-button btn-default controls-item has-tooltip', title: s_('TagsPage|Browse files') do = sprite_icon('folder-open', css_class: 'gl-icon') - = link_to project_commits_path(@project, @tag.name), class: 'btn btn-icon gl-button controls-item has-tooltip', title: s_('TagsPage|Browse commits') do + = link_to project_commits_path(@project, @tag.name), class: 'btn btn-icon gl-button btn-default controls-item has-tooltip', title: s_('TagsPage|Browse commits') do = sprite_icon('history', css_class: 'gl-icon') - .btn-container.controls-item + .controls-item = render 'projects/buttons/download', project: @project, ref: @tag.name - if can?(current_user, :admin_tag, @project) .btn-container.controls-item-full diff --git a/app/views/projects/tree/_readme.html.haml b/app/views/projects/tree/_readme.html.haml deleted file mode 100644 index 6d2bdda8254..00000000000 --- a/app/views/projects/tree/_readme.html.haml +++ /dev/null @@ -1,10 +0,0 @@ -- if readme.rich_viewer - %article.file-holder.readme-holder{ id: 'readme', class: [("limited-width-container" unless fluid_layout)] } - .js-file-title.file-title-flex-parent - .file-header-content - = blob_icon readme.mode, readme.name - = link_to project_blob_path(@project, tree_join(@ref, readme.path)) do - %strong - = readme.name - - = render 'projects/blob/viewer', viewer: readme.rich_viewer, viewer_url: project_blob_path(@project, tree_join(@ref, readme.path), viewer: :rich, format: :json) diff --git a/app/views/projects/tree/_tree_content.html.haml b/app/views/projects/tree/_tree_content.html.haml deleted file mode 100644 index a4427c6eedb..00000000000 --- a/app/views/projects/tree/_tree_content.html.haml +++ /dev/null @@ -1,24 +0,0 @@ -.tree-content-holder.js-tree-content{ data: tree_content_data(@logs_path, @project, @path) } - .table-holder.bordered-box - %table.table#tree-slider{ class: "table_#{@hex_path} tree-table" } - %thead - %tr - %th= s_('ProjectFileTree|Name') - %th.d-none.d-sm-table-cell - .float-left= _('Last commit') - %th.text-right= _('Last update') - - if @path.present? - %tr.tree-item - %td.tree-item-file-name - = link_to "..", project_tree_path(@project, up_dir_path), class: 'gl-ml-3' - %td - %td.d-none.d-sm-table-cell - - = render_tree(tree) - - - if tree.readme - = render "projects/tree/readme", readme: tree.readme - -- if can_edit_tree? - = render 'projects/blob/upload', title: _('Upload New File'), placeholder: _('Upload New File'), button_title: _('Upload file'), form_path: project_create_blob_path(@project, @id), method: :post - = render 'projects/blob/new_dir' diff --git a/app/views/projects/tree/_tree_row.html.haml b/app/views/projects/tree/_tree_row.html.haml deleted file mode 100644 index 04496914c02..00000000000 --- a/app/views/projects/tree/_tree_row.html.haml +++ /dev/null @@ -1,27 +0,0 @@ -- tree_row_name = tree_row.name -- tree_row_type = tree_row.type - -%tr{ class: "tree-item file_#{hexdigest(tree_row_name)}" } - %td.tree-item-file-name - - if tree_row_type == :tree - = tree_icon('folder', tree_row.mode, tree_row.name) - - path = flatten_tree(@path, tree_row) - %a.str-truncated{ href: fast_project_tree_path(@project, tree_join(@id || @commit.id, path)), title: path } - %span= path - - - elsif tree_row_type == :blob - = tree_icon('file', tree_row.mode, tree_row_name) - %a.str-truncated{ href: fast_project_blob_path(@project, tree_join(@id || @commit.id, tree_row_name)), title: tree_row_name } - %span= tree_row_name - - if @lfs_blob_ids.include?(tree_row.id) - %span.badge.label-lfs.gl-ml-2 LFS - - - elsif tree_row_type == :commit - = tree_icon('archive', tree_row.mode, tree_row.name) - = submodule_link(tree_row, @ref) - - %td.d-none.d-sm-table-cell.tree-commit - %td.tree-time-ago.text-right - %span.log_loading.hide - = loading_icon - Loading commit data... diff --git a/app/views/projects/tree/_truncated_notice_tree_row.html.haml b/app/views/projects/tree/_truncated_notice_tree_row.html.haml deleted file mode 100644 index a03e0a549ee..00000000000 --- a/app/views/projects/tree/_truncated_notice_tree_row.html.haml +++ /dev/null @@ -1,7 +0,0 @@ -%tr.tree-truncated-warning - %td{ colspan: '3' } - = sprite_icon('warning-solid') - %span - Too many items to show. To preserve performance only - %strong #{number_with_delimiter(limit)} of #{number_with_delimiter(total)} - items are displayed. diff --git a/app/views/projects/triggers/_form.html.haml b/app/views/projects/triggers/_form.html.haml index dec71cdb56a..1dbf8addb57 100644 --- a/app/views/projects/triggers/_form.html.haml +++ b/app/views/projects/triggers/_form.html.haml @@ -7,5 +7,5 @@ %p.form-control-plaintext= @trigger.token .form-group = f.label :key, "Description", class: "label-bold" - = f.text_field :description, class: "form-control", required: true, title: 'Trigger description is required.', placeholder: "Trigger description" + = f.text_field :description, class: 'form-control gl-form-input', required: true, title: 'Trigger description is required.', placeholder: "Trigger description" = f.submit btn_text, class: "btn btn-success" diff --git a/app/views/projects/triggers/_index.html.haml b/app/views/projects/triggers/_index.html.haml index 4f39c839630..c7cb051e40d 100644 --- a/app/views/projects/triggers/_index.html.haml +++ b/app/views/projects/triggers/_index.html.haml @@ -2,7 +2,7 @@ .col-lg-12 .card .card-header - Manage your project's triggers + = s_("Manage your project's triggers") .card-body = render "projects/triggers/form", btn_text: "Add trigger" %hr @@ -14,39 +14,33 @@ %table.table %thead %th - %strong Token + %strong + = s_("Token") %th - %strong Description + %strong + = s_("Description") %th - %strong Owner + %strong + = s_("Owner") %th - %strong Last used + %strong + = s_("Last used") %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 have been created yet. Add one using the form above. + = s_("No triggers exist yet. Use the form above to create one.") .card-footer %p - In the following examples, you can see the exact API call you need to - make in order to rebuild a specific - %code ref - (branch or tag) with a trigger token. - %p - All you need to do is replace the - %code TOKEN - and - %code REF_NAME - with the trigger token and the branch or tag name respectively. - - %h5.gl-mt-3 - Use cURL + = s_("These examples show how to trigger this project's pipeline for a branch or tag.") %p.light - Copy one of the tokens above, set your branch or tag name, and that - reference will be rebuilt. + = s_("Triggers|In each example, replace %{code_start}TOKEN%{code_end} with the trigger token you generated and replace %{code_start}REF_NAME%{code_end} with the branch or tag name.").html_safe % { code_start: "<code>".html_safe, code_end: "</code>".html_safe } + + %h5.gl-mt-3 + = s_("Use cURL") %pre :plain @@ -55,39 +49,26 @@ -F ref=REF_NAME \ #{builds_trigger_url(@project.id)} %h5.gl-mt-3 - Use .gitlab-ci.yml - - %p.light - In the - %code .gitlab-ci.yml - of another project, include the following snippet. - The project will be rebuilt at the end of the pipeline. + = s_("Use .gitlab-ci.yml") %pre :plain - trigger_build: - stage: deploy - script: - - "curl -X POST -F token=TOKEN -F ref=REF_NAME #{builds_trigger_url(@project.id)}" + script: + - "curl -X POST -F token=TOKEN -F ref=REF_NAME #{builds_trigger_url(@project.id)}" %h5.gl-mt-3 - Use webhook - - %p.light - Add the following webhook to another project for Push and Tag push events. - The project will be rebuilt at the corresponding event. + = s_("Use webhook") %pre :plain - #{builds_trigger_url(@project.id, ref: 'REF_NAME')}?token=TOKEN + #{builds_trigger_url(@project.id, ref: 'REF_NAME')}?token=TOKEN %h5.gl-mt-3 - Pass job variables + = s_("Pass job variables") %p.light - Add - %code variables[VARIABLE]=VALUE - to an API request. Variable values can be used to distinguish between triggered pipelines and normal pipelines. + = s_("Triggers|To pass variables to the triggered pipeline, add %{code_start}variables[VARIABLE]=VALUE%{code_end} to the API request.").html_safe % { code_start: "<code>".html_safe, code_end: "</code>".html_safe } - With cURL: + %p.light + = s_("cURL:") %pre :plain @@ -97,8 +78,8 @@ -F "variables[RUN_NIGHTLY_BUILD]=true" \ #{builds_trigger_url(@project.id)} %p.light - With webhook: + = s_("Webhook:") %pre.gl-mb-0 :plain - #{builds_trigger_url(@project.id, ref: 'REF_NAME')}?token=TOKEN&variables[RUN_NIGHTLY_BUILD]=true + #{builds_trigger_url(@project.id, ref: 'REF_NAME')}?token=TOKEN&variables[RUN_NIGHTLY_BUILD]=true diff --git a/app/views/registrations/welcome/show.html.haml b/app/views/registrations/welcome/show.html.haml index 68de80f26f6..d2a2853ecd7 100644 --- a/app/views/registrations/welcome/show.html.haml +++ b/app/views/registrations/welcome/show.html.haml @@ -1,13 +1,12 @@ - page_title _('Your profile') +- add_page_specific_style 'page_bundles/signup' .row.gl-flex-grow-1 .d-flex.gl-flex-direction-column.gl-align-items-center.gl-w-full.gl-p-5 .edit-profile.login-page.d-flex.flex-column.gl-align-items-center.pt-lg-3 = render_if_exists "registrations/welcome/progress_bar" - %h2.gl-text-center= html_escape(_('Welcome to GitLab%{br_tag}%{name}!')) % { name: html_escape(current_user.first_name), br_tag: '<br/>'.html_safe } - %p - .gl-text-center= html_escape(_('In order to personalize your experience with GitLab%{br_tag}we would like to know a bit more about you.')) % { br_tag: '<br/>'.html_safe } - + %h2.gl-text-center= html_escape(_('Welcome to GitLab,%{br_tag}%{name}!')) % { name: html_escape(current_user.first_name), br_tag: '<br/>'.html_safe } + %p.gl-text-center= html_escape(_('To personalize your GitLab experience, we\'d like to know a bit more about you. We won\'t share this information with anyone.')) % { br_tag: '<br/>'.html_safe } = form_for(current_user, url: users_sign_up_welcome_path, html: { class: 'card gl-w-full! gl-p-5', 'aria-live' => 'assertive' }) do |f| .devise-errors = render 'devise/shared/error_messages', resource: current_user @@ -20,10 +19,6 @@ .form-group.col-sm-12.js-other-role-group{ class: ("hidden") } = f.label :other_role, _('What is your job title? (optional)'), class: 'form-check-label gl-mb-3' = f.text_field :other_role, class: 'form-control' - - else - .row - .form-group.col-sm-12 - .form-text.gl-text-gray-500.gl-mt-0.gl-line-height-normal.gl-px-1= _('This will help us personalize your onboarding experience.') = render_if_exists "registrations/welcome/setup_for_company", f: f .row .form-group.col-sm-12.gl-mb-0 diff --git a/app/views/search/_filter.html.haml b/app/views/search/_filter.html.haml deleted file mode 100644 index e9c6b581c90..00000000000 --- a/app/views/search/_filter.html.haml +++ /dev/null @@ -1,14 +0,0 @@ -- if params[:group_id].present? - = hidden_field_tag :group_id, params[:group_id] -- if params[:project_id].present? - = hidden_field_tag :project_id, params[:project_id] -- project_attributes = @project&.attributes&.slice('id', 'namespace_id', 'name')&.merge(name_with_namespace: @project&.name_with_namespace) - -.dropdown.form-group.mb-lg-0.mx-lg-1.gl-p-0{ data: { testid: "group-filter" } } - %label.d-block{ for: "dashboard_search_group" } - = _("Group") - %input#js-search-group-dropdown.dropdown-menu-toggle{ value: "Loading...", data: { "initial-data": @group.to_json } } -.dropdown.form-group.mb-lg-0.mx-lg-1.gl-p-0{ data: { testid: "project-filter" } } - %label.d-block{ for: "dashboard_search_project" } - = _("Project") - %input#js-search-project-dropdown.dropdown-menu-toggle{ value: "Loading...", data: { "initial-data": project_attributes.to_json } } diff --git a/app/views/search/_form.html.haml b/app/views/search/_form.html.haml deleted file mode 100644 index a9eee1dd2d6..00000000000 --- a/app/views/search/_form.html.haml +++ /dev/null @@ -1,20 +0,0 @@ -= form_tag search_path, method: :get, class: 'search-page-form js-search-form' do |f| - = hidden_field_tag :snippets, params[:snippets] - = hidden_field_tag :scope, params[:scope] - = hidden_field_tag :repository_ref, params[:repository_ref] - - .d-lg-flex.align-items-end - .search-field-holder.form-group.mr-lg-1.mb-lg-0 - %label{ for: "dashboard_search" } - = _("What are you searching for?") - .gl-search-box-by-type - = search_field_tag :search, params[:search], placeholder: _("Search for projects, issues, etc."), class: "gl-form-input form-control search-text-input js-search-input", id: "dashboard_search", autofocus: true, spellcheck: false - = sprite_icon('search', css_class: 'gl-search-box-by-type-search-icon gl-icon') - %button.search-clear.js-search-clear{ class: [("hidden" if params[:search].blank?), "has-tooltip"], type: "button", tabindex: "-1", title: _('Clear') } - = sprite_icon('clear') - %span.sr-only - = _("Clear search") - - unless params[:snippets].eql? 'true' - = render 'filter' - .d-flex-center.flex-column.flex-lg-row - = button_tag _("Search"), class: "gl-button btn btn-success btn-search mt-lg-0 ml-lg-1 align-self-end" diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml index 80d0253d273..d5fbee34fa0 100644 --- a/app/views/search/_results.html.haml +++ b/app/views/search/_results.html.haml @@ -1,7 +1,7 @@ - search_bar_classes = 'search-sidebar gl-display-flex gl-flex-direction-column gl-mr-4' - if @search_objects.to_a.empty? - .gl-display-md-flex + .gl-md-display-flex - if %w(issues merge_requests).include?(@scope) #js-search-sidebar{ class: search_bar_classes } .gl-w-full.gl-flex-fill-1.gl-overflow-x-hidden @@ -11,7 +11,7 @@ = render partial: 'search/results_status', locals: { search_service: @search_service } = render_if_exists 'shared/promotions/promote_advanced_search' - .results.gl-display-md-flex.gl-mt-3 + .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-fill-1.gl-overflow-x-hidden diff --git a/app/views/search/_results_status.html.haml b/app/views/search/_results_status.html.haml index e55f225b162..dcfab046514 100644 --- a/app/views/search/_results_status.html.haml +++ b/app/views/search/_results_status.html.haml @@ -4,7 +4,7 @@ .search-results-status .row-content-block.gl-display-flex - .gl-display-md-flex.gl-text-left.gl-align-items-center.gl-flex-grow-1 + .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? @@ -21,5 +21,5 @@ - 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-display-md-flex.gl-flex-direction-column - = render partial: 'search/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/_sort_dropdown.html.haml b/app/views/search/_sort_dropdown.html.haml deleted file mode 100644 index 4ae6513d395..00000000000 --- a/app/views/search/_sort_dropdown.html.haml +++ /dev/null @@ -1,14 +0,0 @@ -- sort_value = @sort -- sort_title = search_sort_option_title(sort_value) - -.dropdown.gl-display-inline-block.gl-ml-3.filter-dropdown-container - .btn-group{ role: 'group' } - .btn-group{ role: 'group' } - %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' } - = sort_title - = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3') - %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort - %li - = render_if_exists('search/sort_by_relevancy', sort_title: sort_title) - = sortable_item(sort_title_recently_created, page_filter_path(sort: sort_value_recently_created), sort_title) - = search_sort_direction_button(sort_value) diff --git a/app/views/search/opensearch.xml.erb b/app/views/search/opensearch.xml.erb new file mode 100644 index 00000000000..9d08f56f290 --- /dev/null +++ b/app/views/search/opensearch.xml.erb @@ -0,0 +1,9 @@ +<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/" + xmlns:moz="http://www.mozilla.org/2006/browser/search/"> + <ShortName>GitLab</ShortName> + <Description>Search GitLab</Description> + <InputEncoding>UTF-8</InputEncoding> + <Image width="16" height="16" type="image/x-icon"><%= root_url %>favicon.ico</Image> + <Url type="text/html" method="get" template="<%= search_url %>?search={searchTerms}"/> + <moz:SearchForm><%= search_url %></moz:SearchForm> +</OpenSearchDescription>
\ No newline at end of file diff --git a/app/views/search/show.html.haml b/app/views/search/show.html.haml index 3fb91428c56..d54310bfa82 100644 --- a/app/views/search/show.html.haml +++ b/app/views/search/show.html.haml @@ -1,6 +1,11 @@ - @hide_top_links = true - page_title @search_term - @hide_breadcrumbs = true +- if params[:group_id].present? + = hidden_field_tag :group_id, params[:group_id] +- if params[:project_id].present? + = hidden_field_tag :project_id, params[:project_id] +- project_attributes = @project&.attributes&.slice('id', 'namespace_id', 'name')&.merge(name_with_namespace: @project&.name_with_namespace) - if @search_results - page_description(_("%{count} %{scope} for term '%{term}'") % { count: @search_results.formatted_count(@scope), scope: @scope, term: @search_term }) @@ -11,7 +16,7 @@ = render_if_exists 'search/form_elasticsearch', attrs: { class: 'mb-2 mb-sm-0 align-self-center' } .gl-mt-3 - = render 'search/form' + #js-search-topbar{ data: { "group-initial-data": @group.to_json, "project-initial-data": project_attributes.to_json } } - if @search_term = render 'search/category' = render 'search/results' diff --git a/app/views/sent_notifications/unsubscribe.html.haml b/app/views/sent_notifications/unsubscribe.html.haml index a286693e2b6..cacfd601d4d 100644 --- a/app/views/sent_notifications/unsubscribe.html.haml +++ b/app/views/sent_notifications/unsubscribe.html.haml @@ -15,5 +15,5 @@ %p = link_to _('Unsubscribe'), unsubscribe_sent_notification_path(@sent_notification, force: true), - class: 'gl-button btn btn-primary gl-mr-3' + class: 'gl-button btn btn-confirm gl-mr-3' = link_to _('Cancel'), new_user_session_path, class: 'gl-button btn gl-mr-3' diff --git a/app/views/shared/_auto_devops_callout.html.haml b/app/views/shared/_auto_devops_callout.html.haml index 443d801d672..d6d84b2181f 100644 --- a/app/views/shared/_auto_devops_callout.html.haml +++ b/app/views/shared/_auto_devops_callout.html.haml @@ -8,7 +8,7 @@ %p - link = link_to(s_('AutoDevOps|Auto DevOps documentation'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer') = s_('AutoDevOps|Learn more in the %{link_to_documentation}').html_safe % { link_to_documentation: link } - = link_to s_('AutoDevOps|Enable in settings'), project_settings_ci_cd_path(@project, anchor: 'autodevops-settings'), class: 'btn btn-md new-gl-button js-close-callout' + = link_to s_('AutoDevOps|Enable in settings'), project_settings_ci_cd_path(@project, anchor: 'autodevops-settings'), class: 'btn btn-md btn-default gl-button js-close-callout' %button.gl-banner-close.close.js-close-callout{ type: 'button', 'aria-label' => s_('AutoDevOps|Dismiss Auto DevOps box') } diff --git a/app/views/shared/_commit_message_container.html.haml b/app/views/shared/_commit_message_container.html.haml index d65b7492690..47ecc75af1f 100644 --- a/app/views/shared/_commit_message_container.html.haml +++ b/app/views/shared/_commit_message_container.html.haml @@ -8,7 +8,7 @@ .max-width-marker = text_area_tag 'commit_message', (params[:commit_message] || local_assigns[:text] || local_assigns[:placeholder]), - class: 'form-control js-commit-message', placeholder: local_assigns[:placeholder], + class: 'form-control gl-form-input js-commit-message', placeholder: local_assigns[:placeholder], data: descriptions, required: true, rows: (local_assigns[:rows] || 3), id: "commit_message-#{nonce}" diff --git a/app/views/shared/_email_with_badge.html.haml b/app/views/shared/_email_with_badge.html.haml index 294fe74a5ca..8b9ca966ed6 100644 --- a/app/views/shared/_email_with_badge.html.haml +++ b/app/views/shared/_email_with_badge.html.haml @@ -1,5 +1,5 @@ -- css_classes = %w(badge badge-verification-status) -- css_classes << (verified ? 'verified': 'unverified') +- css_classes = %w(badge gl-badge) +- css_classes << (verified ? 'badge-success': 'badge-danger') - text = verified ? _('Verified') : _('Unverified') .email-badge diff --git a/app/views/shared/_file_picker_button.html.haml b/app/views/shared/_file_picker_button.html.haml index 8c10e4958b9..9e6a7626d89 100644 --- a/app/views/shared/_file_picker_button.html.haml +++ b/app/views/shared/_file_picker_button.html.haml @@ -1,7 +1,7 @@ - classes = local_assigns.fetch(:classes, '') %span.js-filepicker - %button.btn.js-filepicker-button{ type: 'button', class: classes }= _("Choose file…") + %button.gl-button.btn.js-filepicker-button{ type: 'button', class: classes }= _("Choose file…") %span.file_name.js-filepicker-filename= _("No file chosen") = f.file_field field, class: "js-filepicker-input hidden" - if help_text.present? diff --git a/app/views/shared/_issuable_meta_data.html.haml b/app/views/shared/_issuable_meta_data.html.haml index 352d51dbb8e..4b006bddbf6 100644 --- a/app/views/shared/_issuable_meta_data.html.haml +++ b/app/views/shared/_issuable_meta_data.html.haml @@ -5,23 +5,23 @@ - issuable_mr = @issuable_meta_data[issuable.id].merge_requests_count - if issuable_mr > 0 - %li.issuable-mr.gl-display-none.gl-display-sm-block.has-tooltip{ title: _('Related merge requests') } + %li.issuable-mr.gl-display-none.gl-sm-display-block.has-tooltip{ title: _('Related merge requests') } = sprite_icon('merge-request', css_class: "gl-vertical-align-middle") = issuable_mr - if upvotes > 0 - %li.issuable-upvotes.gl-display-none.gl-display-sm-block.has-tooltip{ title: _('Upvotes') } + %li.issuable-upvotes.gl-display-none.gl-sm-display-block.has-tooltip{ title: _('Upvotes') } = sprite_icon('thumb-up', css_class: "gl-vertical-align-middle") = upvotes - if downvotes > 0 - %li.issuable-downvotes.gl-display-none.gl-display-sm-block.has-tooltip{ title: _('Downvotes') } + %li.issuable-downvotes.gl-display-none.gl-sm-display-block.has-tooltip{ title: _('Downvotes') } = sprite_icon('thumb-down', css_class: "gl-vertical-align-middle") = downvotes = render_if_exists 'shared/issuable/blocking_issues_count', issuable: issuable -%li.issuable-comments.gl-display-none.gl-display-sm-block +%li.issuable-comments.gl-display-none.gl-sm-display-block = link_to issuable_path, class: ['has-tooltip', ('no-comments' if note_count == 0)], title: _('Comments') do = sprite_icon('comments', css_class: 'gl-vertical-align-text-bottom') = note_count diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml index 4b09e8de896..c70c0572c2b 100644 --- a/app/views/shared/_label.html.haml +++ b/app/views/shared/_label.html.haml @@ -43,10 +43,10 @@ - if current_user %li.inline.label-subscription - if label.can_subscribe_to_label_in_different_levels? - %button.js-unsubscribe-button.label-subscribe-button.btn.btn-default{ class: ('hidden' if status.unsubscribed?), data: { url: toggle_subscription_path, toggle: 'tooltip' }, title: tooltip_title } + %button.js-unsubscribe-button.gl-button.label-subscribe-button.btn.btn-default.gl-ml-3{ class: ('hidden' if status.unsubscribed?), data: { url: toggle_subscription_path, toggle: 'tooltip' }, title: tooltip_title } %span= _('Unsubscribe') .dropdown.dropdown-group-label{ class: ('hidden' unless status.unsubscribed?) } - %button.label-subscribe-button.btn.btn-default{ data: { toggle: 'dropdown' } } + %button.gl-button.label-subscribe-button.btn.btn-default.gl-ml-3{ data: { toggle: 'dropdown' } } %span = _('Subscribe') = sprite_icon('chevron-down') @@ -59,7 +59,7 @@ %button.js-subscribe-button.js-group-level.label-subscribe-button.btn.btn-default{ class: ('hidden' unless status.unsubscribed?), data: { status: status, url: toggle_subscription_group_label_path(label.group, label) } } %span= _('Subscribe at group level') - else - %button.js-subscribe-button.label-subscribe-button.btn.btn-default{ data: { status: status, url: toggle_subscription_path, toggle: 'tooltip' }, title: tooltip_title } + %button.gl-button.js-subscribe-button.label-subscribe-button.btn.btn-default.gl-ml-3{ data: { status: status, url: toggle_subscription_path, toggle: 'tooltip' }, title: tooltip_title } %span= label_subscription_toggle_button_text(label, @project) = render 'shared/delete_label_modal', label: label diff --git a/app/views/shared/_milestone_expired.html.haml b/app/views/shared/_milestone_expired.html.haml index 2261e9e3121..171ae9d2c07 100644 --- a/app/views/shared/_milestone_expired.html.haml +++ b/app/views/shared/_milestone_expired.html.haml @@ -1,6 +1,6 @@ - if milestone.expired? and not milestone.closed? - .status-box.status-box-expired.gl-mb-2= _('Expired') + .gl-badge.badge-warning.badge-pill.gl-mb-2= _('Expired') - if milestone.upcoming? - .status-box.status-box-mr-merged.gl-mb-2= _('Upcoming') + .gl-badge.badge-primary.badge-pill.gl-mb-2= _('Upcoming') - if milestone.closed? - .status-box.status-box-closed.gl-mb-2= _('Closed') + .gl-badge.badge-danger.badge-pill.gl-mb-2= _('Closed') diff --git a/app/views/shared/_new_commit_form.html.haml b/app/views/shared/_new_commit_form.html.haml index 81c33eeea4f..62ba89e2576 100644 --- a/app/views/shared/_new_commit_form.html.haml +++ b/app/views/shared/_new_commit_form.html.haml @@ -10,7 +10,7 @@ .form-group.row.branch = label_tag 'branch_name', _('Target Branch'), class: 'col-form-label col-sm-2' .col-sm-10 - = text_field_tag 'branch_name', branch_name, required: true, class: "form-control js-branch-name ref-name" + = text_field_tag 'branch_name', branch_name, required: true, class: "form-control gl-form-input js-branch-name ref-name" .js-create-merge-request-container = render 'shared/new_merge_request_checkbox' diff --git a/app/views/shared/_no_ssh.html.haml b/app/views/shared/_no_ssh.html.haml index 0a7fa2a3c1e..2c6ceb58654 100644 --- a/app/views/shared/_no_ssh.html.haml +++ b/app/views/shared/_no_ssh.html.haml @@ -6,5 +6,5 @@ .gl-alert-body = s_("MissingSSHKeyWarningLink|You won't be able to pull or push repositories via SSH until you add an SSH key to your profile") .gl-alert-actions - = link_to s_('MissingSSHKeyWarningLink|Add SSH key'), profile_keys_path, class: "btn gl-alert-action btn-warning btn-md new-gl-button" + = link_to s_('MissingSSHKeyWarningLink|Add SSH key'), profile_keys_path, class: "btn gl-alert-action btn-warning btn-md gl-button" = link_to s_("MissingSSHKeyWarningLink|Don't show again"), profile_path(user: {hide_no_ssh_key: true}), method: :put, role: 'button', class: 'btn gl-alert-action btn-md btn-warning gl-button btn-warning-secondary' diff --git a/app/views/shared/_project_limit.html.haml b/app/views/shared/_project_limit.html.haml index 3d5229f87b5..9110f5a7f31 100644 --- a/app/views/shared/_project_limit.html.haml +++ b/app/views/shared/_project_limit.html.haml @@ -1,5 +1,5 @@ - if cookies[:hide_project_limit_message].blank? && !current_user.hide_project_limit && !current_user.can_create_project? && current_user.projects_limit > 0 - .project-limit-message.gl-alert.gl-alert-warning.gl-display-none.gl-display-sm-block + .project-limit-message.gl-alert.gl-alert-warning.gl-display-none.gl-sm-display-block = _("You won't be able to create new projects because you have reached your project limit.") .float-right diff --git a/app/views/shared/_search_settings.html.haml b/app/views/shared/_search_settings.html.haml index ea3d7b97327..d689e9ae5c0 100644 --- a/app/views/shared/_search_settings.html.haml +++ b/app/views/shared/_search_settings.html.haml @@ -1,2 +1,6 @@ +- container_class = local_assigns.fetch(:container_class, 'gl-mt-5') + - if Feature.enabled?(:search_settings_in_page, @project, default_enabled: false) - .js-search-settings-app + %div{ class: container_class } + .js-search-settings-app + %input.gl-form-input.form-control{ type: "text", placeholder: _("Search settings"), aria_label: _("Search settings"), disabled: true } diff --git a/app/views/shared/_service_settings.html.haml b/app/views/shared/_service_settings.html.haml index 194e0eb57f2..7af356c0820 100644 --- a/app/views/shared/_service_settings.html.haml +++ b/app/views/shared/_service_settings.html.haml @@ -1,13 +1,14 @@ = form_errors(integration) -- if lookup_context.template_exists?('help', "projects/services/#{integration.to_param}", true) - = render "projects/services/#{integration.to_param}/help", subject: integration -- elsif integration.help.present? - .info-well - .well-segment - = markdown integration.help - .service-settings - if @default_integration .js-vue-default-integration-settings{ data: integration_form_data(@default_integration, group: @group) } .js-vue-integration-settings{ data: integration_form_data(integration, group: @group) } + .js-integration-help-html.gl-display-none + -# All content below will be repositioned in Vue + - if lookup_context.template_exists?('help', "projects/services/#{integration.to_param}", true) + = render "projects/services/#{integration.to_param}/help", subject: integration + - elsif integration.help.present? + .info-well + .well-segment + = markdown integration.help diff --git a/app/views/shared/access_tokens/_form.html.haml b/app/views/shared/access_tokens/_form.html.haml index f206a2152c2..089643f4748 100644 --- a/app/views/shared/access_tokens/_form.html.haml +++ b/app/views/shared/access_tokens/_form.html.haml @@ -30,4 +30,4 @@ = render 'shared/tokens/scopes_form', prefix: prefix, token: token, scopes: scopes .gl-mt-3 - = f.submit _('Create %{type}') % { type: type }, class: 'btn btn-success', data: { qa_selector: 'create_token_button' } + = f.submit _('Create %{type}') % { type: type }, class: 'gl-button btn btn-success', data: { qa_selector: 'create_token_button' } diff --git a/app/views/shared/access_tokens/_table.html.haml b/app/views/shared/access_tokens/_table.html.haml index 50daa400e6c..d7c74255578 100644 --- a/app/views/shared/access_tokens/_table.html.haml +++ b/app/views/shared/access_tokens/_table.html.haml @@ -43,7 +43,7 @@ - else %span.token-never-expires-label= _('Never') %td= token.scopes.present? ? token.scopes.join(', ') : _('no scopes selected') - %td= link_to _('Revoke'), revoke_route_helper.call(token), method: :put, class: 'btn btn-danger float-right qa-revoke-button', data: { confirm: _('Are you sure you want to revoke this %{type}? This action cannot be undone.') % { type: type } } + %td= link_to _('Revoke'), revoke_route_helper.call(token), method: :put, class: 'gl-button btn btn-danger float-right qa-revoke-button', data: { confirm: _('Are you sure you want to revoke this %{type}? This action cannot be undone.') % { type: type } } - else .settings-message.text-center = no_active_tokens_message diff --git a/app/views/shared/boards/components/_sidebar.html.haml b/app/views/shared/boards/components/_sidebar.html.haml index b4f75967a67..3daa13fb488 100644 --- a/app/views/shared/boards/components/_sidebar.html.haml +++ b/app/views/shared/boards/components/_sidebar.html.haml @@ -1,6 +1,6 @@ %board-sidebar{ "inline-template" => true, ":current-user" => (UserSerializer.new.represent(current_user) || {}).to_json } %transition{ name: "boards-sidebar-slide" } - %aside.right-sidebar.right-sidebar-expanded.issue-boards-sidebar{ "v-show" => "showSidebar" } + %aside.right-sidebar.right-sidebar-expanded.issue-boards-sidebar{ "v-show" => "showSidebar", 'aria-label': s_('Boards|Board') } .issuable-sidebar .block.issuable-sidebar-header.position-relative %span.issuable-header-text.hide-collapsed.float-left diff --git a/app/views/shared/deploy_keys/_form.html.haml b/app/views/shared/deploy_keys/_form.html.haml index 94742d96af7..37a56057268 100644 --- a/app/views/shared/deploy_keys/_form.html.haml +++ b/app/views/shared/deploy_keys/_form.html.haml @@ -6,7 +6,7 @@ .form-group = form.label :title, class: 'col-form-label col-sm-2' - .col-sm-10= form.text_field :title, class: 'form-control', readonly: ('readonly' unless can?(current_user, :update_deploy_key, deploy_key)) + .col-sm-10= form.text_field :title, class: 'form-control gl-form-input', readonly: ('readonly' unless can?(current_user, :update_deploy_key, deploy_key)) .form-group - if deploy_key.new_record? @@ -15,12 +15,12 @@ %p.light - link_start = "<a href='#{help_page_path('ssh/README')}' target='_blank' rel='noreferrer noopener'>".html_safe - link_end = '</a>' - = _('Paste a machine public key here. Read more about how to generate it %{link_start}here%{link_end}').html_safe % { link_start: link_start, link_end: link_end.html_safe } - = form.text_area :key, class: 'form-control thin_area', rows: 5 + = _('Paste a public key here. %{link_start}How do I generate it?%{link_end}').html_safe % { link_start: link_start, link_end: link_end.html_safe } + = form.text_area :key, class: 'form-control gl-form-input thin_area', rows: 5 - else = form.label :fingerprint, class: 'col-form-label col-sm-2' .col-sm-10 - = form.text_field :fingerprint, class: 'form-control', readonly: 'readonly' + = form.text_field :fingerprint, class: 'form-control gl-form-input', readonly: 'readonly' - if deploy_keys_project.present? = form.fields_for :deploy_keys_projects, deploy_keys_project do |deploy_keys_project_form| @@ -29,6 +29,6 @@ .col-sm-10 = deploy_keys_project_form.label :can_push do = deploy_keys_project_form.check_box :can_push - %strong= _('Write access allowed') + %strong= _('Grant write permissions to this key') %p.light.gl-mb-0 - = _('Allow this key to push to repository as well? (Default only allows pull access.)') + = _('Allow this key to push to this repository') diff --git a/app/views/shared/deploy_keys/_index.html.haml b/app/views/shared/deploy_keys/_index.html.haml index f2f577383f8..af4a35264e9 100644 --- a/app/views/shared/deploy_keys/_index.html.haml +++ b/app/views/shared/deploy_keys/_index.html.haml @@ -5,10 +5,10 @@ %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p - = _('Deploy keys allow read-only or read-write (if enabled) access to your repository. Deploy keys can be used for CI, staging or production servers. You can create a deploy key or add an existing one.') + - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/deploy_keys/index') } + = _("Add deploy keys to grant read/write access to this repository. %{link_start}What are Deploy Keys?%{link_end}").html_safe % { link_start: link_start, link_end: '</a>'.html_safe } .settings-content %h5.gl-mt-0 - = _('Create a new deploy key for this project') = render @deploy_keys.form_partial_path %hr #js-deploy-keys{ data: { endpoint: project_deploy_keys_path(@project), project_id: @project.id } } diff --git a/app/views/shared/deploy_keys/_project_group_form.html.haml b/app/views/shared/deploy_keys/_project_group_form.html.haml index 179ec33ee65..bad25086d9f 100644 --- a/app/views/shared/deploy_keys/_project_group_form.html.haml +++ b/app/views/shared/deploy_keys/_project_group_form.html.haml @@ -2,23 +2,23 @@ = form_errors(@deploy_keys.new_key) .form-group.row = f.label :title, class: "label-bold" - = f.text_field :title, class: 'form-control', required: true + = f.text_field :title, class: 'form-control gl-form-input', required: true .form-group.row = f.label :key, class: "label-bold" - = f.text_area :key, class: "form-control", rows: 5, required: true + = f.text_area :key, class: 'form-control gl-form-input', rows: 5, required: true .form-group.row %p.light.gl-mb-0 - = _('Paste a machine public key here. Read more about how to generate it') - = link_to "here", help_page_path("ssh/README") + = _('Paste a public key here.') + = link_to _('How do I generate it?'), help_page_path("ssh/README") = f.fields_for :deploy_keys_projects do |deploy_keys_project_form| .form-group.row = deploy_keys_project_form.label :can_push do = deploy_keys_project_form.check_box :can_push - %strong= _('Write access allowed') + %strong= _('Grant write permissions to this key') .form-group.row %p.light.gl-mb-0 - = _('Allow this key to push to repository as well? (Default only allows pull access.)') + = _('Allow this key to push to this repository') .form-group.row = f.submit _("Add key"), class: "btn-success btn" diff --git a/app/views/shared/deploy_tokens/_form.html.haml b/app/views/shared/deploy_tokens/_form.html.haml index da634d37c55..052d68baf71 100644 --- a/app/views/shared/deploy_tokens/_form.html.haml +++ b/app/views/shared/deploy_tokens/_form.html.haml @@ -1,50 +1,51 @@ %p.profile-settings-content - = s_("DeployTokens|Pick a name for the application, and we'll give you a unique deploy token.") + = s_("DeployTokens|Pick a name for your unique deploy token.") = form_for token, url: create_deploy_token_path(group_or_project, anchor: 'js-deploy-tokens'), method: :post, remote: Feature.enabled?(:ajax_new_deploy_token, group_or_project) do |f| = form_errors(token) .form-group = f.label :name, class: 'label-bold' - = f.text_field :name, class: 'form-control qa-deploy-token-name', required: true + = f.text_field :name, class: 'form-control gl-form-input qa-deploy-token-name', required: true .form-group - = f.label :expires_at, class: 'label-bold' + = f.label :expires_at, _('Expires at (optional)'), class: 'label-bold' = f.text_field :expires_at, class: 'datepicker form-control qa-deploy-token-expires-at', value: f.object.expires_at + .text-secondary= s_('DeployTokens|Unless you enter a date, the token does not expire.') .form-group - = f.label :username, class: 'label-bold' + = f.label :username, _('Username (optional)'), class: 'label-bold' = f.text_field :username, class: 'form-control qa-deploy-token-username' - .text-secondary= s_('DeployTokens|Default format is "gitlab+deploy-token-{n}". Enter custom username if you want to change it.') + .text-secondary= s_('DeployTokens|Unless you specify a username, it is set to "gitlab+deploy-token-{n}".') .form-group - = f.label :scopes, class: 'label-bold' + = f.label :scopes, _('Scopes [Select 1 or more]'), class: 'label-bold' %fieldset.form-group.form-check = f.check_box :read_repository, class: 'form-check-input qa-deploy-token-read-repository' = label_tag ("deploy_token_read_repository"), 'read_repository', class: 'label-bold form-check-label' - .text-secondary= s_('DeployTokens|Allows read-only access to the repository') + .text-secondary= s_('DeployTokens|Allows read-only access to the repository.') - if container_registry_enabled?(group_or_project) %fieldset.form-group.form-check = f.check_box :read_registry, class: 'form-check-input qa-deploy-token-read-registry' = label_tag ("deploy_token_read_registry"), 'read_registry', class: 'label-bold form-check-label' - .text-secondary= s_('DeployTokens|Allows read-only access to the registry images') + .text-secondary= s_('DeployTokens|Allows read-only access to registry images.') %fieldset.form-group.form-check = f.check_box :write_registry, class: 'form-check-input' = label_tag ("deploy_token_write_registry"), 'write_registry', class: 'label-bold form-check-label' - .text-secondary= s_('DeployTokens|Allows write access to the registry images') + .text-secondary= s_('DeployTokens|Allows write access to registry images.') - if packages_registry_enabled?(group_or_project) %fieldset.form-group.form-check = f.check_box :read_package_registry, class: 'form-check-input' = label_tag ("deploy_token_read_package_registry"), 'read_package_registry', class: 'label-bold form-check-label' - .text-secondary= s_('DeployTokens|Allows read access to the package registry') + .text-secondary= s_('DeployTokens|Allows read access to the package registry.') %fieldset.form-group.form-check = f.check_box :write_package_registry, class: 'form-check-input' = label_tag ("deploy_token_write_package_registry"), 'write_package_registry', class: 'label-bold form-check-label' - .text-secondary= s_('DeployTokens|Allows write access to the package registry') + .text-secondary= s_('DeployTokens|Allows write access to the package registry.') .gl-mt-3 - = f.submit s_('DeployTokens|Create deploy token'), class: 'btn btn-success qa-create-deploy-token' + = f.submit s_('DeployTokens|Create deploy token'), class: 'btn gl-button btn-success qa-create-deploy-token' diff --git a/app/views/shared/deploy_tokens/_index.html.haml b/app/views/shared/deploy_tokens/_index.html.haml index c26400690a6..0c0074cf4a6 100644 --- a/app/views/shared/deploy_tokens/_index.html.haml +++ b/app/views/shared/deploy_tokens/_index.html.haml @@ -11,7 +11,7 @@ - if @new_deploy_token.persisted? = render 'shared/deploy_tokens/new_deploy_token', deploy_token: @new_deploy_token %h5.gl-mt-0 - = s_('DeployTokens|Add a deploy token') + = s_('DeployTokens|Add a 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/deploy_tokens/_new_deploy_token.html.haml b/app/views/shared/deploy_tokens/_new_deploy_token.html.haml index 738f2f9db70..41e50138220 100644 --- a/app/views/shared/deploy_tokens/_new_deploy_token.html.haml +++ b/app/views/shared/deploy_tokens/_new_deploy_token.html.haml @@ -1,18 +1,24 @@ .qa-created-deploy-token-section.created-deploy-token-container.info-well .well-segment %h5.gl-mt-0 - = s_('DeployTokens|Your New Deploy Token') + = s_('DeployTokens|Your new Deploy Token username') .form-group .input-group = text_field_tag 'deploy-token-user', deploy_token.username, readonly: true, class: 'deploy-token-field form-control js-select-on-focus qa-deploy-token-user' .input-group-append = clipboard_button(text: deploy_token.username, title: s_('DeployTokens|Copy username'), placement: 'left') - %span.deploy-token-help-block.gl-mt-2.text-success= s_("DeployTokens|Use this username as a login.") + %span.deploy-token-help-block.gl-mt-2.text-success + - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/deploy_tokens/index.md') } + - link_end = "</a>".html_safe + = s_("DeployTokens|This username supports access. %{link_start}What kind of access?%{link_end}").html_safe % { link_start: link_start, link_end: link_end } .form-group .input-group = text_field_tag 'deploy-token', deploy_token.token, readonly: true, class: 'deploy-token-field form-control js-select-on-focus qa-deploy-token' .input-group-append = clipboard_button(text: deploy_token.token, title: s_('DeployTokens|Copy deploy token'), placement: 'left') - %span.deploy-token-help-block.gl-mt-2.text-danger= s_("DeployTokens|Use this token as a password. Make sure you save it - you won't be able to access it again.") + %span.deploy-token-help-block.gl-mt-2.text-danger + - i_start = "<i>".html_safe + - i_end = "</i>".html_safe + = s_("DeployTokens|Use this token as a password. Save it. This password can %{i_start}not%{i_end} be recovered.").html_safe % { i_start: i_start, i_end: i_end } diff --git a/app/views/shared/deploy_tokens/_table.html.haml b/app/views/shared/deploy_tokens/_table.html.haml index 361471af0ad..ad3c53c4925 100644 --- a/app/views/shared/deploy_tokens/_table.html.haml +++ b/app/views/shared/deploy_tokens/_table.html.haml @@ -24,7 +24,7 @@ - else %span.token-never-expires-label= _('Never') %td= token.scopes.present? ? token.scopes.join(', ') : _('no scopes selected') - %td= link_to s_('DeployTokens|Revoke'), "#", class: "btn btn-danger float-right", data: { toggle: "modal", target: "#revoke-modal-#{token.id}"} + %td= link_to s_('DeployTokens|Revoke'), "#", class: "gl-button btn btn-danger float-right", data: { toggle: "modal", target: "#revoke-modal-#{token.id}"} = render 'shared/deploy_tokens/revoke_modal', token: token, group_or_project: group_or_project - else .settings-message.text-center diff --git a/app/views/shared/empty_states/_deploy_keys.html.haml b/app/views/shared/empty_states/_deploy_keys.html.haml index da34b866aa6..6fca64d805b 100644 --- a/app/views/shared/empty_states/_deploy_keys.html.haml +++ b/app/views/shared/empty_states/_deploy_keys.html.haml @@ -4,6 +4,6 @@ = image_tag 'illustrations/empty-state/empty-deploy-keys-lg.svg' .gl-flex-grow-0.gl-flex-shrink-0 .text-content.gl-mx-auto.gl-my-0.gl-p-5 - %h4.h4= _('Deploy keys allow read-only or read-write (if enabled) access to your repository') - %p= _('Deploy keys can be used for CI, staging or production servers. You can create a deploy key or add an existing one.') + %h4.h4= _('Deploy Keys') + %p= _('Deploy keys grant read/write access to all repositories in your instance') = link_to _('New deploy key'), new_admin_deploy_key_path, class: 'btn btn-success btn-md gl-button' diff --git a/app/views/shared/empty_states/_milestones.html.haml b/app/views/shared/empty_states/_milestones.html.haml new file mode 100644 index 00000000000..c22869fb7e6 --- /dev/null +++ b/app/views/shared/empty_states/_milestones.html.haml @@ -0,0 +1,7 @@ +.row.empty-state + .col-12 + .svg-content + = image_tag 'illustrations/milestone_burndown_chart.svg' + .col-12 + .text-content + %h4.text-center= _('No milestones to show') diff --git a/app/views/shared/empty_states/_profile_tabs.html.haml b/app/views/shared/empty_states/_profile_tabs.html.haml index 38c9fe7179c..7780a144a26 100644 --- a/app/views/shared/empty_states/_profile_tabs.html.haml +++ b/app/views/shared/empty_states/_profile_tabs.html.haml @@ -12,9 +12,9 @@ %p= current_user_empty_message_description - if secondary_button_link.present? - = link_to secondary_button_label, secondary_button_link, class: 'btn btn-success btn-inverted' + = link_to secondary_button_label, secondary_button_link, class: 'gl-button btn btn-success btn-inverted' - if primary_button_link.present? - = link_to primary_button_label, primary_button_link, class: 'btn btn-success' + = link_to primary_button_label, primary_button_link, class: 'gl-button btn btn-success' - else %h5= visitor_empty_message diff --git a/app/views/shared/empty_states/_snippets.html.haml b/app/views/shared/empty_states/_snippets.html.haml index aa762782c46..105efcc3c88 100644 --- a/app/views/shared/empty_states/_snippets.html.haml +++ b/app/views/shared/empty_states/_snippets.html.haml @@ -12,7 +12,7 @@ = s_('SnippetsEmptyState|Store, share, and embed small pieces of code and text.') .mt-2< - if button_path - = link_to s_('SnippetsEmptyState|New snippet'), button_path, class: 'btn btn-success', title: s_('SnippetsEmptyState|New snippet'), id: 'new_snippet_link', data: { qa_selector: 'create_first_snippet_link' } - = link_to s_('SnippetsEmptyState|Documentation'), help_page_path('user/snippets.md'), class: 'btn btn-default', title: s_('SnippetsEmptyState|Documentation') + = link_to s_('SnippetsEmptyState|New snippet'), button_path, class: 'btn gl-button btn-success', title: s_('SnippetsEmptyState|New snippet'), id: 'new_snippet_link', data: { qa_selector: 'create_first_snippet_link' } + = link_to s_('SnippetsEmptyState|Documentation'), help_page_path('user/snippets.md'), class: 'btn gl-button btn-default', title: s_('SnippetsEmptyState|Documentation') - else %h4.text-center= s_('SnippetsEmptyState|There are no snippets to show.') diff --git a/app/views/shared/integrations/_form.html.haml b/app/views/shared/integrations/_form.html.haml index 11e390a47e2..62f8d986296 100644 --- a/app/views/shared/integrations/_form.html.haml +++ b/app/views/shared/integrations/_form.html.haml @@ -1,10 +1,7 @@ - integration = local_assigns.fetch(:integration) -.row.gl-mt-3 - .col-lg-4 - %h3.page-title.gl-mt-0 - = integration.title +%h3.page-title + = integration.title - .col-lg-8 - = form_for integration, as: :service, url: scoped_integration_path(integration), method: :put, html: { class: 'gl-show-field-errors integration-settings-form js-integration-settings-form', data: { 'test-url' => scoped_test_integration_path(integration) } } do |form| - = render 'shared/service_settings', form: form, integration: integration += form_for integration, as: :service, url: scoped_integration_path(integration), method: :put, html: { class: 'gl-show-field-errors integration-settings-form js-integration-settings-form', data: { 'test-url' => scoped_test_integration_path(integration) } } do |form| + = render 'shared/service_settings', form: form, integration: integration diff --git a/app/views/shared/issuable/_board_create_list_dropdown.html.haml b/app/views/shared/issuable/_board_create_list_dropdown.html.haml index b6cf23faff8..132a951fd34 100644 --- a/app/views/shared/issuable/_board_create_list_dropdown.html.haml +++ b/app/views/shared/issuable/_board_create_list_dropdown.html.haml @@ -1,5 +1,5 @@ .dropdown.gl-ml-3#js-add-list - %button.btn.btn-success.btn-inverted.js-new-board-list{ type: "button", data: board_list_data } + %button.gl-button.btn.btn-success.btn-inverted.js-new-board-list{ type: "button", data: board_list_data } Add list .dropdown-menu.dropdown-extended-height.dropdown-menu-paging.dropdown-menu-right.dropdown-menu-issues-board-new.dropdown-menu-selectable.js-tab-container-labels = render partial: "shared/issuable/label_page_default", locals: { show_footer: true, show_create: true, show_boards_content: true, title: "Add list" } diff --git a/app/views/shared/issuable/_bulk_update_sidebar.html.haml b/app/views/shared/issuable/_bulk_update_sidebar.html.haml index 2f30958c877..214651c276e 100644 --- a/app/views/shared/issuable/_bulk_update_sidebar.html.haml +++ b/app/views/shared/issuable/_bulk_update_sidebar.html.haml @@ -1,14 +1,15 @@ - type = local_assigns.fetch(:type) - bulk_issue_health_status_flag = type == :issues && @project&.group&.feature_available?(:issuable_health_status) - epic_bulk_edit_flag = @project&.group&.feature_available?(:epics) && type == :issues +- bulk_iterations_flag = @project&.group&.feature_available?(:iterations) && type == :issues -%aside.issues-bulk-update.js-right-sidebar.right-sidebar{ "aria-live" => "polite", data: { 'signed-in': current_user.present? } } +%aside.issues-bulk-update.js-right-sidebar.right-sidebar{ "aria-live" => "polite", data: { 'signed-in': current_user.present? }, 'aria-label': _('Bulk update') } .issuable-sidebar.hidden = form_tag [:bulk_update, @project, type], method: :post, class: "bulk-update" do .block.issuable-sidebar-header .filter-item.inline.update-issues-btn.float-left - = button_tag _('Update all'), class: "btn update-selected-issues btn-info", disabled: true - = button_tag _('Cancel'), class: "btn btn-default js-bulk-update-menu-hide float-right" + = button_tag _('Update all'), class: "gl-button btn update-selected-issues btn-info", disabled: true + = button_tag _('Cancel'), class: "gl-button btn btn-default js-bulk-update-menu-hide float-right" - if params[:state] != 'merged' .block .title @@ -41,6 +42,8 @@ = _('Milestone') .filter-item = dropdown_tag(_("Select milestone"), options: { title: _("Assign milestone"), toggle_class: "js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update", filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: _("Search milestones"), data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, use_id: true, default_label: _("Milestone") } }) + - if bulk_iterations_flag + = render_if_exists 'shared/iterations_dropdown', path: @project.group.full_path .block .title = _('Labels') diff --git a/app/views/shared/issuable/_feed_buttons.html.haml b/app/views/shared/issuable/_feed_buttons.html.haml index 86c2e243718..1fac1d27583 100644 --- a/app/views/shared/issuable/_feed_buttons.html.haml +++ b/app/views/shared/issuable/_feed_buttons.html.haml @@ -1,4 +1,4 @@ -= link_to safe_params.merge(rss_url_options), class: 'btn btn-svg has-tooltip', data: { container: 'body', testid: 'rss-feed-link' }, title: _('Subscribe to RSS feed') do += link_to safe_params.merge(rss_url_options), class: 'btn gl-button btn-default has-tooltip', data: { container: 'body', testid: 'rss-feed-link' }, title: _('Subscribe to RSS feed') do = sprite_icon('rss', css_class: 'qa-rss-icon') -= link_to safe_params.merge(calendar_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: _('Subscribe to calendar') do += link_to safe_params.merge(calendar_url_options), class: 'btn gl-button btn-default has-tooltip', data: { container: 'body' }, title: _('Subscribe to calendar') do = sprite_icon('calendar') diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 552f83906e1..2a91ffbdbaa 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -64,17 +64,17 @@ .row-content-block{ class: (is_footer ? "footer-block" : "middle-block") } .float-right - if issuable.new_record? - = link_to 'Cancel', polymorphic_path([@project, issuable.class]), class: 'btn btn-cancel' + = link_to 'Cancel', polymorphic_path([@project, issuable.class]), class: 'gl-button btn btn-cancel' - else - if can?(current_user, :"destroy_#{issuable.to_ability_name}", @project) = link_to 'Delete', polymorphic_path([@project, issuable], params: { destroy_confirm: true }), data: { confirm: "#{issuable.human_class_name} will be removed! Are you sure?" }, method: :delete, class: 'btn btn-danger btn-grouped' - = link_to 'Cancel', polymorphic_path([@project, issuable]), class: 'btn btn-grouped btn-cancel' + = link_to 'Cancel', polymorphic_path([@project, issuable]), class: 'gl-button btn btn-grouped btn-cancel' %span.gl-mr-3 - if issuable.new_record? - = form.submit "Submit #{issuable.class.model_name.human.downcase}", class: 'btn btn-success qa-issuable-create-button' + = form.submit "Submit #{issuable.class.model_name.human.downcase}", class: 'gl-button btn btn-success qa-issuable-create-button' - else - = form.submit 'Save changes', class: 'btn btn-success' + = form.submit 'Save changes', class: 'gl-button btn btn-success' - if !issuable.persisted? && !issuable.project.empty_repo? && (guide_url = issuable.project.present.contribution_guide_path) .inline.gl-mt-3 diff --git a/app/views/shared/issuable/_label_page_create.html.haml b/app/views/shared/issuable/_label_page_create.html.haml index a0d3bc64f1f..005b76180fd 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.btn.btn-primary.float-left.js-new-label-btn{ type: "button" } + %button.gl-button.btn.btn-success.float-left.js-new-label-btn{ type: "button" } = _('Create') - %button.btn.btn-default.float-right.js-cancel-label-btn{ type: "button" } + %button.gl-button.btn.btn-default.float-right.js-cancel-label-btn{ type: "button" } = _('Cancel') diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 79d86500bd9..1ebb160e591 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -195,7 +195,10 @@ #js-board-labels-toggle .js-board-config{ data: { can_admin_list: user_can_admin_list, has_scope: board.scoped? } } - if user_can_admin_list - = render 'shared/issuable/board_create_list_dropdown', board: board + - if Feature.enabled?(:board_new_list, board.resource_parent, default_enabled: :yaml) + .js-create-column-trigger{ data: board_list_data } + - else + = render 'shared/issuable/board_create_list_dropdown', board: board - if @project #js-add-issues-btn.gl-ml-3{ data: { can_admin_list: can?(current_user, :admin_list, @project) } } - if current_user diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 911bef482dd..a1150fbfe1b 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -8,7 +8,7 @@ - add_page_startup_api_call "#{issuable_sidebar[:issuable_json_path]}?serializer=sidebar_extras" - reviewers = local_assigns.fetch(:reviewers, nil) -%aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { signed: { in: signed_in }, issuable_type: issuable_type }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' } +%aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { signed: { in: signed_in }, issuable_type: issuable_type }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite', 'aria-label': issuable_type } .issuable-sidebar .block.issuable-sidebar-header - if signed_in diff --git a/app/views/shared/issuable/_sidebar_todo.html.haml b/app/views/shared/issuable/_sidebar_todo.html.haml index 7b5926fc186..1f05dcf83bc 100644 --- a/app/views/shared/issuable/_sidebar_todo.html.haml +++ b/app/views/shared/issuable/_sidebar_todo.html.haml @@ -6,7 +6,7 @@ - button_icon = has_todo ? todo_button_data[:mark_icon] : todo_button_data[:todo_icon] %button.issuable-todo-btn.js-issuable-todo{ type: 'button', - class: (is_collapsed ? 'btn-blank sidebar-collapsed-icon dont-change-state has-tooltip' : 'btn btn-default issuable-header-btn float-right'), + class: (is_collapsed ? 'btn-blank sidebar-collapsed-icon dont-change-state has-tooltip' : 'gl-button btn btn-default issuable-header-btn float-right'), title: button_title, 'aria-label' => button_title, data: todo_button_data } diff --git a/app/views/shared/issuable/csv_export/_button.html.haml b/app/views/shared/issuable/csv_export/_button.html.haml index 3584c9c1ed5..ab68e1d69b8 100644 --- a/app/views/shared/issuable/csv_export/_button.html.haml +++ b/app/views/shared/issuable/csv_export/_button.html.haml @@ -1,4 +1,4 @@ - if current_user - %button.csv_download_link.btn.gl-button.has-tooltip{ title: _('Export as CSV'), + %button.csv_download_link.btn.gl-button.btn-default.has-tooltip{ title: _('Export as CSV'), data: { toggle: 'modal', target: ".#{issuable_type}-export-modal", qa_selector: 'export_as_csv_button' } } = sprite_icon('export') diff --git a/app/views/shared/issuable/form/_template_selector.html.haml b/app/views/shared/issuable/form/_template_selector.html.haml index bf34ea4a1b2..24a235277fe 100644 --- a/app/views/shared/issuable/form/_template_selector.html.haml +++ b/app/views/shared/issuable/form/_template_selector.html.haml @@ -3,7 +3,7 @@ - return unless issuable && issuable_templates(issuable).any? .issuable-form-select-holder.selectbox.form-group - .js-issuable-selector-wrap{ data: { issuable_type: issuable.to_ability_name } } + .js-issuable-selector-wrap{ data: { issuable_type: issuable.to_ability_name, qa_selector: 'template_dropdown' } } = template_dropdown_tag(issuable) do %ul.dropdown-footer-list %li diff --git a/app/views/shared/issue_type/_details_header.html.haml b/app/views/shared/issue_type/_details_header.html.haml index d6226760ba5..7e150c544bd 100644 --- a/app/views/shared/issue_type/_details_header.html.haml +++ b/app/views/shared/issue_type/_details_header.html.haml @@ -1,12 +1,12 @@ .detail-page-header .detail-page-header-body .issuable-status-box.status-box.status-box-issue-closed{ class: issue_status_visibility(issuable, status_box: :closed) } - = sprite_icon('mobile-issue-close', css_class: 'gl-display-block gl-display-sm-none!') - .gl-display-none.gl-display-sm-block! + = sprite_icon('mobile-issue-close', css_class: 'gl-display-block gl-sm-display-none!') + .gl-display-none.gl-sm-display-block! = issue_closed_text(issuable, current_user) .issuable-status-box.status-box.status-box-open{ class: issue_status_visibility(issuable, status_box: :open) } - = sprite_icon('issue-open-m', css_class: 'gl-display-block gl-display-sm-none!') - %span.gl-display-none.gl-display-sm-block! + = sprite_icon('issue-open-m', css_class: 'gl-display-block gl-sm-display-none!') + %span.gl-display-none.gl-sm-display-block! = _('Open') .issuable-meta diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index 48a97ed66bb..4301bf01858 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -29,10 +29,10 @@ %div = render('shared/milestone_expired', milestone: milestone) - if milestone.group_milestone? - .label-badge.gl-bg-blue-50.d-inline-block + .gl-badge.badge-info.badge-pill = milestone.group.full_name - if milestone.project_milestone? - .label-badge.gl-bg-gray-50.d-inline-block + .gl-badge.badge-muted.badge-pill = milestone.project.full_name .col-sm-4.milestone-progress diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml index d9d7d18c732..661ace8feaa 100644 --- a/app/views/shared/milestones/_sidebar.html.haml +++ b/app/views/shared/milestones/_sidebar.html.haml @@ -1,7 +1,7 @@ - affix_offset = local_assigns.fetch(:affix_offset, "50") - project = local_assigns[:project] -%aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => affix_offset, "spy" => "affix", "always-show-toggle" => true }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' } +%aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => affix_offset, "spy" => "affix", "always-show-toggle" => true }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite', 'aria-label': _('Milestone') } .issuable-sidebar.milestone-sidebar .block.milestone-progress.issuable-sidebar-header %a.gutter-toggle.float-right.js-sidebar-toggle.has-tooltip{ role: "button", href: "#", "aria-label" => s_('MilestoneSidebar|Toggle sidebar'), title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left', boundary: 'viewport' } } diff --git a/app/views/shared/notes/_comment_button.html.haml b/app/views/shared/notes/_comment_button.html.haml index eb03608e18a..4f1aa3a7b01 100644 --- a/app/views/shared/notes/_comment_button.html.haml +++ b/app/views/shared/notes/_comment_button.html.haml @@ -1,15 +1,15 @@ - noteable_name = @note.noteable.human_class_name .float-left.btn-group.gl-mr-3.droplab-dropdown.comment-type-dropdown.js-comment-type-dropdown - %input.btn.btn-success.js-comment-button.js-comment-submit-button{ type: 'submit', value: _('Comment'), data: { qa_selector: 'comment_button' } } + %input.btn.gl-button.btn-success.js-comment-button.js-comment-submit-button{ type: 'submit', value: _('Comment'), data: { qa_selector: 'comment_button' } } - if @note.can_be_discussion_note? - = button_tag type: 'button', class: 'btn dropdown-toggle btn-success js-note-new-discussion js-disable-on-submit', data: { 'dropdown-trigger' => '#resolvable-comment-menu' }, 'aria-label' => _('Open comment type dropdown') do + = button_tag type: 'button', class: 'gl-button btn dropdown-toggle btn-success js-note-new-discussion js-disable-on-submit', data: { 'dropdown-trigger' => '#resolvable-comment-menu' }, 'aria-label' => _('Open comment type dropdown') do = sprite_icon('chevron-down') %ul#resolvable-comment-menu.dropdown-menu.dropdown-open-top{ data: { dropdown: true } } %li#comment.droplab-item-selected{ data: { value: '', 'submit-text' => _('Comment'), 'close-text' => _("Comment & close %{noteable_name}") % { noteable_name: noteable_name }, 'reopen-text' => _("Comment & reopen %{noteable_name}") % { noteable_name: noteable_name } } } - %button.btn.btn-transparent + %button.btn.gl-button.btn-transparent = sprite_icon('check', css_class: 'icon') .description %strong= _("Comment") @@ -19,7 +19,7 @@ %li.divider.droplab-item-ignore %li#discussion{ data: { value: 'DiscussionNote', 'submit-text' => _('Start thread'), 'close-text' => _("Start thread & close %{noteable_name}") % { noteable_name: noteable_name }, 'reopen-text' => _("Start thread & reopen %{noteable_name}") % { noteable_name: noteable_name } } } - %button.btn.btn-transparent + %button.btn.gl-button.btn-transparent = sprite_icon('check', css_class: 'icon') .description %strong= _("Start thread") diff --git a/app/views/shared/notes/_edit_form.html.haml b/app/views/shared/notes/_edit_form.html.haml index d783fa0d777..63c895a5a03 100644 --- a/app/views/shared/notes/_edit_form.html.haml +++ b/app/views/shared/notes/_edit_form.html.haml @@ -1,4 +1,4 @@ -.note-edit-form +.snippets.note-edit-form = form_tag '#', method: :put, class: 'edit-note common-note-form js-quick-submit' do = hidden_field_tag :target_id, '', class: 'js-form-target-id' = hidden_field_tag :target_type, '', class: 'js-form-target-type' @@ -9,6 +9,6 @@ .note-form-actions.clearfix .settings-message.note-edit-warning.js-finish-edit-warning = _("Finish editing this message first!") - = submit_tag _('Save comment'), class: 'btn btn-success js-comment-save-button', data: { qa_selector: 'save_comment_button' } - %button.btn.btn-cancel.note-edit-cancel{ type: 'button' } + = submit_tag _('Save comment'), class: 'gl-button btn btn-success js-comment-save-button', data: { qa_selector: 'save_comment_button' } + %button.btn.gl-button.btn-cancel.note-edit-cancel{ type: 'button' } = _("Cancel") diff --git a/app/views/shared/notes/_form.html.haml b/app/views/shared/notes/_form.html.haml index 2cf074b9d3f..6f54c54d0a9 100644 --- a/app/views/shared/notes/_form.html.haml +++ b/app/views/shared/notes/_form.html.haml @@ -38,5 +38,5 @@ .note-form-actions.clearfix = render partial: 'shared/notes/comment_button' - %a.btn.btn-cancel.js-close-discussion-note-form.hide{ role: "button", data: { cancel_text: _("Cancel") } } + %a.btn.gl-button.btn-cancel.js-close-discussion-note-form.hide{ role: "button", data: { cancel_text: _("Cancel") } } = _('Cancel') diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml index 1b03225d48d..f7f5c02370d 100644 --- a/app/views/shared/notes/_notes_with_form.html.haml +++ b/app/views/shared/notes/_notes_with_form.html.haml @@ -12,9 +12,6 @@ .timeline-entry-inner .flash-container.timeline-content - .timeline-icon.d-none.d-md-block - %a.author-link{ href: user_path(current_user) } - = image_tag avatar_icon_for_user(current_user), alt: current_user.to_reference, class: 'avatar s40' .timeline-content.timeline-content-form = render "shared/notes/form", view: diff_view, supports_autocomplete: autocomplete - elsif !current_user diff --git a/app/views/shared/ssh_keys/_key_delete.html.haml b/app/views/shared/ssh_keys/_key_delete.html.haml new file mode 100644 index 00000000000..1526e5d3eda --- /dev/null +++ b/app/views/shared/ssh_keys/_key_delete.html.haml @@ -0,0 +1,6 @@ +- if defined?(text) + = button_to text, '#', class: html_class, data: button_data +- else + = button_to '#', class: html_class, data: button_data do + %span.sr-only= _('Delete') + = sprite_icon('remove') diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml index c37a34f9be8..f3d9b9cfe27 100644 --- a/app/views/shared/web_hooks/_form.html.haml +++ b/app/views/shared/web_hooks/_form.html.haml @@ -2,10 +2,10 @@ .form-group = form.label :url, s_('Webhooks|URL'), class: 'label-bold' - = form.text_field :url, class: 'form-control', placeholder: 'http://example.com/trigger-ci.json' + = form.text_field :url, class: 'form-control gl-form-input', placeholder: 'http://example.com/trigger-ci.json' .form-group = form.label :token, s_('Webhooks|Secret Token'), class: 'label-bold' - = form.text_field :token, class: 'form-control', placeholder: '' + = form.text_field :token, class: 'form-control gl-form-input', placeholder: '' %p.form-text.text-muted = s_('Webhooks|Use this token to validate received payloads. It will be sent with the request in the X-Gitlab-Token HTTP header.') .form-group @@ -13,9 +13,9 @@ %ul.list-unstyled.gl-ml-6 %li = form.check_box :push_events, class: 'form-check-input' - = form.label :push_events, class: 'list-label form-check-label gl-ml-1' do + = form.label :push_events, class: 'list-label form-check-label gl-ml-1 gl-mb-3' do %strong= s_('Webhooks|Push events') - = form.text_field :push_events_branch_filter, class: 'form-control', placeholder: 'Branch name or wildcard pattern to trigger on (leave blank for all)' + = form.text_field :push_events_branch_filter, class: 'form-control gl-form-input', placeholder: 'Branch name or wildcard pattern to trigger on (leave blank for all)' %p.text-muted.gl-ml-1 = s_('Webhooks|This URL will be triggered by a push to the repository') %li @@ -50,6 +50,7 @@ = s_('Webhooks|This URL will be triggered when a confidential issue is created/updated/merged') - if @group = render_if_exists 'groups/hooks/member_events', form: form + = render_if_exists 'groups/hooks/subgroup_events', form: form %li = form.check_box :merge_requests_events, class: 'form-check-input' = form.label :merge_requests_events, class: 'list-label form-check-label gl-ml-1' do diff --git a/app/views/shared/web_hooks/_hook.html.haml b/app/views/shared/web_hooks/_hook.html.haml index 13fe8f76bd3..5437748a57e 100644 --- a/app/views/shared/web_hooks/_hook.html.haml +++ b/app/views/shared/web_hooks/_hook.html.haml @@ -12,5 +12,5 @@ .col-md-4.col-lg-5.text-right-md.gl-mt-2 %span>= render 'shared/web_hooks/test_button', hook: hook, button_class: 'btn-sm gl-mr-3' - %span>= link_to _('Edit'), edit_hook_path(hook), class: 'btn btn-sm gl-mr-3' - = link_to _('Delete'), destroy_hook_path(hook), data: { confirm: _('Are you sure?') }, method: :delete, class: 'btn btn-sm' + %span>= link_to _('Edit'), edit_hook_path(hook), class: 'gl-button btn btn-sm gl-mr-3' + = link_to _('Delete'), destroy_hook_path(hook), data: { confirm: _('Are you sure?') }, method: :delete, class: 'gl-button btn btn-sm' diff --git a/app/views/shared/web_hooks/_test_button.html.haml b/app/views/shared/web_hooks/_test_button.html.haml index c46b8a99886..a683f75c779 100644 --- a/app/views/shared/web_hooks/_test_button.html.haml +++ b/app/views/shared/web_hooks/_test_button.html.haml @@ -3,7 +3,7 @@ - triggers = hook.class.triggers .hook-test-button.dropdown.inline> - %button.btn{ 'data-toggle' => 'dropdown', class: button_class } + %button.btn.gl-button{ 'data-toggle' => 'dropdown', class: button_class } = _('Test') = sprite_icon('chevron-down') %ul.dropdown-menu.dropdown-menu-right{ role: 'menu' } diff --git a/app/views/shared/wikis/_sidebar.html.haml b/app/views/shared/wikis/_sidebar.html.haml index 4e9fdc8b95a..5f181371663 100644 --- a/app/views/shared/wikis/_sidebar.html.haml +++ b/app/views/shared/wikis/_sidebar.html.haml @@ -1,6 +1,6 @@ - editing ||= false -%aside.right-sidebar.right-sidebar-expanded.wiki-sidebar.js-wiki-sidebar.js-right-sidebar{ data: { "offset-top" => "50", "spy" => "affix" } } +%aside.right-sidebar.right-sidebar-expanded.wiki-sidebar.js-wiki-sidebar.js-right-sidebar{ data: { "offset-top" => "50", "spy" => "affix" }, 'aria-label': _('Wiki') } .sidebar-container .block.wiki-sidebar-header.gl-mb-3.w-100 %a.gutter-toggle.float-right.d-block.d-md-none.js-sidebar-wiki-toggle{ href: "#" } diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index 9f6b0bc2373..8ef7ce53c46 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -14,22 +14,23 @@ .cover-block.user-cover-block{ class: [('border-bottom' if profile_tabs.empty?)] } = render layout: 'users/cover_controls' do - if @user == current_user - = link_to profile_path, class: link_classes + 'btn gl-button btn-default has-tooltip', title: s_('UserProfile|Edit profile'), 'aria-label': 'Edit profile' do + = link_to profile_path, class: link_classes + 'btn gl-button btn-default btn-icon has-tooltip', title: s_('UserProfile|Edit profile'), 'aria-label': 'Edit profile' do = sprite_icon('pencil') - elsif current_user - if @user.abuse_report - %button{ class: link_classes + 'btn gl-button btn-danger', title: s_('UserProfile|Already reported for abuse'), + %button{ class: link_classes + 'btn gl-button btn-danger btn-icon', title: s_('UserProfile|Already reported for abuse'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } }> = sprite_icon('error') - else - = link_to new_abuse_report_path(user_id: @user.id, ref_url: request.referrer), class: link_classes + 'btn gl-button', + = link_to new_abuse_report_path(user_id: @user.id, ref_url: request.referrer), class: link_classes + 'btn gl-button btn-default btn-icon', title: s_('UserProfile|Report abuse'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do = sprite_icon('error') - if can?(current_user, :read_user_profile, @user) - = link_to user_path(@user, rss_url_options), class: link_classes + 'btn gl-button btn-svg btn-default has-tooltip', title: s_('UserProfile|Subscribe'), 'aria-label': 'Subscribe' do + = link_to user_path(@user, rss_url_options), class: link_classes + 'btn gl-button btn-default btn-icon has-tooltip', + title: s_('UserProfile|Subscribe'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do = sprite_icon('rss', css_class: 'qa-rss-icon') - if current_user && current_user.admin? - = link_to [:admin, @user], class: link_classes + 'btn gl-button btn-default', title: s_('UserProfile|View user in admin area'), + = link_to [:admin, @user], class: link_classes + 'btn gl-button btn-default btn-icon', title: s_('UserProfile|View user in admin area'), data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = sprite_icon('user') diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 4c4a314a1e6..8c26cb02d4b 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -251,6 +251,14 @@ :weight: 1 :idempotent: true :tags: [] +- :name: cronjob:namespaces_in_product_marketing_emails + :feature_category: :subgroups + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: + :tags: [] - :name: cronjob:namespaces_prune_aggregation_schedules :feature_category: :source_code_management :has_external_dependencies: @@ -1109,6 +1117,14 @@ :weight: 1 :idempotent: true :tags: [] +- :name: pipeline_background:ci_pipeline_artifacts_create_quality_report + :feature_category: :code_testing + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: pipeline_background:ci_pipeline_success_unlock_artifacts :feature_category: :continuous_integration :has_external_dependencies: @@ -1999,6 +2015,14 @@ :weight: 1 :idempotent: true :tags: [] +- :name: projects_git_garbage_collect + :feature_category: :gitaly + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: + :tags: [] - :name: prometheus_create_default_alerts :feature_category: :incident_management :has_external_dependencies: @@ -2223,6 +2247,14 @@ :weight: 1 :idempotent: true :tags: [] +- :name: wikis_git_garbage_collect + :feature_category: :gitaly + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: + :tags: [] - :name: x509_certificate_revoke :feature_category: :source_code_management :has_external_dependencies: diff --git a/app/workers/authorized_projects_worker.rb b/app/workers/authorized_projects_worker.rb index f5132459131..6e07d6d0f71 100644 --- a/app/workers/authorized_projects_worker.rb +++ b/app/workers/authorized_projects_worker.rb @@ -25,7 +25,7 @@ class AuthorizedProjectsWorker def perform(user_id) user = User.find_by(id: user_id) - user&.refresh_authorized_projects + user&.refresh_authorized_projects(source: self.class.name) end # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/build_hooks_worker.rb b/app/workers/build_hooks_worker.rb index 9693d3eb57f..ce4aa7229aa 100644 --- a/app/workers/build_hooks_worker.rb +++ b/app/workers/build_hooks_worker.rb @@ -10,7 +10,8 @@ class BuildHooksWorker # rubocop:disable Scalability/IdempotentWorker # rubocop: disable CodeReuse/ActiveRecord def perform(build_id) - Ci::Build.find_by(id: build_id) + Ci::Build.includes({ runner: :tags }) + .find_by(id: build_id) .try(:execute_hooks) end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/workers/bulk_import_worker.rb b/app/workers/bulk_import_worker.rb index 81099d4e5f7..e6bc54895a7 100644 --- a/app/workers/bulk_import_worker.rb +++ b/app/workers/bulk_import_worker.rb @@ -27,6 +27,10 @@ class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker end re_enqueue + rescue => e + Gitlab::ErrorTracking.track_exception(e, bulk_import_id: @bulk_import&.id) + + @bulk_import&.fail_op end private diff --git a/app/workers/bulk_imports/entity_worker.rb b/app/workers/bulk_imports/entity_worker.rb index 9b29ad8f326..5b41ccbdea1 100644 --- a/app/workers/bulk_imports/entity_worker.rb +++ b/app/workers/bulk_imports/entity_worker.rb @@ -18,6 +18,16 @@ module BulkImports BulkImports::Importers::GroupImporter.new(entity).execute end + + rescue => e + extra = { + bulk_import_id: entity&.bulk_import&.id, + entity_id: entity&.id + } + + Gitlab::ErrorTracking.track_exception(e, extra) + + entity&.fail_op end end end diff --git a/app/workers/ci/pipeline_artifacts/create_quality_report_worker.rb b/app/workers/ci/pipeline_artifacts/create_quality_report_worker.rb new file mode 100644 index 00000000000..810106e8d9c --- /dev/null +++ b/app/workers/ci/pipeline_artifacts/create_quality_report_worker.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Ci + module PipelineArtifacts + class CreateQualityReportWorker + include ApplicationWorker + + queue_namespace :pipeline_background + feature_category :code_testing + + idempotent! + + def perform(pipeline_id) + Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline| + Ci::PipelineArtifacts::CreateCodeQualityMrDiffReportService.new.execute(pipeline) + end + end + end + end +end diff --git a/app/workers/concerns/git_garbage_collect_methods.rb b/app/workers/concerns/git_garbage_collect_methods.rb new file mode 100644 index 00000000000..17a80d1ddb3 --- /dev/null +++ b/app/workers/concerns/git_garbage_collect_methods.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +module GitGarbageCollectMethods + extend ActiveSupport::Concern + + included do + include ApplicationWorker + + sidekiq_options retry: false + feature_category :gitaly + loggable_arguments 1, 2, 3 + end + + # Timeout set to 24h + LEASE_TIMEOUT = 86400 + + def perform(resource_id, task = :gc, lease_key = nil, lease_uuid = nil) + resource = find_resource(resource_id) + lease_key ||= default_lease_key(task, resource) + active_uuid = get_lease_uuid(lease_key) + + if active_uuid + return unless active_uuid == lease_uuid + + renew_lease(lease_key, active_uuid) + else + lease_uuid = try_obtain_lease(lease_key) + + return unless lease_uuid + end + + task = task.to_sym + + before_gitaly_call(task, resource) + gitaly_call(task, resource) + + # Refresh the branch cache in case garbage collection caused a ref lookup to fail + flush_ref_caches(resource) if gc?(task) + + update_repository_statistics(resource) if task != :pack_refs + + # In case pack files are deleted, release libgit2 cache and open file + # descriptors ASAP instead of waiting for Ruby garbage collection + resource.cleanup + ensure + cancel_lease(lease_key, lease_uuid) if lease_key.present? && lease_uuid.present? + end + + private + + def default_lease_key(task, resource) + "git_gc:#{task}:#{resource.class.name.underscore.pluralize}:#{resource.id}" + end + + def find_resource(id) + raise NotImplementedError + end + + def gc?(task) + task == :gc || task == :prune + end + + def try_obtain_lease(key) + ::Gitlab::ExclusiveLease.new(key, timeout: LEASE_TIMEOUT).try_obtain + end + + def renew_lease(key, uuid) + ::Gitlab::ExclusiveLease.new(key, uuid: uuid, timeout: LEASE_TIMEOUT).renew + end + + def cancel_lease(key, uuid) + ::Gitlab::ExclusiveLease.cancel(key, uuid) + end + + def get_lease_uuid(key) + ::Gitlab::ExclusiveLease.get_uuid(key) + end + + def before_gitaly_call(task, resource) + # no-op + end + + def gitaly_call(task, resource) + repository = resource.repository.raw_repository + + client = get_gitaly_client(task, repository) + + case task + when :prune, :gc + client.garbage_collect(bitmaps_enabled?, prune: task == :prune) + when :full_repack + client.repack_full(bitmaps_enabled?) + when :incremental_repack + client.repack_incremental + when :pack_refs + client.pack_refs + end + rescue GRPC::NotFound => e + Gitlab::GitLogger.error("#{__method__} failed:\nRepository not found") + raise Gitlab::Git::Repository::NoRepository.new(e) + rescue GRPC::BadStatus => e + Gitlab::GitLogger.error("#{__method__} failed:\n#{e}") + raise Gitlab::Git::CommandError.new(e) + end + + def get_gitaly_client(task, repository) + if task == :pack_refs + Gitlab::GitalyClient::RefService + else + Gitlab::GitalyClient::RepositoryService + end.new(repository) + end + + def bitmaps_enabled? + Gitlab::CurrentSettings.housekeeping_bitmaps_enabled + end + + def flush_ref_caches(resource) + resource.repository.expire_branches_cache + resource.repository.branch_names + resource.repository.has_visible_content? + end + + def update_repository_statistics(resource) + resource.repository.expire_statistics_caches + + return if Gitlab::Database.read_only? # GitGarbageCollectWorker may be run on a Geo secondary + + update_db_repository_statistics(resource) + end + + def update_db_repository_statistics(resource) + # no-op + end +end diff --git a/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb b/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb index 7c86b194574..b4afe53d4bc 100644 --- a/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb +++ b/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb @@ -25,6 +25,7 @@ module ContainerExpirationPolicies return unless container_repository log_extra_metadata_on_done(:container_repository_id, container_repository.id) + log_extra_metadata_on_done(:project_id, project.id) unless allowed_to_run?(container_repository) container_repository.cleanup_unscheduled! @@ -78,7 +79,7 @@ module ContainerExpirationPolicies end def project - container_repository&.project + container_repository.project end def container_repository diff --git a/app/workers/git_garbage_collect_worker.rb b/app/workers/git_garbage_collect_worker.rb index e1dcb16bafb..a2aab23db7b 100644 --- a/app/workers/git_garbage_collect_worker.rb +++ b/app/workers/git_garbage_collect_worker.rb @@ -1,5 +1,11 @@ # frozen_string_literal: true +# According to our docs, we can only remove workers on major releases +# https://docs.gitlab.com/ee/development/sidekiq_style_guide.html#removing-workers. +# +# We need to still maintain this until 14.0 but with the current functionality. +# +# In https://gitlab.com/gitlab-org/gitlab/-/issues/299290 we track that removal. class GitGarbageCollectWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker @@ -7,117 +13,7 @@ class GitGarbageCollectWorker # rubocop:disable Scalability/IdempotentWorker feature_category :gitaly loggable_arguments 1, 2, 3 - # Timeout set to 24h - LEASE_TIMEOUT = 86400 - def perform(project_id, task = :gc, lease_key = nil, lease_uuid = nil) - lease_key ||= "git_gc:#{task}:#{project_id}" - project = Project.find(project_id) - active_uuid = get_lease_uuid(lease_key) - - if active_uuid - return unless active_uuid == lease_uuid - - renew_lease(lease_key, active_uuid) - else - lease_uuid = try_obtain_lease(lease_key) - - return unless lease_uuid - end - - task = task.to_sym - - if gc?(task) - ::Projects::GitDeduplicationService.new(project).execute - cleanup_orphan_lfs_file_references(project) - end - - gitaly_call(task, project) - - # Refresh the branch cache in case garbage collection caused a ref lookup to fail - flush_ref_caches(project) if gc?(task) - - update_repository_statistics(project) if task != :pack_refs - - # In case pack files are deleted, release libgit2 cache and open file - # descriptors ASAP instead of waiting for Ruby garbage collection - project.cleanup - ensure - cancel_lease(lease_key, lease_uuid) if lease_key.present? && lease_uuid.present? - end - - private - - def gc?(task) - task == :gc || task == :prune - end - - def try_obtain_lease(key) - ::Gitlab::ExclusiveLease.new(key, timeout: LEASE_TIMEOUT).try_obtain - end - - def renew_lease(key, uuid) - ::Gitlab::ExclusiveLease.new(key, uuid: uuid, timeout: LEASE_TIMEOUT).renew - end - - def cancel_lease(key, uuid) - ::Gitlab::ExclusiveLease.cancel(key, uuid) - end - - def get_lease_uuid(key) - ::Gitlab::ExclusiveLease.get_uuid(key) - end - - def gitaly_call(task, project) - repository = project.repository.raw_repository - - client = if task == :pack_refs - Gitlab::GitalyClient::RefService.new(repository) - else - Gitlab::GitalyClient::RepositoryService.new(repository) - end - - case task - when :prune, :gc - client.garbage_collect(bitmaps_enabled?, prune: task == :prune) - when :full_repack - client.repack_full(bitmaps_enabled?) - when :incremental_repack - client.repack_incremental - when :pack_refs - client.pack_refs - end - rescue GRPC::NotFound => e - Gitlab::GitLogger.error("#{__method__} failed:\nRepository not found") - raise Gitlab::Git::Repository::NoRepository.new(e) - rescue GRPC::BadStatus => e - Gitlab::GitLogger.error("#{__method__} failed:\n#{e}") - raise Gitlab::Git::CommandError.new(e) - end - - def cleanup_orphan_lfs_file_references(project) - return if Gitlab::Database.read_only? # GitGarbageCollectWorker may be run on a Geo secondary - - ::Gitlab::Cleanup::OrphanLfsFileReferences.new(project, dry_run: false, logger: logger).run! - rescue => err - Gitlab::GitLogger.warn(message: "Cleaning up orphan LFS objects files failed", error: err.message) - Gitlab::ErrorTracking.track_and_raise_for_dev_exception(err) - end - - def flush_ref_caches(project) - project.repository.expire_branches_cache - project.repository.branch_names - project.repository.has_visible_content? - end - - def update_repository_statistics(project) - project.repository.expire_statistics_caches - return if Gitlab::Database.read_only? # GitGarbageCollectWorker may be run on a Geo secondary - - Projects::UpdateStatisticsService.new(project, nil, statistics: [:repository_size, :lfs_objects_size]).execute - end - - def bitmaps_enabled? - Gitlab::CurrentSettings.housekeeping_bitmaps_enabled + ::Projects::GitGarbageCollectWorker.new.perform(project_id, task, lease_key, lease_uuid) end end diff --git a/app/workers/issuable_export_csv_worker.rb b/app/workers/issuable_export_csv_worker.rb index 33452b14edb..eb96a78497c 100644 --- a/app/workers/issuable_export_csv_worker.rb +++ b/app/workers/issuable_export_csv_worker.rb @@ -10,29 +10,21 @@ class IssuableExportCsvWorker # rubocop:disable Scalability/IdempotentWorker def perform(type, current_user_id, project_id, params) user = User.find(current_user_id) project = Project.find(project_id) - finder_params = map_params(params, project_id) - export_service(type.to_sym, user, project, finder_params).email(user) + export_service(type, user, project, params).email(user) rescue ActiveRecord::RecordNotFound => error logger.error("Failed to export CSV (current_user_id:#{current_user_id}, project_id:#{project_id}): #{error.message}") end private - def map_params(params, project_id) - params - .symbolize_keys - .except(:sort) - .merge(project_id: project_id) - end - def export_service(type, user, project, params) - issuable_class = service_classes_for(type) - issuables = issuable_class[:finder].new(user, params).execute - issuable_class[:service].new(issuables, project) + issuable_classes = issuable_classes_for(type.to_sym) + issuables = issuable_classes[:finder].new(user, parse_params(params, project.id)).execute + issuable_classes[:service].new(issuables, project) end - def service_classes_for(type) + def issuable_classes_for(type) case type when :issue { finder: IssuesFinder, service: Issues::ExportCsvService } @@ -43,6 +35,13 @@ class IssuableExportCsvWorker # rubocop:disable Scalability/IdempotentWorker end end + def parse_params(params, project_id) + params + .symbolize_keys + .except(:sort) + .merge(project_id: project_id) + end + def type_error_message(type) "Type parameter must be :issue or :merge_request, it was #{type}" end diff --git a/app/workers/jira_connect/sync_builds_worker.rb b/app/workers/jira_connect/sync_builds_worker.rb index c1c749f6041..9cb5d5d247d 100644 --- a/app/workers/jira_connect/sync_builds_worker.rb +++ b/app/workers/jira_connect/sync_builds_worker.rb @@ -14,7 +14,6 @@ module JiraConnect pipeline = Ci::Pipeline.find_by_id(pipeline_id) return unless pipeline - return unless Feature.enabled?(:jira_sync_builds, pipeline.project) ::JiraConnect::SyncService .new(pipeline.project) diff --git a/app/workers/jira_connect/sync_deployments_worker.rb b/app/workers/jira_connect/sync_deployments_worker.rb index 0f261e29464..7272d35f4cb 100644 --- a/app/workers/jira_connect/sync_deployments_worker.rb +++ b/app/workers/jira_connect/sync_deployments_worker.rb @@ -14,7 +14,6 @@ module JiraConnect deployment = Deployment.find_by_id(deployment_id) return unless deployment - return unless Feature.enabled?(:jira_sync_deployments, deployment.project) ::JiraConnect::SyncService .new(deployment.project) diff --git a/app/workers/jira_connect/sync_feature_flags_worker.rb b/app/workers/jira_connect/sync_feature_flags_worker.rb index 7e98d0eada7..496b9f1626d 100644 --- a/app/workers/jira_connect/sync_feature_flags_worker.rb +++ b/app/workers/jira_connect/sync_feature_flags_worker.rb @@ -14,7 +14,6 @@ module JiraConnect feature_flag = ::Operations::FeatureFlag.find_by_id(feature_flag_id) return unless feature_flag - return unless Feature.enabled?(:jira_sync_feature_flags, feature_flag.project) ::JiraConnect::SyncService .new(feature_flag.project) diff --git a/app/workers/merge_request_cleanup_refs_worker.rb b/app/workers/merge_request_cleanup_refs_worker.rb index 6b991a2253f..fbd62ac0a91 100644 --- a/app/workers/merge_request_cleanup_refs_worker.rb +++ b/app/workers/merge_request_cleanup_refs_worker.rb @@ -7,6 +7,8 @@ class MergeRequestCleanupRefsWorker idempotent! def perform(merge_request_id) + return unless Feature.enabled?(:merge_request_refs_cleanup, default_enabled: false) + merge_request = MergeRequest.find_by_id(merge_request_id) unless merge_request diff --git a/app/workers/namespaces/in_product_marketing_emails_worker.rb b/app/workers/namespaces/in_product_marketing_emails_worker.rb new file mode 100644 index 00000000000..66d140928a7 --- /dev/null +++ b/app/workers/namespaces/in_product_marketing_emails_worker.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Namespaces + class InProductMarketingEmailsWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + include CronjobQueue # rubocop:disable Scalability/CronWorkerContext + + feature_category :subgroups + urgency :low + + def perform + return unless Gitlab::Experimentation.active?(:in_product_marketing_emails) + + Namespaces::InProductMarketingEmailsService.send_for_all_tracks_and_intervals + end + end +end diff --git a/app/workers/pipeline_hooks_worker.rb b/app/workers/pipeline_hooks_worker.rb index 85ecdd02fb5..b8dd4768cfb 100644 --- a/app/workers/pipeline_hooks_worker.rb +++ b/app/workers/pipeline_hooks_worker.rb @@ -10,7 +10,8 @@ class PipelineHooksWorker # rubocop:disable Scalability/IdempotentWorker # rubocop: disable CodeReuse/ActiveRecord def perform(pipeline_id) - Ci::Pipeline.find_by(id: pipeline_id) + Ci::Pipeline.includes({ builds: { runner: :tags } }) + .find_by(id: pipeline_id) .try(:execute_hooks) end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/workers/projects/git_garbage_collect_worker.rb b/app/workers/projects/git_garbage_collect_worker.rb new file mode 100644 index 00000000000..4f908529b34 --- /dev/null +++ b/app/workers/projects/git_garbage_collect_worker.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Projects + class GitGarbageCollectWorker # rubocop:disable Scalability/IdempotentWorker + extend ::Gitlab::Utils::Override + include GitGarbageCollectMethods + + private + + override :find_resource + def find_resource(id) + Project.find(id) + end + + override :before_gitaly_call + def before_gitaly_call(task, resource) + return unless gc?(task) + + ::Projects::GitDeduplicationService.new(resource).execute + cleanup_orphan_lfs_file_references(resource) + end + + def cleanup_orphan_lfs_file_references(resource) + return if Gitlab::Database.read_only? # GitGarbageCollectWorker may be run on a Geo secondary + + ::Gitlab::Cleanup::OrphanLfsFileReferences.new(resource, dry_run: false, logger: logger).run! + rescue => err + Gitlab::GitLogger.warn(message: "Cleaning up orphan LFS objects files failed", error: err.message) + Gitlab::ErrorTracking.track_and_raise_for_dev_exception(err) + end + + override :update_db_repository_statistics + def update_db_repository_statistics(resource) + Projects::UpdateStatisticsService.new(resource, nil, statistics: [:repository_size, :lfs_objects_size]).execute + end + end +end diff --git a/app/workers/schedule_merge_request_cleanup_refs_worker.rb b/app/workers/schedule_merge_request_cleanup_refs_worker.rb index 59b8993f78f..967032f99e5 100644 --- a/app/workers/schedule_merge_request_cleanup_refs_worker.rb +++ b/app/workers/schedule_merge_request_cleanup_refs_worker.rb @@ -16,6 +16,7 @@ class ScheduleMergeRequestCleanupRefsWorker def perform return if Gitlab::Database.read_only? + return unless Feature.enabled?(:merge_request_refs_cleanup, default_enabled: false) ids = MergeRequest::CleanupSchedule.scheduled_merge_request_ids(LIMIT).map { |id| [id] } diff --git a/app/workers/wikis/git_garbage_collect_worker.rb b/app/workers/wikis/git_garbage_collect_worker.rb new file mode 100644 index 00000000000..1b455c50618 --- /dev/null +++ b/app/workers/wikis/git_garbage_collect_worker.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Wikis + class GitGarbageCollectWorker # rubocop:disable Scalability/IdempotentWorker + extend ::Gitlab::Utils::Override + include GitGarbageCollectMethods + + private + + override :find_resource + def find_resource(id) + Project.find(id).wiki + end + + override :update_db_repository_statistics + def update_db_repository_statistics(resource) + Projects::UpdateStatisticsService.new(resource.container, nil, statistics: [:wiki_size]).execute + end + end +end |