diff options
Diffstat (limited to 'app')
1869 files changed, 33072 insertions, 10065 deletions
diff --git a/app/assets/images/bot_avatars/alert-bot.png b/app/assets/images/bot_avatars/alert-bot.png Binary files differnew file mode 100644 index 00000000000..985d67d6179 --- /dev/null +++ b/app/assets/images/bot_avatars/alert-bot.png diff --git a/app/assets/images/bot_avatars/security-bot.png b/app/assets/images/bot_avatars/security-bot.png Binary files differnew file mode 100644 index 00000000000..0709f62f07b --- /dev/null +++ b/app/assets/images/bot_avatars/security-bot.png diff --git a/app/assets/images/bot_avatars/support-bot.png b/app/assets/images/bot_avatars/support-bot.png Binary files differnew file mode 100644 index 00000000000..1335205c191 --- /dev/null +++ b/app/assets/images/bot_avatars/support-bot.png diff --git a/app/assets/images/confluence.svg b/app/assets/images/confluence.svg new file mode 100644 index 00000000000..f51d4318b6b --- /dev/null +++ b/app/assets/images/confluence.svg @@ -0,0 +1 @@ +<svg height="16" width="16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a"><stop offset="0" stop-color="#344563"/><stop offset=".68" stop-color="#637088"/><stop offset="1" stop-color="#7a869a"/></linearGradient><linearGradient id="b" gradientUnits="userSpaceOnUse" x1="14.873" x2="5.739" xlink:href="#a" y1="15.883" y2="10.625"/><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="-168376" x2="-168177" xlink:href="#a" y1="-6722.4" y2="-6493.53"/><path d="m1.517 11.68c-.15.243-.32.53-.453.757a.462.462 0 0 0 .155.63l3.013 1.863a.466.466 0 0 0 .645-.158l.445-.735c1.197-1.97 2.402-1.732 4.571-.703l2.995 1.424a.468.468 0 0 0 .626-.232l1.448-3.24a.466.466 0 0 0 -.229-.606c-.633-.298-1.89-.89-3.016-1.434-4.089-2.004-7.551-1.86-10.2 2.434z" fill="url(#b)"/><path d="m14.479 4.315c.15-.243.324-.53.456-.758a.46.46 0 0 0 -.158-.63l-3.025-1.857a.464.464 0 0 0 -.644.158 22.81 22.81 0 0 1 -.446.736c-1.196 1.972-2.4 1.733-4.567.703l-2.993-1.424a.468.468 0 0 0 -.625.231l-1.437 3.246a.46.46 0 0 0 .225.607c.633.298 1.892.89 3.014 1.435 4.097 1.99 7.556 1.858 10.199-2.446z" fill="url(#c)"/></svg>
\ No newline at end of file diff --git a/app/assets/images/logos/jira-gray.svg b/app/assets/images/logos/jira-gray.svg new file mode 100644 index 00000000000..0e7069f2bd2 --- /dev/null +++ b/app/assets/images/logos/jira-gray.svg @@ -0,0 +1 @@ +<svg id="Logos" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="80" height="80" viewBox="0 0 80 80"><defs><style>.cls-1{fill:#7a869a;}.cls-2{fill:url(#linear-gradient);}.cls-3{fill:url(#linear-gradient-2);}</style><linearGradient id="linear-gradient" x1="38.11" y1="18.54" x2="23.17" y2="33.48" gradientUnits="userSpaceOnUse"><stop offset="0.18" stop-color="#344563"/><stop offset="1" stop-color="#7a869a"/></linearGradient><linearGradient id="linear-gradient-2" x1="42.07" y1="61.47" x2="56.98" y2="46.55" xlink:href="#linear-gradient"/></defs><title>jira software-icon-gradient-neutral</title><path class="cls-1" d="M74.18,38,43,6.9l-3-3h0L16.58,27.32h0L5.86,38a2.86,2.86,0,0,0,0,4.05L27.28,63.51,40,76.25,63.47,52.81l.36-.36L74.18,42.09A2.86,2.86,0,0,0,74.18,38ZM40,50.77l-10.7-10.7L40,29.37l10.7,10.7Z"/><path class="cls-2" d="M40,29.37A18,18,0,0,1,40,4L16.54,27.37,29.28,40.11,40,29.37Z"/><path class="cls-3" d="M50.75,40,40,50.77a18,18,0,0,1,0,25.48h0L63.5,52.78Z"/></svg> diff --git a/app/assets/javascripts/alert_management/components/alert_details.vue b/app/assets/javascripts/alert_management/components/alert_details.vue index ed6b4b7fdb2..0731349630c 100644 --- a/app/assets/javascripts/alert_management/components/alert_details.vue +++ b/app/assets/javascripts/alert_management/components/alert_details.vue @@ -12,18 +12,21 @@ import { GlTable, } from '@gitlab/ui'; import { s__ } from '~/locale'; -import query from '../graphql/queries/details.query.graphql'; +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 createIssueQuery from '../graphql/mutations/create_issue_from_alert.graphql'; +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 SystemNote from './system_notes/system_note.vue'; import AlertSidebar from './alert_sidebar.vue'; +import AlertMetrics from './alert_metrics.vue'; const containerEl = document.querySelector('.page-with-contextual-sidebar'); @@ -34,6 +37,7 @@ export default { ), fullAlertDetailsTitle: s__('AlertManagement|Alert details'), overviewTitle: s__('AlertManagement|Overview'), + metricsTitle: s__('AlertManagement|Metrics'), reportedAt: s__('AlertManagement|Reported %{when}'), reportedAtWithTool: s__('AlertManagement|Reported %{when} by %{tool}'), }, @@ -51,25 +55,29 @@ export default { TimeAgoTooltip, AlertSidebar, SystemNote, + AlertMetrics, }, - props: { + inject: { + projectPath: { + default: '', + }, alertId: { type: String, - required: true, + default: '', }, - projectPath: { + projectId: { type: String, - required: true, + default: '', }, projectIssuesPath: { type: String, - required: true, + default: '', }, }, apollo: { alert: { fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, - query, + query: alertQuery, variables() { return { fullPath: this.projectPath, @@ -84,15 +92,18 @@ export default { Sentry.captureException(error); }, }, + sidebarStatus: { + query: sidebarStatusQuery, + }, }, data() { return { alert: null, errored: false, + sidebarStatus: false, isErrorDismissed: false, createIssueError: '', issueCreationInProgress: false, - sidebarCollapsed: false, sidebarErrorMessage: '', }; }, @@ -128,10 +139,10 @@ export default { this.sidebarErrorMessage = ''; }, toggleSidebar() { - this.sidebarCollapsed = !this.sidebarCollapsed; + this.$apollo.mutate({ mutation: toggleSidebarStatusMutation }); toggleContainerClasses(containerEl, { - 'right-sidebar-collapsed': this.sidebarCollapsed, - 'right-sidebar-expanded': !this.sidebarCollapsed, + 'right-sidebar-collapsed': !this.sidebarStatus, + 'right-sidebar-expanded': this.sidebarStatus, }); }, handleAlertSidebarError(errorMessage) { @@ -143,7 +154,7 @@ export default { this.$apollo .mutate({ - mutation: createIssueQuery, + mutation: createIssueMutation, variables: { iid: this.alert.iid, projectPath: this.projectPath, @@ -169,9 +180,6 @@ export default { const { category, action } = trackAlertsDetailsViewsOptions; Tracking.event(category, action); }, - alertRefresh() { - this.$apollo.queries.alert.refetch(); - }, }, }; </script> @@ -179,7 +187,7 @@ export default { <template> <div> <gl-alert v-if="showErrorMsg" variant="danger" @dismiss="dismissError"> - {{ sidebarErrorMessage || $options.i18n.errorMsg }} + <p v-html="sidebarErrorMessage || $options.i18n.errorMsg"></p> </gl-alert> <gl-alert v-if="createIssueError" @@ -193,10 +201,10 @@ export default { <div v-if="alert" class="alert-management-details gl-relative" - :class="{ 'pr-sm-8': sidebarCollapsed }" + :class="{ 'pr-sm-8': sidebarStatus }" > <div - class="gl-display-flex gl-justify-content-space-between gl-align-items-baseline gl-px-1 py-3 py-md-4 gl-border-b-1 gl-border-b-gray-200 gl-border-b-solid flex-column flex-sm-row" + class="gl-display-flex gl-justify-content-space-between gl-align-items-baseline gl-px-1 py-3 py-md-4 gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid flex-column flex-sm-row" > <div data-testid="alert-header" @@ -324,14 +332,14 @@ export default { </template> </gl-table> </gl-tab> + <gl-tab data-testId="metricsTab" :title="$options.i18n.metricsTitle"> + <alert-metrics :dashboard-url="alert.metricsDashboardUrl" /> + </gl-tab> </gl-tabs> <alert-sidebar - :project-path="projectPath" :alert="alert" - :sidebar-collapsed="sidebarCollapsed" - @alert-refresh="alertRefresh" @toggle-sidebar="toggleSidebar" - @alert-sidebar-error="handleAlertSidebarError" + @alert-error="handleAlertSidebarError" /> </div> </div> diff --git a/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue b/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue new file mode 100644 index 00000000000..13b6a8e6653 --- /dev/null +++ b/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue @@ -0,0 +1,90 @@ +<script> +import { GlEmptyState, GlButton } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + i18n: { + emptyState: { + opsgenie: { + title: s__('AlertManagement|Opsgenie is enabled'), + info: s__( + 'AlertManagement|You have enabled the Opsgenie integration. Your alerts will be visible directly in Opsgenie.', + ), + buttonText: s__('AlertManagement|View alerts in Opsgenie'), + }, + gitlab: { + title: s__('AlertManagement|Surface alerts in GitLab'), + info: s__( + 'AlertManagement|Display alerts from all your monitoring tools directly within GitLab. Streamline the investigation of your alerts and the escalation of alerts to incidents.', + ), + buttonText: s__('AlertManagement|Authorize external service'), + }, + }, + moreInformation: s__('AlertManagement|More information'), + }, + components: { + GlEmptyState, + GlButton, + }, + props: { + enableAlertManagementPath: { + type: String, + required: true, + }, + userCanEnableAlertManagement: { + type: Boolean, + required: true, + }, + emptyAlertSvgPath: { + type: String, + required: true, + }, + opsgenieMvcEnabled: { + type: Boolean, + required: false, + default: false, + }, + opsgenieMvcTargetUrl: { + type: String, + required: false, + default: '', + }, + }, + computed: { + emptyState() { + return { + ...(this.opsgenieMvcEnabled + ? this.$options.i18n.emptyState.opsgenie + : this.$options.i18n.emptyState.gitlab), + link: this.opsgenieMvcEnabled ? this.opsgenieMvcTargetUrl : this.enableAlertManagementPath, + }; + }, + alertsCanBeEnabled() { + return this.userCanEnableAlertManagement || this.opsgenieMvcEnabled; + }, + }, +}; +</script> +<template> + <div> + <gl-empty-state :title="emptyState.title" :svg-path="emptyAlertSvgPath"> + <template #description> + <div class="gl-display-block"> + <span>{{ emptyState.info }}</span> + <a + v-if="!opsgenieMvcEnabled" + href="/help/user/project/operations/alert_management.html" + target="_blank" + > + {{ $options.i18n.moreInformation }} + </a> + </div> + <div v-if="alertsCanBeEnabled" class="gl-display-block center gl-pt-4"> + <gl-button category="primary" variant="success" :href="emptyState.link"> + {{ emptyState.buttonText }} + </gl-button> + </div> + </template> + </gl-empty-state> + </div> +</template> diff --git a/app/assets/javascripts/alert_management/components/alert_management_list_wrapper.vue b/app/assets/javascripts/alert_management/components/alert_management_list_wrapper.vue new file mode 100644 index 00000000000..094f33fed3b --- /dev/null +++ b/app/assets/javascripts/alert_management/components/alert_management_list_wrapper.vue @@ -0,0 +1,75 @@ +<script> +import Tracking from '~/tracking'; +import { trackAlertListViewsOptions } from '../constants'; +import AlertManagementEmptyState from './alert_management_empty_state.vue'; +import AlertManagementTable from './alert_management_table.vue'; + +export default { + components: { + AlertManagementEmptyState, + AlertManagementTable, + }, + props: { + projectPath: { + type: String, + required: true, + }, + alertManagementEnabled: { + type: Boolean, + required: true, + }, + enableAlertManagementPath: { + type: String, + required: true, + }, + populatingAlertsHelpUrl: { + type: String, + required: true, + }, + userCanEnableAlertManagement: { + type: Boolean, + required: true, + }, + emptyAlertSvgPath: { + type: String, + required: true, + }, + opsgenieMvcEnabled: { + type: Boolean, + required: false, + default: false, + }, + opsgenieMvcTargetUrl: { + type: String, + required: false, + default: '', + }, + }, + mounted() { + this.trackPageViews(); + }, + methods: { + trackPageViews() { + const { category, action } = trackAlertListViewsOptions; + Tracking.event(category, action); + }, + }, +}; +</script> +<template> + <div> + <alert-management-table + v-if="alertManagementEnabled" + :populating-alerts-help-url="populatingAlertsHelpUrl" + :project-path="projectPath" + /> + <alert-management-empty-state + v-else + :empty-alert-svg-path="emptyAlertSvgPath" + :enable-alert-management-path="enableAlertManagementPath" + :user-can-enable-alert-management="userCanEnableAlertManagement" + :opsgenie-mvc-enabled="opsgenieMvcEnabled" + :opsgenie-mvc-target-url="opsgenieMvcTargetUrl" + /> + </div> +</template> diff --git a/app/assets/javascripts/alert_management/components/alert_management_list.vue b/app/assets/javascripts/alert_management/components/alert_management_table.vue index 37901c21f9b..7dd3d7b5dc3 100644 --- a/app/assets/javascripts/alert_management/components/alert_management_list.vue +++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue @@ -1,23 +1,24 @@ <script> import { - GlEmptyState, - GlDeprecatedButton, GlLoadingIcon, GlTable, GlAlert, GlIcon, - GlDropdown, - GlDropdownItem, + GlLink, GlTabs, GlTab, GlBadge, GlPagination, + GlSearchBoxByType, + GlSprintf, } from '@gitlab/ui'; -import createFlash from '~/flash'; -import { s__ } from '~/locale'; +import { __, s__ } from '~/locale'; +import { debounce, trim } from 'lodash'; import { joinPaths, visitUrl } from '~/lib/utils/url_utility'; import { fetchPolicies } from '~/lib/graphql'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import { convertToSnakeCase } from '~/lib/utils/text_utility'; +import Tracking from '~/tracking'; import getAlerts from '../graphql/queries/get_alerts.query.graphql'; import getAlertsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql'; import { @@ -27,11 +28,10 @@ import { trackAlertListViewsOptions, trackAlertStatusUpdateOptions, } from '../constants'; -import updateAlertStatus from '../graphql/mutations/update_alert_status.graphql'; -import { convertToSnakeCase } from '~/lib/utils/text_utility'; -import Tracking from '~/tracking'; +import AlertStatus from './alert_status.vue'; -const tdClass = 'table-col gl-display-flex d-md-table-cell gl-align-items-center'; +const tdClass = + 'table-col gl-display-flex d-md-table-cell gl-align-items-center gl-white-space-nowrap'; const thClass = 'gl-hover-bg-blue-50'; const bodyTrClass = 'gl-border-1 gl-border-t-solid gl-border-gray-100 gl-hover-bg-blue-50 gl-hover-cursor-pointer gl-hover-border-b-solid gl-hover-border-blue-200'; @@ -44,54 +44,57 @@ const initialPaginationState = { lastPageSize: null, }; +const TWELVE_HOURS_IN_MS = 12 * 60 * 60 * 1000; + export default { i18n: { noAlertsMsg: s__( - "AlertManagement|No alerts available to display. If you think you're seeing this message in error, refresh the page.", + 'AlertManagement|No alerts available to display. See %{linkStart}enabling alert management%{linkEnd} for more information on adding alerts to the list.', ), errorMsg: s__( "AlertManagement|There was an error displaying the alerts. Confirm your endpoint's configuration details to ensure alerts appear.", ), + searchPlaceholder: __('Search or filter results...'), }, fields: [ { key: 'severity', label: s__('AlertManagement|Severity'), tdClass: `${tdClass} rounded-top text-capitalize`, - thClass, + thClass: `${thClass} gl-w-eighth`, sortable: true, }, { key: 'startedAt', label: s__('AlertManagement|Start time'), - thClass: `${thClass} js-started-at`, - tdClass, - sortable: true, - }, - { - key: 'endedAt', - label: s__('AlertManagement|End time'), - thClass, + thClass: `${thClass} js-started-at w-15p`, tdClass, sortable: true, }, { key: 'title', label: s__('AlertManagement|Alert'), - thClass: `${thClass} w-30p gl-pointer-events-none`, + thClass: `gl-pointer-events-none`, tdClass, - sortable: false, }, { key: 'eventCount', label: s__('AlertManagement|Events'), - thClass: `${thClass} text-right gl-pr-9 w-3rem`, + thClass: `${thClass} text-right gl-w-12`, tdClass: `${tdClass} text-md-right`, sortable: true, }, { + key: 'issue', + label: s__('AlertManagement|Issue'), + thClass: 'gl-w-12 gl-pointer-events-none', + tdClass, + sortable: false, + }, + { key: 'assignees', label: s__('AlertManagement|Assignees'), + thClass: 'gl-w-eighth gl-pointer-events-none', tdClass, }, { @@ -102,46 +105,29 @@ export default { sortable: true, }, ], - statuses: { - TRIGGERED: s__('AlertManagement|Triggered'), - ACKNOWLEDGED: s__('AlertManagement|Acknowledged'), - RESOLVED: s__('AlertManagement|Resolved'), - }, severityLabels: ALERTS_SEVERITY_LABELS, statusTabs: ALERTS_STATUS_TABS, components: { - GlEmptyState, GlLoadingIcon, GlTable, GlAlert, - GlDeprecatedButton, TimeAgo, - GlDropdown, - GlDropdownItem, GlIcon, + GlLink, GlTabs, GlTab, GlBadge, GlPagination, + GlSearchBoxByType, + GlSprintf, + AlertStatus, }, props: { projectPath: { type: String, required: true, }, - alertManagementEnabled: { - type: Boolean, - required: true, - }, - enableAlertManagementPath: { - type: String, - required: true, - }, - userCanEnableAlertManagement: { - type: Boolean, - required: true, - }, - emptyAlertSvgPath: { + populatingAlertsHelpUrl: { type: String, required: true, }, @@ -152,6 +138,7 @@ export default { query: getAlerts, variables() { return { + searchTerm: this.searchTerm, projectPath: this.projectPath, statuses: this.statusFilter, sort: this.sort, @@ -164,9 +151,20 @@ export default { update(data) { const { alertManagementAlerts: { nodes: list = [], pageInfo = {} } = {} } = data.project || {}; + const now = new Date(); + + const listWithData = list.map(alert => { + const then = new Date(alert.startedAt); + const diff = now - then; + + return { + ...alert, + isNew: diff < TWELVE_HOURS_IN_MS, + }; + }); return { - list, + list: listWithData, pageInfo, }; }, @@ -178,6 +176,7 @@ export default { query: getAlertsCountByStatus, variables() { return { + searchTerm: this.searchTerm, projectPath: this.projectPath, }; }, @@ -188,7 +187,9 @@ export default { }, data() { return { + searchTerm: '', errored: false, + errorMessage: '', isAlertDismissed: false, isErrorAlertDismissed: false, sort: 'STARTED_AT_DESC', @@ -203,7 +204,11 @@ export default { computed: { showNoAlertsMsg() { return ( - !this.errored && !this.loading && this.alertsCount?.all === 0 && !this.isAlertDismissed + !this.errored && + !this.loading && + this.alertsCount?.all === 0 && + !this.searchTerm && + !this.isAlertDismissed ); }, showErrorMsg() { @@ -215,9 +220,6 @@ export default { hasAlerts() { return this.alerts?.list?.length; }, - tbodyTrClass() { - return !this.loading && this.hasAlerts ? bodyTrClass : ''; - }, showPaginationControls() { return Boolean(this.prevPage || this.nextPage); }, @@ -249,30 +251,13 @@ export default { this.resetPagination(); this.sort = `${sortingColumn}_${sortingDirection}`; }, - updateAlertStatus(status, iid) { - this.$apollo - .mutate({ - mutation: updateAlertStatus, - variables: { - iid, - status: status.toUpperCase(), - projectPath: this.projectPath, - }, - }) - .then(() => { - this.trackStatusUpdate(status); - this.$apollo.queries.alerts.refetch(); - this.$apollo.queries.alertsCount.refetch(); - this.resetPagination(); - }) - .catch(() => { - createFlash( - s__( - 'AlertManagement|There was an error while updating the status of the alert. Please try again.', - ), - ); - }); - }, + onInputChange: debounce(function debounceSearch(input) { + const trimmedInput = trim(input); + if (trimmedInput !== this.searchTerm) { + this.resetPagination(); + this.searchTerm = trimmedInput; + } + }, 500), navigateToAlertDetails({ iid }) { return visitUrl(joinPaths(window.location.pathname, iid, 'details')); }, @@ -290,6 +275,9 @@ export default { ? assignees.nodes[0]?.username : s__('AlertManagement|Unassigned'); }, + getIssueLink(item) { + return joinPaths('/', this.projectPath, '-', 'issues', item.issueIid); + }, handlePageChange(page) { const { startCursor, endCursor } = this.alerts.pageInfo; @@ -312,20 +300,49 @@ export default { resetPagination() { this.pagination = initialPaginationState; }, + tbodyTrClass(item) { + return { + [bodyTrClass]: !this.loading && this.hasAlerts, + 'new-alert': item?.isNew, + }; + }, + handleAlertError(errorMessage) { + this.errored = true; + this.errorMessage = errorMessage; + }, + dismissError() { + this.isErrorAlertDismissed = true; + this.errorMessage = ''; + }, }, }; </script> <template> <div> - <div v-if="alertManagementEnabled" class="alert-management-list"> + <div class="alert-management-list"> <gl-alert v-if="showNoAlertsMsg" @dismiss="isAlertDismissed = true"> - {{ $options.i18n.noAlertsMsg }} + <gl-sprintf :message="$options.i18n.noAlertsMsg"> + <template #link="{ content }"> + <gl-link + class="gl-display-inline-block" + :href="populatingAlertsHelpUrl" + target="_blank" + > + {{ content }} + </gl-link> + </template> + </gl-sprintf> </gl-alert> - <gl-alert v-if="showErrorMsg" variant="danger" @dismiss="isErrorAlertDismissed = true"> - {{ $options.i18n.errorMsg }} + <gl-alert + v-if="showErrorMsg" + variant="danger" + data-testid="alert-error" + @dismiss="dismissError" + > + <p v-html="errorMessage || $options.i18n.errorMsg"></p> </gl-alert> - <gl-tabs @input="filterAlertsByStatus"> + <gl-tabs content-class="gl-p-0" @input="filterAlertsByStatus"> <gl-tab v-for="tab in $options.statusTabs" :key="tab.status"> <template slot="title"> <span>{{ tab.title }}</span> @@ -336,11 +353,19 @@ export default { </gl-tab> </gl-tabs> + <div class="gl-bg-gray-10 gl-p-5 gl-border-b-solid gl-border-b-1 gl-border-gray-100"> + <gl-search-box-by-type + class="gl-bg-white" + :placeholder="$options.i18n.searchPlaceholder" + @input="onInputChange" + /> + </div> + <h4 class="d-block d-md-none my-3"> {{ s__('AlertManagement|Alerts') }} </h4> <gl-table - class="alert-management-table mt-3" + class="alert-management-table" :items="alerts ? alerts.list : []" :fields="$options.fields" :show-empty="true" @@ -352,6 +377,7 @@ export default { :sort-desc.sync="sortDesc" :sort-by.sync="sortBy" sort-icon-left + fixed @row-clicked="navigateToAlertDetails" @sort-changed="fetchSortedData" > @@ -374,16 +400,19 @@ export default { <time-ago v-if="item.startedAt" :time="item.startedAt" /> </template> - <template #cell(endedAt)="{ item }"> - <time-ago v-if="item.endedAt" :time="item.endedAt" /> - </template> - <template #cell(eventCount)="{ item }"> {{ item.eventCount }} </template> <template #cell(title)="{ item }"> - <div class="gl-max-w-full text-truncate">{{ item.title }}</div> + <div class="gl-max-w-full text-truncate" :title="item.title">{{ item.title }}</div> + </template> + + <template #cell(issue)="{ item }"> + <gl-link v-if="item.issueIid" data-testid="issueField" :href="getIssueLink(item)"> + #{{ item.issueIid }} + </gl-link> + <div v-else data-testid="issueField">{{ s__('AlertManagement|None') }}</div> </template> <template #cell(assignees)="{ item }"> @@ -393,22 +422,12 @@ export default { </template> <template #cell(status)="{ item }"> - <gl-dropdown :text="$options.statuses[item.status]" class="w-100" right> - <gl-dropdown-item - v-for="(label, field) in $options.statuses" - :key="field" - @click="updateAlertStatus(label, item.iid)" - > - <span class="d-flex"> - <gl-icon - class="flex-shrink-0 append-right-4" - :class="{ invisible: label.toUpperCase() !== item.status }" - name="mobile-issue-close" - /> - {{ label }} - </span> - </gl-dropdown-item> - </gl-dropdown> + <alert-status + :alert="item" + :project-path="projectPath" + :is-sidebar="false" + @alert-error="handleAlertError" + /> </template> <template #empty> @@ -426,36 +445,9 @@ export default { :prev-page="prevPage" :next-page="nextPage" align="center" - class="gl-pagination prepend-top-default" + class="gl-pagination gl-mt-3" @input="handlePageChange" /> </div> - <gl-empty-state - v-else - :title="s__('AlertManagement|Surface alerts in GitLab')" - :svg-path="emptyAlertSvgPath" - > - <template #description> - <div class="d-block"> - <span>{{ - s__( - 'AlertManagement|Display alerts from all your monitoring tools directly within GitLab. Streamline the investigation of your alerts and the escalation of alerts to incidents.', - ) - }}</span> - <a href="/help/user/project/operations/alert_management.html" target="_blank"> - {{ s__('AlertManagement|More information') }} - </a> - </div> - <div v-if="userCanEnableAlertManagement" class="d-block center pt-4"> - <gl-deprecated-button - category="primary" - variant="success" - :href="enableAlertManagementPath" - > - {{ s__('AlertManagement|Authorize external service') }} - </gl-deprecated-button> - </div> - </template> - </gl-empty-state> </div> </template> diff --git a/app/assets/javascripts/alert_management/components/alert_metrics.vue b/app/assets/javascripts/alert_management/components/alert_metrics.vue new file mode 100644 index 00000000000..c5b40edc672 --- /dev/null +++ b/app/assets/javascripts/alert_management/components/alert_metrics.vue @@ -0,0 +1,56 @@ +<script> +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as Sentry from '@sentry/browser'; + +Vue.use(Vuex); + +export default { + props: { + dashboardUrl: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + metricEmbedComponent: null, + namespace: 'alertMetrics', + }; + }, + mounted() { + if (this.dashboardUrl) { + Promise.all([ + import('~/monitoring/components/embeds/metric_embed.vue'), + import('~/monitoring/stores'), + ]) + .then(([{ default: MetricEmbed }, { monitoringDashboard }]) => { + this.$store = new Vuex.Store({ + modules: { + [this.namespace]: monitoringDashboard, + }, + }); + this.metricEmbedComponent = MetricEmbed; + }) + .catch(e => Sentry.captureException(e)); + } + }, +}; +</script> + +<template> + <div class="gl-py-3"> + <div v-if="dashboardUrl" ref="metricsChart"> + <component + :is="metricEmbedComponent" + v-if="metricEmbedComponent" + :dashboard-url="dashboardUrl" + :namespace="namespace" + /> + </div> + <div v-else ref="emptyState"> + {{ s__("AlertManagement|Metrics weren't available in the alerts payload.") }} + </div> + </div> +</template> diff --git a/app/assets/javascripts/alert_management/components/alert_sidebar.vue b/app/assets/javascripts/alert_management/components/alert_sidebar.vue index dcd22e2062e..64e4089c85a 100644 --- a/app/assets/javascripts/alert_management/components/alert_sidebar.vue +++ b/app/assets/javascripts/alert_management/components/alert_sidebar.vue @@ -4,6 +4,8 @@ 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, @@ -11,23 +13,34 @@ export default { SidebarTodo, SidebarStatus, }, - props: { - sidebarCollapsed: { - type: Boolean, - required: true, - }, + inject: { projectPath: { + default: '', + }, + projectId: { type: String, - required: true, + default: '', }, + }, + props: { alert: { type: Object, required: true, }, }, + apollo: { + sidebarStatus: { + query: sidebarStatusQuery, + }, + }, + data() { + return { + sidebarStatus: false, + }; + }, computed: { sidebarCollapsedClass() { - return this.sidebarCollapsed ? 'right-sidebar-collapsed' : 'right-sidebar-expanded'; + return this.sidebarStatus ? 'right-sidebar-collapsed' : 'right-sidebar-expanded'; }, }, }; @@ -37,23 +50,32 @@ export default { <aside :class="sidebarCollapsedClass" class="right-sidebar alert-sidebar"> <div class="issuable-sidebar js-issuable-update"> <sidebar-header - :sidebar-collapsed="sidebarCollapsed" + :sidebar-collapsed="sidebarStatus" + :project-path="projectPath" + :alert="alert" @toggle-sidebar="$emit('toggle-sidebar')" + @alert-error="$emit('alert-error', $event)" + /> + <sidebar-todo + v-if="sidebarStatus" + :project-path="projectPath" + :alert="alert" + :sidebar-collapsed="sidebarStatus" + @alert-error="$emit('alert-error', $event)" /> - <sidebar-todo v-if="sidebarCollapsed" :sidebar-collapsed="sidebarCollapsed" /> <sidebar-status :project-path="projectPath" :alert="alert" @toggle-sidebar="$emit('toggle-sidebar')" - @alert-sidebar-error="$emit('alert-sidebar-error', $event)" + @alert-error="$emit('alert-error', $event)" /> <sidebar-assignees :project-path="projectPath" + :project-id="projectId" :alert="alert" - :sidebar-collapsed="sidebarCollapsed" - @alert-refresh="$emit('alert-refresh')" + :sidebar-collapsed="sidebarStatus" @toggle-sidebar="$emit('toggle-sidebar')" - @alert-sidebar-error="$emit('alert-sidebar-error', $event)" + @alert-error="$emit('alert-error', $event)" /> <div class="block"></div> </div> diff --git a/app/assets/javascripts/alert_management/components/alert_status.vue b/app/assets/javascripts/alert_management/components/alert_status.vue new file mode 100644 index 00000000000..9b726fe2944 --- /dev/null +++ b/app/assets/javascripts/alert_management/components/alert_status.vue @@ -0,0 +1,129 @@ +<script> +import { GlDropdown, GlDropdownItem, GlButton } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import Tracking from '~/tracking'; +import { trackAlertStatusUpdateOptions } from '../constants'; +import updateAlertStatus from '../graphql/mutations/update_alert_status.mutation.graphql'; + +export default { + i18n: { + UPDATE_ALERT_STATUS_ERROR: s__( + 'AlertManagement|There was an error while updating the status of the alert.', + ), + UPDATE_ALERT_STATUS_INSTRUCTION: s__('AlertManagement|Please try again.'), + }, + statuses: { + TRIGGERED: s__('AlertManagement|Triggered'), + ACKNOWLEDGED: s__('AlertManagement|Acknowledged'), + RESOLVED: s__('AlertManagement|Resolved'), + }, + components: { + GlDropdown, + GlDropdownItem, + GlButton, + }, + props: { + projectPath: { + type: String, + required: true, + }, + alert: { + type: Object, + required: true, + }, + isDropdownShowing: { + type: Boolean, + required: false, + }, + isSidebar: { + type: Boolean, + required: true, + }, + }, + computed: { + dropdownClass() { + // eslint-disable-next-line no-nested-ternary + return this.isSidebar ? (this.isDropdownShowing ? 'show' : 'gl-display-none') : ''; + }, + }, + methods: { + updateAlertStatus(status) { + this.$emit('handle-updating', true); + this.$apollo + .mutate({ + mutation: updateAlertStatus, + variables: { + iid: this.alert.iid, + status: status.toUpperCase(), + projectPath: this.projectPath, + }, + }) + .then(resp => { + this.trackStatusUpdate(status); + this.$emit('hide-dropdown'); + + const errors = resp.data?.updateAlertStatus?.errors || []; + + if (errors[0]) { + this.$emit( + 'alert-error', + `${this.$options.i18n.UPDATE_ALERT_STATUS_ERROR} ${errors[0]}`, + ); + } + }) + .catch(() => { + this.$emit( + 'alert-error', + `${this.$options.i18n.UPDATE_ALERT_STATUS_ERROR} ${this.$options.i18n.UPDATE_ALERT_STATUS_INSTRUCTION}`, + ); + }) + .finally(() => { + this.$emit('handle-updating', false); + }); + }, + trackStatusUpdate(status) { + const { category, action, label } = trackAlertStatusUpdateOptions; + Tracking.event(category, action, { label, property: status }); + }, + }, +}; +</script> + +<template> + <div class="dropdown dropdown-menu-selectable" :class="dropdownClass"> + <gl-dropdown + ref="dropdown" + right + :text="$options.statuses[alert.status]" + class="w-100" + toggle-class="dropdown-menu-toggle" + variant="outline-default" + @keydown.esc.native="$emit('hide-dropdown')" + @hide="$emit('hide-dropdown')" + > + <div v-if="isSidebar" class="dropdown-title text-center"> + <span class="alert-title">{{ s__('AlertManagement|Assign status') }}</span> + <gl-button + :aria-label="__('Close')" + variant="link" + class="dropdown-title-button dropdown-menu-close" + icon="close" + @click="$emit('hide-dropdown')" + /> + </div> + <div class="dropdown-content dropdown-body"> + <gl-dropdown-item + v-for="(label, field) in $options.statuses" + :key="field" + data-testid="statusDropdownItem" + class="gl-vertical-align-middle" + :active="label.toUpperCase() === alert.status" + :active-class="'is-active'" + @click="updateAlertStatus(label)" + > + {{ label }} + </gl-dropdown-item> + </div> + </gl-dropdown> + </div> +</template> diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue index 453a3901665..cb32a5ffd4f 100644 --- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue +++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue @@ -11,20 +11,26 @@ import { GlSprintf, } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; -import { s__ } from '~/locale'; -import alertSetAssignees from '../../graphql/mutations/alert_set_assignees.graphql'; +import { s__, __ } from '~/locale'; +import alertSetAssignees from '../../graphql/mutations/alert_set_assignees.mutation.graphql'; import SidebarAssignee from './sidebar_assignee.vue'; import { debounce } from 'lodash'; const DATA_REFETCH_DELAY = 250; export default { - FETCH_USERS_ERROR: s__( - 'AlertManagement|There was an error while updating the assignee(s) list. Please try again.', - ), - UPDATE_ALERT_ASSIGNEES_ERROR: s__( - 'AlertManagement|There was an error while updating the assignee(s) of the alert. Please try again.', - ), + i18n: { + FETCH_USERS_ERROR: s__( + 'AlertManagement|There was an error while updating the assignee(s) list. Please try again.', + ), + UPDATE_ALERT_ASSIGNEES_ERROR: s__( + 'AlertManagement|There was an error while updating the assignee(s) of the alert. Please try again.', + ), + UPDATE_ALERT_ASSIGNEES_GRAPHQL_ERROR: s__( + 'AlertManagement|This assignee cannot be assigned to this alert.', + ), + ASSIGNEES_BLOCK: s__('AlertManagement|Alert assignee(s): %{assignees}'), + }, components: { GlIcon, GlDropdown, @@ -38,6 +44,10 @@ export default { SidebarAssignee, }, props: { + projectId: { + type: String, + required: true, + }, projectPath: { type: String, required: true, @@ -73,7 +83,7 @@ export default { return this.alert?.assignees?.nodes[0]?.username; }, assignedUser() { - return this.userName || s__('AlertManagement|None'); + return this.userName || __('None'); }, sortedUsers() { return this.users @@ -122,20 +132,20 @@ export default { updateAssigneesDropdown() { this.isDropdownSearching = true; return axios - .get(this.buildUrl(gon.relative_url_root, '/autocomplete/users.json'), { + .get(this.buildUrl(gon.relative_url_root, '/-/autocomplete/users.json'), { params: { search: this.search, per_page: 20, active: true, current_user: true, - project_id: gon?.current_project_id, + project_id: this.projectId, }, }) .then(({ data }) => { this.users = data; }) .catch(() => { - this.$emit('alert-sidebar-error', this.$options.FETCH_USERS_ERROR); + this.$emit('alert-error', this.$options.i18n.FETCH_USERS_ERROR); }) .finally(() => { this.isDropdownSearching = false; @@ -152,12 +162,18 @@ export default { projectPath: this.projectPath, }, }) - .then(() => { + .then(({ data: { alertSetAssignees: { errors } = [] } = {} } = {}) => { this.hideDropdown(); - this.$emit('alert-refresh'); + + if (errors[0]) { + this.$emit( + 'alert-error', + `${this.$options.i18n.UPDATE_ALERT_ASSIGNEES_GRAPHQL_ERROR} ${errors[0]}.`, + ); + } }) .catch(() => { - this.$emit('alert-sidebar-error', this.$options.UPDATE_ALERT_ASSIGNEES_ERROR); + this.$emit('alert-error', this.$options.i18n.UPDATE_ALERT_ASSIGNEES_ERROR); }) .finally(() => { this.isUpdating = false; @@ -174,7 +190,7 @@ export default { <gl-loading-icon v-if="isUpdating" /> </div> <gl-tooltip :target="() => $refs.status" boundary="viewport" placement="left"> - <gl-sprintf :message="s__('AlertManagement|Alert assignee(s): %{assignees}')"> + <gl-sprintf :message="$options.i18n.ASSIGNEES_BLOCK"> <template #assignees> {{ assignedUser }} </template> @@ -183,7 +199,7 @@ export default { <div class="hide-collapsed"> <p class="title gl-display-flex gl-justify-content-space-between"> - {{ s__('AlertManagement|Assignee') }} + {{ __('Assignee') }} <a v-if="isEditable" ref="editButton" @@ -192,7 +208,7 @@ export default { @click="toggleFormDropdown" @keydown.esc="hideDropdown" > - {{ s__('AlertManagement|Edit') }} + {{ __('Edit') }} </a> </p> @@ -207,7 +223,7 @@ export default { @hide="hideDropdown" > <div class="dropdown-title"> - <span class="alert-title">{{ s__('AlertManagement|Assign To') }}</span> + <span class="alert-title">{{ __('Assign To') }}</span> <gl-button :aria-label="__('Close')" variant="link" @@ -232,12 +248,12 @@ export default { active-class="is-active" @click="updateAlertAssignees('')" > - {{ s__('AlertManagement|Unassigned') }} + {{ __('Unassigned') }} </gl-dropdown-item> <gl-dropdown-divider /> <gl-dropdown-header class="mt-0"> - {{ s__('AlertManagement|Assignee') }} + {{ __('Assignee') }} </gl-dropdown-header> <sidebar-assignee v-for="user in sortedUsers" @@ -248,7 +264,7 @@ export default { /> </template> <gl-dropdown-item v-else-if="userListEmpty"> - {{ s__('AlertManagement|No Matching Results') }} + {{ __('No Matching Results') }} </gl-dropdown-item> <gl-loading-icon v-else /> </div> @@ -261,7 +277,7 @@ export default { assignedUser }}</span> <span v-else class="gl-display-flex gl-align-items-center"> - {{ s__('AlertManagement|None -') }} + {{ __('None') }} - <gl-button class="gl-pl-2" href="#" @@ -269,7 +285,7 @@ export default { data-testid="unassigned-users" @click="updateAlertAssignees(currentUser)" > - {{ s__('AlertManagement| assign yourself') }} + {{ __('assign yourself') }} </gl-button> </span> </p> diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_header.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_header.vue index 047793d8cee..fd40b5d9f65 100644 --- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_header.vue +++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_header.vue @@ -8,6 +8,14 @@ export default { SidebarTodo, }, props: { + alert: { + type: Object, + required: true, + }, + projectPath: { + type: String, + required: true, + }, sidebarCollapsed: { type: Boolean, required: true, @@ -17,18 +25,17 @@ export default { </script> <template> - <div class="block d-flex justify-content-between"> + <div class="block gl-display-flex gl-justify-content-space-between"> <span class="issuable-header-text hide-collapsed"> - {{ __('Quick actions') }} + {{ __('To Do') }} </span> - <toggle-sidebar - :collapsed="sidebarCollapsed" - css-classes="ml-auto" - @toggle="$emit('toggle-sidebar')" + <sidebar-todo + v-if="!sidebarCollapsed" + :project-path="projectPath" + :alert="alert" + :sidebar-collapsed="sidebarCollapsed" + @alert-error="$emit('alert-error', $event)" /> - <!-- TODO: Implement after or as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/215946 --> - <template v-if="false"> - <sidebar-todo v-if="!sidebarCollapsed" :sidebar-collapsed="sidebarCollapsed" /> - </template> + <toggle-sidebar :collapsed="sidebarCollapsed" @toggle="$emit('toggle-sidebar')" /> </div> </template> diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_status.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_status.vue index 89dbbedd9c1..44a81aba828 100644 --- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_status.vue +++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_status.vue @@ -1,17 +1,7 @@ <script> -import { - GlIcon, - GlDropdown, - GlDropdownItem, - GlLoadingIcon, - GlTooltip, - GlButton, - GlSprintf, -} from '@gitlab/ui'; +import { GlIcon, GlLoadingIcon, GlTooltip, GlSprintf } from '@gitlab/ui'; import { s__ } from '~/locale'; -import Tracking from '~/tracking'; -import { trackAlertStatusUpdateOptions } from '../../constants'; -import updateAlertStatus from '../../graphql/mutations/update_alert_status.graphql'; +import AlertStatus from '../alert_status.vue'; export default { statuses: { @@ -21,12 +11,10 @@ export default { }, components: { GlIcon, - GlDropdown, - GlDropdownItem, GlLoadingIcon, GlTooltip, - GlButton, GlSprintf, + AlertStatus, }, props: { projectPath: { @@ -60,44 +48,13 @@ export default { }, toggleFormDropdown() { this.isDropdownShowing = !this.isDropdownShowing; - const { dropdown } = this.$refs.dropdown.$refs; + const { dropdown } = this.$children[2].$refs.dropdown.$refs; if (dropdown && this.isDropdownShowing) { dropdown.show(); } }, - isSelected(status) { - return this.alert.status === status; - }, - updateAlertStatus(status) { - this.isUpdating = true; - this.$apollo - .mutate({ - mutation: updateAlertStatus, - variables: { - iid: this.alert.iid, - status: status.toUpperCase(), - projectPath: this.projectPath, - }, - }) - .then(() => { - this.trackStatusUpdate(status); - this.hideDropdown(); - }) - .catch(() => { - this.$emit( - 'alert-sidebar-error', - s__( - 'AlertManagement|There was an error while updating the status of the alert. Please try again.', - ), - ); - }) - .finally(() => { - this.isUpdating = false; - }); - }, - trackStatusUpdate(status) { - const { category, action, label } = trackAlertStatusUpdateOptions; - Tracking.event(category, action, { label, property: status }); + handleUpdating(updating) { + this.isUpdating = updating; }, }, }; @@ -132,41 +89,15 @@ export default { </a> </p> - <div class="dropdown dropdown-menu-selectable" :class="dropdownClass"> - <gl-dropdown - ref="dropdown" - :text="$options.statuses[alert.status]" - class="w-100" - toggle-class="dropdown-menu-toggle" - variant="outline-default" - @keydown.esc.native="hideDropdown" - @hide="hideDropdown" - > - <div class="dropdown-title"> - <span class="alert-title">{{ s__('AlertManagement|Assign status') }}</span> - <gl-button - :aria-label="__('Close')" - variant="link" - class="dropdown-title-button dropdown-menu-close" - icon="close" - @click="hideDropdown" - /> - </div> - <div class="dropdown-content dropdown-body"> - <gl-dropdown-item - v-for="(label, field) in $options.statuses" - :key="field" - data-testid="statusDropdownItem" - class="gl-vertical-align-middle" - :active="label.toUpperCase() === alert.status" - :active-class="'is-active'" - @click="updateAlertStatus(label)" - > - {{ label }} - </gl-dropdown-item> - </div> - </gl-dropdown> - </div> + <alert-status + :alert="alert" + :project-path="projectPath" + :is-dropdown-showing="isDropdownShowing" + :is-sidebar="true" + @alert-error="$emit('alert-error', $event)" + @hide-dropdown="hideDropdown" + @handle-updating="handleUpdating" + /> <gl-loading-icon v-if="isUpdating" :inline="true" /> <p diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_todo.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_todo.vue index 87090165f82..7d3135ad50d 100644 --- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_todo.vue +++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_todo.vue @@ -1,29 +1,123 @@ <script> +import { s__ } from '~/locale'; import Todo from '~/sidebar/components/todo_toggle/todo.vue'; +import axios from '~/lib/utils/axios_utils'; +import createAlertTodo from '../../graphql/mutations/alert_todo_create.graphql'; export default { + i18n: { + UPDATE_ALERT_TODO_ERROR: s__( + 'AlertManagement|There was an error while updating the To Do of the alert.', + ), + }, components: { Todo, }, props: { + alert: { + type: Object, + required: true, + }, + projectPath: { + type: String, + required: true, + }, sidebarCollapsed: { type: Boolean, required: true, }, }, + data() { + return { + isUpdating: false, + isTodo: false, + todo: '', + }; + }, + computed: { + alertID() { + return parseInt(this.alert.iid, 10); + }, + }, + methods: { + updateToDoCount(add) { + const oldCount = parseInt(document.querySelector('.todos-count').innerText, 10); + const count = add ? oldCount + 1 : oldCount - 1; + const headerTodoEvent = new CustomEvent('todo:toggle', { + detail: { + count, + }, + }); + + return document.dispatchEvent(headerTodoEvent); + }, + toggleTodo() { + if (this.todo) { + return this.markAsDone(); + } + + this.isUpdating = true; + return this.$apollo + .mutate({ + mutation: createAlertTodo, + variables: { + iid: this.alert.iid, + projectPath: this.projectPath, + }, + }) + .then(({ data: { alertTodoCreate: { todo = {}, errors = [] } } = {} } = {}) => { + if (errors[0]) { + return this.$emit( + 'alert-error', + `${this.$options.i18n.UPDATE_ALERT_TODO_ERROR} ${errors[0]}.`, + ); + } + + this.todo = todo.id; + return this.updateToDoCount(true); + }) + .catch(() => { + this.$emit( + 'alert-error', + `${this.$options.i18n.UPDATE_ALERT_TODO_ERROR} ${s__( + 'AlertManagement|Please try again.', + )}`, + ); + }) + .finally(() => { + this.isUpdating = false; + }); + }, + markAsDone() { + this.isUpdating = true; + + return axios + .delete(`/dashboard/todos/${this.todo.split('/').pop()}`) + .then(() => { + this.todo = ''; + return this.updateToDoCount(false); + }) + .catch(() => { + this.$emit('alert-error', this.$options.i18n.UPDATE_ALERT_TODO_ERROR); + }) + .finally(() => { + this.isUpdating = false; + }); + }, + }, }; </script> -<!-- TODO: Implement after or as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/215946 --> <template> - <div v-if="false" :class="{ 'block todo': sidebarCollapsed }"> + <div :class="{ 'block todo': sidebarCollapsed, 'gl-ml-auto': !sidebarCollapsed }"> <todo + data-testid="alert-todo-button" :collapsed="sidebarCollapsed" - :issuable-id="1" - :is-todo="false" - :is-action-active="false" + :issuable-id="alertID" + :is-todo="todo !== ''" + :is-action-active="isUpdating" issuable-type="alert" - @toggleTodo="() => {}" + @toggleTodo="toggleTodo" /> </div> </template> diff --git a/app/assets/javascripts/alert_management/components/system_notes/system_note.vue b/app/assets/javascripts/alert_management/components/system_notes/system_note.vue index 9042d51aecf..39717ab609f 100644 --- a/app/assets/javascripts/alert_management/components/system_notes/system_note.vue +++ b/app/assets/javascripts/alert_management/components/system_notes/system_note.vue @@ -24,7 +24,7 @@ export default { return { ...author, id: id?.split('/').pop() }; }, iconHtml() { - return spriteIcon('user'); + return spriteIcon(this.note?.systemNoteIconName); }, }, }; diff --git a/app/assets/javascripts/alert_management/details.js b/app/assets/javascripts/alert_management/details.js index aa8a839ea3f..2820bcb9665 100644 --- a/app/assets/javascripts/alert_management/details.js +++ b/app/assets/javascripts/alert_management/details.js @@ -3,45 +3,59 @@ import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; import AlertDetails from './components/alert_details.vue'; +import sidebarStatusQuery from './graphql/queries/sidebar_status.query.graphql'; Vue.use(VueApollo); export default selector => { const domEl = document.querySelector(selector); - const { alertId, projectPath, projectIssuesPath } = domEl.dataset; + const { alertId, projectPath, projectIssuesPath, projectId } = domEl.dataset; + + const resolvers = { + Mutation: { + toggleSidebarStatus: (_, __, { cache }) => { + const data = cache.readQuery({ query: sidebarStatusQuery }); + data.sidebarStatus = !data.sidebarStatus; + cache.writeQuery({ query: sidebarStatusQuery, data }); + }, + }, + }; const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient( - {}, - { - cacheConfig: { - dataIdFromObject: object => { - // eslint-disable-next-line no-underscore-dangle - if (object.__typename === 'AlertManagementAlert') { - return object.iid; - } - return defaultDataIdFromObject(object); - }, + defaultClient: createDefaultClient(resolvers, { + cacheConfig: { + dataIdFromObject: object => { + // eslint-disable-next-line no-underscore-dangle + if (object.__typename === 'AlertManagementAlert') { + return object.iid; + } + return defaultDataIdFromObject(object); }, }, - ), + }), + }); + + apolloProvider.clients.defaultClient.cache.writeData({ + data: { + sidebarStatus: false, + }, }); // eslint-disable-next-line no-new new Vue({ el: selector, + provide: { + projectPath, + alertId, + projectIssuesPath, + projectId, + }, apolloProvider, components: { AlertDetails, }, render(createElement) { - return createElement('alert-details', { - props: { - alertId, - projectPath, - projectIssuesPath, - }, - }); + return createElement('alert-details', {}); }, }); }; diff --git a/app/assets/javascripts/alert_management/graphql/fragments/alert_note.fragment.graphql b/app/assets/javascripts/alert_management/graphql/fragments/alert_note.fragment.graphql index c72300e9757..74b425717a0 100644 --- a/app/assets/javascripts/alert_management/graphql/fragments/alert_note.fragment.graphql +++ b/app/assets/javascripts/alert_management/graphql/fragments/alert_note.fragment.graphql @@ -1,16 +1,17 @@ #import "~/graphql_shared/fragments/author.fragment.graphql" fragment AlertNote on Note { + id + author { id - author { - id - state - ...Author - } - body - bodyHtml - createdAt - discussion { - id - } + state + ...Author + } + body + bodyHtml + createdAt + discussion { + id + } + systemNoteIconName } diff --git a/app/assets/javascripts/alert_management/graphql/fragments/detail_item.fragment.graphql b/app/assets/javascripts/alert_management/graphql/fragments/detail_item.fragment.graphql index cbe7e169be3..18fab429164 100644 --- a/app/assets/javascripts/alert_management/graphql/fragments/detail_item.fragment.graphql +++ b/app/assets/javascripts/alert_management/graphql/fragments/detail_item.fragment.graphql @@ -5,9 +5,11 @@ fragment AlertDetailItem on AlertManagementAlert { ...AlertListItem createdAt monitoringTool + metricsDashboardUrl service description updatedAt + endedAt details notes { nodes { diff --git a/app/assets/javascripts/alert_management/graphql/fragments/list_item.fragment.graphql b/app/assets/javascripts/alert_management/graphql/fragments/list_item.fragment.graphql index 746c4435f38..c37f29c74fc 100644 --- a/app/assets/javascripts/alert_management/graphql/fragments/list_item.fragment.graphql +++ b/app/assets/javascripts/alert_management/graphql/fragments/list_item.fragment.graphql @@ -4,7 +4,6 @@ fragment AlertListItem on AlertManagementAlert { severity status startedAt - endedAt eventCount issueIid assignees { diff --git a/app/assets/javascripts/alert_management/graphql/mutations/alert_set_assignees.graphql b/app/assets/javascripts/alert_management/graphql/mutations/alert_set_assignees.mutation.graphql index efeaf8fa372..40b4b6ae854 100644 --- a/app/assets/javascripts/alert_management/graphql/mutations/alert_set_assignees.graphql +++ b/app/assets/javascripts/alert_management/graphql/mutations/alert_set_assignees.mutation.graphql @@ -1,4 +1,6 @@ -mutation($projectPath: ID!, $assigneeUsernames: [String!]!, $iid: String!) { +#import "../fragments/alert_note.fragment.graphql" + +mutation alertSetAssignees($projectPath: ID!, $assigneeUsernames: [String!]!, $iid: String!) { alertSetAssignees( input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $projectPath } ) { @@ -10,6 +12,11 @@ mutation($projectPath: ID!, $assigneeUsernames: [String!]!, $iid: String!) { username } } + notes { + nodes { + ...AlertNote + } + } } } } diff --git a/app/assets/javascripts/alert_management/graphql/mutations/alert_todo_create.graphql b/app/assets/javascripts/alert_management/graphql/mutations/alert_todo_create.graphql new file mode 100644 index 00000000000..cdf3d763302 --- /dev/null +++ b/app/assets/javascripts/alert_management/graphql/mutations/alert_todo_create.graphql @@ -0,0 +1,11 @@ +mutation($projectPath: ID!, $iid: String!) { + alertTodoCreate(input: { iid: $iid, projectPath: $projectPath }) { + errors + alert { + iid + } + todo { + id + } + } +} diff --git a/app/assets/javascripts/alert_management/graphql/mutations/create_issue_from_alert.graphql b/app/assets/javascripts/alert_management/graphql/mutations/create_issue_from_alert.graphql deleted file mode 100644 index 664596ab88f..00000000000 --- a/app/assets/javascripts/alert_management/graphql/mutations/create_issue_from_alert.graphql +++ /dev/null @@ -1,8 +0,0 @@ -mutation ($projectPath: ID!, $iid: String!) { - createAlertIssue(input: { iid: $iid, projectPath: $projectPath }) { - errors - issue { - iid - } - } -} diff --git a/app/assets/javascripts/alert_management/graphql/mutations/create_issue_from_alert.mutation.graphql b/app/assets/javascripts/alert_management/graphql/mutations/create_issue_from_alert.mutation.graphql new file mode 100644 index 00000000000..bc4d91a51d1 --- /dev/null +++ b/app/assets/javascripts/alert_management/graphql/mutations/create_issue_from_alert.mutation.graphql @@ -0,0 +1,8 @@ +mutation createAlertIssue($projectPath: ID!, $iid: String!) { + createAlertIssue(input: { iid: $iid, projectPath: $projectPath }) { + errors + issue { + iid + } + } +} diff --git a/app/assets/javascripts/alert_management/graphql/mutations/toggle_sidebar_status.mutation.graphql b/app/assets/javascripts/alert_management/graphql/mutations/toggle_sidebar_status.mutation.graphql new file mode 100644 index 00000000000..f666fcd6782 --- /dev/null +++ b/app/assets/javascripts/alert_management/graphql/mutations/toggle_sidebar_status.mutation.graphql @@ -0,0 +1,3 @@ +mutation toggleSidebarStatus { + toggleSidebarStatus @client +} diff --git a/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.graphql b/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.graphql deleted file mode 100644 index 09151f233f5..00000000000 --- a/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.graphql +++ /dev/null @@ -1,10 +0,0 @@ -mutation ($projectPath: ID!, $status: AlertManagementStatus!, $iid: String!) { - updateAlertStatus(input: { iid: $iid, status: $status, projectPath: $projectPath }) { - errors - alert { - iid, - status, - endedAt - } - } -} diff --git a/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.mutation.graphql b/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.mutation.graphql new file mode 100644 index 00000000000..ba1e607bc10 --- /dev/null +++ b/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.mutation.graphql @@ -0,0 +1,17 @@ +#import "../fragments/alert_note.fragment.graphql" + +mutation updateAlertStatus($projectPath: ID!, $status: AlertManagementStatus!, $iid: String!) { + updateAlertStatus(input: { iid: $iid, status: $status, projectPath: $projectPath }) { + errors + alert { + iid + status + endedAt + notes { + nodes { + ...AlertNote + } + } + } + } +} diff --git a/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql index c02b8accdd1..8881f49b689 100644 --- a/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql +++ b/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql @@ -1,11 +1,11 @@ #import "../fragments/detail_item.fragment.graphql" query alertDetails($fullPath: ID!, $alertId: String) { - project(fullPath: $fullPath) { - alertManagementAlerts(iid: $alertId) { - nodes { - ...AlertDetailItem - } - } + project(fullPath: $fullPath) { + alertManagementAlerts(iid: $alertId) { + nodes { + ...AlertDetailItem + } } + } } diff --git a/app/assets/javascripts/alert_management/graphql/queries/get_alerts.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/get_alerts.query.graphql index 1d3c3c83cc1..8ac00bbc6b5 100644 --- a/app/assets/javascripts/alert_management/graphql/queries/get_alerts.query.graphql +++ b/app/assets/javascripts/alert_management/graphql/queries/get_alerts.query.graphql @@ -1,32 +1,34 @@ #import "../fragments/list_item.fragment.graphql" query getAlerts( - $projectPath: ID!, - $statuses: [AlertManagementStatus!], - $sort: AlertManagementAlertSort, - $firstPageSize: Int, - $lastPageSize: Int, - $prevPageCursor: String = "" - $nextPageCursor: String = "" + $searchTerm: String + $projectPath: ID! + $statuses: [AlertManagementStatus!] + $sort: AlertManagementAlertSort + $firstPageSize: Int + $lastPageSize: Int + $prevPageCursor: String = "" + $nextPageCursor: String = "" ) { - project(fullPath: $projectPath, ) { - alertManagementAlerts( - statuses: $statuses, - sort: $sort, - first: $firstPageSize - last: $lastPageSize, - after: $nextPageCursor, - before: $prevPageCursor - ) { - nodes { - ...AlertListItem - }, - pageInfo { - hasNextPage - endCursor - hasPreviousPage - startCursor - } - } + project(fullPath: $projectPath) { + alertManagementAlerts( + search: $searchTerm + statuses: $statuses + sort: $sort + first: $firstPageSize + last: $lastPageSize + after: $nextPageCursor + before: $prevPageCursor + ) { + nodes { + ...AlertListItem + } + pageInfo { + hasNextPage + endCursor + hasPreviousPage + startCursor + } } + } } diff --git a/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql index 1143050200c..5a6faea5cd8 100644 --- a/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql +++ b/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql @@ -1,11 +1,11 @@ -query getAlertsCount($projectPath: ID!) { - project(fullPath: $projectPath) { - alertManagementAlertStatusCounts { - all - open - acknowledged - resolved - triggered - } +query getAlertsCount($searchTerm: String, $projectPath: ID!) { + project(fullPath: $projectPath) { + alertManagementAlertStatusCounts(search: $searchTerm) { + all + open + acknowledged + resolved + triggered } + } } diff --git a/app/assets/javascripts/alert_management/graphql/queries/sidebar_status.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/sidebar_status.query.graphql new file mode 100644 index 00000000000..61c570c5cd0 --- /dev/null +++ b/app/assets/javascripts/alert_management/graphql/queries/sidebar_status.query.graphql @@ -0,0 +1,3 @@ +query sidebarStatus { + sidebarStatus @client +} diff --git a/app/assets/javascripts/alert_management/list.js b/app/assets/javascripts/alert_management/list.js index cae6a536b56..3f78ca66a59 100644 --- a/app/assets/javascripts/alert_management/list.js +++ b/app/assets/javascripts/alert_management/list.js @@ -3,7 +3,7 @@ import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; import { parseBoolean } from '~/lib/utils/common_utils'; -import AlertManagementList from './components/alert_management_list.vue'; +import AlertManagementList from './components/alert_management_list_wrapper.vue'; Vue.use(VueApollo); @@ -11,11 +11,18 @@ export default () => { const selector = '#js-alert_management'; const domEl = document.querySelector(selector); - const { projectPath, enableAlertManagementPath, emptyAlertSvgPath } = domEl.dataset; - let { alertManagementEnabled, userCanEnableAlertManagement } = domEl.dataset; + const { + projectPath, + enableAlertManagementPath, + emptyAlertSvgPath, + populatingAlertsHelpUrl, + opsgenieMvcTargetUrl, + } = domEl.dataset; + let { alertManagementEnabled, userCanEnableAlertManagement, opsgenieMvcEnabled } = domEl.dataset; alertManagementEnabled = parseBoolean(alertManagementEnabled); userCanEnableAlertManagement = parseBoolean(userCanEnableAlertManagement); + opsgenieMvcEnabled = parseBoolean(opsgenieMvcEnabled); const apolloProvider = new VueApollo({ defaultClient: createDefaultClient( @@ -45,9 +52,12 @@ export default () => { props: { projectPath, enableAlertManagementPath, + populatingAlertsHelpUrl, emptyAlertSvgPath, alertManagementEnabled, userCanEnableAlertManagement, + opsgenieMvcTargetUrl, + opsgenieMvcEnabled, }, }); }, diff --git a/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue b/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue index ac30b086875..a2d94fb8083 100644 --- a/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue +++ b/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue @@ -64,6 +64,11 @@ export default { type: Boolean, required: true, }, + isDisabled: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -142,7 +147,7 @@ export default { <gl-form-group :label="__('Active')" label-for="activated" label-class="label-bold"> <toggle-button id="activated" - :disabled-input="loadingActivated" + :disabled-input="loadingActivated || isDisabled" :is-loading="loadingActivated" :value="activated" @change="toggleActivated" @@ -152,7 +157,11 @@ export default { <div class="input-group"> <gl-form-input id="url" :readonly="true" :value="url" /> <span class="input-group-append"> - <clipboard-button :text="url" :title="$options.COPY_TO_CLIPBOARD" /> + <clipboard-button + :text="url" + :title="$options.COPY_TO_CLIPBOARD" + :disabled="isDisabled" + /> </span> </div> </gl-form-group> @@ -164,10 +173,16 @@ export default { <div class="input-group"> <gl-form-input id="authorization-key" :readonly="true" :value="authorizationKey" /> <span class="input-group-append"> - <clipboard-button :text="authorizationKey" :title="$options.COPY_TO_CLIPBOARD" /> + <clipboard-button + :text="authorizationKey" + :title="$options.COPY_TO_CLIPBOARD" + :disabled="isDisabled" + /> </span> </div> - <gl-button v-gl-modal.authKeyModal class="mt-2">{{ $options.RESET_KEY }}</gl-button> + <gl-button v-gl-modal.authKeyModal class="mt-2" :disabled="isDisabled">{{ + $options.RESET_KEY + }}</gl-button> <gl-modal modal-id="authKeyModal" :title="$options.RESET_KEY" diff --git a/app/assets/javascripts/alerts_service_settings/index.js b/app/assets/javascripts/alerts_service_settings/index.js index c26adf24a7f..fe83ced2ee7 100644 --- a/app/assets/javascripts/alerts_service_settings/index.js +++ b/app/assets/javascripts/alerts_service_settings/index.js @@ -14,8 +14,11 @@ export default el => { formPath, authorizationKey, url, + disabled, } = el.dataset; + const activated = parseBoolean(activatedStr); + const isDisabled = parseBoolean(disabled); return new Vue({ el, @@ -28,6 +31,7 @@ export default el => { formPath, initialAuthorizationKey: authorizationKey, url, + isDisabled, }, }); }, diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue new file mode 100644 index 00000000000..18c9f82f052 --- /dev/null +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue @@ -0,0 +1,563 @@ +<script> +import { + GlAlert, + GlButton, + GlForm, + GlFormGroup, + GlFormInput, + GlFormInputGroup, + GlFormTextarea, + GlLink, + GlModal, + GlModalDirective, + GlSprintf, + GlFormSelect, +} from '@gitlab/ui'; +import { debounce } from 'lodash'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import ToggleButton from '~/vue_shared/components/toggle_button.vue'; +import csrf from '~/lib/utils/csrf'; +import service from '../services'; +import { + i18n, + serviceOptions, + JSON_VALIDATE_DELAY, + targetPrometheusUrlPlaceholder, + targetOpsgenieUrlPlaceholder, +} from '../constants'; + +export default { + i18n, + csrf, + targetOpsgenieUrlPlaceholder, + targetPrometheusUrlPlaceholder, + components: { + GlAlert, + GlButton, + GlForm, + GlFormGroup, + GlFormInput, + GlFormInputGroup, + GlFormSelect, + GlFormTextarea, + GlLink, + GlModal, + GlSprintf, + ClipboardButton, + ToggleButton, + }, + directives: { + 'gl-modal': GlModalDirective, + }, + mixins: [glFeatureFlagsMixin()], + props: { + prometheus: { + type: Object, + required: true, + validator: ({ activated }) => { + return activated !== undefined; + }, + }, + generic: { + type: Object, + required: true, + validator: ({ formPath }) => { + return formPath !== undefined; + }, + }, + opsgenie: { + type: Object, + required: true, + }, + }, + data() { + return { + activated: { + generic: this.generic.activated, + prometheus: this.prometheus.activated, + opsgenie: this.opsgenie?.activated, + }, + loading: false, + authorizationKey: { + generic: this.generic.initialAuthorizationKey, + prometheus: this.prometheus.prometheusAuthorizationKey, + }, + selectedEndpoint: serviceOptions[0].value, + options: serviceOptions, + targetUrl: null, + feedback: { + variant: 'danger', + feedbackMessage: null, + isFeedbackDismissed: false, + }, + serverError: null, + testAlert: { + json: null, + error: null, + }, + canSaveForm: false, + }; + }, + computed: { + sections() { + return [ + { + text: this.$options.i18n.usageSection, + url: this.generic.alertsUsageUrl, + }, + { + text: this.$options.i18n.setupSection, + url: this.generic.alertsSetupUrl, + }, + ]; + }, + isPrometheus() { + return this.selectedEndpoint === 'prometheus'; + }, + isOpsgenie() { + return this.selectedEndpoint === 'opsgenie'; + }, + selectedService() { + switch (this.selectedEndpoint) { + case 'generic': { + return { + url: this.generic.url, + authKey: this.authorizationKey.generic, + active: this.activated.generic, + resetKey: this.resetGenericKey.bind(this), + }; + } + case 'prometheus': { + return { + url: this.prometheus.prometheusUrl, + authKey: this.authorizationKey.prometheus, + active: this.activated.prometheus, + resetKey: this.resetPrometheusKey.bind(this), + targetUrl: this.prometheus.prometheusApiUrl, + }; + } + case 'opsgenie': { + return { + targetUrl: this.opsgenie.opsgenieMvcTargetUrl, + active: this.activated.opsgenie, + }; + } + default: { + return {}; + } + } + }, + showFeedbackMsg() { + return this.feedback.feedbackMessage && !this.isFeedbackDismissed; + }, + showAlertSave() { + return ( + this.feedback.feedbackMessage === this.$options.i18n.testAlertFailed && + !this.isFeedbackDismissed + ); + }, + prometheusInfo() { + return this.isPrometheus ? this.$options.i18n.prometheusInfo : ''; + }, + jsonIsValid() { + return this.testAlert.error === null; + }, + canTestAlert() { + return this.selectedService.active && this.testAlert.json !== null; + }, + canSaveConfig() { + return !this.loading && this.canSaveForm; + }, + baseUrlPlaceholder() { + return this.isOpsgenie + ? this.$options.targetOpsgenieUrlPlaceholder + : this.$options.targetPrometheusUrlPlaceholder; + }, + }, + watch: { + 'testAlert.json': debounce(function debouncedJsonValidate() { + this.validateJson(); + }, JSON_VALIDATE_DELAY), + targetUrl(oldVal, newVal) { + if (newVal && oldVal !== this.selectedService.targetUrl) { + this.canSaveForm = true; + } + }, + }, + mounted() { + if ( + this.activated.prometheus || + this.activated.generic || + !this.opsgenie.opsgenieMvcIsAvailable + ) { + this.removeOpsGenieOption(); + } else if (this.activated.opsgenie) { + this.setOpsgenieAsDefault(); + } + }, + methods: { + createUserErrorMessage(errors) { + // eslint-disable-next-line prefer-destructuring + this.serverError = Object.values(errors)[0][0]; + }, + setOpsgenieAsDefault() { + this.options = this.options.map(el => { + if (el.value !== 'opsgenie') { + return { ...el, disabled: true }; + } + return { ...el, disabled: false }; + }); + this.selectedEndpoint = this.options.find(({ value }) => value === 'opsgenie').value; + if (this.targetUrl === null) { + this.targetUrl = this.selectedService.targetUrl; + } + }, + removeOpsGenieOption() { + this.options = this.options.map(el => { + if (el.value !== 'opsgenie') { + return { ...el, disabled: false }; + } + return { ...el, disabled: true }; + }); + }, + resetFormValues() { + this.testAlert.json = null; + this.targetUrl = this.selectedService.targetUrl; + }, + dismissFeedback() { + this.serverError = null; + this.feedback = { ...this.feedback, feedbackMessage: null }; + this.isFeedbackDismissed = false; + }, + resetGenericKey() { + return service + .updateGenericKey({ endpoint: this.generic.formPath, params: { service: { token: '' } } }) + .then(({ data: { token } }) => { + this.authorizationKey.generic = token; + this.setFeedback({ feedbackMessage: this.$options.i18n.authKeyRest, variant: 'success' }); + }) + .catch(() => { + this.setFeedback({ feedbackMessage: this.$options.i18n.errorKeyMsg, variant: 'danger' }); + }); + }, + resetPrometheusKey() { + return service + .updatePrometheusKey({ endpoint: this.prometheus.prometheusResetKeyPath }) + .then(({ data: { token } }) => { + this.authorizationKey.prometheus = token; + this.setFeedback({ feedbackMessage: this.$options.i18n.authKeyRest, variant: 'success' }); + }) + .catch(() => { + this.setFeedback({ feedbackMessage: this.$options.i18n.errorKeyMsg, variant: 'danger' }); + }); + }, + toggleService(value) { + this.canSaveForm = true; + if (this.isPrometheus) { + this.activated.prometheus = value; + } else { + this.activated[this.selectedEndpoint] = value; + } + }, + toggle(value) { + return this.isPrometheus ? this.togglePrometheusActive(value) : this.toggleActivated(value); + }, + toggleActivated(value) { + this.loading = true; + return service + .updateGenericActive({ + endpoint: this[this.selectedEndpoint].formPath, + params: this.isOpsgenie + ? { service: { opsgenie_mvc_target_url: this.targetUrl, opsgenie_mvc_enabled: value } } + : { service: { active: value } }, + }) + .then(() => { + this.activated[this.selectedEndpoint] = value; + this.toggleSuccess(value); + + if (!this.isOpsgenie && value) { + if (!this.selectedService.authKey) { + return window.location.reload(); + } + + return this.removeOpsGenieOption(); + } + + if (this.isOpsgenie && value) { + return this.setOpsgenieAsDefault(); + } + + // eslint-disable-next-line no-return-assign + return (this.options = serviceOptions); + }) + .catch(({ response: { data: { errors } = {} } = {} }) => { + this.createUserErrorMessage(errors); + this.setFeedback({ + feedbackMessage: `${this.$options.i18n.errorMsg}.`, + variant: 'danger', + }); + }) + .finally(() => { + this.loading = false; + this.canSaveForm = false; + }); + }, + togglePrometheusActive(value) { + this.loading = true; + return service + .updatePrometheusActive({ + endpoint: this.prometheus.prometheusFormPath, + params: { + token: this.$options.csrf.token, + config: value, + url: this.targetUrl, + redirect: window.location, + }, + }) + .then(() => { + this.activated.prometheus = value; + this.toggleSuccess(value); + this.removeOpsGenieOption(); + }) + .catch(({ response: { data: { errors } = {} } = {} }) => { + this.createUserErrorMessage(errors); + this.setFeedback({ + feedbackMessage: `${this.$options.i18n.errorMsg}.`, + variant: 'danger', + }); + }) + .finally(() => { + this.loading = false; + this.canSaveForm = false; + }); + }, + toggleSuccess(value) { + if (value) { + this.setFeedback({ + feedbackMessage: this.$options.i18n.endPointActivated, + variant: 'info', + }); + } else { + this.setFeedback({ + feedbackMessage: this.$options.i18n.changesSaved, + variant: 'info', + }); + } + }, + setFeedback({ feedbackMessage, variant }) { + this.feedback = { feedbackMessage, variant }; + }, + validateJson() { + this.testAlert.error = null; + try { + JSON.parse(this.testAlert.json); + } catch (e) { + this.testAlert.error = JSON.stringify(e.message); + } + }, + validateTestAlert() { + this.loading = true; + this.validateJson(); + return service + .updateTestAlert({ + endpoint: this.selectedService.url, + data: this.testAlert.json, + authKey: this.selectedService.authKey, + }) + .then(() => { + this.setFeedback({ + feedbackMessage: this.$options.i18n.testAlertSuccess, + variant: 'success', + }); + }) + .catch(() => { + this.setFeedback({ + feedbackMessage: this.$options.i18n.testAlertFailed, + variant: 'danger', + }); + }) + .finally(() => { + this.loading = false; + }); + }, + onSubmit() { + this.toggle(this.selectedService.active); + }, + onReset() { + this.testAlert.json = null; + this.dismissFeedback(); + this.targetUrl = this.selectedService.targetUrl; + + if (this.canSaveForm) { + this.canSaveForm = false; + this.activated[this.selectedEndpoint] = this[this.selectedEndpoint].activated; + } + }, + }, +}; +</script> + +<template> + <div> + <gl-alert v-if="showFeedbackMsg" :variant="feedback.variant" @dismiss="dismissFeedback"> + {{ feedback.feedbackMessage }} + <br /> + <i v-if="serverError">{{ __('Error message:') }} {{ serverError }}</i> + <gl-button + v-if="showAlertSave" + variant="danger" + category="primary" + class="gl-display-block gl-mt-3" + @click="toggle(selectedService.active)" + > + {{ __('Save anyway') }} + </gl-button> + </gl-alert> + <div data-testid="alert-settings-description" class="gl-mt-5"> + <p v-for="section in sections" :key="section.text"> + <gl-sprintf :message="section.text"> + <template #link="{ content }"> + <gl-link :href="section.url" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + </div> + <gl-form @submit.prevent="onSubmit" @reset.prevent="onReset"> + <gl-form-group + :label="$options.i18n.integrationsLabel" + label-for="integrations" + label-class="label-bold" + > + <gl-form-select + v-model="selectedEndpoint" + :options="options" + data-testid="alert-settings-select" + @change="resetFormValues" + /> + <span class="gl-text-gray-400"> + <gl-sprintf :message="$options.i18n.integrationsInfo"> + <template #link="{ content }"> + <gl-link + class="gl-display-inline-block" + href="https://gitlab.com/groups/gitlab-org/-/epics/3362" + target="_blank" + >{{ content }}</gl-link + > + </template> + </gl-sprintf> + </span> + </gl-form-group> + <gl-form-group + :label="$options.i18n.activeLabel" + label-for="activated" + label-class="label-bold" + > + <toggle-button + id="activated" + :disabled-input="loading" + :is-loading="loading" + :value="selectedService.active" + @change="toggleService" + /> + </gl-form-group> + <gl-form-group + v-if="isOpsgenie || isPrometheus" + :label="$options.i18n.apiBaseUrlLabel" + label-for="api-url" + label-class="label-bold" + > + <gl-form-input + id="api-url" + v-model="targetUrl" + type="url" + :placeholder="baseUrlPlaceholder" + :disabled="!selectedService.active" + /> + <span class="gl-text-gray-400"> + {{ $options.i18n.apiBaseUrlHelpText }} + </span> + </gl-form-group> + <template v-if="!isOpsgenie"> + <gl-form-group :label="$options.i18n.urlLabel" label-for="url" label-class="label-bold"> + <gl-form-input-group id="url" readonly :value="selectedService.url"> + <template #append> + <clipboard-button + :text="selectedService.url" + :title="$options.i18n.copyToClipboard" + class="gl-m-0!" + /> + </template> + </gl-form-input-group> + <span class="gl-text-gray-400"> + {{ prometheusInfo }} + </span> + </gl-form-group> + <gl-form-group + :label="$options.i18n.authKeyLabel" + label-for="authorization-key" + label-class="label-bold" + > + <gl-form-input-group + id="authorization-key" + class="gl-mb-2" + readonly + :value="selectedService.authKey" + > + <template #append> + <clipboard-button + :text="selectedService.authKey || ''" + :title="$options.i18n.copyToClipboard" + class="gl-m-0!" + /> + </template> + </gl-form-input-group> + <gl-button v-gl-modal.authKeyModal :disabled="!selectedService.active" class="gl-mt-3">{{ + $options.i18n.resetKey + }}</gl-button> + <gl-modal + modal-id="authKeyModal" + :title="$options.i18n.resetKey" + :ok-title="$options.i18n.resetKey" + ok-variant="danger" + @ok="selectedService.resetKey" + > + {{ $options.i18n.restKeyInfo }} + </gl-modal> + </gl-form-group> + <gl-form-group + :label="$options.i18n.alertJson" + label-for="alert-json" + label-class="label-bold" + :invalid-feedback="testAlert.error" + > + <gl-form-textarea + id="alert-json" + v-model.trim="testAlert.json" + :disabled="!selectedService.active" + :state="jsonIsValid" + :placeholder="$options.i18n.alertJsonPlaceholder" + rows="6" + max-rows="10" + /> + </gl-form-group> + <gl-button :disabled="!canTestAlert" @click="validateTestAlert">{{ + $options.i18n.testAlertInfo + }}</gl-button> + </template> + <div class="footer-block row-content-block gl-display-flex gl-justify-content-space-between"> + <gl-button + variant="success" + category="primary" + :disabled="!canSaveConfig" + @click="onSubmit" + > + {{ __('Save changes') }} + </gl-button> + <gl-button variant="default" category="primary" :disabled="!canSaveConfig" @click="onReset"> + {{ __('Cancel') }} + </gl-button> + </div> + </gl-form> + </div> +</template> diff --git a/app/assets/javascripts/alerts_settings/constants.js b/app/assets/javascripts/alerts_settings/constants.js new file mode 100644 index 00000000000..d15e8619df4 --- /dev/null +++ b/app/assets/javascripts/alerts_settings/constants.js @@ -0,0 +1,50 @@ +import { s__ } from '~/locale'; + +export const i18n = { + usageSection: s__( + 'AlertSettings|You must provide this URL and authorization key to authorize an external service to send alerts to GitLab. You can provide this URL and key to multiple services. After configuring an external service, alerts from your service will display on the GitLab %{linkStart}Alerts%{linkEnd} page.', + ), + setupSection: s__( + "AlertSettings|Review your external service's documentation to learn where to provide this information to your external service, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.", + ), + errorMsg: s__('AlertSettings|There was an error updating the alert settings'), + errorKeyMsg: s__( + 'AlertSettings|There was an error while trying to reset the key. Please refresh the page to try again.', + ), + restKeyInfo: s__( + 'AlertSettings|Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.', + ), + endPointActivated: s__('AlertSettings|Alerts endpoint successfully activated.'), + changesSaved: s__('AlertSettings|Your changes were successfully updated.'), + prometheusInfo: s__('AlertSettings|Add URL and auth key to your Prometheus config file'), + integrationsInfo: s__( + 'AlertSettings|Learn more about our %{linkStart}upcoming integrations%{linkEnd}', + ), + resetKey: s__('AlertSettings|Reset key'), + copyToClipboard: s__('AlertSettings|Copy'), + integrationsLabel: s__('AlertSettings|Integrations'), + apiBaseUrlLabel: s__('AlertSettings|API URL'), + authKeyLabel: s__('AlertSettings|Authorization key'), + urlLabel: s__('AlertSettings|Webhook URL'), + activeLabel: s__('AlertSettings|Active'), + apiBaseUrlHelpText: s__('AlertSettings|URL cannot be blank and must start with http or https'), + testAlertInfo: s__('AlertSettings|Test alert payload'), + alertJson: s__('AlertSettings|Alert test payload'), + alertJsonPlaceholder: s__('AlertSettings|Enter test alert JSON....'), + testAlertFailed: s__('AlertSettings|Test failed. Do you still want to save your changes anyway?'), + testAlertSuccess: s__( + 'AlertSettings|Test alert sent successfully. If you have made other changes, please save them now.', + ), + authKeyRest: s__('AlertSettings|Authorization key has been successfully reset'), +}; + +export const serviceOptions = [ + { value: 'generic', text: s__('AlertSettings|Generic') }, + { value: 'prometheus', text: s__('AlertSettings|External Prometheus') }, + { value: 'opsgenie', text: s__('AlertSettings|Opsgenie') }, +]; + +export const JSON_VALIDATE_DELAY = 250; + +export const targetPrometheusUrlPlaceholder = 'http://prometheus.example.com/'; +export const targetOpsgenieUrlPlaceholder = 'https://app.opsgenie.com/alert/list/'; diff --git a/app/assets/javascripts/alerts_settings/index.js b/app/assets/javascripts/alerts_settings/index.js new file mode 100644 index 00000000000..a4c2bf6b18e --- /dev/null +++ b/app/assets/javascripts/alerts_settings/index.js @@ -0,0 +1,67 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import AlertSettingsForm from './components/alerts_settings_form.vue'; + +export default el => { + if (!el) { + return null; + } + + const { + prometheusActivated, + prometheusUrl, + prometheusAuthorizationKey, + prometheusFormPath, + prometheusResetKeyPath, + prometheusApiUrl, + activated: activatedStr, + alertsSetupUrl, + alertsUsageUrl, + formPath, + authorizationKey, + url, + opsgenieMvcAvailable, + opsgenieMvcFormPath, + opsgenieMvcEnabled, + opsgenieMvcTargetUrl, + } = el.dataset; + + const genericActivated = parseBoolean(activatedStr); + const prometheusIsActivated = parseBoolean(prometheusActivated); + const opsgenieMvcActivated = parseBoolean(opsgenieMvcEnabled); + const opsgenieMvcIsAvailable = parseBoolean(opsgenieMvcAvailable); + + const props = { + prometheus: { + activated: prometheusIsActivated, + prometheusUrl, + prometheusAuthorizationKey, + prometheusFormPath, + prometheusResetKeyPath, + prometheusApiUrl, + }, + generic: { + alertsSetupUrl, + alertsUsageUrl, + activated: genericActivated, + formPath, + initialAuthorizationKey: authorizationKey, + url, + }, + opsgenie: { + formPath: opsgenieMvcFormPath, + activated: opsgenieMvcActivated, + opsgenieMvcTargetUrl, + opsgenieMvcIsAvailable, + }, + }; + + return new Vue({ + el, + render(createElement) { + return createElement(AlertSettingsForm, { + props, + }); + }, + }); +}; diff --git a/app/assets/javascripts/alerts_settings/services/index.js b/app/assets/javascripts/alerts_settings/services/index.js new file mode 100644 index 00000000000..c49992d4f57 --- /dev/null +++ b/app/assets/javascripts/alerts_settings/services/index.js @@ -0,0 +1,36 @@ +/* eslint-disable @gitlab/require-i18n-strings */ +import axios from '~/lib/utils/axios_utils'; + +export default { + updateGenericKey({ endpoint, params }) { + return axios.put(endpoint, params); + }, + updatePrometheusKey({ endpoint }) { + return axios.post(endpoint); + }, + updateGenericActive({ endpoint, params }) { + return axios.put(endpoint, params); + }, + updatePrometheusActive({ endpoint, params: { token, config, url, redirect } }) { + const data = new FormData(); + data.set('_method', 'put'); + data.set('authenticity_token', token); + data.set('service[manual_configuration]', config); + data.set('service[api_url]', url); + data.set('redirect_to', redirect); + + return axios.post(endpoint, data, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + }, + updateTestAlert({ endpoint, data, authKey }) { + return axios.post(endpoint, data, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authKey}`, + }, + }); + }, +}; diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 94d155840ea..c84e73ccdb4 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -11,6 +11,9 @@ const Api = { groupMembersPath: '/api/:version/groups/:id/members', subgroupsPath: '/api/:version/groups/:id/subgroups', namespacesPath: '/api/:version/namespaces.json', + groupPackagesPath: '/api/:version/groups/:id/packages', + projectPackagesPath: '/api/:version/projects/:id/packages', + projectPackagePath: '/api/:version/projects/:id/packages/:package_id', groupProjectsPath: '/api/:version/groups/:id/projects.json', projectsPath: '/api/:version/projects.json', projectPath: '/api/:version/projects/:id', @@ -36,7 +39,9 @@ const Api = { userStatusPath: '/api/:version/users/:id/status', userProjectsPath: '/api/:version/users/:id/projects', userPostStatusPath: '/api/:version/user/status', - commitPath: '/api/:version/projects/:id/repository/commits', + commitPath: '/api/:version/projects/:id/repository/commits/:sha', + commitsPath: '/api/:version/projects/:id/repository/commits', + applySuggestionPath: '/api/:version/suggestions/:id/apply', applySuggestionBatchPath: '/api/:version/suggestions/batch_apply', commitPipelinesPath: '/:project_id/commit/:sha/pipelines', @@ -64,6 +69,32 @@ const Api = { }); }, + groupPackages(id, options = {}) { + const url = Api.buildUrl(this.groupPackagesPath).replace(':id', id); + return axios.get(url, options); + }, + + projectPackages(id, options = {}) { + const url = Api.buildUrl(this.projectPackagesPath).replace(':id', id); + return axios.get(url, options); + }, + + buildProjectPackageUrl(projectId, packageId) { + return Api.buildUrl(this.projectPackagePath) + .replace(':id', projectId) + .replace(':package_id', packageId); + }, + + projectPackage(projectId, packageId) { + const url = this.buildProjectPackageUrl(projectId, packageId); + return axios.get(url); + }, + + deleteProjectPackage(projectId, packageId) { + const url = this.buildProjectPackageUrl(projectId, packageId); + return axios.delete(url); + }, + groupMembers(id, options) { const url = Api.buildUrl(this.groupMembersPath).replace(':id', encodeURIComponent(id)); @@ -308,9 +339,17 @@ const Api = { .catch(() => flash(__('Something went wrong while fetching projects'))); }, + commit(id, sha, params = {}) { + const url = Api.buildUrl(this.commitPath) + .replace(':id', encodeURIComponent(id)) + .replace(':sha', encodeURIComponent(sha)); + + return axios.get(url, { params }); + }, + commitMultiple(id, data) { // see https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions - const url = Api.buildUrl(Api.commitPath).replace(':id', encodeURIComponent(id)); + const url = Api.buildUrl(Api.commitsPath).replace(':id', encodeURIComponent(id)); return axios.post(url, JSON.stringify(data), { headers: { 'Content-Type': 'application/json; charset=utf-8', diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 8381b050900..0e83ba3d528 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -9,14 +9,10 @@ import { updateTooltipTitle } from './lib/utils/common_utils'; import { isInVueNoteablePage } from './lib/utils/dom_utils'; import flash from './flash'; import axios from './lib/utils/axios_utils'; +import * as Emoji from '~/emoji'; const animationEndEventString = 'animationend webkitAnimationEnd MSAnimationEnd oAnimationEnd'; const transitionEndEventString = 'transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd'; -const requestAnimationFrame = - window.requestAnimationFrame || - window.webkitRequestAnimationFrame || - window.mozRequestAnimationFrame || - window.setTimeout; const FROM_SENTENCE_REGEX = /(?:, and | and |, )/; // For separating lists produced by ruby's Array#toSentence @@ -619,7 +615,7 @@ export class AwardsHandler { let awardsHandlerPromise = null; export default function loadAwardsHandler(reload = false) { if (!awardsHandlerPromise || reload) { - awardsHandlerPromise = import(/* webpackChunkName: 'emoji' */ './emoji').then(Emoji => { + awardsHandlerPromise = Emoji.initEmojiMap().then(() => { const awardsHandler = new AwardsHandler(Emoji); awardsHandler.bindEvents(); return awardsHandler; diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue index dccc0b024ba..4145a4a4145 100644 --- a/app/assets/javascripts/badges/components/badge_form.vue +++ b/app/assets/javascripts/badges/components/badge_form.vue @@ -164,7 +164,7 @@ export default { <template> <form :class="{ 'was-validated': wasValidated }" - class="prepend-top-default append-bottom-default needs-validation" + class="gl-mt-3 gl-mb-3 needs-validation" novalidate @submit.prevent.stop="onSubmit" > diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue index 963d104b6b3..4c100ec7335 100644 --- a/app/assets/javascripts/batch_comments/components/draft_note.vue +++ b/app/assets/javascripts/batch_comments/components/draft_note.vue @@ -51,6 +51,7 @@ export default { 'scrollToDraft', 'toggleResolveDiscussion', ]), + ...mapActions(['setSelectedCommentPositionHover']), update(data) { this.updateDraft(data); }, @@ -67,12 +68,16 @@ export default { }; </script> <template> - <article class="draft-note-component note-wrapper"> + <article + class="draft-note-component note-wrapper" + @mouseenter="setSelectedCommentPositionHover(draft.position.line_range)" + @mouseleave="setSelectedCommentPositionHover()" + > <ul class="notes draft-notes"> <noteable-note :note="draft" - :diff-lines="diffFile.highlighted_diff_lines" :line="line" + :discussion-root="true" class="draft-note" @handleEdit="handleEditing" @cancelForm="handleNotEditing" @@ -81,7 +86,7 @@ export default { @handleUpdateNote="update" @toggleResolveStatus="toggleResolveDiscussion(draft.id)" > - <strong slot="note-header-info" class="badge draft-pending-label append-right-4"> + <strong slot="note-header-info" class="badge draft-pending-label gl-mr-2"> {{ __('Pending') }} </strong> </noteable-note> diff --git a/app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue b/app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue index 68fd20e56bc..b0916623cd2 100644 --- a/app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue +++ b/app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue @@ -35,11 +35,15 @@ export default { <tr :class="className" class="notes_holder"> <td class="notes_line old"></td> <td class="notes-content parallel old" colspan="2"> - <div v-if="leftDraft.isDraft" class="content"><draft-note :draft="leftDraft" /></div> + <div v-if="leftDraft.isDraft" class="content"> + <draft-note :draft="leftDraft" :line="line.left" /> + </div> </td> <td class="notes_line new"></td> <td class="notes-content parallel new" colspan="2"> - <div v-if="rightDraft.isDraft" class="content"><draft-note :draft="rightDraft" /></div> + <div v-if="rightDraft.isDraft" class="content"> + <draft-note :draft="rightDraft" :line="line.right" /> + </div> </td> </tr> </template> diff --git a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue index 195e1b7ec5c..7520cc2401b 100644 --- a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue +++ b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue @@ -96,7 +96,7 @@ export default { <preview-item :draft="draft" :is-last="isLast(index)" /> </li> </ul> - <gl-loading-icon v-else size="lg" class="prepend-top-default append-bottom-default" /> + <gl-loading-icon v-else size="lg" class="gl-mt-3 gl-mb-3" /> </div> <div class="dropdown-footer"> <publish-button diff --git a/app/assets/javascripts/batch_comments/components/preview_item.vue b/app/assets/javascripts/batch_comments/components/preview_item.vue index 22495eb4d7d..3162a83f099 100644 --- a/app/assets/javascripts/batch_comments/components/preview_item.vue +++ b/app/assets/javascripts/batch_comments/components/preview_item.vue @@ -52,14 +52,12 @@ export default { }); }, linePosition() { - if (this.draft.position && this.draft.position.position_type === IMAGE_DIFF_POSITION_TYPE) { + if (this.position?.position_type === IMAGE_DIFF_POSITION_TYPE) { // eslint-disable-next-line @gitlab/require-i18n-strings - return `${this.draft.position.x}x ${this.draft.position.y}y`; + return `${this.position.x}x ${this.position.y}y`; } - const position = this.discussion ? this.discussion.position : this.draft.position; - - return position?.new_line || position?.old_line; + return this.position?.new_line || this.position?.old_line; }, content() { const el = document.createElement('div'); @@ -70,11 +68,14 @@ export default { showLinePosition() { return this.draft.file_hash || this.isDiffDiscussion; }, + position() { + return this.draft.position || this.discussion.position; + }, startLineNumber() { - return getStartLineNumber(this.draft.position?.line_range); + return getStartLineNumber(this.position?.line_range); }, endLineNumber() { - return getEndLineNumber(this.draft.position?.line_range); + return getEndLineNumber(this.position?.line_range); }, }, methods: { diff --git a/app/assets/javascripts/behaviors/collapse_sidebar_on_window_resize.js b/app/assets/javascripts/behaviors/collapse_sidebar_on_window_resize.js new file mode 100644 index 00000000000..d9164f6204a --- /dev/null +++ b/app/assets/javascripts/behaviors/collapse_sidebar_on_window_resize.js @@ -0,0 +1,41 @@ +import $ from 'jquery'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; + +/** + * This behavior collapses the right sidebar + * if the window size changes + * + * @sentrify + */ +export default () => { + const $sidebarGutterToggle = $('.js-sidebar-toggle'); + let bootstrapBreakpoint = bp.getBreakpointSize(); + + $(window).on('resize.app', () => { + const oldBootstrapBreakpoint = bootstrapBreakpoint; + bootstrapBreakpoint = bp.getBreakpointSize(); + + if (bootstrapBreakpoint !== oldBootstrapBreakpoint) { + const breakpointSizes = ['md', 'sm', 'xs']; + + if (breakpointSizes.includes(bootstrapBreakpoint)) { + const $gutterIcon = $sidebarGutterToggle.find('i'); + if ($gutterIcon.hasClass('fa-angle-double-right')) { + $sidebarGutterToggle.trigger('click'); + } + + const sidebarGutterVueToggleEl = document.querySelector('.js-sidebar-vue-toggle'); + + // Sidebar has an icon which corresponds to collapsing the sidebar + // only then trigger the click. + if (sidebarGutterVueToggleEl) { + const collapseIcon = sidebarGutterVueToggleEl.querySelector('i.fa-angle-double-right'); + + if (collapseIcon) { + collapseIcon.click(); + } + } + } + } + }); +}; diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js index d1d75658181..bcf732e9522 100644 --- a/app/assets/javascripts/behaviors/gl_emoji.js +++ b/app/assets/javascripts/behaviors/gl_emoji.js @@ -1,47 +1,69 @@ import 'document-register-element'; import isEmojiUnicodeSupported from '../emoji/support'; +import { initEmojiMap, getEmojiInfo, emojiFallbackImageSrc, emojiImageTag } from '../emoji'; class GlEmoji extends HTMLElement { constructor() { super(); - const emojiUnicode = this.textContent.trim(); - const { name, unicodeVersion, fallbackSrc, fallbackSpriteClass } = this.dataset; - - const isEmojiUnicode = - this.childNodes && - Array.prototype.every.call(this.childNodes, childNode => childNode.nodeType === 3); - const hasImageFallback = fallbackSrc && fallbackSrc.length > 0; - const hasCssSpriteFalback = fallbackSpriteClass && fallbackSpriteClass.length > 0; - - if (emojiUnicode && isEmojiUnicode && !isEmojiUnicodeSupported(emojiUnicode, unicodeVersion)) { - // CSS sprite fallback takes precedence over image fallback - if (hasCssSpriteFalback) { - if (!gon.emoji_sprites_css_added && gon.emoji_sprites_css_path) { - const emojiSpriteLinkTag = document.createElement('link'); - emojiSpriteLinkTag.setAttribute('rel', 'stylesheet'); - emojiSpriteLinkTag.setAttribute('href', gon.emoji_sprites_css_path); - document.head.appendChild(emojiSpriteLinkTag); - gon.emoji_sprites_css_added = true; + this.initialize(); + } + initialize() { + let emojiUnicode = this.textContent.trim(); + const { fallbackSpriteClass, fallbackSrc } = this.dataset; + let { name, unicodeVersion } = this.dataset; + + return initEmojiMap().then(() => { + if (!unicodeVersion) { + const emojiInfo = getEmojiInfo(name); + + if (emojiInfo) { + if (name !== emojiInfo.name) { + ({ name } = emojiInfo); + this.dataset.name = emojiInfo.name; + } + unicodeVersion = emojiInfo.u; + this.dataset.unicodeVersion = unicodeVersion; + + emojiUnicode = emojiInfo.e; + this.innerHTML = emojiInfo.e; + + this.title = emojiInfo.d; + } + } + + const isEmojiUnicode = + this.childNodes && + Array.prototype.every.call(this.childNodes, childNode => childNode.nodeType === 3); + + if ( + emojiUnicode && + isEmojiUnicode && + !isEmojiUnicodeSupported(emojiUnicode, unicodeVersion) + ) { + const hasImageFallback = fallbackSrc && fallbackSrc.length > 0; + const hasCssSpriteFallback = fallbackSpriteClass && fallbackSpriteClass.length > 0; + + // CSS sprite fallback takes precedence over image fallback + if (hasCssSpriteFallback) { + if (!gon.emoji_sprites_css_added && gon.emoji_sprites_css_path) { + const emojiSpriteLinkTag = document.createElement('link'); + emojiSpriteLinkTag.setAttribute('rel', 'stylesheet'); + emojiSpriteLinkTag.setAttribute('href', gon.emoji_sprites_css_path); + document.head.appendChild(emojiSpriteLinkTag); + gon.emoji_sprites_css_added = true; + } + // IE 11 doesn't like adding multiple at once :( + this.classList.add('emoji-icon'); + this.classList.add(fallbackSpriteClass); + } else if (hasImageFallback) { + this.innerHTML = emojiImageTag(name, fallbackSrc); + } else { + const src = emojiFallbackImageSrc(name); + this.innerHTML = emojiImageTag(name, src); } - // IE 11 doesn't like adding multiple at once :( - this.classList.add('emoji-icon'); - this.classList.add(fallbackSpriteClass); - } else { - import(/* webpackChunkName: 'emoji' */ '../emoji') - .then(({ emojiImageTag, emojiFallbackImageSrc }) => { - if (hasImageFallback) { - this.innerHTML = emojiImageTag(name, fallbackSrc); - } else { - const src = emojiFallbackImageSrc(name); - this.innerHTML = emojiImageTag(name, src); - } - }) - .catch(() => { - // do nothing - }); } - } + }); } } diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js index 8c4eccc34a3..8060938c72a 100644 --- a/app/assets/javascripts/behaviors/index.js +++ b/app/assets/javascripts/behaviors/index.js @@ -11,9 +11,13 @@ import './requires_input'; import initPageShortcuts from './shortcuts'; import './toggler_behavior'; import './preview_markdown'; +import initCollapseSidebarOnWindowResize from './collapse_sidebar_on_window_resize'; +import initSelect2Dropdowns from './select2'; installGlEmojiElement(); initGFMInput(); initCopyAsGFM(); initCopyToClipboard(); initPageShortcuts(); +initCollapseSidebarOnWindowResize(); +initSelect2Dropdowns(); diff --git a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js index 03c1b5a0169..bbcfa50ba35 100644 --- a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import { getSelectedFragment } from '~/lib/utils/common_utils'; +import { getSelectedFragment, insertText } from '~/lib/utils/common_utils'; export class CopyAsGFM { constructor() { @@ -79,7 +79,7 @@ export class CopyAsGFM { } static insertPastedText(target, text, gfm) { - window.gl.utils.insertText(target, textBefore => { + insertText(target, textBefore => { // If the text before the cursor contains an odd number of backticks, // we are either inside an inline code span that starts with 1 backtick // or a code block that starts with 3 backticks. diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js index e4c69a114e0..94033e914ef 100644 --- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js +++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js @@ -174,7 +174,7 @@ export default function renderMermaid($els) { if (!$els.length) return; const visibleMermaids = $els.filter(function filter() { - return $(this).closest('details').length === 0; + return $(this).closest('details').length === 0 && $(this).is(':visible'); }); renderMermaids(visibleMermaids); diff --git a/app/assets/javascripts/behaviors/select2.js b/app/assets/javascripts/behaviors/select2.js new file mode 100644 index 00000000000..37b75bb5e56 --- /dev/null +++ b/app/assets/javascripts/behaviors/select2.js @@ -0,0 +1,23 @@ +import $ from 'jquery'; + +export default () => { + if ($('select.select2').length) { + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + $('select.select2').select2({ + width: 'resolve', + minimumResultsForSearch: 10, + dropdownAutoWidth: true, + }); + + // Close select2 on escape + $('.js-select2').on('select2-close', () => { + setTimeout(() => { + $('.select2-container-active').removeClass('select2-container-active'); + $(':focus').blur(); + }, 1); + }); + }) + .catch(() => {}); + } +}; diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue b/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue index a53b1b06be9..8418c0f66ac 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue @@ -1,11 +1,10 @@ <script> -import { GlToggle, GlSprintf } from '@gitlab/ui'; +import { GlToggle } from '@gitlab/ui'; import AccessorUtilities from '~/lib/utils/accessor'; import { disableShortcuts, enableShortcuts, shouldDisableShortcuts } from './shortcuts_toggle'; export default { components: { - GlSprintf, GlToggle, }, data() { @@ -32,29 +31,10 @@ export default { <gl-toggle v-model="shortcutsEnabled" aria-describedby="shortcutsToggle" - class="prepend-left-10 mb-0" - label-position="right" + label="Keyboard shortcuts" + label-position="left" @change="onChange" - > - <template #labelOn> - <gl-sprintf - :message="__('%{screenreaderOnlyStart}Keyboard shorcuts%{screenreaderOnlyEnd} Enabled')" - > - <template #screenreaderOnly="{ content }"> - <span class="sr-only">{{ content }}</span> - </template> - </gl-sprintf> - </template> - <template #labelOff> - <gl-sprintf - :message="__('%{screenreaderOnlyStart}Keyboard shorcuts%{screenreaderOnlyEnd} Disabled')" - > - <template #screenreaderOnly="{ content }"> - <span class="sr-only">{{ content }}</span> - </template> - </gl-sprintf> - </template> - </gl-toggle> + /> <div id="shortcutsToggle" class="sr-only">{{ __('Enable or disable keyboard shortcuts') }}</div> </div> </template> diff --git a/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue b/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue index ed03213d7cf..5b15fe2d7cc 100644 --- a/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue +++ b/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue @@ -1,5 +1,5 @@ <script> -import { GlDeprecatedButton, GlButtonGroup, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlButton, GlButtonGroup, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { RICH_BLOB_VIEWER, RICH_BLOB_VIEWER_TITLE, @@ -11,7 +11,7 @@ export default { components: { GlIcon, GlButtonGroup, - GlDeprecatedButton, + GlButton, }, directives: { GlTooltip: GlTooltipDirective, @@ -46,7 +46,7 @@ export default { </script> <template> <gl-button-group class="js-blob-viewer-switcher mx-2"> - <gl-deprecated-button + <gl-button v-gl-tooltip.hover :aria-label="$options.SIMPLE_BLOB_VIEWER_TITLE" :title="$options.SIMPLE_BLOB_VIEWER_TITLE" @@ -55,8 +55,8 @@ export default { @click="switchToViewer($options.SIMPLE_BLOB_VIEWER)" > <gl-icon name="code" :size="14" /> - </gl-deprecated-button> - <gl-deprecated-button + </gl-button> + <gl-button v-gl-tooltip.hover :aria-label="$options.RICH_BLOB_VIEWER_TITLE" :title="$options.RICH_BLOB_VIEWER_TITLE" @@ -65,6 +65,6 @@ export default { @click="switchToViewer($options.RICH_BLOB_VIEWER)" > <gl-icon name="document" :size="14" /> - </gl-deprecated-button> + </gl-button> </gl-button-group> </template> diff --git a/app/assets/javascripts/blob/components/constants.js b/app/assets/javascripts/blob/components/constants.js index 93dceacabdd..0137bd38d28 100644 --- a/app/assets/javascripts/blob/components/constants.js +++ b/app/assets/javascripts/blob/components/constants.js @@ -25,7 +25,7 @@ export const BLOB_RENDER_ERRORS = { TOO_LARGE: { id: 'too_large', text: sprintf(__('it is larger than %{limit}'), { - limit: numberToHumanSize(104857600), // 100MB in bytes + limit: numberToHumanSize(10485760), // 10MB in bytes }), }, EXTERNAL: { diff --git a/app/assets/javascripts/blob/notebook/notebook_viewer.vue b/app/assets/javascripts/blob/notebook/notebook_viewer.vue index 401fe9beb62..b1713989997 100644 --- a/app/assets/javascripts/blob/notebook/notebook_viewer.vue +++ b/app/assets/javascripts/blob/notebook/notebook_viewer.vue @@ -62,9 +62,7 @@ export default { </script> <template> - <div - class="js-notebook-viewer-mounted container-fluid md prepend-top-default append-bottom-default" - > + <div class="js-notebook-viewer-mounted container-fluid md gl-mt-3 gl-mb-3"> <div v-if="loading && !error" class="text-center loading"> <gl-loading-icon class="mt-5" size="lg" /> </div> diff --git a/app/assets/javascripts/blob/pdf/pdf_viewer.vue b/app/assets/javascripts/blob/pdf/pdf_viewer.vue index 5eaddfc099a..64fc832ee54 100644 --- a/app/assets/javascripts/blob/pdf/pdf_viewer.vue +++ b/app/assets/javascripts/blob/pdf/pdf_viewer.vue @@ -34,7 +34,7 @@ export default { </script> <template> - <div class="js-pdf-viewer container-fluid md prepend-top-default append-bottom-default"> + <div class="js-pdf-viewer container-fluid md gl-mt-3 gl-mb-3"> <div v-if="loading && !error" class="text-center loading"> <gl-loading-icon class="mt-5" size="lg" /> </div> diff --git a/app/assets/javascripts/blob/sketch/index.js b/app/assets/javascripts/blob/sketch/index.js index dbff03dc734..767e205fcaa 100644 --- a/app/assets/javascripts/blob/sketch/index.js +++ b/app/assets/javascripts/blob/sketch/index.js @@ -56,7 +56,7 @@ export default class SketchLoader { error() { const errorMsg = document.createElement('p'); - errorMsg.className = 'prepend-top-default append-bottom-default text-center'; + errorMsg.className = 'gl-mt-3 gl-mb-3 text-center'; errorMsg.textContent = __(` Cannot show preview. For previews on sketch files, they must have the file format introduced by Sketch version 43 and above. diff --git a/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue b/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue index 1e9e36feecc..932b6e8a0f7 100644 --- a/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue +++ b/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue @@ -2,7 +2,6 @@ import { GlPopover, GlSprintf, GlDeprecatedButton, GlIcon } from '@gitlab/ui'; import { parseBoolean, scrollToElement, setCookie, getCookie } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; -import { glEmojiTag } from '~/emoji'; import Tracking from '~/tracking'; const trackingMixin = Tracking.mixin(); @@ -11,14 +10,16 @@ const popoverStates = { suggest_gitlab_ci_yml: { title: s__(`suggestPipeline|1/2: Choose a template`), content: s__( - `suggestPipeline|We recommend the %{boldStart}Code Quality%{boldEnd} template, which will add a report widget to your Merge Requests. This way you’ll learn about code quality degradations much sooner. %{footerStart} Goodbye technical debt! %{footerEnd}`, + `suggestPipeline|We’re adding a GitLab CI configuration file to add a pipeline to the project. You could create it manually, but we recommend that you start with a GitLab template that works out of the box.`, + ), + footer: s__( + `suggestPipeline|Choose %{boldStart}Code Quality%{boldEnd} to add a pipeline that tests the quality of your code.`, ), - emoji: glEmojiTag('wave'), }, suggest_commit_first_project_gitlab_ci_yml: { title: s__(`suggestPipeline|2/2: Commit your changes`), content: s__( - `suggestPipeline|Commit the changes and your pipeline will automatically run for the first time.`, + `suggestPipeline|The template is ready! You can now commit it to create your first pipeline.`, ), }, }; @@ -66,6 +67,9 @@ export default { suggestContent() { return popoverStates[this.trackLabel].content || ''; }, + suggestFooter() { + return popoverStates[this.trackLabel].footer || ''; + }, emoji() { return popoverStates[this.trackLabel].emoji || ''; }, @@ -123,16 +127,13 @@ export default { </span> </template> - <gl-sprintf :message="suggestContent"> - <template #bold="{content}"> - <strong> {{ content }} </strong> - </template> - <template #footer="{content}"> - <div class="mt-3"> - {{ content }} - <span v-html="emoji"></span> - </div> - </template> - </gl-sprintf> + <gl-sprintf :message="suggestContent" /> + <div class="mt-3"> + <gl-sprintf :message="suggestFooter"> + <template #bold="{ content }"> + <strong> {{ content }} </strong> + </template> + </gl-sprintf> + </div> </gl-popover> </template> diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js index 3ac419557eb..b18faea628a 100644 --- a/app/assets/javascripts/blob/viewer/index.js +++ b/app/assets/javascripts/blob/viewer/index.js @@ -3,6 +3,7 @@ import '~/behaviors/markdown/render_gfm'; import 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'; const loadRichBlobViewer = type => { @@ -178,6 +179,10 @@ export default class BlobViewer { viewer.innerHTML = data.html; viewer.setAttribute('data-loaded', 'true'); + if (window.gon?.features?.codeNavigation) { + eventHub.$emit('showBlobInteractionZones', viewer.dataset.path); + } + return viewer; }); } diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js index 95b84497de3..9b9ade28623 100644 --- a/app/assets/javascripts/blob_edit/blob_bundle.js +++ b/app/assets/javascripts/blob_edit/blob_bundle.js @@ -5,7 +5,7 @@ import NewCommitForm from '../new_commit_form'; import EditBlob from './edit_blob'; import BlobFileDropzone from '../blob/blob_file_dropzone'; import initPopover from '~/blob/suggest_gitlab_ci_yml'; -import { setCookie } from '~/lib/utils/common_utils'; +import { disableButtonIfEmptyField, setCookie } from '~/lib/utils/common_utils'; import Tracking from '~/tracking'; export default () => { @@ -51,10 +51,7 @@ export default () => { new BlobFileDropzone(uploadBlobForm, method); new NewCommitForm(uploadBlobForm); - window.gl.utils.disableButtonIfEmptyField( - uploadBlobForm.find('.js-commit-message'), - '.btn-upload-file', - ); + disableButtonIfEmptyField(uploadBlobForm.find('.js-commit-message'), '.btn-upload-file'); } if (deleteBlobForm.length) { diff --git a/app/assets/javascripts/blob_edit/constants.js b/app/assets/javascripts/blob_edit/constants.js new file mode 100644 index 00000000000..a19da2098cf --- /dev/null +++ b/app/assets/javascripts/blob_edit/constants.js @@ -0,0 +1,4 @@ +import { __ } from '~/locale'; + +export const BLOB_EDITOR_ERROR = __('An error occurred while rendering the editor'); +export const BLOB_PREVIEW_ERROR = __('An error occurred previewing the blob'); diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js index 011898a5e7a..7e5be8454fe 100644 --- a/app/assets/javascripts/blob_edit/edit_blob.js +++ b/app/assets/javascripts/blob_edit/edit_blob.js @@ -3,39 +3,87 @@ import $ from 'jquery'; import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; -import { __ } from '~/locale'; +import { BLOB_EDITOR_ERROR, BLOB_PREVIEW_ERROR } from './constants'; import TemplateSelectorMediator from '../blob/file_template_mediator'; import getModeByFileExtension from '~/lib/utils/ace_utils'; import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown'; +const monacoEnabledGlobally = window.gon.features?.monacoBlobs; + export default class EditBlob { // The options object has: // assetsPath, filePath, currentAction, projectId, isMarkdown constructor(options) { this.options = options; - this.configureAceEditor(); - this.initModePanesAndLinks(); - this.initSoftWrap(); - this.initFileSelectors(); + this.options.monacoEnabled = this.options.monacoEnabled ?? monacoEnabledGlobally; + const { isMarkdown, monacoEnabled } = this.options; + return Promise.resolve() + .then(() => { + return monacoEnabled ? this.configureMonacoEditor() : this.configureAceEditor(); + }) + .then(() => { + this.initModePanesAndLinks(); + this.initFileSelectors(); + this.initSoftWrap(); + if (isMarkdown) { + addEditorMarkdownListeners(this.editor); + } + this.editor.focus(); + }) + .catch(() => createFlash(BLOB_EDITOR_ERROR)); + } + + configureMonacoEditor() { + const EditorPromise = import( + /* webpackChunkName: 'monaco_editor_lite' */ '~/editor/editor_lite' + ); + const MarkdownExtensionPromise = this.options.isMarkdown + ? import('~/editor/editor_markdown_ext') + : Promise.resolve(false); + + return Promise.all([EditorPromise, MarkdownExtensionPromise]) + .then(([EditorModule, MarkdownExtension]) => { + const EditorLite = EditorModule.default; + const editorEl = document.getElementById('editor'); + const fileNameEl = + document.getElementById('file_path') || document.getElementById('file_name'); + const fileContentEl = document.getElementById('file-content'); + const form = document.querySelector('.js-edit-blob-form'); + + this.editor = new EditorLite(); + + if (MarkdownExtension) { + this.editor.use(MarkdownExtension.default); + } + + this.editor.createInstance({ + el: editorEl, + blobPath: fileNameEl.value, + blobContent: editorEl.innerText, + }); + + fileNameEl.addEventListener('change', () => { + this.editor.updateModelLanguage(fileNameEl.value); + }); + + form.addEventListener('submit', () => { + fileContentEl.value = this.editor.getValue(); + }); + }) + .catch(() => createFlash(BLOB_EDITOR_ERROR)); } configureAceEditor() { - const { filePath, assetsPath, isMarkdown } = this.options; + const { filePath, assetsPath } = this.options; ace.config.set('modePath', `${assetsPath}/ace`); ace.config.loadModule('ace/ext/searchbox'); ace.config.loadModule('ace/ext/modelist'); this.editor = ace.edit('editor'); - if (isMarkdown) { - addEditorMarkdownListeners(this.editor); - } - // This prevents warnings re: automatic scrolling being logged this.editor.$blockScrolling = Infinity; - this.editor.focus(); - if (filePath) { this.editor.getSession().setMode(getModeByFileExtension(filePath)); } @@ -81,7 +129,7 @@ export default class EditBlob { currentPane.empty().append(data); currentPane.renderGFM(); }) - .catch(() => createFlash(__('An error occurred previewing the blob'))); + .catch(() => createFlash(BLOB_PREVIEW_ERROR)); } this.$toggleButton.show(); @@ -90,14 +138,19 @@ export default class EditBlob { } initSoftWrap() { - this.isSoftWrapped = false; + this.isSoftWrapped = Boolean(this.options.monacoEnabled); this.$toggleButton = $('.soft-wrap-toggle'); + this.$toggleButton.toggleClass('soft-wrap-active', this.isSoftWrapped); this.$toggleButton.on('click', () => this.toggleSoftWrap()); } toggleSoftWrap() { this.isSoftWrapped = !this.isSoftWrapped; this.$toggleButton.toggleClass('soft-wrap-active', this.isSoftWrapped); - this.editor.getSession().setUseWrapMode(this.isSoftWrapped); + if (this.options.monacoEnabled) { + this.editor.updateOptions({ wordWrap: this.isSoftWrapped ? 'on' : 'off' }); + } else { + this.editor.getSession().setUseWrapMode(this.isSoftWrapped); + } } } diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js deleted file mode 100644 index 517a13ceb27..00000000000 --- a/app/assets/javascripts/boards/components/board.js +++ /dev/null @@ -1,192 +0,0 @@ -import $ from 'jquery'; -import Sortable from 'sortablejs'; -import Vue from 'vue'; -import { GlButtonGroup, GlDeprecatedButton, GlLabel, GlTooltip } from '@gitlab/ui'; -import isWipLimitsOn from 'ee_else_ce/boards/mixins/is_wip_limits'; -import { s__, __, sprintf } from '~/locale'; -import Icon from '~/vue_shared/components/icon.vue'; -import Tooltip from '~/vue_shared/directives/tooltip'; -import AccessorUtilities from '../../lib/utils/accessor'; -import BoardBlankState from './board_blank_state.vue'; -import BoardDelete from './board_delete'; -import BoardList from './board_list.vue'; -import IssueCount from './issue_count.vue'; -import boardsStore from '../stores/boards_store'; -import { getBoardSortableDefaultOptions, sortableEnd } from '../mixins/sortable_default_options'; -import { ListType } from '../constants'; -import { isScopedLabel } from '~/lib/utils/common_utils'; - -/** - * Please don't edit this file, have a look at: - * ./board_column.vue - * https://gitlab.com/gitlab-org/gitlab/-/issues/212300 - * - * This file here will be deleted soon - * @deprecated - */ -export default Vue.extend({ - components: { - BoardBlankState, - BoardDelete, - BoardList, - Icon, - GlButtonGroup, - IssueCount, - GlDeprecatedButton, - GlLabel, - GlTooltip, - }, - directives: { - Tooltip, - }, - mixins: [isWipLimitsOn], - props: { - list: { - type: Object, - default: () => ({}), - required: false, - }, - disabled: { - type: Boolean, - required: true, - }, - issueLinkBase: { - type: String, - required: true, - }, - rootPath: { - type: String, - required: true, - }, - boardId: { - type: String, - required: true, - }, - // Does not do anything but is used - // to support the API of the new board_column.vue - canAdminList: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - detailIssue: boardsStore.detail, - filter: boardsStore.filter, - }; - }, - computed: { - isLoggedIn() { - return Boolean(gon.current_user_id); - }, - showListHeaderButton() { - return ( - !this.disabled && this.list.type !== ListType.closed && this.list.type !== ListType.blank - ); - }, - issuesTooltip() { - const { issuesSize } = this.list; - - return sprintf(__('%{issuesSize} issues'), { issuesSize }); - }, - // Only needed to make karma pass. - weightCountToolTip() {}, // eslint-disable-line vue/return-in-computed-property - caretTooltip() { - return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand'); - }, - isNewIssueShown() { - return this.list.type === ListType.backlog || this.showListHeaderButton; - }, - isSettingsShown() { - return ( - this.list.type !== ListType.backlog && - this.showListHeaderButton && - this.list.isExpanded && - this.isWipLimitsOn - ); - }, - showBoardListAndBoardInfo() { - return this.list.type !== ListType.blank && this.list.type !== ListType.promotion; - }, - uniqueKey() { - // eslint-disable-next-line @gitlab/require-i18n-strings - return `boards.${this.boardId}.${this.list.type}.${this.list.id}`; - }, - }, - watch: { - filter: { - handler() { - this.list.page = 1; - this.list.getIssues(true).catch(() => { - // TODO: handle request error - }); - }, - deep: true, - }, - }, - mounted() { - const instance = this; - - const sortableOptions = getBoardSortableDefaultOptions({ - disabled: this.disabled, - group: 'boards', - draggable: '.is-draggable', - handle: '.js-board-handle', - onEnd(e) { - sortableEnd(); - - const sortable = this; - - if (e.newIndex !== undefined && e.oldIndex !== e.newIndex) { - const order = sortable.toArray(); - const list = boardsStore.findList('id', parseInt(e.item.dataset.id, 10)); - - instance.$nextTick(() => { - boardsStore.moveList(list, order); - }); - } - }, - }); - - Sortable.create(this.$el.parentNode, sortableOptions); - }, - created() { - if ( - this.list.isExpandable && - AccessorUtilities.isLocalStorageAccessSafe() && - !this.isLoggedIn - ) { - const isCollapsed = localStorage.getItem(`${this.uniqueKey}.expanded`) === 'false'; - - this.list.isExpanded = !isCollapsed; - } - }, - methods: { - showScopedLabels(label) { - return boardsStore.scopedLabels.enabled && isScopedLabel(label); - }, - - showNewIssueForm() { - this.$refs['board-list'].showIssueForm = !this.$refs['board-list'].showIssueForm; - }, - toggleExpanded() { - if (this.list.isExpandable) { - this.list.isExpanded = !this.list.isExpanded; - - if (AccessorUtilities.isLocalStorageAccessSafe() && !this.isLoggedIn) { - localStorage.setItem(`${this.uniqueKey}.expanded`, this.list.isExpanded); - } - - if (this.isLoggedIn) { - this.list.update(); - } - - // When expanding/collapsing, the tooltip on the caret button sometimes stays open. - // Close all tooltips manually to prevent dangling tooltips. - $('.tooltip').tooltip('hide'); - } - }, - }, - template: '#js-board-template', -}); diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index f0497ea0b64..6ac7fdce6a7 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -54,7 +54,7 @@ export default { <div> <div v-if="!isSwimlanesOn" - class="boards-list w-100 py-3 px-2 text-nowrap" + class="boards-list gl-w-full gl-py-5 gl-px-3 gl-white-space-nowrap" data-qa-selector="boards_list" > <board-column @@ -77,6 +77,7 @@ export default { :can-admin-list="canAdminList" :disabled="disabled" :board-id="boardId" + :group-id="groupId" /> </div> </template> diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index eb12617a66e..02a04cb4e46 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -5,10 +5,11 @@ import { GlLabel, GlTooltip, GlIcon, + GlSprintf, GlTooltipDirective, } from '@gitlab/ui'; import isWipLimitsOn from 'ee_else_ce/boards/mixins/is_wip_limits'; -import { s__, __, sprintf } from '~/locale'; +import { n__, s__ } from '~/locale'; import AccessorUtilities from '../../lib/utils/accessor'; import BoardDelete from './board_delete'; import IssueCount from './issue_count.vue'; @@ -25,6 +26,7 @@ export default { GlLabel, GlTooltip, GlIcon, + GlSprintf, IssueCount, }, directives: { @@ -82,10 +84,20 @@ export default { this.listType !== ListType.promotion ); }, - issuesTooltip() { + showMilestoneListDetails() { + return ( + this.list.type === 'milestone' && + this.list.milestone && + (this.list.isExpanded || !this.isSwimlanesHeader) + ); + }, + showAssigneeListDetails() { + return this.list.type === 'assignee' && (this.list.isExpanded || !this.isSwimlanesHeader); + }, + issuesTooltipLabel() { const { issuesSize } = this.list; - return sprintf(__('%{issuesSize} issues'), { issuesSize }); + return n__(`%d issue`, `%d issues`, issuesSize); }, chevronTooltip() { return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand'); @@ -111,6 +123,9 @@ export default { // eslint-disable-next-line @gitlab/require-i18n-strings return `boards.${this.boardId}.${this.listType}.${this.list.id}`; }, + collapsedTooltipTitle() { + return this.listTitle || this.listAssignee; + }, }, methods: { showScopedLabels(label) { @@ -147,7 +162,7 @@ export default { 'has-border': list.label && list.label.color, 'gl-relative': list.isExpanded, 'gl-h-full': !list.isExpanded, - 'board-inner gl-rounded-base gl-border-b-0': isSwimlanesHeader, + 'board-inner gl-rounded-top-left-base gl-rounded-top-right-base': isSwimlanesHeader, }" :style="{ borderTopColor: list.label && list.label.color ? list.label.color : null }" class="board-header gl-relative" @@ -157,7 +172,9 @@ export default { <h3 :class="{ 'user-can-drag': !disabled && !list.preset, - 'gl-border-b-0': !list.isExpanded, + 'gl-py-3': !list.isExpanded && !isSwimlanesHeader, + 'gl-border-b-0': !list.isExpanded || isSwimlanesHeader, + 'gl-py-2': !list.isExpanded && isSwimlanesHeader, }" class="board-title gl-m-0 gl-display-flex js-board-handle" > @@ -167,21 +184,17 @@ export default { :aria-label="chevronTooltip" :title="chevronTooltip" :icon="chevronIcon" - class="board-title-caret no-drag" + class="board-title-caret no-drag gl-cursor-pointer" variant="link" @click="toggleExpanded" /> <!-- The following is only true in EE and if it is a milestone --> - <span - v-if="list.type === 'milestone' && list.milestone" - aria-hidden="true" - class="gl-mr-2 milestone-icon" - > + <span v-if="showMilestoneListDetails" aria-hidden="true" class="gl-mr-2 milestone-icon"> <gl-icon name="timer" /> </span> <a - v-if="list.type === 'assignee'" + v-if="showAssigneeListDetails" :href="list.assignee.path" class="user-avatar-link js-no-trigger" > @@ -195,7 +208,10 @@ export default { width="20" /> </a> - <div class="board-title-text"> + <div + class="board-title-text" + :class="{ 'gl-display-none': !list.isExpanded && isSwimlanesHeader }" + > <span v-if="list.type !== 'label'" v-gl-tooltip.hover @@ -208,7 +224,7 @@ export default { {{ list.title }} </span> <span v-if="list.type === 'assignee'" class="board-title-sub-text gl-ml-2"> - @{{ list.assignee.username }} + @{{ listAssignee }} </span> <gl-label v-if="list.type === 'label'" @@ -220,6 +236,33 @@ export default { :title="list.label.title" /> </div> + + <span + v-if="isSwimlanesHeader && !list.isExpanded" + ref="collapsedInfo" + aria-hidden="true" + class="board-header-collapsed-info-icon gl-mt-2 gl-cursor-pointer gl-text-gray-700" + > + <gl-icon name="information" /> + </span> + <gl-tooltip v-if="isSwimlanesHeader && !list.isExpanded" :target="() => $refs.collapsedInfo"> + <div class="gl-font-weight-bold gl-pb-2">{{ collapsedTooltipTitle }}</div> + <div v-if="list.maxIssueCount !== 0"> + • + <gl-sprintf :message="__('%{issuesSize} with a limit of %{maxIssueCount}')"> + <template #issuesSize>{{ issuesTooltipLabel }}</template> + <template #maxIssueCount>{{ list.maxIssueCount }}</template> + </gl-sprintf> + </div> + <div v-else>• {{ issuesTooltipLabel }}</div> + <div v-if="weightFeatureAvailable"> + • + <gl-sprintf :message="__('%{totalWeight} total weight')"> + <template #totalWeight>{{ list.totalWeight }}</template> + </gl-sprintf> + </div> + </gl-tooltip> + <board-delete v-if="canAdminList && !list.preset && list.id" :list="list" @@ -229,7 +272,7 @@ export default { v-gl-tooltip.hover.bottom :class="{ 'gl-display-none': !list.isExpanded }" :aria-label="__('Delete list')" - class="board-delete no-drag gl-pr-0 gl-shadow-none gl-mr-3" + class="board-delete no-drag gl-pr-0 gl-shadow-none! gl-mr-3" :title="__('Delete list')" icon="remove" size="small" @@ -238,10 +281,11 @@ export default { </board-delete> <div v-if="showBoardListAndBoardInfo" - class="issue-count-badge gl-pr-0 no-drag text-secondary" + class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag text-secondary" + :class="{ 'gl-display-none': !list.isExpanded && isSwimlanesHeader }" > <span class="gl-display-inline-flex"> - <gl-tooltip :target="() => $refs.issueCount" :title="issuesTooltip" /> + <gl-tooltip :target="() => $refs.issueCount" :title="issuesTooltipLabel" /> <span ref="issueCount" class="issue-count-badge-count"> <gl-icon class="gl-mr-2" name="issues" /> <issue-count :issues-size="list.issuesSize" :max-issue-count="list.maxIssueCount" /> diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index c72fb7b30f9..02ac45f8ef9 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -1,6 +1,6 @@ <script> import $ from 'jquery'; -import { GlDeprecatedButton } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { getMilestone } from 'ee_else_ce/boards/boards_util'; import ListIssue from 'ee_else_ce/boards/models/issue'; import eventHub from '../eventhub'; @@ -11,7 +11,7 @@ export default { name: 'BoardNewIssue', components: { ProjectSelect, - GlDeprecatedButton, + GlButton, }, props: { groupId: { @@ -120,21 +120,18 @@ export default { /> <project-select v-if="groupId" :group-id="groupId" :list="list" /> <div class="clearfix prepend-top-10"> - <gl-deprecated-button + <gl-button ref="submit-button" :disabled="disabled" class="float-left" variant="success" + category="primary" type="submit" - >{{ __('Submit issue') }}</gl-deprecated-button - > - <gl-deprecated-button - class="float-right" - type="button" - variant="default" - @click="cancel" - >{{ __('Cancel') }}</gl-deprecated-button + >{{ __('Submit issue') }}</gl-button > + <gl-button class="float-right" type="button" variant="default" @click="cancel">{{ + __('Cancel') + }}</gl-button> </div> </form> </div> diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index 80db9930259..dbe3e0790f6 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -233,7 +233,7 @@ export default { </script> <template> - <div class="boards-switcher js-boards-selector append-right-10"> + <div class="boards-switcher js-boards-selector gl-mr-3"> <span class="boards-selector-wrapper js-boards-selector-wrapper"> <gl-dropdown data-qa-selector="boards_dropdown" diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index f2e198eaedb..d90928f35b6 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -153,7 +153,7 @@ export default { v-gl-tooltip name="issue-block" :title="__('Blocked issue')" - class="issue-blocked-icon append-right-4" + class="issue-blocked-icon gl-mr-2" :aria-label="__('Blocked issue')" /> <icon @@ -161,7 +161,7 @@ export default { v-gl-tooltip name="eye-slash" :title="__('Confidential')" - class="confidential-icon append-right-4" + class="confidential-icon gl-mr-2" :aria-label="__('Confidential')" /> <a :href="issue.path" :title="issue.title" class="js-no-trigger" @mousemove.stop>{{ diff --git a/app/assets/javascripts/boards/components/modal/header.vue b/app/assets/javascripts/boards/components/modal/header.vue index a42e691dcf3..8eae8e4726f 100644 --- a/app/assets/javascripts/boards/components/modal/header.vue +++ b/app/assets/javascripts/boards/components/modal/header.vue @@ -72,7 +72,7 @@ export default { <button ref="selectAllBtn" type="button" - class="btn btn-success btn-inverted prepend-left-10" + class="btn btn-success btn-inverted gl-ml-3" @click="toggleAll" > {{ selectAllText }} diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index a882cd1cdfa..5b4a1d262dd 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -80,15 +80,7 @@ export default () => { el: $boardApp, components: { BoardContent, - Board: () => - window?.gon?.features?.sfcIssueBoards - ? import('ee_else_ce/boards/components/board_column.vue') - : /** - * Please have a look at, we are moving to the SFC soon: - * https://gitlab.com/gitlab-org/gitlab/-/issues/212300 - * @deprecated - */ - import('ee_else_ce/boards/components/board'), + Board: () => import('ee_else_ce/boards/components/board_column.vue'), BoardSidebar, BoardAddIssuesModal, BoardSettingsSidebar: () => @@ -360,7 +352,7 @@ export default () => { template: ` <div class="board-extra-actions"> <button - class="btn btn-success prepend-left-10" + class="btn btn-success gl-ml-3" type="button" data-placement="bottom" ref="addIssuesButton" diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index 0bd606c6297..2aa92f86125 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -119,16 +119,12 @@ class List { } moveMultipleIssues({ issues, oldIndicies, newIndex, moveBeforeId, moveAfterId }) { - oldIndicies.reverse().forEach(index => { - this.issues.splice(index, 1); - }); - this.issues.splice(newIndex, 0, ...issues); - boardsStore - .moveMultipleIssues({ - ids: issues.map(issue => issue.id), - fromListId: null, - toListId: null, + .moveListMultipleIssues({ + list: this, + issues, + oldIndicies, + newIndex, moveBeforeId, moveAfterId, }) @@ -170,12 +166,7 @@ class List { } onNewIssueResponse(issue, data) { - issue.refreshData(data); - - if (this.issuesSize > 1) { - const moveBeforeId = this.issues[1].id; - boardsStore.moveIssue(issue.id, null, null, null, moveBeforeId); - } + boardsStore.onNewListIssueResponse(this, issue, data); } } diff --git a/app/assets/javascripts/boards/queries/board.fragment.graphql b/app/assets/javascripts/boards/queries/board.fragment.graphql index 48f55e899bf..872a4c4afbc 100644 --- a/app/assets/javascripts/boards/queries/board.fragment.graphql +++ b/app/assets/javascripts/boards/queries/board.fragment.graphql @@ -1,4 +1,4 @@ fragment BoardFragment on Board { - id, + id name } diff --git a/app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql b/app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql index 6ba6c05d6d9..5b532906f6a 100644 --- a/app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql +++ b/app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql @@ -1,15 +1,15 @@ fragment BoardListShared on BoardList { - id, - title, - position, - listType, - collapsed, + id + title + position + listType + collapsed label { - id, - title, - color, - textColor, - description, + id + title + color + textColor + description descriptionHtml } } diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index a930f39189e..da7d2e19ec1 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -296,6 +296,15 @@ const boardsStore = { Object.assign(this.moving, { list, issue }); }, + onNewListIssueResponse(list, issue, data) { + issue.refreshData(data); + + if (list.issuesSize > 1) { + const moveBeforeId = list.issues[1].id; + this.moveIssue(issue.id, null, null, null, moveBeforeId); + } + }, + moveMultipleIssuesToList({ listFrom, listTo, issues, newIndex }) { const issueTo = issues.map(issue => listTo.findIssue(issue.id)); const issueLists = issues.map(issue => issue.getLists()).flat(); @@ -675,6 +684,21 @@ const boardsStore = { }); }, + moveListMultipleIssues({ list, issues, oldIndicies, newIndex, moveBeforeId, moveAfterId }) { + oldIndicies.reverse().forEach(index => { + list.issues.splice(index, 1); + }); + list.issues.splice(newIndex, 0, ...issues); + + return this.moveMultipleIssues({ + ids: issues.map(issue => issue.id), + fromListId: null, + toListId: null, + moveBeforeId, + moveAfterId, + }); + }, + newIssue(id, issue) { return axios.post(this.generateIssuesPath(id), { issue, diff --git a/app/assets/javascripts/boards/toggle_focus.js b/app/assets/javascripts/boards/toggle_focus.js index a437a34c948..e60e7059192 100644 --- a/app/assets/javascripts/boards/toggle_focus.js +++ b/app/assets/javascripts/boards/toggle_focus.js @@ -25,7 +25,7 @@ export default (ModalStore, boardsStore) => { <div class="board-extra-actions"> <a href="#" - class="btn btn-default has-tooltip prepend-left-10 js-focus-mode-btn" + 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" diff --git a/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue b/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue deleted file mode 100644 index c15d638d92b..00000000000 --- a/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue +++ /dev/null @@ -1,169 +0,0 @@ -<script> -import { uniqueId } from 'lodash'; -import { GlButton, GlFormGroup, GlFormInput } from '@gitlab/ui'; - -export default { - name: 'CiKeyField', - components: { - GlButton, - GlFormGroup, - GlFormInput, - }, - model: { - prop: 'value', - event: 'input', - }, - props: { - tokenList: { - type: Array, - required: true, - }, - value: { - type: String, - required: true, - }, - }, - data() { - return { - results: [], - arrowCounter: -1, - userDismissedResults: false, - suggestionsId: uniqueId('token-suggestions-'), - }; - }, - computed: { - showAutocomplete() { - return this.showSuggestions ? 'off' : 'on'; - }, - showSuggestions() { - return this.results.length > 0; - }, - }, - mounted() { - document.addEventListener('click', this.handleClickOutside); - }, - destroyed() { - document.removeEventListener('click', this.handleClickOutside); - }, - methods: { - closeSuggestions() { - this.results = []; - this.arrowCounter = -1; - }, - handleClickOutside(event) { - if (!this.$el.contains(event.target)) { - this.closeSuggestions(); - } - }, - onArrowDown() { - const newCount = this.arrowCounter + 1; - - if (newCount >= this.results.length) { - this.arrowCounter = 0; - return; - } - - this.arrowCounter = newCount; - }, - onArrowUp() { - const newCount = this.arrowCounter - 1; - - if (newCount < 0) { - this.arrowCounter = this.results.length - 1; - return; - } - - this.arrowCounter = newCount; - }, - onEnter() { - const currentToken = this.results[this.arrowCounter] || this.value; - this.selectToken(currentToken); - }, - onEsc() { - if (!this.showSuggestions) { - this.$emit('input', ''); - } - this.closeSuggestions(); - this.userDismissedResults = true; - }, - onEntry(value) { - this.$emit('input', value); - this.userDismissedResults = false; - - // short circuit so that we don't false match on empty string - if (value.length < 1) { - this.closeSuggestions(); - return; - } - - const filteredTokens = this.tokenList.filter(token => - token.toLowerCase().includes(value.toLowerCase()), - ); - - if (filteredTokens.length) { - this.openSuggestions(filteredTokens); - } else { - this.closeSuggestions(); - } - }, - openSuggestions(filteredResults) { - this.results = filteredResults; - }, - selectToken(value) { - this.$emit('input', value); - this.closeSuggestions(); - this.$emit('key-selected'); - }, - }, -}; -</script> -<template> - <div> - <div class="dropdown position-relative" role="combobox" aria-owns="token-suggestions"> - <gl-form-group :label="__('Key')" label-for="ci-variable-key"> - <gl-form-input - id="ci-variable-key" - :value="value" - type="text" - role="searchbox" - class="form-control pl-2 js-env-input" - :autocomplete="showAutocomplete" - aria-autocomplete="list" - aria-controls="token-suggestions" - aria-haspopup="listbox" - :aria-expanded="showSuggestions" - data-qa-selector="ci_variable_key_field" - @input="onEntry" - @keydown.down="onArrowDown" - @keydown.up="onArrowUp" - @keydown.enter.prevent="onEnter" - @keydown.esc.stop="onEsc" - @keydown.tab="closeSuggestions" - /> - </gl-form-group> - - <div - v-show="showSuggestions && !userDismissedResults" - id="ci-variable-dropdown" - class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width" - :class="{ 'd-block': showSuggestions }" - > - <div class="dropdown-content"> - <ul :id="suggestionsId"> - <li - v-for="(result, i) in results" - :key="i" - role="option" - :class="{ 'gl-bg-gray-50': i === arrowCounter }" - :aria-selected="i === arrowCounter" - > - <gl-button tabindex="-1" class="btn-transparent pl-2" @click="selectToken(result)">{{ - result - }}</gl-button> - </li> - </ul> - </div> - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js b/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js index 9022bf51514..3f25e3df305 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js @@ -1,28 +1,14 @@ -import { __ } from '~/locale'; - import { AWS_ACCESS_KEY_ID, AWS_DEFAULT_REGION, AWS_SECRET_ACCESS_KEY } from '../constants'; export const awsTokens = { [AWS_ACCESS_KEY_ID]: { name: AWS_ACCESS_KEY_ID, - /* Checks for exactly twenty characters that match key. - Based on greps suggested by Amazon at: - https://aws.amazon.com/blogs/security/a-safer-way-to-distribute-aws-credentials-to-ec2/ - */ - validation: val => /^[A-Za-z0-9]{20}$/.test(val), - invalidMessage: __('This variable does not match the expected pattern.'), }, [AWS_DEFAULT_REGION]: { name: AWS_DEFAULT_REGION, }, [AWS_SECRET_ACCESS_KEY]: { name: AWS_SECRET_ACCESS_KEY, - /* Checks for exactly forty characters that match secret. - Based on greps suggested by Amazon at: - https://aws.amazon.com/blogs/security/a-safer-way-to-distribute-aws-credentials-to-ec2/ - */ - validation: val => /^[A-Za-z0-9/+=]{40}$/.test(val), - invalidMessage: __('This variable does not match the expected pattern.'), }, }; diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue index 6531b945212..0ba58430de1 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue @@ -5,6 +5,7 @@ import { GlCollapse, GlDeprecatedButton, GlFormCheckbox, + GlFormCombobox, GlFormGroup, GlFormInput, GlFormSelect, @@ -16,6 +17,7 @@ import { } from '@gitlab/ui'; import Cookies from 'js-cookie'; import { mapActions, mapState } from 'vuex'; +import { mapComputed } from '~/vuex_shared/bindings'; import { __ } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { @@ -25,19 +27,21 @@ import { AWS_TIP_MESSAGE, } from '../constants'; import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens'; -import CiKeyField from './ci_key_field.vue'; import CiEnvironmentsDropdown from './ci_environments_dropdown.vue'; export default { modalId: ADD_CI_VARIABLE_MODAL_ID, + tokens: awsTokens, + tokenList: awsTokenList, + awsTipMessage: AWS_TIP_MESSAGE, components: { CiEnvironmentsDropdown, - CiKeyField, GlAlert, GlButton, GlCollapse, GlDeprecatedButton, GlFormCheckbox, + GlFormCombobox, GlFormGroup, GlFormInput, GlFormSelect, @@ -48,9 +52,6 @@ export default { GlSprintf, }, mixins: [glFeatureFlagsMixin()], - tokens: awsTokens, - tokenList: awsTokenList, - awsTipMessage: AWS_TIP_MESSAGE, data() { return { isTipDismissed: Cookies.get(AWS_TIP_DISMISSED_COOKIE_NAME) === 'true', @@ -74,22 +75,34 @@ export default { 'protectedEnvironmentVariablesLink', 'maskedEnvironmentVariablesLink', ]), + ...mapComputed( + [ + { key: 'key', updateFn: 'updateVariableKey' }, + { key: 'secret_value', updateFn: 'updateVariableValue' }, + { key: 'variable_type', updateFn: 'updateVariableType' }, + { key: 'environment_scope', updateFn: 'setEnvironmentScope' }, + { key: 'protected_variable', updateFn: 'updateVariableProtected' }, + { key: 'masked', updateFn: 'updateVariableMasked' }, + ], + false, + 'variable', + ), isTipVisible() { - return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variableData.key); + return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variable.key); }, canSubmit() { return ( this.variableValidationState && - this.variableData.key !== '' && - this.variableData.secret_value !== '' + this.variable.key !== '' && + this.variable.secret_value !== '' ); }, canMask() { const regex = RegExp(this.maskableRegex); - return regex.test(this.variableData.secret_value); + return regex.test(this.variable.secret_value); }, displayMaskedError() { - return !this.canMask && this.variableData.masked; + return !this.canMask && this.variable.masked; }, maskedState() { if (this.displayMaskedError) { @@ -97,9 +110,6 @@ export default { } return true; }, - variableData() { - return this.variableBeingEdited || this.variable; - }, modalActionText() { return this.variableBeingEdited ? __('Update variable') : __('Add variable'); }, @@ -107,7 +117,7 @@ export default { return this.displayMaskedError ? __('This variable can not be masked.') : ''; }, tokenValidationFeedback() { - const tokenSpecificFeedback = this.$options.tokens?.[this.variableData.key]?.invalidMessage; + const tokenSpecificFeedback = this.$options.tokens?.[this.variable.key]?.invalidMessage; if (!this.tokenValidationState && tokenSpecificFeedback) { return tokenSpecificFeedback; } @@ -119,10 +129,10 @@ export default { return true; } - const validator = this.$options.tokens?.[this.variableData.key]?.validation; + const validator = this.$options.tokens?.[this.variable.key]?.validation; if (validator) { - return validator(this.variableData.secret_value); + return validator(this.variable.secret_value); } return true; @@ -131,14 +141,7 @@ export default { return `${this.tokenValidationFeedback} ${this.maskedFeedback}`; }, variableValidationState() { - if ( - this.variableData.secret_value === '' || - (this.tokenValidationState && this.maskedState) - ) { - return true; - } - - return false; + return this.variable.secret_value === '' || (this.tokenValidationState && this.maskedState); }, }, methods: { @@ -160,7 +163,7 @@ export default { this.isTipDismissed = true; }, deleteVarAndClose() { - this.deleteVariable(this.variableBeingEdited); + this.deleteVariable(); this.hideModal(); }, hideModal() { @@ -169,14 +172,14 @@ export default { resetModalHandler() { if (this.variableBeingEdited) { this.resetEditing(); - } else { - this.clearModal(); } + + this.clearModal(); this.resetSelectedEnvironment(); }, updateOrAddVariable() { if (this.variableBeingEdited) { - this.updateVariable(this.variableBeingEdited); + this.updateVariable(); } else { this.addVariable(); } @@ -202,16 +205,17 @@ export default { @shown="setVariableProtectedByDefault" > <form> - <ci-key-field + <gl-form-combobox v-if="glFeatures.ciKeyAutocomplete" - v-model="variableData.key" + v-model="key" :token-list="$options.tokenList" + :label-text="__('Key')" /> <gl-form-group v-else :label="__('Key')" label-for="ci-variable-key"> <gl-form-input id="ci-variable-key" - v-model="variableData.key" + v-model="key" data-qa-selector="ci_variable_key_field" /> </gl-form-group> @@ -225,11 +229,12 @@ export default { <gl-form-textarea id="ci-variable-value" ref="valueField" - v-model="variableData.secret_value" + v-model="secret_value" :state="variableValidationState" rows="3" max-rows="6" data-qa-selector="ci_variable_value_field" + class="gl-font-monospace!" /> </gl-form-group> @@ -240,11 +245,7 @@ export default { class="w-50 append-right-15" :class="{ 'w-100': isGroup }" > - <gl-form-select - id="ci-variable-type" - v-model="variableData.variable_type" - :options="typeOptions" - /> + <gl-form-select id="ci-variable-type" v-model="variable_type" :options="typeOptions" /> </gl-form-group> <gl-form-group @@ -255,7 +256,7 @@ export default { > <ci-environments-dropdown class="w-100" - :value="variableData.environment_scope" + :value="environment_scope" @selectEnvironment="setEnvironmentScope" @createClicked="addWildCardScope" /> @@ -263,7 +264,7 @@ export default { </div> <gl-form-group :label="__('Flags')" label-for="ci-variable-flags"> - <gl-form-checkbox v-model="variableData.protected" class="mb-0"> + <gl-form-checkbox v-model="protected_variable" class="mb-0"> {{ __('Protect variable') }} <gl-link target="_blank" :href="protectedEnvironmentVariablesLink"> <gl-icon name="question" :size="12" /> @@ -275,7 +276,7 @@ export default { <gl-form-checkbox ref="masked-ci-variable" - v-model="variableData.masked" + v-model="masked" data-qa-selector="ci_variable_masked_checkbox" > {{ __('Mask variable') }} diff --git a/app/assets/javascripts/ci_variable_list/store/actions.js b/app/assets/javascripts/ci_variable_list/store/actions.js index d9129c919f8..60c7a480769 100644 --- a/app/assets/javascripts/ci_variable_list/store/actions.js +++ b/app/assets/javascripts/ci_variable_list/store/actions.js @@ -65,10 +65,10 @@ export const receiveUpdateVariableError = ({ commit }, error) => { commit(types.RECEIVE_UPDATE_VARIABLE_ERROR, error); }; -export const updateVariable = ({ state, dispatch }, variable) => { +export const updateVariable = ({ state, dispatch }) => { dispatch('requestUpdateVariable'); - const updatedVariable = prepareDataForApi(variable); + const updatedVariable = prepareDataForApi(state.variable); updatedVariable.secrect_value = updateVariable.value; return axios @@ -121,13 +121,13 @@ export const receiveDeleteVariableError = ({ commit }, error) => { commit(types.RECEIVE_DELETE_VARIABLE_ERROR, error); }; -export const deleteVariable = ({ dispatch, state }, variable) => { +export const deleteVariable = ({ dispatch, state }) => { dispatch('requestDeleteVariable'); const destroy = true; return axios - .patch(state.endpoint, { variables_attributes: [prepareDataForApi(variable, destroy)] }) + .patch(state.endpoint, { variables_attributes: [prepareDataForApi(state.variable, destroy)] }) .then(() => { dispatch('receiveDeleteVariableSuccess'); dispatch('fetchVariables'); @@ -176,3 +176,23 @@ export const resetSelectedEnvironment = ({ commit }) => { export const setSelectedEnvironment = ({ commit }, environment) => { commit(types.SET_SELECTED_ENVIRONMENT, environment); }; + +export const updateVariableKey = ({ commit }, { key }) => { + commit(types.UPDATE_VARIABLE_KEY, key); +}; + +export const updateVariableValue = ({ commit }, { secret_value }) => { + commit(types.UPDATE_VARIABLE_VALUE, secret_value); +}; + +export const updateVariableType = ({ commit }, { variable_type }) => { + commit(types.UPDATE_VARIABLE_TYPE, variable_type); +}; + +export const updateVariableProtected = ({ commit }, { protected_variable }) => { + commit(types.UPDATE_VARIABLE_PROTECTED, protected_variable); +}; + +export const updateVariableMasked = ({ commit }, { masked }) => { + commit(types.UPDATE_VARIABLE_MASKED, masked); +}; diff --git a/app/assets/javascripts/ci_variable_list/store/mutation_types.js b/app/assets/javascripts/ci_variable_list/store/mutation_types.js index ccf8fbd3cb5..5db8f610192 100644 --- a/app/assets/javascripts/ci_variable_list/store/mutation_types.js +++ b/app/assets/javascripts/ci_variable_list/store/mutation_types.js @@ -25,3 +25,9 @@ export const SET_ENVIRONMENT_SCOPE = 'SET_ENVIRONMENT_SCOPE'; export const ADD_WILD_CARD_SCOPE = 'ADD_WILD_CARD_SCOPE'; export const RESET_SELECTED_ENVIRONMENT = 'RESET_SELECTED_ENVIRONMENT'; export const SET_SELECTED_ENVIRONMENT = 'SET_SELECTED_ENVIRONMENT'; + +export const UPDATE_VARIABLE_KEY = 'UPDATE_VARIABLE_KEY'; +export const UPDATE_VARIABLE_VALUE = 'UPDATE_VARIABLE_VALUE'; +export const UPDATE_VARIABLE_TYPE = 'UPDATE_VARIABLE_TYPE'; +export const UPDATE_VARIABLE_PROTECTED = 'UPDATE_VARIABLE_PROTECTED'; +export const UPDATE_VARIABLE_MASKED = 'UPDATE_VARIABLE_MASKED'; diff --git a/app/assets/javascripts/ci_variable_list/store/mutations.js b/app/assets/javascripts/ci_variable_list/store/mutations.js index 7d9cd0dd727..961cecee298 100644 --- a/app/assets/javascripts/ci_variable_list/store/mutations.js +++ b/app/assets/javascripts/ci_variable_list/store/mutations.js @@ -65,7 +65,8 @@ export default { }, [types.VARIABLE_BEING_EDITED](state, variable) { - state.variableBeingEdited = variable; + state.variableBeingEdited = true; + state.variable = variable; }, [types.CLEAR_MODAL](state) { @@ -73,23 +74,19 @@ export default { variable_type: displayText.variableText, key: '', secret_value: '', - protected: false, + protected_variable: false, masked: false, environment_scope: displayText.allEnvironmentsText, }; }, [types.RESET_EDITING](state) { - state.variableBeingEdited = null; + state.variableBeingEdited = false; state.showInputValue = false; }, [types.SET_ENVIRONMENT_SCOPE](state, environment) { - if (state.variableBeingEdited) { - state.variableBeingEdited.environment_scope = environment; - } else { - state.variable.environment_scope = environment; - } + state.variable.environment_scope = environment; }, [types.ADD_WILD_CARD_SCOPE](state, environment) { @@ -106,6 +103,26 @@ export default { }, [types.SET_VARIABLE_PROTECTED](state) { - state.variable.protected = true; + state.variable.protected_variable = true; + }, + + [types.UPDATE_VARIABLE_KEY](state, key) { + state.variable.key = key; + }, + + [types.UPDATE_VARIABLE_VALUE](state, value) { + state.variable.secret_value = value; + }, + + [types.UPDATE_VARIABLE_TYPE](state, type) { + state.variable.variable_type = type; + }, + + [types.UPDATE_VARIABLE_PROTECTED](state, bool) { + state.variable.protected_variable = bool; + }, + + [types.UPDATE_VARIABLE_MASKED](state, bool) { + state.variable.masked = bool; }, }; diff --git a/app/assets/javascripts/ci_variable_list/store/state.js b/app/assets/javascripts/ci_variable_list/store/state.js index 2fffd115589..96b27792664 100644 --- a/app/assets/javascripts/ci_variable_list/store/state.js +++ b/app/assets/javascripts/ci_variable_list/store/state.js @@ -12,7 +12,7 @@ export default () => ({ variable_type: displayText.variableText, key: '', secret_value: '', - protected: false, + protected_variable: false, masked: false, environment_scope: displayText.allEnvironmentsText, }, @@ -21,6 +21,6 @@ export default () => ({ error: null, environments: [], typeOptions: [displayText.variableText, displayText.fileText], - variableBeingEdited: null, + variableBeingEdited: false, selectedEnvironment: '', }); diff --git a/app/assets/javascripts/ci_variable_list/store/utils.js b/app/assets/javascripts/ci_variable_list/store/utils.js index 3cd8c85024b..f04530359e7 100644 --- a/app/assets/javascripts/ci_variable_list/store/utils.js +++ b/app/assets/javascripts/ci_variable_list/store/utils.js @@ -18,6 +18,7 @@ export const prepareDataForDisplay = variables => { if (variableCopy.environment_scope === types.allEnvironmentsType) { variableCopy.environment_scope = displayText.allEnvironmentsText; } + variableCopy.protected_variable = variableCopy.protected; variablesToDisplay.push(variableCopy); }); return variablesToDisplay; @@ -25,7 +26,8 @@ export const prepareDataForDisplay = variables => { export const prepareDataForApi = (variable, destroy = false) => { const variableCopy = cloneDeep(variable); - variableCopy.protected = variableCopy.protected.toString(); + variableCopy.protected = variableCopy.protected_variable.toString(); + delete variableCopy.protected_variable; variableCopy.masked = variableCopy.masked.toString(); variableCopy.variable_type = variableTypeHandler(variableCopy.variable_type); if (variableCopy.environment_scope === displayText.allEnvironmentsText) { diff --git a/app/assets/javascripts/close_reopen_report_toggle.js b/app/assets/javascripts/close_reopen_report_toggle.js index bcddce6e727..9bbbe07e7a1 100644 --- a/app/assets/javascripts/close_reopen_report_toggle.js +++ b/app/assets/javascripts/close_reopen_report_toggle.js @@ -80,12 +80,7 @@ class CloseReopenReportToggle { { input: this.button, valueAttribute: 'data-url', - inputAttribute: 'href', - }, - { - input: this.button, - valueAttribute: 'data-method', - inputAttribute: 'data-method', + inputAttribute: 'data-endpoint', }, ], }; diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index f15efb2fdeb..83bdea15e62 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -222,7 +222,7 @@ export default class Clusters { initRemoveClusterActions() { const el = document.querySelector('#js-cluster-remove-actions'); if (el && el.dataset) { - const { clusterName, clusterPath } = el.dataset; + const { clusterName, clusterPath, hasManagementProject } = el.dataset; this.removeClusterAction = new Vue({ el, @@ -231,6 +231,7 @@ export default class Clusters { props: { clusterName, clusterPath, + hasManagementProject, }, }); }, diff --git a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue index 54f5468bdd0..87c3225085f 100644 --- a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue +++ b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue @@ -198,13 +198,7 @@ export default { </strong> </p> <div class="form-check form-check-inline mt-3"> - <gl-toggle - v-model="modSecurityEnabled" - :label-on="__('Enabled')" - :label-off="__('Disabled')" - :disabled="saveButtonDisabled" - label-position="right" - /> + <gl-toggle v-model="modSecurityEnabled" :disabled="saveButtonDisabled" /> </div> <div v-if="ingress.modsecurity_enabled" diff --git a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue index c5375cbfbdc..45f2dd48961 100644 --- a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue +++ b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue @@ -1,7 +1,7 @@ <script> import { escape } from 'lodash'; import SplitButton from '~/vue_shared/components/split_button.vue'; -import { GlModal, GlDeprecatedButton, GlFormInput } from '@gitlab/ui'; +import { GlModal, GlButton, GlDeprecatedButton, GlFormInput } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; import csrf from '~/lib/utils/csrf'; @@ -27,6 +27,7 @@ export default { components: { SplitButton, GlModal, + GlButton, GlDeprecatedButton, GlFormInput, }, @@ -39,6 +40,10 @@ export default { type: String, required: true, }, + hasManagementProject: { + type: Boolean, + required: false, + }, }, data() { return { @@ -90,6 +95,9 @@ export default { canSubmit() { return this.enteredClusterName === this.clusterName; }, + canCleanupResources() { + return !this.hasManagementProject; + }, }, methods: { handleClickRemoveCluster(cleanup = false) { @@ -112,12 +120,21 @@ export default { <template> <div> <split-button + v-if="canCleanupResources" :action-items="$options.splitButtonActionItems" menu-class="dropdown-menu-large" variant="danger" @remove-cluster="handleClickRemoveCluster(false)" @remove-cluster-and-cleanup="handleClickRemoveCluster(true)" /> + <gl-button + v-else + variant="danger" + data-testid="btnRemove" + @click="handleClickRemoveCluster(false)" + > + {{ s__('ClusterIntegration|Remove integration') }} + </gl-button> <gl-modal ref="modal" size="lg" diff --git a/app/assets/javascripts/clusters_list/components/ancestor_notice.vue b/app/assets/javascripts/clusters_list/components/ancestor_notice.vue new file mode 100644 index 00000000000..7954fc61785 --- /dev/null +++ b/app/assets/javascripts/clusters_list/components/ancestor_notice.vue @@ -0,0 +1,34 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { mapState } from 'vuex'; + +export default { + components: { + GlLink, + GlSprintf, + }, + computed: { + ...mapState(['ancestorHelperPath', 'hasAncestorClusters']), + }, +}; +</script> + +<template> + <div v-if="hasAncestorClusters" class="bs-callout bs-callout-info"> + <p> + <gl-sprintf + :message=" + s__( + 'ClusterIntegration|Clusters are utilized by selecting the nearest ancestor with a matching environment scope. For example, project clusters will override group clusters. %{linkStart}More information%{linkEnd}', + ) + " + > + <template #link="{ content }"> + <gl-link :href="ancestorHelperPath"> + <strong>{{ content }}</strong> + </gl-link> + </template> + </gl-sprintf> + </p> + </div> +</template> diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue index a3104038c17..7e9b720d269 100644 --- a/app/assets/javascripts/clusters_list/components/clusters.vue +++ b/app/assets/javascripts/clusters_list/components/clusters.vue @@ -1,14 +1,15 @@ <script> -import * as Sentry from '@sentry/browser'; import { mapState, mapActions } from 'vuex'; import { GlDeprecatedBadge as GlBadge, GlLink, GlLoadingIcon, GlPagination, + GlSkeletonLoading, GlSprintf, GlTable, } from '@gitlab/ui'; +import AncestorNotice from './ancestor_notice.vue'; import tooltip from '~/vue_shared/directives/tooltip'; import { CLUSTER_TYPES, STATUSES } from '../constants'; import { __, sprintf } from '~/locale'; @@ -17,10 +18,12 @@ export default { nodeMemoryText: __('%{totalMemory} (%{freeSpacePercentage}%{percentSymbol} free)'), nodeCpuText: __('%{totalCpu} (%{freeSpacePercentage}%{percentSymbol} free)'), components: { + AncestorNotice, GlBadge, GlLink, GlLoadingIcon, GlPagination, + GlSkeletonLoading, GlSprintf, GlTable, }, @@ -28,7 +31,18 @@ export default { tooltip, }, computed: { - ...mapState(['clusters', 'clustersPerPage', 'loading', 'page', 'providers', 'totalCulsters']), + ...mapState([ + 'clusters', + 'clustersPerPage', + 'loadingClusters', + 'loadingNodes', + 'page', + 'providers', + 'totalCulsters', + ]), + contentAlignClasses() { + return 'gl-display-flex gl-align-items-center gl-justify-content-end gl-justify-content-md-start'; + }, currentPage: { get() { return this.page; @@ -75,7 +89,7 @@ export default { this.fetchClusters(); }, methods: { - ...mapActions(['fetchClusters', 'setPage']), + ...mapActions(['fetchClusters', 'reportSentryError', 'setPage']), k8sQuantityToGb(quantity) { if (!quantity) { return 0; @@ -137,7 +151,7 @@ export default { }; } } catch (error) { - Sentry.captureException(error); + this.reportSentryError({ error, tag: 'totalMemoryAndUsageError' }); } return { totalMemory: null, freeSpacePercentage: null }; @@ -170,7 +184,7 @@ export default { }; } } catch (error) { - Sentry.captureException(error); + this.reportSentryError({ error, tag: 'totalCpuAndUsageError' }); } return { totalCpu: null, freeSpacePercentage: null }; @@ -180,14 +194,14 @@ export default { </script> <template> - <gl-loading-icon v-if="loading" size="md" class="mt-3" /> + <gl-loading-icon v-if="loadingClusters" size="md" class="gl-mt-3" /> <section v-else> + <ancestor-notice /> + <gl-table :items="clusters" :fields="fields" stacked="md" class="qa-clusters-table"> <template #cell(name)="{ item }"> - <div - class="gl-display-flex gl-align-items-center gl-justify-content-end gl-justify-content-md-start js-status" - > + <div :class="[contentAlignClasses, 'js-status']"> <img :src="selectedProvider(item.provider_type).path" :alt="selectedProvider(item.provider_type).text" @@ -214,6 +228,9 @@ export default { <template #cell(node_size)="{ item }"> <span v-if="item.nodes">{{ item.nodes.length }}</span> + + <gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" /> + <small v-else class="gl-font-sm gl-font-style-italic gl-text-gray-400">{{ __('Unknown') }}</small> @@ -231,6 +248,8 @@ export default { > </gl-sprintf> </span> + + <gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" /> </template> <template #cell(total_memory)="{ item }"> @@ -245,6 +264,8 @@ export default { > </gl-sprintf> </span> + + <gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" /> </template> <template #cell(cluster_type)="{value}"> diff --git a/app/assets/javascripts/clusters_list/store/actions.js b/app/assets/javascripts/clusters_list/store/actions.js index 5245c307c8c..dddcfb3d975 100644 --- a/app/assets/javascripts/clusters_list/store/actions.js +++ b/app/assets/javascripts/clusters_list/store/actions.js @@ -16,9 +16,18 @@ const allNodesPresent = (clusters, retryCount) => { return retryCount > MAX_REQUESTS || clusters.every(cluster => cluster.nodes != null); }; -export const fetchClusters = ({ state, commit }) => { +export const reportSentryError = (_store, { error, tag }) => { + Sentry.withScope(scope => { + scope.setTag('javascript_clusters_list', tag); + Sentry.captureException(error); + }); +}; + +export const fetchClusters = ({ state, commit, dispatch }) => { let retryCount = 0; + commit(types.SET_LOADING_NODES, true); + const poll = new Poll({ resource: { fetchClusters: paginatedEndPoint => axios.get(paginatedEndPoint), @@ -34,31 +43,30 @@ export const fetchClusters = ({ state, commit }) => { const paginationInformation = parseIntPagination(normalizedHeaders); commit(types.SET_CLUSTERS_DATA, { data, paginationInformation }); - commit(types.SET_LOADING_STATE, false); + commit(types.SET_LOADING_CLUSTERS, false); if (allNodesPresent(data.clusters, retryCount)) { poll.stop(); + commit(types.SET_LOADING_NODES, false); } } } catch (error) { poll.stop(); - Sentry.withScope(scope => { - scope.setTag('javascript_clusters_list', 'fetchClustersSuccessCallback'); - Sentry.captureException(error); - }); + commit(types.SET_LOADING_CLUSTERS, false); + commit(types.SET_LOADING_NODES, false); + + dispatch('reportSentryError', { error, tag: 'fetchClustersSuccessCallback' }); } }, errorCallback: response => { poll.stop(); - commit(types.SET_LOADING_STATE, false); + commit(types.SET_LOADING_CLUSTERS, false); + commit(types.SET_LOADING_NODES, false); flash(__('Clusters|An error occurred while loading clusters')); - Sentry.withScope(scope => { - scope.setTag('javascript_clusters_list', 'fetchClustersErrorCallback'); - Sentry.captureException(response); - }); + dispatch('reportSentryError', { error: response, tag: 'fetchClustersErrorCallback' }); }, }); diff --git a/app/assets/javascripts/clusters_list/store/mutation_types.js b/app/assets/javascripts/clusters_list/store/mutation_types.js index a5275f28c13..beb4388c93e 100644 --- a/app/assets/javascripts/clusters_list/store/mutation_types.js +++ b/app/assets/javascripts/clusters_list/store/mutation_types.js @@ -1,3 +1,4 @@ export const SET_CLUSTERS_DATA = 'SET_CLUSTERS_DATA'; -export const SET_LOADING_STATE = 'SET_LOADING_STATE'; +export const SET_LOADING_CLUSTERS = 'SET_LOADING_CLUSTERS'; +export const SET_LOADING_NODES = 'SET_LOADING_NODES'; export const SET_PAGE = 'SET_PAGE'; diff --git a/app/assets/javascripts/clusters_list/store/mutations.js b/app/assets/javascripts/clusters_list/store/mutations.js index 2a9df9f38f0..5b462928518 100644 --- a/app/assets/javascripts/clusters_list/store/mutations.js +++ b/app/assets/javascripts/clusters_list/store/mutations.js @@ -1,8 +1,11 @@ import * as types from './mutation_types'; export default { - [types.SET_LOADING_STATE](state, value) { - state.loading = value; + [types.SET_LOADING_CLUSTERS](state, value) { + state.loadingClusters = value; + }, + [types.SET_LOADING_NODES](state, value) { + state.loadingNodes = value; }, [types.SET_CLUSTERS_DATA](state, { data, paginationInformation }) { Object.assign(state, { diff --git a/app/assets/javascripts/clusters_list/store/state.js b/app/assets/javascripts/clusters_list/store/state.js index 0023b43ed92..51fafd49479 100644 --- a/app/assets/javascripts/clusters_list/store/state.js +++ b/app/assets/javascripts/clusters_list/store/state.js @@ -1,9 +1,11 @@ export default (initialState = {}) => ({ + ancestorHelperPath: initialState.ancestorHelpPath, endpoint: initialState.endpoint, hasAncestorClusters: false, - loading: true, clusters: [], clustersPerPage: 0, + loadingClusters: true, + loadingNodes: true, page: 1, providers: { aws: { path: initialState.imgTagsAwsPath, text: initialState.imgTagsAwsText }, diff --git a/app/assets/javascripts/code_navigation/components/popover.vue b/app/assets/javascripts/code_navigation/components/popover.vue index df5f89e4faf..b7fa3242fbf 100644 --- a/app/assets/javascripts/code_navigation/components/popover.vue +++ b/app/assets/javascripts/code_navigation/components/popover.vue @@ -1,10 +1,14 @@ <script> -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlTabs, GlTab, GlLink, GlBadge } from '@gitlab/ui'; import DocLine from './doc_line.vue'; export default { components: { GlButton, + GlTabs, + GlTab, + GlLink, + GlBadge, DocLine, }, props: { @@ -31,6 +35,9 @@ export default { }; }, computed: { + isCurrentDefinition() { + return this.data.definitionLineNumber - 1 === this.position.lineIndex; + }, positionStyles() { return { left: `${this.position.x - this.offsetLeft}px`, @@ -43,7 +50,7 @@ export default { } if (this.isDefinitionCurrentBlob) { - return `#${this.data.definition_path.split('#').pop()}`; + return `#L${this.data.definitionLineNumber}`; } return `${this.definitionPathPrefix}/${this.data.definition_path}`; @@ -51,6 +58,9 @@ export default { isDefinitionCurrentBlob() { return this.data.definition_path.indexOf(this.blobPath) === 0; }, + references() { + return this.data.references || []; + }, }, watch: { position: { @@ -79,27 +89,61 @@ export default { class="popover code-navigation-popover popover-font-size-normal gl-popover bs-popover-bottom show" > <div :style="{ left: `${offsetLeft}px` }" class="arrow"></div> - <div v-for="(hover, index) in data.hover" :key="index" class="border-bottom"> - <pre - v-if="hover.language" - ref="code-output" - :class="$options.colorScheme" - class="border-0 bg-transparent m-0 code highlight" - ><doc-line v-for="(tokens, tokenIndex) in hover.tokens" :key="tokenIndex" :language="hover.language" :tokens="tokens"/></pre> - <p v-else ref="doc-output" class="p-3 m-0"> - {{ hover.value }} - </p> - </div> - <div v-if="definitionPath" class="popover-body"> - <gl-button - :href="definitionPath" - :target="isDefinitionCurrentBlob ? null : '_blank'" - class="w-100" - variant="default" - data-testid="go-to-definition-btn" - > - {{ __('Go to definition') }} - </gl-button> - </div> + <gl-tabs nav-class="gl-hidden" content-class="gl-py-0"> + <gl-tab :title="__('Definition')"> + <div class="overflow-auto code-navigation-popover-container"> + <div + v-for="(hover, index) in data.hover" + :key="index" + :class="{ 'border-bottom': index !== data.hover.length - 1 }" + > + <pre + v-if="hover.language" + ref="code-output" + :class="$options.colorScheme" + class="border-0 bg-transparent m-0 code highlight text-wrap" + ><doc-line v-for="(tokens, tokenIndex) in hover.tokens" :key="tokenIndex" :language="hover.language" :tokens="tokens"/></pre> + <p v-else ref="doc-output" class="p-3 m-0"> + {{ hover.value }} + </p> + </div> + </div> + <div v-if="definitionPath || isCurrentDefinition" class="popover-body border-top"> + <span v-if="isCurrentDefinition" class="gl-font-weight-bold gl-font-base"> + {{ s__('CodeIntelligence|This is the definition') }} + </span> + <gl-button + v-else + :href="definitionPath" + :target="isDefinitionCurrentBlob ? null : '_blank'" + class="w-100" + variant="default" + data-testid="go-to-definition-btn" + > + {{ __('Go to definition') }} + </gl-button> + </div> + </gl-tab> + <gl-tab data-testid="references-tab" class="py-2"> + <template #title> + {{ __('References') }} + <gl-badge size="sm" class="gl-tab-counter-badge">{{ references.length }}</gl-badge> + </template> + <template v-if="references.length"> + <div v-for="(reference, index) in references" :key="index" class="gl-dropdown-item"> + <gl-link + :href="`${definitionPathPrefix}/${reference.path}`" + class="dropdown-item" + data-testid="reference-link" + > + {{ reference.path }} + </gl-link> + </div> + </template> + <p v-else class="gl-my-4 gl-px-4"> + {{ s__('CodeNavigation|No references found') }} + </p> + </gl-tab> + </gl-tabs> </div> </template> diff --git a/app/assets/javascripts/code_navigation/store/actions.js b/app/assets/javascripts/code_navigation/store/actions.js index 7b2669691bd..9a472ca014f 100644 --- a/app/assets/javascripts/code_navigation/store/actions.js +++ b/app/assets/javascripts/code_navigation/store/actions.js @@ -18,7 +18,10 @@ export default { .then(({ data }) => { const normalizedData = data.reduce((acc, d) => { if (d.hover) { - acc[`${d.start_line}:${d.start_char}`] = d; + acc[`${d.start_line}:${d.start_char}`] = { + ...d, + definitionLineNumber: parseInt(d.definition_path?.split('#L').pop() || 0, 10), + }; addInteractionClass(path, d); } return acc; @@ -67,6 +70,7 @@ export default { x: x || 0, y: y + window.scrollY || 0, height: el.offsetHeight, + lineIndex: parseInt(lineIndex, 10), }; definition = data[`${lineIndex}:${charIndex}`]; diff --git a/app/assets/javascripts/code_navigation/utils/index.js b/app/assets/javascripts/code_navigation/utils/index.js index 4d118852a94..bb33bc556af 100644 --- a/app/assets/javascripts/code_navigation/utils/index.js +++ b/app/assets/javascripts/code_navigation/utils/index.js @@ -22,6 +22,7 @@ export const addInteractionClass = (path, d) => { el.setAttribute('data-char-index', d.start_char); el.setAttribute('data-line-index', d.start_line); el.classList.add('cursor-pointer', 'code-navigation', 'js-code-navigation'); + el.closest('.line').classList.add('code-navigation-line'); } }); }; diff --git a/app/assets/javascripts/commit_merge_requests.js b/app/assets/javascripts/commit_merge_requests.js index 3a0ab119df6..3cdb1587a3b 100644 --- a/app/assets/javascripts/commit_merge_requests.js +++ b/app/assets/javascripts/commit_merge_requests.js @@ -15,14 +15,14 @@ export function createHeader(childElementCount, mergeRequestCount) { const headerText = getHeaderText(childElementCount, mergeRequestCount); return $('<span />', { - class: 'append-right-5', + class: 'gl-mr-2', text: headerText, }); } export function createLink(mergeRequest) { return $('<a />', { - class: 'append-right-5', + class: 'gl-mr-2', href: mergeRequest.path, text: `!${mergeRequest.iid}`, }); diff --git a/app/assets/javascripts/commons/polyfills.js b/app/assets/javascripts/commons/polyfills.js index fdeb64a7644..655109bad9a 100644 --- a/app/assets/javascripts/commons/polyfills.js +++ b/app/assets/javascripts/commons/polyfills.js @@ -1,27 +1,24 @@ -// Browser polyfills - -/** - * Polyfill: fetch - * @what https://fetch.spec.whatwg.org/ - * @why Because Apollo GraphQL client relies on fetch - * @browsers Internet Explorer 11 - * @see https://caniuse.com/#feat=fetch - */ -import 'unfetch/polyfill/index'; - /** - * Polyfill: FormData APIs - * @what delete(), get(), getAll(), has(), set(), entries(), keys(), values(), - * and support for for...of - * @why Because Apollo GraphQL client relies on fetch - * @browsers Internet Explorer 11, Edge < 18 - * @see https://caniuse.com/#feat=mdn-api_formdata and subfeatures + * Polyfill + * @what requestIdleCallback + * @why To align browser features + * @browsers Safari (all versions) + * @see https://caniuse.com/#feat=requestidlecallback */ -import 'formdata-polyfill'; +window.requestIdleCallback = + window.requestIdleCallback || + function requestShim(cb) { + const start = Date.now(); + return setTimeout(() => { + cb({ + didTimeout: false, + timeRemaining: () => Math.max(0, 50 - (Date.now() - start)), + }); + }, 1); + }; -import './polyfills/custom_event'; -import './polyfills/element'; -import './polyfills/event'; -import './polyfills/nodelist'; -import './polyfills/request_idle_callback'; -import './polyfills/svg'; +window.cancelIdleCallback = + window.cancelIdleCallback || + function cancelShim(id) { + clearTimeout(id); + }; diff --git a/app/assets/javascripts/commons/polyfills/custom_event.js b/app/assets/javascripts/commons/polyfills/custom_event.js deleted file mode 100644 index 6b14eff6f05..00000000000 --- a/app/assets/javascripts/commons/polyfills/custom_event.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Polyfill: CustomEvent constructor - * @what new CustomEvent() - * @why Certain features, e.g. notes utilize this - * @browsers Internet Explorer 11 - * @see https://caniuse.com/#feat=customevent - */ -if (typeof window.CustomEvent !== 'function') { - window.CustomEvent = function CustomEvent(event, params) { - const evt = document.createEvent('CustomEvent'); - const evtParams = { - bubbles: false, - cancelable: false, - detail: undefined, - ...params, - }; - evt.initCustomEvent(event, evtParams.bubbles, evtParams.cancelable, evtParams.detail); - return evt; - }; - window.CustomEvent.prototype = Event; -} diff --git a/app/assets/javascripts/commons/polyfills/element.js b/app/assets/javascripts/commons/polyfills/element.js deleted file mode 100644 index b13ceccf511..00000000000 --- a/app/assets/javascripts/commons/polyfills/element.js +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Polyfill - * @what Element.classList - * @why In order to align browser features - * @browsers Internet Explorer 11 - * @see https://caniuse.com/#feat=classlist - */ -import 'classlist-polyfill'; - -/** - * Polyfill - * @what Element.closest - * @why In order to align browser features - * @browsers Internet Explorer 11 - * @see https://caniuse.com/#feat=element-closest - */ -Element.prototype.closest = - Element.prototype.closest || - function closest(selector, selectedElement = this) { - if (!selectedElement) return null; - return selectedElement.matches(selector) - ? selectedElement - : Element.prototype.closest(selector, selectedElement.parentElement); - }; - -/** - * Polyfill - * @what Element.matches - * @why In order to align browser features - * @browsers Internet Explorer 11 - * @see https://caniuse.com/#feat=mdn-api_element_matches - */ -Element.prototype.matches = - Element.prototype.matches || - Element.prototype.matchesSelector || - Element.prototype.mozMatchesSelector || - Element.prototype.msMatchesSelector || - Element.prototype.oMatchesSelector || - Element.prototype.webkitMatchesSelector || - function matches(selector) { - const elms = (this.document || this.ownerDocument).querySelectorAll(selector); - let i = elms.length - 1; - while (i >= 0 && elms.item(i) !== this) { - i -= 1; - } - return i > -1; - }; - -/** - * Polyfill - * @what ChildNode.remove, Element.remove, CharacterData.remove, DocumentType.remove - * @why In order to align browser features - * @browsers Internet Explorer 11 - * @see https://caniuse.com/#feat=childnode-remove - * - * From the polyfill on MDN, https://developer.mozilla.org/en-US/docs/Web/API/ChildNode/remove#Polyfill - */ -(arr => { - arr.forEach(item => { - if (Object.prototype.hasOwnProperty.call(item, 'remove')) { - return; - } - Object.defineProperty(item, 'remove', { - configurable: true, - enumerable: true, - writable: true, - value: function remove() { - if (this.parentNode !== null) { - this.parentNode.removeChild(this); - } - }, - }); - }); -})([Element.prototype, CharacterData.prototype, DocumentType.prototype]); diff --git a/app/assets/javascripts/commons/polyfills/event.js b/app/assets/javascripts/commons/polyfills/event.js deleted file mode 100644 index 543dd5f9a93..00000000000 --- a/app/assets/javascripts/commons/polyfills/event.js +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Polyfill: Event constructor - * @what new Event() - * @why To align browser support - * @browsers Internet Explorer 11 - * @see https://caniuse.com/#feat=mdn-api_event_event - * - * Although `initEvent` is deprecated for modern browsers it is the one supported by IE - */ -if (typeof window.Event !== 'function') { - window.Event = function Event(event, params) { - const evt = document.createEvent('Event'); - const evtParams = { - bubbles: false, - cancelable: false, - ...params, - }; - evt.initEvent(event, evtParams.bubbles, evtParams.cancelable); - return evt; - }; - window.Event.prototype = Event; -} diff --git a/app/assets/javascripts/commons/polyfills/nodelist.js b/app/assets/javascripts/commons/polyfills/nodelist.js deleted file mode 100644 index 3a9111e64f8..00000000000 --- a/app/assets/javascripts/commons/polyfills/nodelist.js +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Polyfill - * @what NodeList.forEach - * @why To align browser support - * @browsers Internet Explorer 11 - * @see https://caniuse.com/#feat=mdn-api_nodelist_foreach - */ -if (window.NodeList && !NodeList.prototype.forEach) { - NodeList.prototype.forEach = function forEach(callback, thisArg = window) { - for (let i = 0; i < this.length; i += 1) { - callback.call(thisArg, this[i], i, this); - } - }; -} diff --git a/app/assets/javascripts/commons/polyfills/request_idle_callback.js b/app/assets/javascripts/commons/polyfills/request_idle_callback.js deleted file mode 100644 index 51dc82e593a..00000000000 --- a/app/assets/javascripts/commons/polyfills/request_idle_callback.js +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Polyfill - * @what requestIdleCallback - * @why To align browser features - * @browsers Safari (all versions), Internet Explorer 11 - * @see https://caniuse.com/#feat=requestidlecallback - */ -window.requestIdleCallback = - window.requestIdleCallback || - function requestShim(cb) { - const start = Date.now(); - return setTimeout(() => { - cb({ - didTimeout: false, - timeRemaining: () => Math.max(0, 50 - (Date.now() - start)), - }); - }, 1); - }; - -window.cancelIdleCallback = - window.cancelIdleCallback || - function cancelShim(id) { - clearTimeout(id); - }; diff --git a/app/assets/javascripts/commons/polyfills/svg.js b/app/assets/javascripts/commons/polyfills/svg.js deleted file mode 100644 index 92a8b03fbb4..00000000000 --- a/app/assets/javascripts/commons/polyfills/svg.js +++ /dev/null @@ -1,11 +0,0 @@ -/** - * polyfill support for external SVG file references via <use xlink:href> - * @what polyfill support for external SVG file references via <use xlink:href> - * @why This is used in our GitLab SVG icon library - * @browsers Internet Explorer 11 - * @see https://caniuse.com/#feat=mdn-svg_elements_use_external_uri - * @see https//css-tricks.com/svg-use-external-source/ - */ -import svg4everybody from 'svg4everybody'; - -svg4everybody(); diff --git a/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue b/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue index 3c18608eb75..4b15bd55cbd 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue +++ b/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue @@ -25,11 +25,6 @@ export default { default: '', required: false, }, - canEdit: { - type: Boolean, - default: false, - required: false, - }, }, computed: { hasValue() { diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue index 5505704f430..1b8668b533e 100644 --- a/app/assets/javascripts/deploy_keys/components/app.vue +++ b/app/assets/javascripts/deploy_keys/components/app.vue @@ -7,12 +7,14 @@ import eventHub from '../eventhub'; import DeployKeysService from '../service'; import DeployKeysStore from '../store'; import KeysPanel from './keys_panel.vue'; +import Icon from '~/vue_shared/components/icon.vue'; export default { components: { KeysPanel, NavigationTabs, GlLoadingIcon, + Icon, }, props: { endpoint: { @@ -115,7 +117,7 @@ export default { </script> <template> - <div class="append-bottom-default deploy-keys"> + <div class="gl-mb-3 deploy-keys"> <gl-loading-icon v-if="isLoading && !hasKeys" :label="s__('DeployKeys|Loading deploy keys')" @@ -123,8 +125,8 @@ export default { /> <template v-else-if="hasKeys"> <div class="top-area scrolling-tabs-container inner-page-scroll-tabs"> - <div class="fade-left"><i class="fa fa-angle-left" aria-hidden="true"> </i></div> - <div class="fade-right"><i class="fa fa-angle-right" aria-hidden="true"> </i></div> + <div class="fade-left"><icon name="chevron-lg-left" :size="12" /></div> + <div class="fade-right"><icon name="chevron-lg-right" :size="12" /></div> <navigation-tabs :tabs="tabs" scope="deployKeys" @onChangeTab="onChangeTab" /> </div> diff --git a/app/assets/javascripts/design_management/components/design_destroyer.vue b/app/assets/javascripts/design_management/components/design_destroyer.vue index ad3f2736c4a..62460ca551c 100644 --- a/app/assets/javascripts/design_management/components/design_destroyer.vue +++ b/app/assets/javascripts/design_management/components/design_destroyer.vue @@ -1,7 +1,7 @@ <script> import { ApolloMutation } from 'vue-apollo'; import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql'; -import destroyDesignMutation from '../graphql/mutations/destroyDesign.mutation.graphql'; +import destroyDesignMutation from '../graphql/mutations/destroy_design.mutation.graphql'; import { updateStoreAfterDesignsDelete } from '../utils/cache_update'; export default { 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 7e442bb295f..4aaf43e3a5b 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 @@ -5,9 +5,9 @@ import { s__ } from '~/locale'; import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import allVersionsMixin from '../../mixins/all_versions'; -import createNoteMutation from '../../graphql/mutations/createNote.mutation.graphql'; +import createNoteMutation from '../../graphql/mutations/create_note.mutation.graphql'; import toggleResolveDiscussionMutation from '../../graphql/mutations/toggle_resolve_discussion.mutation.graphql'; -import getDesignQuery from '../../graphql/queries/getDesign.query.graphql'; +import getDesignQuery from '../../graphql/queries/get_design.query.graphql'; import activeDiscussionQuery from '../../graphql/queries/active_discussion.query.graphql'; import DesignNote from './design_note.vue'; import DesignReplyForm from './design_reply_form.vue'; diff --git a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue index 756da7f55aa..969034909f2 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue @@ -62,7 +62,7 @@ export default { }, }, mounted() { - this.$refs.textarea.focus(); + this.focusInput(); }, methods: { submitForm() { @@ -75,6 +75,9 @@ export default { this.$emit('cancelForm'); } }, + focusInput() { + this.$refs.textarea.focus(); + }, }, }; </script> diff --git a/app/assets/javascripts/design_management/components/toolbar/index.vue b/app/assets/javascripts/design_management/components/toolbar/index.vue index ea9f7300981..b998dfc47b8 100644 --- a/app/assets/javascripts/design_management/components/toolbar/index.vue +++ b/app/assets/javascripts/design_management/components/toolbar/index.vue @@ -6,7 +6,7 @@ import timeagoMixin from '~/vue_shared/mixins/timeago'; import Pagination from './pagination.vue'; import DeleteButton from '../delete_button.vue'; import permissionsQuery from '../../graphql/queries/design_permissions.query.graphql'; -import appDataQuery from '../../graphql/queries/appData.query.graphql'; +import appDataQuery from '../../graphql/queries/app_data.query.graphql'; import { DESIGNS_ROUTE_NAME } from '../../router/constants'; export default { diff --git a/app/assets/javascripts/design_management/components/upload/design_dropzone.vue b/app/assets/javascripts/design_management/components/upload/design_dropzone.vue index e2e1fc8bfad..33261134c15 100644 --- a/app/assets/javascripts/design_management/components/upload/design_dropzone.vue +++ b/app/assets/javascripts/design_management/components/upload/design_dropzone.vue @@ -1,7 +1,7 @@ <script> import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; import createFlash from '~/flash'; -import uploadDesignMutation from '../../graphql/mutations/uploadDesign.mutation.graphql'; +import uploadDesignMutation from '../../graphql/mutations/upload_design.mutation.graphql'; import { UPLOAD_DESIGN_INVALID_FILETYPE_ERROR } from '../../utils/error_messages'; import { isValidDesignFile } from '../../utils/design_management_utils'; import { VALID_DATA_TRANSFER_TYPE, VALID_DESIGN_FILE_MIMETYPE } from '../../constants'; diff --git a/app/assets/javascripts/design_management/graphql/fragments/design.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/design.fragment.graphql index c1439c56ff5..4b1703e41c3 100644 --- a/app/assets/javascripts/design_management/graphql/fragments/design.fragment.graphql +++ b/app/assets/javascripts/design_management/graphql/fragments/design.fragment.graphql @@ -1,6 +1,6 @@ -#import "./designNote.fragment.graphql" -#import "./designList.fragment.graphql" -#import "./diffRefs.fragment.graphql" +#import "./design_note.fragment.graphql" +#import "./design_list.fragment.graphql" +#import "./diff_refs.fragment.graphql" #import "./discussion_resolved_status.fragment.graphql" fragment DesignItem on Design { diff --git a/app/assets/javascripts/design_management/graphql/fragments/designList.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/design_list.fragment.graphql index bc3132f9b42..bc3132f9b42 100644 --- a/app/assets/javascripts/design_management/graphql/fragments/designList.fragment.graphql +++ b/app/assets/javascripts/design_management/graphql/fragments/design_list.fragment.graphql diff --git a/app/assets/javascripts/design_management/graphql/fragments/designNote.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/design_note.fragment.graphql index cb7cfd89abf..26edd2c0be1 100644 --- a/app/assets/javascripts/design_management/graphql/fragments/designNote.fragment.graphql +++ b/app/assets/javascripts/design_management/graphql/fragments/design_note.fragment.graphql @@ -1,4 +1,4 @@ -#import "./diffRefs.fragment.graphql" +#import "./diff_refs.fragment.graphql" #import "~/graphql_shared/fragments/author.fragment.graphql" #import "./note_permissions.fragment.graphql" diff --git a/app/assets/javascripts/design_management/graphql/fragments/diffRefs.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/diff_refs.fragment.graphql index 984a55814b0..984a55814b0 100644 --- a/app/assets/javascripts/design_management/graphql/fragments/diffRefs.fragment.graphql +++ b/app/assets/javascripts/design_management/graphql/fragments/diff_refs.fragment.graphql diff --git a/app/assets/javascripts/design_management/graphql/mutations/createImageDiffNote.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/create_image_diff_note.mutation.graphql index 9e2931b23f2..c8ade328120 100644 --- a/app/assets/javascripts/design_management/graphql/mutations/createImageDiffNote.mutation.graphql +++ b/app/assets/javascripts/design_management/graphql/mutations/create_image_diff_note.mutation.graphql @@ -1,4 +1,4 @@ -#import "../fragments/designNote.fragment.graphql" +#import "../fragments/design_note.fragment.graphql" mutation createImageDiffNote($input: CreateImageDiffNoteInput!) { createImageDiffNote(input: $input) { diff --git a/app/assets/javascripts/design_management/graphql/mutations/createNote.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/create_note.mutation.graphql index 3ae478d658e..184ee6955dc 100644 --- a/app/assets/javascripts/design_management/graphql/mutations/createNote.mutation.graphql +++ b/app/assets/javascripts/design_management/graphql/mutations/create_note.mutation.graphql @@ -1,4 +1,4 @@ -#import "../fragments/designNote.fragment.graphql" +#import "../fragments/design_note.fragment.graphql" mutation createNote($input: CreateNoteInput!) { createNote(input: $input) { diff --git a/app/assets/javascripts/design_management/graphql/mutations/destroyDesign.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/destroy_design.mutation.graphql index 0b3cf636cdb..0b3cf636cdb 100644 --- a/app/assets/javascripts/design_management/graphql/mutations/destroyDesign.mutation.graphql +++ b/app/assets/javascripts/design_management/graphql/mutations/destroy_design.mutation.graphql diff --git a/app/assets/javascripts/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql index d5f54ec9b58..1157fc05d5f 100644 --- a/app/assets/javascripts/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql +++ b/app/assets/javascripts/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql @@ -1,4 +1,4 @@ -#import "../fragments/designNote.fragment.graphql" +#import "../fragments/design_note.fragment.graphql" #import "../fragments/discussion_resolved_status.fragment.graphql" mutation toggleResolveDiscussion($id: ID!, $resolve: Boolean!) { diff --git a/app/assets/javascripts/design_management/graphql/mutations/update_active_discussion.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/update_active_discussion.mutation.graphql index 343de4e3025..a24b6737159 100644 --- a/app/assets/javascripts/design_management/graphql/mutations/update_active_discussion.mutation.graphql +++ b/app/assets/javascripts/design_management/graphql/mutations/update_active_discussion.mutation.graphql @@ -1,3 +1,3 @@ mutation updateActiveDiscussion($id: String, $source: String) { - updateActiveDiscussion (id: $id, source: $source ) @client + updateActiveDiscussion(id: $id, source: $source) @client } diff --git a/app/assets/javascripts/design_management/graphql/mutations/updateImageDiffNote.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/update_image_diff_note.mutation.graphql index cdb2264d233..5562ca9d89f 100644 --- a/app/assets/javascripts/design_management/graphql/mutations/updateImageDiffNote.mutation.graphql +++ b/app/assets/javascripts/design_management/graphql/mutations/update_image_diff_note.mutation.graphql @@ -1,4 +1,4 @@ -#import "../fragments/designNote.fragment.graphql" +#import "../fragments/design_note.fragment.graphql" mutation updateImageDiffNote($input: UpdateImageDiffNoteInput!) { updateImageDiffNote(input: $input) { diff --git a/app/assets/javascripts/design_management/graphql/mutations/update_note.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/update_note.mutation.graphql index d96b2f3934a..b995e99fb6a 100644 --- a/app/assets/javascripts/design_management/graphql/mutations/update_note.mutation.graphql +++ b/app/assets/javascripts/design_management/graphql/mutations/update_note.mutation.graphql @@ -1,4 +1,4 @@ -#import "../fragments/designNote.fragment.graphql" +#import "../fragments/design_note.fragment.graphql" mutation updateNote($input: UpdateNoteInput!) { updateNote(input: $input) { diff --git a/app/assets/javascripts/design_management/graphql/mutations/uploadDesign.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql index 904acef599b..d694e6558a0 100644 --- a/app/assets/javascripts/design_management/graphql/mutations/uploadDesign.mutation.graphql +++ b/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql @@ -11,7 +11,7 @@ mutation uploadDesign($files: [Upload!]!, $projectPath: ID!, $iid: ID!) { sha } } - }, + } } skippedDesigns { filename diff --git a/app/assets/javascripts/design_management/graphql/queries/appData.query.graphql b/app/assets/javascripts/design_management/graphql/queries/app_data.query.graphql index e1269761206..e1269761206 100644 --- a/app/assets/javascripts/design_management/graphql/queries/appData.query.graphql +++ b/app/assets/javascripts/design_management/graphql/queries/app_data.query.graphql diff --git a/app/assets/javascripts/design_management/graphql/queries/getDesign.query.graphql b/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql index 07a9af55787..07a9af55787 100644 --- a/app/assets/javascripts/design_management/graphql/queries/getDesign.query.graphql +++ b/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql diff --git a/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql b/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql index 857f205ab07..121a50555b3 100644 --- a/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql +++ b/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql @@ -1,4 +1,4 @@ -#import "../fragments/designList.fragment.graphql" +#import "../fragments/design_list.fragment.graphql" #import "../fragments/version.fragment.graphql" query getDesignList($fullPath: ID!, $iid: String!, $atVersion: ID) { diff --git a/app/assets/javascripts/design_management/index.js b/app/assets/javascripts/design_management/index.js index eb00e1742ea..1fc5779515a 100644 --- a/app/assets/javascripts/design_management/index.js +++ b/app/assets/javascripts/design_management/index.js @@ -1,3 +1,6 @@ +// This application is being moved, please do not touch this files +// Please see https://gitlab.com/gitlab-org/gitlab/-/issues/14744#note_364468096 for details + import $ from 'jquery'; import Vue from 'vue'; import createRouter from './router'; diff --git a/app/assets/javascripts/design_management/mixins/all_versions.js b/app/assets/javascripts/design_management/mixins/all_versions.js index 41c93064c26..3966fe71732 100644 --- a/app/assets/javascripts/design_management/mixins/all_versions.js +++ b/app/assets/javascripts/design_management/mixins/all_versions.js @@ -1,5 +1,5 @@ import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql'; -import appDataQuery from '../graphql/queries/appData.query.graphql'; +import appDataQuery from '../graphql/queries/app_data.query.graphql'; import { findVersionId } from '../utils/design_management_utils'; export default { diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue index fe121b6530a..9a959222e22 100644 --- a/app/assets/javascripts/design_management/pages/design/index.vue +++ b/app/assets/javascripts/design_management/pages/design/index.vue @@ -11,10 +11,10 @@ import DesignScaler from '../../components/design_scaler.vue'; import DesignPresentation from '../../components/design_presentation.vue'; import DesignReplyForm from '../../components/design_notes/design_reply_form.vue'; import DesignSidebar from '../../components/design_sidebar.vue'; -import getDesignQuery from '../../graphql/queries/getDesign.query.graphql'; -import appDataQuery from '../../graphql/queries/appData.query.graphql'; -import createImageDiffNoteMutation from '../../graphql/mutations/createImageDiffNote.mutation.graphql'; -import updateImageDiffNoteMutation from '../../graphql/mutations/updateImageDiffNote.mutation.graphql'; +import getDesignQuery from '../../graphql/queries/get_design.query.graphql'; +import appDataQuery from '../../graphql/queries/app_data.query.graphql'; +import createImageDiffNoteMutation from '../../graphql/mutations/create_image_diff_note.mutation.graphql'; +import updateImageDiffNoteMutation from '../../graphql/mutations/update_image_diff_note.mutation.graphql'; import updateActiveDiscussionMutation from '../../graphql/mutations/update_active_discussion.mutation.graphql'; import { extractDiscussions, @@ -254,6 +254,9 @@ export default { }, openCommentForm(annotationCoordinates) { this.annotationCoordinates = annotationCoordinates; + if (this.$refs.newDiscussionForm) { + this.$refs.newDiscussionForm.focusInput(); + } }, closeCommentForm() { this.comment = ''; @@ -361,6 +364,7 @@ export default { @error="onCreateImageDiffNoteError" > <design-reply-form + ref="newDiscussionForm" v-model="comment" :is-saving="loading" :markdown-preview-path="markdownPreviewPath" diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue index 922c800009f..d14a1fc8c1c 100644 --- a/app/assets/javascripts/design_management/pages/index.vue +++ b/app/assets/javascripts/design_management/pages/index.vue @@ -8,7 +8,7 @@ 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 '../components/upload/design_dropzone.vue'; -import uploadDesignMutation from '../graphql/mutations/uploadDesign.mutation.graphql'; +import uploadDesignMutation from '../graphql/mutations/upload_design.mutation.graphql'; import permissionsQuery from '../graphql/queries/design_permissions.query.graphql'; import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql'; import allDesignsMixin from '../mixins/all_designs'; diff --git a/app/assets/javascripts/design_management/utils/tracking.js b/app/assets/javascripts/design_management/utils/tracking.js index 39c20376271..b3ecc1453a6 100644 --- a/app/assets/javascripts/design_management/utils/tracking.js +++ b/app/assets/javascripts/design_management/utils/tracking.js @@ -1,18 +1,9 @@ import Tracking from '~/tracking'; -function assembleDesignPayload(payloadArr) { - return { - value: { - 'internal-object-refrerer': payloadArr[0], - 'design-collection-owner': payloadArr[1], - 'design-version-number': payloadArr[2], - 'design-is-current-version': payloadArr[3], - }, - }; -} - // Tracking Constants +const DESIGN_TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/design_management_context/jsonschema/1-0-0'; const DESIGN_TRACKING_PAGE_NAME = 'projects:issues:design'; +const DESIGN_TRACKING_EVENT_NAME = 'view_design'; // eslint-disable-next-line import/prefer-default-export export function trackDesignDetailView( @@ -21,8 +12,16 @@ export function trackDesignDetailView( designVersion = 1, latestVersion = false, ) { - Tracking.event(DESIGN_TRACKING_PAGE_NAME, 'design_viewed', { - label: 'design_viewed', - ...assembleDesignPayload([referer, owner, designVersion, latestVersion]), + Tracking.event(DESIGN_TRACKING_PAGE_NAME, DESIGN_TRACKING_EVENT_NAME, { + label: DESIGN_TRACKING_EVENT_NAME, + context: { + schema: DESIGN_TRACKING_CONTEXT_SCHEMA, + data: { + 'design-version-number': designVersion, + 'design-is-current-version': latestVersion, + 'internal-object-referrer': referer, + 'design-collection-owner': owner, + }, + }, }); } diff --git a/app/assets/javascripts/design_management_new/components/app.vue b/app/assets/javascripts/design_management_new/components/app.vue new file mode 100644 index 00000000000..98240aef810 --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/app.vue @@ -0,0 +1,3 @@ +<template> + <router-view /> +</template> diff --git a/app/assets/javascripts/design_management_new/components/delete_button.vue b/app/assets/javascripts/design_management_new/components/delete_button.vue new file mode 100644 index 00000000000..77e1b97a227 --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/delete_button.vue @@ -0,0 +1,81 @@ +<script> +import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui'; +import { uniqueId } from 'lodash'; +import { s__ } from '~/locale'; + +export default { + name: 'DeleteButton', + components: { + GlButton, + GlModal, + }, + directives: { + GlModalDirective, + }, + props: { + isDeleting: { + type: Boolean, + required: false, + default: false, + }, + buttonClass: { + type: String, + required: false, + default: '', + }, + buttonVariant: { + type: String, + required: false, + default: 'info', + }, + buttonSize: { + type: String, + required: false, + default: 'medium', + }, + hasSelectedDesigns: { + type: Boolean, + required: false, + default: true, + }, + }, + data() { + return { + modalId: uniqueId('design-deletion-confirmation-'), + }; + }, + modal: { + title: s__('DesignManagement|Delete designs confirmation'), + actionPrimary: { + text: s__('Delete'), + attributes: { variant: 'danger' }, + }, + actionCancel: { + text: s__('Cancel'), + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-align-items-center gl-h-full"> + <gl-modal + :modal-id="modalId" + :title="$options.modal.title" + :action-primary="$options.modal.actionPrimary" + :action-cancel="$options.modal.actionCancel" + @ok="$emit('deleteSelectedDesigns')" + > + <p>{{ s__('DesignManagement|Are you sure you want to delete the selected designs?') }}</p> + </gl-modal> + <gl-button + v-gl-modal-directive="modalId" + :variant="buttonVariant" + :size="buttonSize" + :class="buttonClass" + :disabled="isDeleting || !hasSelectedDesigns" + > + <slot></slot> + </gl-button> + </div> +</template> diff --git a/app/assets/javascripts/design_management_new/components/design_destroyer.vue b/app/assets/javascripts/design_management_new/components/design_destroyer.vue new file mode 100644 index 00000000000..7ae569216f0 --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/design_destroyer.vue @@ -0,0 +1,67 @@ +<script> +import { ApolloMutation } from 'vue-apollo'; +import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql'; +import destroyDesignMutation from '../graphql/mutations/destroy_design.mutation.graphql'; +import { updateStoreAfterDesignsDelete } from '../utils/cache_update'; + +export default { + components: { + ApolloMutation, + }, + props: { + filenames: { + type: Array, + required: true, + }, + }, + inject: { + projectPath: { + default: '', + }, + iid: { + from: 'issueIid', + defaut: '', + }, + }, + computed: { + projectQueryBody() { + return { + query: getDesignListQuery, + variables: { fullPath: this.projectPath, iid: this.iid, atVersion: null }, + }; + }, + }, + methods: { + updateStoreAfterDelete( + store, + { + data: { designManagementDelete }, + }, + ) { + updateStoreAfterDesignsDelete( + store, + designManagementDelete, + this.projectQueryBody, + this.filenames, + ); + }, + }, + destroyDesignMutation, +}; +</script> + +<template> + <apollo-mutation + #default="{ mutate, loading, error }" + :mutation="$options.destroyDesignMutation" + :variables="{ + filenames, + projectPath, + iid, + }" + :update="updateStoreAfterDelete" + v-on="$listeners" + > + <slot v-bind="{ mutate, loading, error }"></slot> + </apollo-mutation> +</template> diff --git a/app/assets/javascripts/design_management_new/components/design_note_pin.vue b/app/assets/javascripts/design_management_new/components/design_note_pin.vue new file mode 100644 index 00000000000..0811397fbad --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/design_note_pin.vue @@ -0,0 +1,61 @@ +<script> +import { __, sprintf } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + name: 'DesignNotePin', + components: { + Icon, + }, + props: { + position: { + type: Object, + required: true, + }, + label: { + type: Number, + required: false, + default: null, + }, + repositioning: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + isNewNote() { + return this.label === null; + }, + pinStyle() { + return this.repositioning ? { ...this.position, cursor: 'move' } : this.position; + }, + pinLabel() { + return this.isNewNote + ? __('Comment form position') + : sprintf(__("Comment '%{label}' position"), { label: this.label }); + }, + }, +}; +</script> + +<template> + <button + :style="pinStyle" + :aria-label="pinLabel" + :class="{ + 'btn-transparent comment-indicator': isNewNote, + 'js-image-badge badge badge-pill': !isNewNote, + }" + class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center" + type="button" + @mousedown="$emit('mousedown', $event)" + @mouseup="$emit('mouseup', $event)" + @click="$emit('click', $event)" + > + <icon v-if="isNewNote" name="image-comment-dark" /> + <template v-else> + {{ label }} + </template> + </button> +</template> diff --git a/app/assets/javascripts/design_management_new/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management_new/components/design_notes/design_discussion.vue new file mode 100644 index 00000000000..4aaf43e3a5b --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/design_notes/design_discussion.vue @@ -0,0 +1,297 @@ +<script> +import { ApolloMutation } from 'vue-apollo'; +import { GlTooltipDirective, GlIcon, GlLoadingIcon, GlLink } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +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 getDesignQuery from '../../graphql/queries/get_design.query.graphql'; +import activeDiscussionQuery from '../../graphql/queries/active_discussion.query.graphql'; +import DesignNote from './design_note.vue'; +import DesignReplyForm from './design_reply_form.vue'; +import { updateStoreAfterAddDiscussionComment } from '../../utils/cache_update'; +import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants'; +import ToggleRepliesWidget from './toggle_replies_widget.vue'; + +export default { + components: { + ApolloMutation, + DesignNote, + ReplyPlaceholder, + DesignReplyForm, + GlIcon, + GlLoadingIcon, + GlLink, + ToggleRepliesWidget, + TimeAgoTooltip, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [allVersionsMixin], + props: { + discussion: { + type: Object, + required: true, + }, + noteableId: { + type: String, + required: true, + }, + designId: { + type: String, + required: true, + }, + markdownPreviewPath: { + type: String, + required: false, + default: '', + }, + resolvedDiscussionsExpanded: { + type: Boolean, + required: true, + }, + discussionWithOpenForm: { + type: String, + required: true, + }, + }, + apollo: { + activeDiscussion: { + query: activeDiscussionQuery, + result({ data }) { + const discussionId = data.activeDiscussion.id; + if (this.discussion.resolved && !this.resolvedDiscussionsExpanded) { + return; + } + // We watch any changes to the active discussion from the design pins and scroll to this discussion if it exists + // We don't want scrollIntoView to be triggered from the discussion click itself + if ( + discussionId && + data.activeDiscussion.source === ACTIVE_DISCUSSION_SOURCE_TYPES.pin && + discussionId === this.discussion.notes[0].id + ) { + this.$el.scrollIntoView({ + behavior: 'smooth', + inline: 'start', + }); + } + }, + }, + }, + data() { + return { + discussionComment: '', + isFormRendered: false, + activeDiscussion: {}, + isResolving: false, + shouldChangeResolvedStatus: false, + areRepliesCollapsed: this.discussion.resolved, + }; + }, + computed: { + mutationPayload() { + return { + noteableId: this.noteableId, + body: this.discussionComment, + discussionId: this.discussion.id, + }; + }, + designVariables() { + return { + fullPath: this.projectPath, + iid: this.issueIid, + filenames: [this.$route.params.id], + atVersion: this.designsVersion, + }; + }, + isDiscussionHighlighted() { + return this.discussion.notes[0].id === this.activeDiscussion.id; + }, + resolveCheckboxText() { + return this.discussion.resolved + ? s__('DesignManagement|Unresolve thread') + : s__('DesignManagement|Resolve thread'); + }, + firstNote() { + return this.discussion.notes[0]; + }, + discussionReplies() { + return this.discussion.notes.slice(1); + }, + areRepliesShown() { + return !this.discussion.resolved || !this.areRepliesCollapsed; + }, + resolveIconName() { + return this.discussion.resolved ? 'check-circle-filled' : 'check-circle'; + }, + isRepliesWidgetVisible() { + return this.discussion.resolved && this.discussionReplies.length > 0; + }, + isReplyPlaceholderVisible() { + return this.areRepliesShown || !this.discussionReplies.length; + }, + isFormVisible() { + return this.isFormRendered && this.discussionWithOpenForm === this.discussion.id; + }, + }, + methods: { + addDiscussionComment( + store, + { + data: { createNote }, + }, + ) { + updateStoreAfterAddDiscussionComment( + store, + createNote, + getDesignQuery, + this.designVariables, + this.discussion.id, + ); + }, + onDone() { + this.discussionComment = ''; + this.hideForm(); + if (this.shouldChangeResolvedStatus) { + this.toggleResolvedStatus(); + } + }, + onCreateNoteError(err) { + this.$emit('createNoteError', err); + }, + hideForm() { + this.isFormRendered = false; + this.discussionComment = ''; + }, + showForm() { + this.$emit('openForm', this.discussion.id); + this.isFormRendered = true; + }, + toggleResolvedStatus() { + this.isResolving = true; + this.$apollo + .mutate({ + mutation: toggleResolveDiscussionMutation, + variables: { id: this.discussion.id, resolve: !this.discussion.resolved }, + }) + .then(({ data }) => { + if (data.errors?.length > 0) { + this.$emit('resolveDiscussionError', data.errors[0]); + } + }) + .catch(err => { + this.$emit('resolveDiscussionError', err); + }) + .finally(() => { + this.isResolving = false; + }); + }, + }, + createNoteMutation, +}; +</script> + +<template> + <div class="design-discussion-wrapper"> + <div + class="badge badge-pill gl-display-flex gl-align-items-center gl-justify-content-center" + :class="{ resolved: discussion.resolved }" + type="button" + > + {{ discussion.index }} + </div> + <ul + class="design-discussion bordered-box gl-relative gl-p-0 gl-list-style-none" + data-qa-selector="design_discussion_content" + > + <design-note + :note="firstNote" + :markdown-preview-path="markdownPreviewPath" + :is-resolving="isResolving" + :class="{ 'gl-bg-blue-50': isDiscussionHighlighted }" + @error="$emit('updateNoteError', $event)" + > + <template v-if="discussion.resolvable" #resolveDiscussion> + <button + v-gl-tooltip + :class="{ 'is-active': discussion.resolved }" + :title="resolveCheckboxText" + :aria-label="resolveCheckboxText" + type="button" + class="line-resolve-btn note-action-button gl-mr-3" + data-testid="resolve-button" + @click.stop="toggleResolvedStatus" + > + <gl-icon v-if="!isResolving" :name="resolveIconName" data-testid="resolve-icon" /> + <gl-loading-icon v-else inline /> + </button> + </template> + <template v-if="discussion.resolved" #resolvedStatus> + <p class="gl-text-gray-700 gl-font-sm gl-m-0 gl-mt-5" data-testid="resolved-message"> + {{ __('Resolved by') }} + <gl-link + class="gl-text-gray-700 gl-text-decoration-none gl-font-sm link-inherit-color" + :href="discussion.resolvedBy.webUrl" + target="_blank" + >{{ discussion.resolvedBy.name }}</gl-link + > + <time-ago-tooltip :time="discussion.resolvedAt" tooltip-placement="bottom" /> + </p> + </template> + </design-note> + <toggle-replies-widget + v-if="isRepliesWidgetVisible" + :collapsed="areRepliesCollapsed" + :replies="discussionReplies" + @toggle="areRepliesCollapsed = !areRepliesCollapsed" + /> + <design-note + v-for="note in discussionReplies" + v-show="areRepliesShown" + :key="note.id" + :note="note" + :markdown-preview-path="markdownPreviewPath" + :is-resolving="isResolving" + :class="{ 'gl-bg-blue-50': isDiscussionHighlighted }" + @error="$emit('updateNoteError', $event)" + /> + <li v-show="isReplyPlaceholderVisible" class="reply-wrapper"> + <reply-placeholder + v-if="!isFormVisible" + class="qa-discussion-reply" + :button-text="__('Reply...')" + @onClick="showForm" + /> + <apollo-mutation + v-else + #default="{ mutate, loading }" + :mutation="$options.createNoteMutation" + :variables="{ + input: mutationPayload, + }" + :update="addDiscussionComment" + @done="onDone" + @error="onCreateNoteError" + > + <design-reply-form + v-model="discussionComment" + :is-saving="loading" + :markdown-preview-path="markdownPreviewPath" + @submitForm="mutate" + @cancelForm="hideForm" + > + <template v-if="discussion.resolvable" #resolveCheckbox> + <label data-testid="resolve-checkbox"> + <input v-model="shouldChangeResolvedStatus" type="checkbox" /> + {{ resolveCheckboxText }} + </label> + </template> + </design-reply-form> + </apollo-mutation> + </li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/design_management_new/components/design_notes/design_note.vue b/app/assets/javascripts/design_management_new/components/design_notes/design_note.vue new file mode 100644 index 00000000000..172e61920ef --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/design_notes/design_note.vue @@ -0,0 +1,156 @@ +<script> +import { ApolloMutation } from 'vue-apollo'; +import { GlTooltipDirective, GlIcon } 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 { findNoteId } from '../../utils/design_management_utils'; +import { hasErrors } from '../../utils/cache_update'; + +export default { + components: { + UserAvatarLink, + TimelineEntryItem, + TimeAgoTooltip, + DesignReplyForm, + ApolloMutation, + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + note: { + type: Object, + required: true, + }, + markdownPreviewPath: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + noteText: this.note.body, + isEditing: false, + }; + }, + computed: { + author() { + return this.note.author; + }, + noteAnchorId() { + return findNoteId(this.note.id); + }, + isNoteLinked() { + return this.$route.hash === `#note_${this.noteAnchorId}`; + }, + mutationPayload() { + return { + id: this.note.id, + body: this.noteText, + }; + }, + isEditButtonVisible() { + return !this.isEditing && this.note.userPermissions.adminNote; + }, + }, + mounted() { + if (this.isNoteLinked) { + this.$el.scrollIntoView({ behavior: 'smooth', inline: 'start' }); + } + }, + methods: { + hideForm() { + this.isEditing = false; + this.noteText = this.note.body; + }, + onDone({ data }) { + this.hideForm(); + if (hasErrors(data.updateNote)) { + this.$emit('error', data.errors[0]); + } + }, + }, + updateNoteMutation, +}; +</script> + +<template> + <timeline-entry-item :id="`note_${noteAnchorId}`" class="design-note note-form"> + <user-avatar-link + :link-href="author.webUrl" + :img-src="author.avatarUrl" + :img-alt="author.username" + :img-size="40" + /> + <div class="d-flex justify-content-between"> + <div> + <a + v-once + :href="author.webUrl" + class="js-user-link" + :data-user-id="author.id" + :data-username="author.username" + > + <span class="note-header-author-name bold">{{ author.name }}</span> + <span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span> + <span class="note-headline-light">@{{ author.username }}</span> + </a> + <span class="note-headline-light note-headline-meta"> + <span class="system-note-message"> <slot></slot> </span> + <template v-if="note.createdAt"> + <span class="system-note-separator"></span> + <a class="note-timestamp system-note-separator" :href="`#note_${noteAnchorId}`"> + <time-ago-tooltip :time="note.createdAt" tooltip-placement="bottom" /> + </a> + </template> + </span> + </div> + <div class="gl-display-flex"> + <slot name="resolveDiscussion"></slot> + <button + v-if="isEditButtonVisible" + v-gl-tooltip + type="button" + :title="__('Edit comment')" + class="note-action-button js-note-edit btn btn-transparent qa-note-edit-button" + @click="isEditing = true" + > + <gl-icon name="pencil" class="link-highlight" /> + </button> + </div> + </div> + <template v-if="!isEditing"> + <div + class="note-text js-note-text md" + data-qa-selector="note_content" + v-html="note.bodyHtml" + ></div> + <slot name="resolvedStatus"></slot> + </template> + <apollo-mutation + v-else + #default="{ mutate, loading }" + :mutation="$options.updateNoteMutation" + :variables="{ + input: mutationPayload, + }" + @error="$emit('error', $event)" + @done="onDone" + > + <design-reply-form + v-model="noteText" + :is-saving="loading" + :markdown-preview-path="markdownPreviewPath" + :is-new-comment="false" + class="mt-5" + @submitForm="mutate" + @cancelForm="hideForm" + /> + </apollo-mutation> + </timeline-entry-item> +</template> diff --git a/app/assets/javascripts/design_management_new/components/design_notes/design_reply_form.vue b/app/assets/javascripts/design_management_new/components/design_notes/design_reply_form.vue new file mode 100644 index 00000000000..969034909f2 --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/design_notes/design_reply_form.vue @@ -0,0 +1,141 @@ +<script> +import { GlDeprecatedButton, GlModal } from '@gitlab/ui'; +import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import { s__ } from '~/locale'; + +export default { + name: 'DesignReplyForm', + components: { + MarkdownField, + GlDeprecatedButton, + GlModal, + }, + props: { + markdownPreviewPath: { + type: String, + required: false, + default: '', + }, + value: { + type: String, + required: true, + }, + isSaving: { + type: Boolean, + required: true, + }, + isNewComment: { + type: Boolean, + required: false, + default: true, + }, + }, + data() { + return { + formText: this.value, + }; + }, + computed: { + hasValue() { + return this.value.trim().length > 0; + }, + modalSettings() { + if (this.isNewComment) { + return { + title: s__('DesignManagement|Cancel comment confirmation'), + okTitle: s__('DesignManagement|Discard comment'), + cancelTitle: s__('DesignManagement|Keep comment'), + content: s__('DesignManagement|Are you sure you want to cancel creating this comment?'), + }; + } + return { + title: s__('DesignManagement|Cancel comment update confirmation'), + okTitle: s__('DesignManagement|Cancel changes'), + cancelTitle: s__('DesignManagement|Keep changes'), + content: s__('DesignManagement|Are you sure you want to cancel changes to this comment?'), + }; + }, + buttonText() { + return this.isNewComment + ? s__('DesignManagement|Comment') + : s__('DesignManagement|Save comment'); + }, + }, + mounted() { + this.focusInput(); + }, + methods: { + submitForm() { + if (this.hasValue) this.$emit('submitForm'); + }, + cancelComment() { + if (this.hasValue && this.formText !== this.value) { + this.$refs.cancelCommentModal.show(); + } else { + this.$emit('cancelForm'); + } + }, + focusInput() { + this.$refs.textarea.focus(); + }, + }, +}; +</script> + +<template> + <form class="new-note common-note-form" @submit.prevent> + <markdown-field + :markdown-preview-path="markdownPreviewPath" + :can-attach-file="false" + :enable-autocomplete="true" + :textarea-value="value" + markdown-docs-path="/help/user/markdown" + class="bordered-box" + > + <template #textarea> + <textarea + ref="textarea" + :value="value" + class="note-textarea js-gfm-input js-autosize markdown-area" + dir="auto" + data-supports-quick-actions="false" + data-qa-selector="note_textarea" + :aria-label="__('Description')" + :placeholder="__('Write a comment…')" + @input="$emit('input', $event.target.value)" + @keydown.meta.enter="submitForm" + @keydown.ctrl.enter="submitForm" + @keyup.esc.stop="cancelComment" + > + </textarea> + </template> + </markdown-field> + <slot name="resolveCheckbox"></slot> + <div class="note-form-actions gl-display-flex gl-justify-content-space-between"> + <gl-deprecated-button + ref="submitButton" + :disabled="!hasValue || isSaving" + variant="success" + type="submit" + data-track-event="click_button" + data-qa-selector="save_comment_button" + @click="$emit('submitForm')" + > + {{ buttonText }} + </gl-deprecated-button> + <gl-deprecated-button ref="cancelButton" @click="cancelComment">{{ + __('Cancel') + }}</gl-deprecated-button> + </div> + <gl-modal + ref="cancelCommentModal" + ok-variant="danger" + :title="modalSettings.title" + :ok-title="modalSettings.okTitle" + :cancel-title="modalSettings.cancelTitle" + modal-id="cancel-comment-modal" + @ok="$emit('cancelForm')" + >{{ modalSettings.content }} + </gl-modal> + </form> +</template> diff --git a/app/assets/javascripts/design_management_new/components/design_notes/toggle_replies_widget.vue b/app/assets/javascripts/design_management_new/components/design_notes/toggle_replies_widget.vue new file mode 100644 index 00000000000..46c73e3eea8 --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/design_notes/toggle_replies_widget.vue @@ -0,0 +1,70 @@ +<script> +import { GlIcon, GlButton, GlLink } from '@gitlab/ui'; +import { __, n__ } from '~/locale'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + name: 'ToggleNotesWidget', + components: { + GlIcon, + GlButton, + GlLink, + TimeAgoTooltip, + }, + props: { + collapsed: { + type: Boolean, + required: true, + }, + replies: { + type: Array, + required: true, + }, + }, + computed: { + lastReply() { + return this.replies[this.replies.length - 1]; + }, + iconName() { + return this.collapsed ? 'chevron-right' : 'chevron-down'; + }, + toggleText() { + return this.collapsed + ? `${this.replies.length} ${n__('reply', 'replies', this.replies.length)}` + : __('Collapse replies'); + }, + }, +}; +</script> + +<template> + <li + class="toggle-comments gl-bg-gray-50 gl-display-flex gl-align-items-center gl-py-3" + :class="{ expanded: !collapsed }" + data-testid="toggle-comments-wrapper" + > + <gl-icon :name="iconName" class="gl-ml-3" @click.stop="$emit('toggle')" /> + <gl-button + variant="link" + class="toggle-comments-button gl-ml-2 gl-mr-2" + @click.stop="$emit('toggle')" + > + {{ toggleText }} + </gl-button> + <template v-if="collapsed"> + <span class="gl-text-gray-700">{{ __('Last reply by') }}</span> + <gl-link + :href="lastReply.author.webUrl" + target="_blank" + class="link-inherit-color gl-text-black-normal gl-text-decoration-none gl-font-weight-bold gl-ml-2 gl-mr-2" + > + {{ lastReply.author.name }} + </gl-link> + <time-ago-tooltip + :time="lastReply.createdAt" + tooltip-placement="bottom" + class="gl-text-gray-700" + /> + </template> + </li> +</template> diff --git a/app/assets/javascripts/design_management_new/components/design_overlay.vue b/app/assets/javascripts/design_management_new/components/design_overlay.vue new file mode 100644 index 00000000000..926e7c74802 --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/design_overlay.vue @@ -0,0 +1,287 @@ +<script> +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'; + +export default { + name: 'DesignOverlay', + components: { + DesignNotePin, + }, + props: { + dimensions: { + type: Object, + required: true, + }, + position: { + type: Object, + required: true, + }, + notes: { + type: Array, + required: false, + default: () => [], + }, + currentCommentForm: { + type: Object, + required: false, + default: null, + }, + disableCommenting: { + type: Boolean, + required: false, + default: false, + }, + resolvedDiscussionsExpanded: { + type: Boolean, + required: true, + }, + }, + apollo: { + activeDiscussion: { + query: activeDiscussionQuery, + }, + }, + data() { + return { + movingNoteNewPosition: null, + movingNoteStartPosition: null, + activeDiscussion: {}, + }; + }, + computed: { + overlayStyle() { + const cursor = this.disableCommenting ? 'unset' : undefined; + + return { + cursor, + width: `${this.dimensions.width}px`, + height: `${this.dimensions.height}px`, + ...this.position, + }; + }, + isMovingCurrentComment() { + return Boolean(this.movingNoteStartPosition && !this.movingNoteStartPosition.noteId); + }, + currentCommentPositionStyle() { + return this.isMovingCurrentComment && this.movingNoteNewPosition + ? this.getNotePositionStyle(this.movingNoteNewPosition) + : this.getNotePositionStyle(this.currentCommentForm); + }, + }, + methods: { + setNewNoteCoordinates({ x, y }) { + this.$emit('openCommentForm', { x, y }); + }, + getNoteRelativePosition(position) { + const { x, y, width, height } = position; + const widthRatio = this.dimensions.width / width; + const heightRatio = this.dimensions.height / height; + return { + left: Math.round(x * widthRatio), + top: Math.round(y * heightRatio), + }; + }, + getNotePositionStyle(position) { + const { left, top } = this.getNoteRelativePosition(position); + return { + left: `${left}px`, + top: `${top}px`, + }; + }, + getMovingNotePositionDelta(e) { + let deltaX = 0; + let deltaY = 0; + + if (this.movingNoteStartPosition) { + const { clientX, clientY } = this.movingNoteStartPosition; + deltaX = e.clientX - clientX; + deltaY = e.clientY - clientY; + } + + return { + deltaX, + deltaY, + }; + }, + isMovingNote(noteId) { + const movingNoteId = this.movingNoteStartPosition?.noteId; + return Boolean(movingNoteId && movingNoteId === noteId); + }, + canMoveNote(note) { + const { userPermissions } = note; + const { adminNote } = userPermissions || {}; + + return Boolean(adminNote); + }, + isPositionInOverlay(position) { + const { top, left } = this.getNoteRelativePosition(position); + const { height, width } = this.dimensions; + + return top >= 0 && top <= height && left >= 0 && left <= width; + }, + onNewNoteMove(e) { + if (!this.isMovingCurrentComment) return; + + const { deltaX, deltaY } = this.getMovingNotePositionDelta(e); + const x = this.currentCommentForm.x + deltaX; + const y = this.currentCommentForm.y + deltaY; + + const movingNoteNewPosition = { + x, + y, + width: this.dimensions.width, + height: this.dimensions.height, + }; + + if (!this.isPositionInOverlay(movingNoteNewPosition)) { + this.onNewNoteMouseup(); + return; + } + + this.movingNoteNewPosition = movingNoteNewPosition; + }, + onExistingNoteMove(e) { + const note = this.notes.find(({ id }) => id === this.movingNoteStartPosition.noteId); + if (!note || !this.canMoveNote(note)) return; + + const { position } = note; + const { width, height } = position; + const widthRatio = this.dimensions.width / width; + const heightRatio = this.dimensions.height / height; + + const { deltaX, deltaY } = this.getMovingNotePositionDelta(e); + const x = position.x * widthRatio + deltaX; + const y = position.y * heightRatio + deltaY; + + const movingNoteNewPosition = { + x, + y, + width: this.dimensions.width, + height: this.dimensions.height, + }; + + if (!this.isPositionInOverlay(movingNoteNewPosition)) { + this.onExistingNoteMouseup(); + return; + } + + this.movingNoteNewPosition = movingNoteNewPosition; + }, + onNewNoteMouseup() { + if (!this.movingNoteNewPosition) return; + + const { x, y } = this.movingNoteNewPosition; + this.setNewNoteCoordinates({ x, y }); + }, + onExistingNoteMouseup(note) { + if (!this.movingNoteStartPosition || !this.movingNoteNewPosition) { + this.updateActiveDiscussion(note.id); + this.$emit('closeCommentForm'); + return; + } + + const { x, y } = this.movingNoteNewPosition; + this.$emit('moveNote', { + noteId: this.movingNoteStartPosition.noteId, + discussionId: this.movingNoteStartPosition.discussionId, + coordinates: { x, y }, + }); + }, + onNoteMousedown({ clientX, clientY }, note) { + this.movingNoteStartPosition = { + noteId: note?.id, + discussionId: note?.discussion.id, + clientX, + clientY, + }; + }, + onOverlayMousemove(e) { + if (!this.movingNoteStartPosition) return; + + if (this.isMovingCurrentComment) { + this.onNewNoteMove(e); + } else { + this.onExistingNoteMove(e); + } + }, + onNoteMouseup(note) { + if (!this.movingNoteStartPosition) return; + + if (this.isMovingCurrentComment) { + this.onNewNoteMouseup(); + } else { + this.onExistingNoteMouseup(note); + } + + this.movingNoteStartPosition = null; + this.movingNoteNewPosition = null; + }, + onAddCommentMouseup({ offsetX, offsetY }) { + if (this.disableCommenting) return; + if (this.activeDiscussion.id) { + this.updateActiveDiscussion(); + } + + this.setNewNoteCoordinates({ x: offsetX, y: offsetY }); + }, + updateActiveDiscussion(id) { + this.$apollo.mutate({ + mutation: updateActiveDiscussionMutation, + variables: { + id, + source: ACTIVE_DISCUSSION_SOURCE_TYPES.pin, + }, + }); + }, + isNoteInactive(note) { + return this.activeDiscussion.id && this.activeDiscussion.id !== note.id; + }, + designPinClass(note) { + return { inactive: this.isNoteInactive(note), resolved: note.resolved }; + }, + }, +}; +</script> + +<template> + <div + class="position-absolute image-diff-overlay frame" + :style="overlayStyle" + @mousemove="onOverlayMousemove" + @mouseleave="onNoteMouseup" + > + <button + v-show="!disableCommenting" + type="button" + class="btn-transparent position-absolute image-diff-overlay-add-comment w-100 h-100 js-add-image-diff-note-button" + data-qa-selector="design_image_button" + @mouseup="onAddCommentMouseup" + ></button> + <template v-for="note in notes"> + <design-note-pin + v-if="resolvedDiscussionsExpanded || !note.resolved" + :key="note.id" + :label="note.index" + :repositioning="isMovingNote(note.id)" + :position=" + isMovingNote(note.id) && movingNoteNewPosition + ? getNotePositionStyle(movingNoteNewPosition) + : getNotePositionStyle(note.position) + " + :class="designPinClass(note)" + @mousedown.stop="onNoteMousedown($event, note)" + @mouseup.stop="onNoteMouseup(note)" + /> + </template> + + <design-note-pin + v-if="currentCommentForm" + :position="currentCommentPositionStyle" + :repositioning="isMovingCurrentComment" + @mousedown.stop="onNoteMousedown" + @mouseup.stop="onNoteMouseup" + /> + </div> +</template> diff --git a/app/assets/javascripts/design_management_new/components/design_presentation.vue b/app/assets/javascripts/design_management_new/components/design_presentation.vue new file mode 100644 index 00000000000..84dbb2809d9 --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/design_presentation.vue @@ -0,0 +1,322 @@ +<script> +import { throttle } from 'lodash'; +import DesignImage from './image.vue'; +import DesignOverlay from './design_overlay.vue'; + +const CLICK_DRAG_BUFFER_PX = 2; + +export default { + components: { + DesignImage, + DesignOverlay, + }, + props: { + image: { + type: String, + required: false, + default: '', + }, + imageName: { + type: String, + required: false, + default: '', + }, + discussions: { + type: Array, + required: true, + }, + isAnnotating: { + type: Boolean, + required: false, + default: false, + }, + scale: { + type: Number, + required: false, + default: 1, + }, + resolvedDiscussionsExpanded: { + type: Boolean, + required: true, + }, + }, + data() { + return { + overlayDimensions: null, + overlayPosition: null, + currentAnnotationPosition: null, + zoomFocalPoint: { + x: 0, + y: 0, + width: 0, + height: 0, + }, + initialLoad: true, + lastDragPosition: null, + isDraggingDesign: false, + }; + }, + computed: { + discussionStartingNotes() { + return this.discussions.map(discussion => ({ + ...discussion.notes[0], + index: discussion.index, + })); + }, + currentCommentForm() { + return (this.isAnnotating && this.currentAnnotationPosition) || null; + }, + presentationStyle() { + return { + cursor: this.isDraggingDesign ? 'grabbing' : undefined, + }; + }, + }, + beforeDestroy() { + const { presentationViewport } = this.$refs; + if (!presentationViewport) return; + + presentationViewport.removeEventListener('scroll', this.scrollThrottled, false); + }, + mounted() { + const { presentationViewport } = this.$refs; + if (!presentationViewport) return; + + this.scrollThrottled = throttle(() => { + this.shiftZoomFocalPoint(); + }, 400); + + presentationViewport.addEventListener('scroll', this.scrollThrottled, false); + }, + methods: { + syncCurrentAnnotationPosition() { + if (!this.currentAnnotationPosition) return; + + const widthRatio = this.overlayDimensions.width / this.currentAnnotationPosition.width; + const heightRatio = this.overlayDimensions.height / this.currentAnnotationPosition.height; + const x = this.currentAnnotationPosition.x * widthRatio; + const y = this.currentAnnotationPosition.y * heightRatio; + + this.currentAnnotationPosition = this.getAnnotationPositon({ x, y }); + }, + setOverlayDimensions(overlayDimensions) { + this.overlayDimensions = overlayDimensions; + + // every time we set overlay dimensions, we need to + // update the current annotation as well + this.syncCurrentAnnotationPosition(); + }, + setOverlayPosition() { + if (!this.overlayDimensions) { + this.overlayPosition = {}; + } + + const { presentationViewport } = this.$refs; + if (!presentationViewport) return; + + // default to center + this.overlayPosition = { + left: `calc(50% - ${this.overlayDimensions.width / 2}px)`, + top: `calc(50% - ${this.overlayDimensions.height / 2}px)`, + }; + + // if the overlay overflows, then don't center + if (this.overlayDimensions.width > presentationViewport.offsetWidth) { + this.overlayPosition.left = '0'; + } + if (this.overlayDimensions.height > presentationViewport.offsetHeight) { + this.overlayPosition.top = '0'; + } + }, + /** + * Return a point that represents the center of an + * overflowing child element w.r.t it's parent + */ + getViewportCenter() { + const { presentationViewport } = this.$refs; + if (!presentationViewport) return {}; + + // get height of scroll bars (i.e. the max values for scrollTop, scrollLeft) + const scrollBarWidth = presentationViewport.scrollWidth - presentationViewport.offsetWidth; + const scrollBarHeight = presentationViewport.scrollHeight - presentationViewport.offsetHeight; + + // determine how many child pixels have been scrolled + const xScrollRatio = + presentationViewport.scrollLeft > 0 ? presentationViewport.scrollLeft / scrollBarWidth : 0; + const yScrollRatio = + presentationViewport.scrollTop > 0 ? presentationViewport.scrollTop / scrollBarHeight : 0; + const xScrollOffset = + (presentationViewport.scrollWidth - presentationViewport.offsetWidth - 0) * xScrollRatio; + const yScrollOffset = + (presentationViewport.scrollHeight - presentationViewport.offsetHeight - 0) * yScrollRatio; + + const viewportCenterX = presentationViewport.offsetWidth / 2; + const viewportCenterY = presentationViewport.offsetHeight / 2; + const focalPointX = viewportCenterX + xScrollOffset; + const focalPointY = viewportCenterY + yScrollOffset; + + return { + x: focalPointX, + y: focalPointY, + }; + }, + /** + * Scroll the viewport such that the focal point is positioned centrally + */ + scrollToFocalPoint() { + const { presentationViewport } = this.$refs; + if (!presentationViewport) return; + + const scrollX = this.zoomFocalPoint.x - presentationViewport.offsetWidth / 2; + const scrollY = this.zoomFocalPoint.y - presentationViewport.offsetHeight / 2; + + presentationViewport.scrollTo(scrollX, scrollY); + }, + scaleZoomFocalPoint() { + const { x, y, width, height } = this.zoomFocalPoint; + const widthRatio = this.overlayDimensions.width / width; + const heightRatio = this.overlayDimensions.height / height; + + this.zoomFocalPoint = { + x: Math.round(x * widthRatio * 100) / 100, + y: Math.round(y * heightRatio * 100) / 100, + ...this.overlayDimensions, + }; + }, + shiftZoomFocalPoint() { + this.zoomFocalPoint = { + ...this.getViewportCenter(), + ...this.overlayDimensions, + }; + }, + onImageResize(imageDimensions) { + this.setOverlayDimensions(imageDimensions); + this.setOverlayPosition(); + + this.$nextTick(() => { + if (this.initialLoad) { + // set focal point on initial load + this.shiftZoomFocalPoint(); + this.initialLoad = false; + } else { + this.scaleZoomFocalPoint(); + this.scrollToFocalPoint(); + } + }); + }, + getAnnotationPositon(coordinates) { + const { x, y } = coordinates; + const { width, height } = this.overlayDimensions; + return { + x: Math.round(x), + y: Math.round(y), + width: Math.round(width), + height: Math.round(height), + }; + }, + openCommentForm(coordinates) { + this.currentAnnotationPosition = this.getAnnotationPositon(coordinates); + this.$emit('openCommentForm', this.currentAnnotationPosition); + }, + closeCommentForm() { + this.currentAnnotationPosition = null; + this.$emit('closeCommentForm'); + }, + moveNote({ noteId, discussionId, coordinates }) { + const position = this.getAnnotationPositon(coordinates); + this.$emit('moveNote', { noteId, discussionId, position }); + }, + onPresentationMousedown({ clientX, clientY }) { + if (!this.isDesignOverflowing()) return; + + this.lastDragPosition = { + x: clientX, + y: clientY, + }; + }, + getDragDelta(clientX, clientY) { + return { + deltaX: this.lastDragPosition.x - clientX, + deltaY: this.lastDragPosition.y - clientY, + }; + }, + exceedsDragThreshold(clientX, clientY) { + const { deltaX, deltaY } = this.getDragDelta(clientX, clientY); + + return Math.abs(deltaX) > CLICK_DRAG_BUFFER_PX || Math.abs(deltaY) > CLICK_DRAG_BUFFER_PX; + }, + shouldDragDesign(clientX, clientY) { + return ( + this.lastDragPosition && + (this.isDraggingDesign || this.exceedsDragThreshold(clientX, clientY)) + ); + }, + onPresentationMousemove({ clientX, clientY }) { + const { presentationViewport } = this.$refs; + if (!presentationViewport || !this.shouldDragDesign(clientX, clientY)) return; + + this.isDraggingDesign = true; + + const { scrollLeft, scrollTop } = presentationViewport; + const { deltaX, deltaY } = this.getDragDelta(clientX, clientY); + presentationViewport.scrollTo(scrollLeft + deltaX, scrollTop + deltaY); + + this.lastDragPosition = { + x: clientX, + y: clientY, + }; + }, + onPresentationMouseup() { + this.lastDragPosition = null; + this.isDraggingDesign = false; + }, + isDesignOverflowing() { + const { presentationViewport } = this.$refs; + if (!presentationViewport) return false; + + return ( + presentationViewport.scrollWidth > presentationViewport.offsetWidth || + presentationViewport.scrollHeight > presentationViewport.offsetHeight + ); + }, + }, +}; +</script> + +<template> + <div + ref="presentationViewport" + class="h-100 w-100 p-3 overflow-auto position-relative" + :style="presentationStyle" + @mousedown="onPresentationMousedown" + @mousemove="onPresentationMousemove" + @mouseup="onPresentationMouseup" + @mouseleave="onPresentationMouseup" + @touchstart="onPresentationMousedown" + @touchmove="onPresentationMousemove" + @touchend="onPresentationMouseup" + @touchcancel="onPresentationMouseup" + > + <div class="h-100 w-100 d-flex align-items-center position-relative"> + <design-image + v-if="image" + :image="image" + :name="imageName" + :scale="scale" + @resize="onImageResize" + /> + <design-overlay + v-if="overlayDimensions && overlayPosition" + :dimensions="overlayDimensions" + :position="overlayPosition" + :notes="discussionStartingNotes" + :current-comment-form="currentCommentForm" + :disable-commenting="isDraggingDesign" + :resolved-discussions-expanded="resolvedDiscussionsExpanded" + @openCommentForm="openCommentForm" + @closeCommentForm="closeCommentForm" + @moveNote="moveNote" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/design_management_new/components/design_scaler.vue b/app/assets/javascripts/design_management_new/components/design_scaler.vue new file mode 100644 index 00000000000..55dee74bef5 --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/design_scaler.vue @@ -0,0 +1,65 @@ +<script> +import { GlIcon } from '@gitlab/ui'; + +const SCALE_STEP_SIZE = 0.2; +const DEFAULT_SCALE = 1; +const MIN_SCALE = 1; +const MAX_SCALE = 2; + +export default { + components: { + GlIcon, + }, + data() { + return { + scale: DEFAULT_SCALE, + }; + }, + computed: { + disableReset() { + return this.scale <= MIN_SCALE; + }, + disableDecrease() { + return this.scale === DEFAULT_SCALE; + }, + disableIncrease() { + return this.scale >= MAX_SCALE; + }, + }, + methods: { + setScale(scale) { + if (scale < MIN_SCALE) { + return; + } + + this.scale = Math.round(scale * 100) / 100; + this.$emit('scale', this.scale); + }, + incrementScale() { + this.setScale(this.scale + SCALE_STEP_SIZE); + }, + decrementScale() { + this.setScale(this.scale - SCALE_STEP_SIZE); + }, + resetScale() { + this.setScale(DEFAULT_SCALE); + }, + }, +}; +</script> + +<template> + <div class="design-scaler btn-group" role="group"> + <button class="btn" :disabled="disableDecrease" @click="decrementScale"> + <span class="d-flex-center gl-icon s16"> + – + </span> + </button> + <button class="btn" :disabled="disableReset" @click="resetScale"> + <gl-icon name="redo" /> + </button> + <button class="btn" :disabled="disableIncrease" @click="incrementScale"> + <gl-icon name="plus" /> + </button> + </div> +</template> diff --git a/app/assets/javascripts/design_management_new/components/design_sidebar.vue b/app/assets/javascripts/design_management_new/components/design_sidebar.vue new file mode 100644 index 00000000000..333ad2557e8 --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/design_sidebar.vue @@ -0,0 +1,178 @@ +<script> +import { s__ } from '~/locale'; +import Cookies from 'js-cookie'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import { GlCollapse, GlButton, GlPopover } from '@gitlab/ui'; +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'; + +export default { + components: { + DesignDiscussion, + Participants, + GlCollapse, + GlButton, + GlPopover, + }, + props: { + design: { + type: Object, + required: true, + }, + resolvedDiscussionsExpanded: { + type: Boolean, + required: true, + }, + markdownPreviewPath: { + type: String, + required: true, + }, + }, + data() { + return { + isResolvedCommentsPopoverHidden: parseBoolean(Cookies.get(this.$options.cookieKey)), + discussionWithOpenForm: '', + }; + }, + computed: { + discussions() { + return extractDiscussions(this.design.discussions); + }, + issue() { + return { + ...this.design.issue, + webPath: this.design.issue.webPath.substr(1), + }; + }, + discussionParticipants() { + return extractParticipants(this.issue.participants); + }, + resolvedDiscussions() { + return this.discussions.filter(discussion => discussion.resolved); + }, + unresolvedDiscussions() { + return this.discussions.filter(discussion => !discussion.resolved); + }, + resolvedCommentsToggleIcon() { + return this.resolvedDiscussionsExpanded ? 'chevron-down' : 'chevron-right'; + }, + }, + methods: { + handleSidebarClick() { + this.isResolvedCommentsPopoverHidden = true; + Cookies.set(this.$options.cookieKey, 'true', { expires: 365 * 10 }); + this.updateActiveDiscussion(); + }, + updateActiveDiscussion(id) { + this.$apollo.mutate({ + mutation: updateActiveDiscussionMutation, + variables: { + id, + source: ACTIVE_DISCUSSION_SOURCE_TYPES.discussion, + }, + }); + }, + closeCommentForm() { + this.comment = ''; + this.$emit('closeCommentForm'); + }, + updateDiscussionWithOpenForm(id) { + this.discussionWithOpenForm = id; + }, + }, + resolveCommentsToggleText: s__('DesignManagement|Resolved Comments'), + cookieKey: 'hide_design_resolved_comments_popover', +}; +</script> + +<template> + <div class="image-notes" @click="handleSidebarClick"> + <h2 class="gl-font-weight-bold gl-mt-0"> + {{ issue.title }} + </h2> + <a + class="gl-text-gray-600 gl-text-decoration-none gl-mb-6 gl-display-block" + :href="issue.webUrl" + >{{ issue.webPath }}</a + > + <participants + :participants="discussionParticipants" + :show-participant-label="false" + class="gl-mb-4" + /> + <h2 + v-if="unresolvedDiscussions.length === 0" + class="new-discussion-disclaimer gl-font-base gl-m-0 gl-mb-4" + data-testid="new-discussion-disclaimer" + > + {{ s__("DesignManagement|Click the image where you'd like to start a new discussion") }} + </h2> + <design-discussion + v-for="discussion in unresolvedDiscussions" + :key="discussion.id" + :discussion="discussion" + :design-id="$route.params.id" + :noteable-id="design.id" + :markdown-preview-path="markdownPreviewPath" + :resolved-discussions-expanded="resolvedDiscussionsExpanded" + :discussion-with-open-form="discussionWithOpenForm" + data-testid="unresolved-discussion" + @createNoteError="$emit('onDesignDiscussionError', $event)" + @updateNoteError="$emit('updateNoteError', $event)" + @resolveDiscussionError="$emit('resolveDiscussionError', $event)" + @click.native.stop="updateActiveDiscussion(discussion.notes[0].id)" + @openForm="updateDiscussionWithOpenForm" + /> + <template v-if="resolvedDiscussions.length > 0"> + <gl-button + id="resolved-comments" + data-testid="resolved-comments" + :icon="resolvedCommentsToggleIcon" + variant="link" + class="link-inherit-color gl-text-black-normal gl-text-decoration-none gl-font-weight-bold gl-mb-4" + @click="$emit('toggleResolvedComments')" + >{{ $options.resolveCommentsToggleText }} ({{ resolvedDiscussions.length }}) + </gl-button> + <gl-popover + v-if="!isResolvedCommentsPopoverHidden" + :show="!isResolvedCommentsPopoverHidden" + target="resolved-comments" + container="popovercontainer" + placement="top" + :title="s__('DesignManagement|Resolved Comments')" + > + <p> + {{ + s__( + 'DesignManagement|Comments you resolve can be viewed and unresolved by going to the "Resolved Comments" section below', + ) + }} + </p> + <a href="#" rel="noopener noreferrer" target="_blank">{{ + s__('DesignManagement|Learn more about resolving comments') + }}</a> + </gl-popover> + <gl-collapse :visible="resolvedDiscussionsExpanded" class="gl-mt-3"> + <design-discussion + v-for="discussion in resolvedDiscussions" + :key="discussion.id" + :discussion="discussion" + :design-id="$route.params.id" + :noteable-id="design.id" + :markdown-preview-path="markdownPreviewPath" + :resolved-discussions-expanded="resolvedDiscussionsExpanded" + :discussion-with-open-form="discussionWithOpenForm" + data-testid="resolved-discussion" + @error="$emit('onDesignDiscussionError', $event)" + @updateNoteError="$emit('updateNoteError', $event)" + @openForm="updateDiscussionWithOpenForm" + @click.native.stop="updateActiveDiscussion(discussion.notes[0].id)" + /> + </gl-collapse> + </template> + <slot name="replyForm"></slot> + </div> +</template> diff --git a/app/assets/javascripts/design_management_new/components/image.vue b/app/assets/javascripts/design_management_new/components/image.vue new file mode 100644 index 00000000000..91b7b576e0c --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/image.vue @@ -0,0 +1,110 @@ +<script> +import { throttle } from 'lodash'; +import { GlIcon } from '@gitlab/ui'; + +export default { + components: { + GlIcon, + }, + props: { + image: { + type: String, + required: false, + default: '', + }, + name: { + type: String, + required: false, + default: '', + }, + scale: { + type: Number, + required: false, + default: 1, + }, + }, + data() { + return { + baseImageSize: null, + imageStyle: null, + imageError: false, + }; + }, + watch: { + scale(val) { + this.zoom(val); + }, + }, + beforeDestroy() { + window.removeEventListener('resize', this.resizeThrottled, false); + }, + mounted() { + this.onImgLoad(); + + this.resizeThrottled = throttle(() => { + // NOTE: if imageStyle is set, then baseImageSize + // won't change due to resize. We must still emit a + // `resize` event so that the parent can handle + // resizes appropriately (e.g. for design_overlay) + this.setBaseImageSize(); + }, 400); + window.addEventListener('resize', this.resizeThrottled, false); + }, + methods: { + onImgLoad() { + requestIdleCallback(this.setBaseImageSize, { timeout: 1000 }); + }, + onImgError() { + this.imageError = true; + }, + setBaseImageSize() { + const { contentImg } = this.$refs; + if (!contentImg || contentImg.offsetHeight === 0 || contentImg.offsetWidth === 0) return; + + this.baseImageSize = { + height: contentImg.offsetHeight, + width: contentImg.offsetWidth, + }; + this.onResize({ width: this.baseImageSize.width, height: this.baseImageSize.height }); + }, + onResize({ width, height }) { + this.$emit('resize', { width, height }); + }, + zoom(amount) { + if (amount === 1) { + this.imageStyle = null; + this.$nextTick(() => { + this.setBaseImageSize(); + }); + return; + } + const width = this.baseImageSize.width * amount; + const height = this.baseImageSize.height * amount; + + this.imageStyle = { + width: `${width}px`, + height: `${height}px`, + }; + + this.onResize({ width, height }); + }, + }, +}; +</script> + +<template> + <div class="m-auto js-design-image"> + <gl-icon v-if="imageError" class="text-secondary-100" name="media-broken" :size="48" /> + <img + v-show="!imageError" + ref="contentImg" + class="mh-100" + :src="image" + :alt="name" + :style="imageStyle" + :class="{ 'img-fluid': !imageStyle }" + @error="onImgError" + @load="onImgLoad" + /> + </div> +</template> diff --git a/app/assets/javascripts/design_management_new/components/list/item.vue b/app/assets/javascripts/design_management_new/components/list/item.vue new file mode 100644 index 00000000000..b19aef9c22d --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/list/item.vue @@ -0,0 +1,174 @@ +<script> +import { GlLoadingIcon, GlIcon, GlIntersectionObserver } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import Timeago from '~/vue_shared/components/time_ago_tooltip.vue'; +import { n__, __ } from '~/locale'; +import { DESIGN_ROUTE_NAME } from '../../router/constants'; + +export default { + components: { + GlLoadingIcon, + GlIntersectionObserver, + GlIcon, + Icon, + Timeago, + }, + props: { + id: { + type: [Number, String], + required: true, + }, + event: { + type: String, + required: true, + }, + notesCount: { + type: Number, + required: true, + }, + image: { + type: String, + required: true, + }, + filename: { + type: String, + required: true, + }, + updatedAt: { + type: String, + required: false, + default: null, + }, + isUploading: { + type: Boolean, + required: false, + default: true, + }, + imageV432x230: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + imageLoading: true, + imageError: false, + wasInView: false, + }; + }, + computed: { + icon() { + const normalizedEvent = this.event.toLowerCase(); + const icons = { + creation: { + name: 'file-addition-solid', + classes: 'text-success-500', + tooltip: __('Added in this version'), + }, + modification: { + name: 'file-modified-solid', + classes: 'text-primary-500', + tooltip: __('Modified in this version'), + }, + deletion: { + name: 'file-deletion-solid', + classes: 'text-danger-500', + tooltip: __('Deleted in this version'), + }, + }; + + return icons[normalizedEvent] ? icons[normalizedEvent] : {}; + }, + notesLabel() { + return n__('%d comment', '%d comments', this.notesCount); + }, + imageLink() { + return this.wasInView ? this.imageV432x230 || this.image : ''; + }, + showLoadingSpinner() { + return this.imageLoading || this.isUploading; + }, + showImageErrorIcon() { + return this.wasInView && this.imageError; + }, + showImage() { + return !this.showLoadingSpinner && !this.showImageErrorIcon; + }, + }, + methods: { + onImageLoad() { + this.imageLoading = false; + this.imageError = false; + }, + onImageError() { + this.imageLoading = false; + this.imageError = true; + }, + onAppear() { + // do nothing if image has previously + // been in view + if (this.wasInView) { + return; + } + + this.wasInView = true; + this.imageLoading = true; + }, + }, + DESIGN_ROUTE_NAME, +}; +</script> + +<template> + <router-link + :to="{ + name: $options.DESIGN_ROUTE_NAME, + params: { id: filename }, + query: $route.query, + }" + class="card cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new" + > + <div class="card-body p-0 d-flex-center overflow-hidden position-relative"> + <div v-if="icon.name" class="design-event position-absolute"> + <span :title="icon.tooltip" :aria-label="icon.tooltip"> + <icon :name="icon.name" :size="18" :class="icon.classes" /> + </span> + </div> + <gl-intersection-observer @appear="onAppear"> + <gl-loading-icon v-if="showLoadingSpinner" size="md" /> + <gl-icon + v-else-if="showImageErrorIcon" + name="media-broken" + class="text-secondary" + :size="32" + /> + <img + v-show="showImage" + :src="imageLink" + :alt="filename" + class="block mx-auto mw-100 mh-100 design-img" + data-qa-selector="design_image" + @load="onImageLoad" + @error="onImageError" + /> + </gl-intersection-observer> + </div> + <div class="card-footer d-flex w-100"> + <div class="d-flex flex-column str-truncated-100"> + <span class="bold str-truncated-100" data-qa-selector="design_file_name">{{ + filename + }}</span> + <span v-if="updatedAt" class="str-truncated-100"> + {{ __('Updated') }} <timeago :time="updatedAt" tooltip-placement="bottom" /> + </span> + </div> + <div v-if="notesCount" class="ml-auto d-flex align-items-center text-secondary"> + <icon name="comments" class="ml-1" /> + <span :aria-label="notesLabel" class="ml-1"> + {{ notesCount }} + </span> + </div> + </div> + </router-link> +</template> diff --git a/app/assets/javascripts/design_management_new/components/toolbar/index.vue b/app/assets/javascripts/design_management_new/components/toolbar/index.vue new file mode 100644 index 00000000000..0b51035e83e --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/toolbar/index.vue @@ -0,0 +1,124 @@ +<script> +import { GlDeprecatedButton } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import Pagination from './pagination.vue'; +import DeleteButton from '../delete_button.vue'; +import permissionsQuery from '../../graphql/queries/design_permissions.query.graphql'; +import { DESIGNS_ROUTE_NAME } from '../../router/constants'; + +export default { + components: { + Icon, + Pagination, + DeleteButton, + GlDeprecatedButton, + }, + mixins: [timeagoMixin], + props: { + id: { + type: String, + required: true, + }, + isDeleting: { + type: Boolean, + required: true, + }, + filename: { + type: String, + required: false, + default: '', + }, + updatedAt: { + type: String, + required: false, + default: null, + }, + updatedBy: { + type: Object, + required: false, + default: () => ({}), + }, + isLatestVersion: { + type: Boolean, + required: true, + }, + image: { + type: String, + required: true, + }, + }, + data() { + return { + permissions: { + createDesign: false, + }, + }; + }, + inject: { + projectPath: { + default: '', + }, + issueIid: { + default: '', + }, + }, + apollo: { + permissions: { + query: permissionsQuery, + variables() { + return { + fullPath: this.projectPath, + iid: this.issueIid, + }; + }, + update: data => data.project.issue.userPermissions, + }, + }, + computed: { + updatedText() { + return sprintf(__('Updated %{updated_at} by %{updated_by}'), { + updated_at: this.timeFormatted(this.updatedAt), + updated_by: this.updatedBy.name, + }); + }, + canDeleteDesign() { + return this.permissions.createDesign; + }, + }, + DESIGNS_ROUTE_NAME, +}; +</script> + +<template> + <header class="d-flex p-2 bg-white align-items-center js-design-header"> + <router-link + :to="{ + name: $options.DESIGNS_ROUTE_NAME, + query: $route.query, + }" + :aria-label="s__('DesignManagement|Go back to designs')" + data-testid="close-design" + class="mr-3 text-plain d-flex justify-content-center align-items-center" + > + <icon :size="18" name="close" /> + </router-link> + <div class="overflow-hidden d-flex align-items-center"> + <h2 class="m-0 str-truncated-100 gl-font-base">{{ filename }}</h2> + <small v-if="updatedAt" class="text-secondary">{{ updatedText }}</small> + </div> + <pagination :id="id" class="ml-auto flex-shrink-0" /> + <gl-deprecated-button :href="image" class="mr-2"> + <icon :size="18" name="download" /> + </gl-deprecated-button> + <delete-button + v-if="isLatestVersion && canDeleteDesign" + :is-deleting="isDeleting" + button-variant="danger" + @deleteSelectedDesigns="$emit('delete')" + > + <icon :size="18" name="remove" /> + </delete-button> + </header> +</template> diff --git a/app/assets/javascripts/design_management_new/components/toolbar/pagination.vue b/app/assets/javascripts/design_management_new/components/toolbar/pagination.vue new file mode 100644 index 00000000000..bf62a8f66a6 --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/toolbar/pagination.vue @@ -0,0 +1,83 @@ +<script> +/* global Mousetrap */ +import 'mousetrap'; +import { s__, sprintf } from '~/locale'; +import PaginationButton from './pagination_button.vue'; +import allDesignsMixin from '../../mixins/all_designs'; +import { DESIGN_ROUTE_NAME } from '../../router/constants'; + +export default { + components: { + PaginationButton, + }, + mixins: [allDesignsMixin], + props: { + id: { + type: String, + required: true, + }, + }, + computed: { + designsCount() { + return this.designs.length; + }, + currentIndex() { + return this.designs.findIndex(design => design.filename === this.id); + }, + paginationText() { + return sprintf(s__('DesignManagement|%{current_design} of %{designs_count}'), { + current_design: this.currentIndex + 1, + designs_count: this.designsCount, + }); + }, + previousDesign() { + if (!this.designsCount) return null; + + return this.designs[this.currentIndex - 1]; + }, + nextDesign() { + if (!this.designsCount) return null; + + return this.designs[this.currentIndex + 1]; + }, + }, + mounted() { + Mousetrap.bind('left', () => this.navigateToDesign(this.previousDesign)); + Mousetrap.bind('right', () => this.navigateToDesign(this.nextDesign)); + }, + beforeDestroy() { + Mousetrap.unbind(['left', 'right'], this.navigateToDesign); + }, + methods: { + navigateToDesign(design) { + if (design) { + this.$router.push({ + name: DESIGN_ROUTE_NAME, + params: { id: design.filename }, + query: this.$route.query, + }); + } + }, + }, +}; +</script> + +<template> + <div v-if="designsCount" class="d-flex align-items-center"> + {{ paginationText }} + <div class="btn-group ml-3 mr-3"> + <pagination-button + :design="previousDesign" + :title="s__('DesignManagement|Go to previous design')" + icon-name="angle-left" + class="js-previous-design" + /> + <pagination-button + :design="nextDesign" + :title="s__('DesignManagement|Go to next design')" + icon-name="angle-right" + class="js-next-design" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/design_management_new/components/toolbar/pagination_button.vue b/app/assets/javascripts/design_management_new/components/toolbar/pagination_button.vue new file mode 100644 index 00000000000..f00ecefca01 --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/toolbar/pagination_button.vue @@ -0,0 +1,48 @@ +<script> +import Icon from '~/vue_shared/components/icon.vue'; +import { DESIGN_ROUTE_NAME } from '../../router/constants'; + +export default { + components: { + Icon, + }, + props: { + design: { + type: Object, + required: false, + default: null, + }, + title: { + type: String, + required: true, + }, + iconName: { + type: String, + required: true, + }, + }, + computed: { + designLink() { + if (!this.design) return {}; + + return { + name: DESIGN_ROUTE_NAME, + params: { id: this.design.filename }, + query: this.$route.query, + }; + }, + }, +}; +</script> + +<template> + <router-link + :to="designLink" + :disabled="!design" + :class="{ disabled: !design }" + :aria-label="title" + class="btn btn-default" + > + <icon :name="iconName" /> + </router-link> +</template> diff --git a/app/assets/javascripts/design_management_new/components/upload/button.vue b/app/assets/javascripts/design_management_new/components/upload/button.vue new file mode 100644 index 00000000000..de8a38334ac --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/upload/button.vue @@ -0,0 +1,59 @@ +<script> +import { GlButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import { VALID_DESIGN_FILE_MIMETYPE } from '../../constants'; + +export default { + components: { + GlButton, + GlLoadingIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + isSaving: { + type: Boolean, + required: true, + }, + }, + methods: { + openFileUpload() { + this.$refs.fileUpload.click(); + }, + onFileUploadChange(e) { + this.$emit('upload', e.target.files); + }, + }, + VALID_DESIGN_FILE_MIMETYPE, +}; +</script> + +<template> + <div> + <gl-button + v-gl-tooltip.hover + :title=" + s__( + 'DesignManagement|Adding a design with the same filename replaces the file in a new version.', + ) + " + :disabled="isSaving" + variant="success" + size="small" + @click="openFileUpload" + > + {{ s__('DesignManagement|Upload designs') }} + <gl-loading-icon v-if="isSaving" inline class="ml-1" /> + </gl-button> + + <input + ref="fileUpload" + type="file" + name="design_file" + :accept="$options.VALID_DESIGN_FILE_MIMETYPE.mimetype" + class="hide" + multiple + @change="onFileUploadChange" + /> + </div> +</template> diff --git a/app/assets/javascripts/design_management_new/components/upload/design_dropzone.vue b/app/assets/javascripts/design_management_new/components/upload/design_dropzone.vue new file mode 100644 index 00000000000..7b829d63330 --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/upload/design_dropzone.vue @@ -0,0 +1,136 @@ +<script> +import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; +import createFlash from '~/flash'; +import uploadDesignMutation from '../../graphql/mutations/upload_design.mutation.graphql'; +import { UPLOAD_DESIGN_INVALID_FILETYPE_ERROR } from '../../utils/error_messages'; +import { isValidDesignFile } from '../../utils/design_management_utils'; +import { VALID_DATA_TRANSFER_TYPE, VALID_DESIGN_FILE_MIMETYPE } from '../../constants'; + +export default { + components: { + GlIcon, + GlLink, + GlSprintf, + }, + props: { + hasDesigns: { + type: Boolean, + required: true, + }, + }, + data() { + return { + dragCounter: 0, + isDragDataValid: false, + }; + }, + computed: { + dragging() { + return this.dragCounter !== 0; + }, + }, + methods: { + isValidUpload(files) { + return files.every(isValidDesignFile); + }, + isValidDragDataType({ dataTransfer }) { + return Boolean(dataTransfer && dataTransfer.types.some(t => t === VALID_DATA_TRANSFER_TYPE)); + }, + ondrop({ dataTransfer = {} }) { + this.dragCounter = 0; + // User already had feedback when dropzone was active, so bail here + if (!this.isDragDataValid) { + return; + } + + const { files } = dataTransfer; + if (!this.isValidUpload(Array.from(files))) { + createFlash(UPLOAD_DESIGN_INVALID_FILETYPE_ERROR); + return; + } + + this.$emit('change', files); + }, + ondragenter(e) { + this.dragCounter += 1; + this.isDragDataValid = this.isValidDragDataType(e); + }, + ondragleave() { + this.dragCounter -= 1; + }, + openFileUpload() { + this.$refs.fileUpload.click(); + }, + onDesignInputChange(e) { + this.$emit('change', e.target.files); + }, + }, + uploadDesignMutation, + VALID_DESIGN_FILE_MIMETYPE, +}; +</script> + +<template> + <div + class="w-100 position-relative" + @dragstart.prevent.stop + @dragend.prevent.stop + @dragover.prevent.stop + @dragenter.prevent.stop="ondragenter" + @dragleave.prevent.stop="ondragleave" + @drop.prevent.stop="ondrop" + > + <slot> + <button + class="card design-dropzone-card design-dropzone-border w-100 h-100 gl-align-items-center gl-justify-content-center gl-p-3" + @click="openFileUpload" + > + <div + :class="{ 'gl-flex-direction-column': hasDesigns }" + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center" + data-testid="dropzone-area" + > + <gl-icon name="upload" :size="24" :class="hasDesigns ? 'gl-mb-2' : 'gl-mr-4'" /> + <p class="gl-font-weight-bold gl-mb-0"> + <gl-sprintf :message="__('Drop or %{linkStart}upload%{linkEnd} Designs to attach')"> + <template #link="{ content }"> + <gl-link class="gl-font-weight-normal" @click.stop="openFileUpload"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </p> + </div> + </button> + + <input + ref="fileUpload" + type="file" + name="design_file" + :accept="$options.VALID_DESIGN_FILE_MIMETYPE.mimetype" + class="hide" + multiple + @change="onDesignInputChange" + /> + </slot> + <transition name="design-dropzone-fade"> + <div + v-show="dragging" + class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" + > + <div v-show="!isDragDataValid" class="mw-50 text-center"> + <h3 :class="{ 'gl-font-base gl-display-inline': !hasDesigns }">{{ __('Oh no!') }}</h3> + <span>{{ + __( + 'You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.', + ) + }}</span> + </div> + <div v-show="isDragDataValid" class="mw-50 text-center"> + <h3 :class="{ 'gl-font-base gl-display-inline': !hasDesigns }">{{ __('Incoming!') }}</h3> + <span>{{ __('Drop your designs to start your upload.') }}</span> + </div> + </div> + </transition> + </div> +</template> diff --git a/app/assets/javascripts/design_management_new/components/upload/design_version_dropdown.vue b/app/assets/javascripts/design_management_new/components/upload/design_version_dropdown.vue new file mode 100644 index 00000000000..5299d0ce09e --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/upload/design_version_dropdown.vue @@ -0,0 +1,76 @@ +<script> +import { GlNewDropdown, GlNewDropdownItem } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import allVersionsMixin from '../../mixins/all_versions'; +import { findVersionId } from '../../utils/design_management_utils'; + +export default { + components: { + GlNewDropdown, + GlNewDropdownItem, + }, + mixins: [allVersionsMixin], + computed: { + queryVersion() { + return this.$route.query.version; + }, + currentVersionIdx() { + if (!this.queryVersion) return 0; + + const idx = this.allVersions.findIndex( + version => this.findVersionId(version.node.id) === this.queryVersion, + ); + + // if the currentVersionId isn't a valid version (i.e. not in allVersions) + // then return the latest version (index 0) + return idx !== -1 ? idx : 0; + }, + currentVersionId() { + if (this.queryVersion) return this.queryVersion; + + const currentVersion = this.allVersions[this.currentVersionIdx]; + return this.findVersionId(currentVersion.node.id); + }, + dropdownText() { + if (this.isLatestVersion) { + return __('Showing Latest Version'); + } + // allVersions is sorted in reverse chronological order (latest first) + const currentVersionNumber = this.allVersions.length - this.currentVersionIdx; + + return sprintf(__('Showing Version #%{versionNumber}'), { + versionNumber: currentVersionNumber, + }); + }, + }, + methods: { + findVersionId, + }, +}; +</script> + +<template> + <gl-new-dropdown :text="dropdownText" size="small" class="design-version-dropdown"> + <gl-new-dropdown-item v-for="(version, index) in allVersions" :key="version.node.id"> + <router-link + class="d-flex js-version-link" + :to="{ path: $route.path, query: { version: findVersionId(version.node.id) } }" + > + <div class="flex-grow-1 ml-2"> + <div> + <strong + >{{ __('Version') }} {{ allVersions.length - index }} + <span v-if="findVersionId(version.node.id) === latestVersionId" + >({{ __('latest') }})</span + > + </strong> + </div> + </div> + <i + v-if="findVersionId(version.node.id) === currentVersionId" + class="fa fa-check pull-right" + ></i> + </router-link> + </gl-new-dropdown-item> + </gl-new-dropdown> +</template> diff --git a/app/assets/javascripts/design_management_new/constants.js b/app/assets/javascripts/design_management_new/constants.js new file mode 100644 index 00000000000..21ff361a277 --- /dev/null +++ b/app/assets/javascripts/design_management_new/constants.js @@ -0,0 +1,16 @@ +// WARNING: replace this with something +// more sensical as per https://gitlab.com/gitlab-org/gitlab/issues/118611 +export const VALID_DESIGN_FILE_MIMETYPE = { + mimetype: 'image/*', + regex: /image\/.+/, +}; + +// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/types +export const VALID_DATA_TRANSFER_TYPE = 'Files'; + +export const ACTIVE_DISCUSSION_SOURCE_TYPES = { + pin: 'pin', + discussion: 'discussion', +}; + +export const DESIGN_DETAIL_LAYOUT_CLASSLIST = ['design-detail-layout', 'overflow-hidden', 'm-0']; diff --git a/app/assets/javascripts/design_management_new/graphql.js b/app/assets/javascripts/design_management_new/graphql.js new file mode 100644 index 00000000000..fae337aa75b --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql.js @@ -0,0 +1,45 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { uniqueId } from 'lodash'; +import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; +import createDefaultClient from '~/lib/graphql'; +import activeDiscussionQuery from './graphql/queries/active_discussion.query.graphql'; +import typeDefs from './graphql/typedefs.graphql'; + +Vue.use(VueApollo); + +const resolvers = { + Mutation: { + updateActiveDiscussion: (_, { id = null, source }, { cache }) => { + const data = cache.readQuery({ query: activeDiscussionQuery }); + data.activeDiscussion = { + __typename: 'ActiveDiscussion', + id, + source, + }; + cache.writeQuery({ query: activeDiscussionQuery, data }); + }, + }, +}; + +const defaultClient = createDefaultClient( + resolvers, + // This config is added temporarily to resolve an issue with duplicate design IDs. + // Should be removed as soon as https://gitlab.com/gitlab-org/gitlab/issues/13495 is resolved + { + cacheConfig: { + dataIdFromObject: object => { + // eslint-disable-next-line no-underscore-dangle, @gitlab/require-i18n-strings + if (object.__typename === 'Design') { + return object.id && object.image ? `${object.id}-${object.image}` : uniqueId(); + } + return defaultDataIdFromObject(object); + }, + }, + typeDefs, + }, +); + +export default new VueApollo({ + defaultClient, +}); diff --git a/app/assets/javascripts/design_management_new/graphql/fragments/design.fragment.graphql b/app/assets/javascripts/design_management_new/graphql/fragments/design.fragment.graphql new file mode 100644 index 00000000000..4b1703e41c3 --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/fragments/design.fragment.graphql @@ -0,0 +1,24 @@ +#import "./design_note.fragment.graphql" +#import "./design_list.fragment.graphql" +#import "./diff_refs.fragment.graphql" +#import "./discussion_resolved_status.fragment.graphql" + +fragment DesignItem on Design { + ...DesignListItem + fullPath + diffRefs { + ...DesignDiffRefs + } + discussions { + nodes { + id + replyId + ...ResolvedStatus + notes { + nodes { + ...DesignNote + } + } + } + } +} diff --git a/app/assets/javascripts/design_management_new/graphql/fragments/design_list.fragment.graphql b/app/assets/javascripts/design_management_new/graphql/fragments/design_list.fragment.graphql new file mode 100644 index 00000000000..bc3132f9b42 --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/fragments/design_list.fragment.graphql @@ -0,0 +1,8 @@ +fragment DesignListItem on Design { + id + event + filename + notesCount + image + imageV432x230 +} diff --git a/app/assets/javascripts/design_management_new/graphql/fragments/design_note.fragment.graphql b/app/assets/javascripts/design_management_new/graphql/fragments/design_note.fragment.graphql new file mode 100644 index 00000000000..26edd2c0be1 --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/fragments/design_note.fragment.graphql @@ -0,0 +1,29 @@ +#import "./diff_refs.fragment.graphql" +#import "~/graphql_shared/fragments/author.fragment.graphql" +#import "./note_permissions.fragment.graphql" + +fragment DesignNote on Note { + id + author { + ...Author + } + body + bodyHtml + createdAt + resolved + position { + diffRefs { + ...DesignDiffRefs + } + x + y + height + width + } + userPermissions { + ...DesignNotePermissions + } + discussion { + id + } +} diff --git a/app/assets/javascripts/design_management_new/graphql/fragments/diff_refs.fragment.graphql b/app/assets/javascripts/design_management_new/graphql/fragments/diff_refs.fragment.graphql new file mode 100644 index 00000000000..984a55814b0 --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/fragments/diff_refs.fragment.graphql @@ -0,0 +1,5 @@ +fragment DesignDiffRefs on DiffRefs { + baseSha + startSha + headSha +} diff --git a/app/assets/javascripts/design_management_new/graphql/fragments/discussion_resolved_status.fragment.graphql b/app/assets/javascripts/design_management_new/graphql/fragments/discussion_resolved_status.fragment.graphql new file mode 100644 index 00000000000..7483b508721 --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/fragments/discussion_resolved_status.fragment.graphql @@ -0,0 +1,9 @@ +fragment ResolvedStatus on Discussion { + resolvable + resolved + resolvedAt + resolvedBy { + name + webUrl + } +} diff --git a/app/assets/javascripts/design_management_new/graphql/fragments/note_permissions.fragment.graphql b/app/assets/javascripts/design_management_new/graphql/fragments/note_permissions.fragment.graphql new file mode 100644 index 00000000000..c243e39f3d3 --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/fragments/note_permissions.fragment.graphql @@ -0,0 +1,3 @@ +fragment DesignNotePermissions on NotePermissions { + adminNote +} diff --git a/app/assets/javascripts/design_management_new/graphql/fragments/version.fragment.graphql b/app/assets/javascripts/design_management_new/graphql/fragments/version.fragment.graphql new file mode 100644 index 00000000000..7eb40b12f51 --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/fragments/version.fragment.graphql @@ -0,0 +1,4 @@ +fragment VersionListItem on DesignVersion { + id + sha +} diff --git a/app/assets/javascripts/design_management_new/graphql/mutations/create_image_diff_note.mutation.graphql b/app/assets/javascripts/design_management_new/graphql/mutations/create_image_diff_note.mutation.graphql new file mode 100644 index 00000000000..c8ade328120 --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/mutations/create_image_diff_note.mutation.graphql @@ -0,0 +1,21 @@ +#import "../fragments/design_note.fragment.graphql" + +mutation createImageDiffNote($input: CreateImageDiffNoteInput!) { + createImageDiffNote(input: $input) { + note { + ...DesignNote + discussion { + id + replyId + notes { + edges { + node { + ...DesignNote + } + } + } + } + } + errors + } +} diff --git a/app/assets/javascripts/design_management_new/graphql/mutations/create_note.mutation.graphql b/app/assets/javascripts/design_management_new/graphql/mutations/create_note.mutation.graphql new file mode 100644 index 00000000000..184ee6955dc --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/mutations/create_note.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/design_note.fragment.graphql" + +mutation createNote($input: CreateNoteInput!) { + createNote(input: $input) { + note { + ...DesignNote + } + errors + } +} diff --git a/app/assets/javascripts/design_management_new/graphql/mutations/destroy_design.mutation.graphql b/app/assets/javascripts/design_management_new/graphql/mutations/destroy_design.mutation.graphql new file mode 100644 index 00000000000..0b3cf636cdb --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/mutations/destroy_design.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/version.fragment.graphql" + +mutation destroyDesign($filenames: [String!]!, $projectPath: ID!, $iid: ID!) { + designManagementDelete(input: { projectPath: $projectPath, iid: $iid, filenames: $filenames }) { + version { + ...VersionListItem + } + errors + } +} diff --git a/app/assets/javascripts/design_management_new/graphql/mutations/toggle_resolve_discussion.mutation.graphql b/app/assets/javascripts/design_management_new/graphql/mutations/toggle_resolve_discussion.mutation.graphql new file mode 100644 index 00000000000..1157fc05d5f --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/mutations/toggle_resolve_discussion.mutation.graphql @@ -0,0 +1,17 @@ +#import "../fragments/design_note.fragment.graphql" +#import "../fragments/discussion_resolved_status.fragment.graphql" + +mutation toggleResolveDiscussion($id: ID!, $resolve: Boolean!) { + discussionToggleResolve(input: { id: $id, resolve: $resolve }) { + discussion { + id + ...ResolvedStatus + notes { + nodes { + ...DesignNote + } + } + } + errors + } +} diff --git a/app/assets/javascripts/design_management_new/graphql/mutations/update_active_discussion.mutation.graphql b/app/assets/javascripts/design_management_new/graphql/mutations/update_active_discussion.mutation.graphql new file mode 100644 index 00000000000..a24b6737159 --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/mutations/update_active_discussion.mutation.graphql @@ -0,0 +1,3 @@ +mutation updateActiveDiscussion($id: String, $source: String) { + updateActiveDiscussion(id: $id, source: $source) @client +} diff --git a/app/assets/javascripts/design_management_new/graphql/mutations/update_image_diff_note.mutation.graphql b/app/assets/javascripts/design_management_new/graphql/mutations/update_image_diff_note.mutation.graphql new file mode 100644 index 00000000000..5562ca9d89f --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/mutations/update_image_diff_note.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/design_note.fragment.graphql" + +mutation updateImageDiffNote($input: UpdateImageDiffNoteInput!) { + updateImageDiffNote(input: $input) { + errors + note { + ...DesignNote + } + } +} diff --git a/app/assets/javascripts/design_management_new/graphql/mutations/update_note.mutation.graphql b/app/assets/javascripts/design_management_new/graphql/mutations/update_note.mutation.graphql new file mode 100644 index 00000000000..b995e99fb6a --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/mutations/update_note.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/design_note.fragment.graphql" + +mutation updateNote($input: UpdateNoteInput!) { + updateNote(input: $input) { + note { + ...DesignNote + } + errors + } +} diff --git a/app/assets/javascripts/design_management_new/graphql/mutations/upload_design.mutation.graphql b/app/assets/javascripts/design_management_new/graphql/mutations/upload_design.mutation.graphql new file mode 100644 index 00000000000..d694e6558a0 --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/mutations/upload_design.mutation.graphql @@ -0,0 +1,21 @@ +#import "../fragments/design.fragment.graphql" + +mutation uploadDesign($files: [Upload!]!, $projectPath: ID!, $iid: ID!) { + designManagementUpload(input: { projectPath: $projectPath, iid: $iid, files: $files }) { + designs { + ...DesignItem + versions { + edges { + node { + id + sha + } + } + } + } + skippedDesigns { + filename + } + errors + } +} diff --git a/app/assets/javascripts/design_management_new/graphql/queries/active_discussion.query.graphql b/app/assets/javascripts/design_management_new/graphql/queries/active_discussion.query.graphql new file mode 100644 index 00000000000..111023cea68 --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/queries/active_discussion.query.graphql @@ -0,0 +1,6 @@ +query activeDiscussion { + activeDiscussion @client { + id + source + } +} diff --git a/app/assets/javascripts/design_management_new/graphql/queries/design_permissions.query.graphql b/app/assets/javascripts/design_management_new/graphql/queries/design_permissions.query.graphql new file mode 100644 index 00000000000..a87b256dc95 --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/queries/design_permissions.query.graphql @@ -0,0 +1,10 @@ +query permissions($fullPath: ID!, $iid: String!) { + project(fullPath: $fullPath) { + id + issue(iid: $iid) { + userPermissions { + createDesign + } + } + } +} diff --git a/app/assets/javascripts/design_management_new/graphql/queries/get_design.query.graphql b/app/assets/javascripts/design_management_new/graphql/queries/get_design.query.graphql new file mode 100644 index 00000000000..07a9af55787 --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/queries/get_design.query.graphql @@ -0,0 +1,31 @@ +#import "../fragments/design.fragment.graphql" +#import "~/graphql_shared/fragments/author.fragment.graphql" + +query getDesign($fullPath: ID!, $iid: String!, $atVersion: ID, $filenames: [String!]) { + project(fullPath: $fullPath) { + id + issue(iid: $iid) { + designCollection { + designs(atVersion: $atVersion, filenames: $filenames) { + edges { + node { + ...DesignItem + issue { + title + webPath + webUrl + participants { + edges { + node { + ...Author + } + } + } + } + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/design_management_new/graphql/queries/get_design_list.query.graphql b/app/assets/javascripts/design_management_new/graphql/queries/get_design_list.query.graphql new file mode 100644 index 00000000000..121a50555b3 --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/queries/get_design_list.query.graphql @@ -0,0 +1,26 @@ +#import "../fragments/design_list.fragment.graphql" +#import "../fragments/version.fragment.graphql" + +query getDesignList($fullPath: ID!, $iid: String!, $atVersion: ID) { + project(fullPath: $fullPath) { + id + issue(iid: $iid) { + designCollection { + designs(atVersion: $atVersion) { + edges { + node { + ...DesignListItem + } + } + } + versions { + edges { + node { + ...VersionListItem + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/design_management_new/graphql/typedefs.graphql b/app/assets/javascripts/design_management_new/graphql/typedefs.graphql new file mode 100644 index 00000000000..fdbad4a90e0 --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/typedefs.graphql @@ -0,0 +1,12 @@ +type ActiveDiscussion { + id: ID + source: String +} + +extend type Query { + activeDiscussion: ActiveDiscussion +} + +extend type Mutation { + updateActiveDiscussion(id: ID!, source: String!): Boolean +} diff --git a/app/assets/javascripts/design_management_new/index.js b/app/assets/javascripts/design_management_new/index.js new file mode 100644 index 00000000000..20c9cacf83f --- /dev/null +++ b/app/assets/javascripts/design_management_new/index.js @@ -0,0 +1,33 @@ +import Vue from 'vue'; +import createRouter from './router'; +import App from './components/app.vue'; +import apolloProvider from './graphql'; + +export default () => { + const el = document.querySelector('.js-design-management-new'); + const { issueIid, projectPath, issuePath } = el.dataset; + const router = createRouter(issuePath); + + apolloProvider.clients.defaultClient.cache.writeData({ + data: { + activeDiscussion: { + __typename: 'ActiveDiscussion', + id: null, + source: null, + }, + }, + }); + + return new Vue({ + el, + router, + apolloProvider, + provide: { + projectPath, + issueIid, + }, + render(createElement) { + return createElement(App); + }, + }); +}; diff --git a/app/assets/javascripts/design_management_new/mixins/all_designs.js b/app/assets/javascripts/design_management_new/mixins/all_designs.js new file mode 100644 index 00000000000..f7d6551c46c --- /dev/null +++ b/app/assets/javascripts/design_management_new/mixins/all_designs.js @@ -0,0 +1,49 @@ +import { propertyOf } from 'lodash'; +import createFlash from '~/flash'; +import { s__ } from '~/locale'; +import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql'; +import { extractNodes } from '../utils/design_management_utils'; +import allVersionsMixin from './all_versions'; +import { DESIGNS_ROUTE_NAME } from '../router/constants'; + +export default { + mixins: [allVersionsMixin], + apollo: { + designs: { + query: getDesignListQuery, + variables() { + return { + fullPath: this.projectPath, + iid: this.issueIid, + atVersion: this.designsVersion, + }; + }, + update: data => { + const designEdges = propertyOf(data)(['project', 'issue', 'designCollection', 'designs']); + if (designEdges) { + return extractNodes(designEdges); + } + return []; + }, + error() { + this.error = true; + }, + result() { + if (this.$route.query.version && !this.hasValidVersion) { + createFlash( + s__( + 'DesignManagement|Requested design version does not exist. Showing latest version instead', + ), + ); + this.$router.replace({ name: DESIGNS_ROUTE_NAME, query: { version: undefined } }); + } + }, + }, + }, + data() { + return { + designs: [], + error: false, + }; + }, +}; diff --git a/app/assets/javascripts/design_management_new/mixins/all_versions.js b/app/assets/javascripts/design_management_new/mixins/all_versions.js new file mode 100644 index 00000000000..99e2ee9561c --- /dev/null +++ b/app/assets/javascripts/design_management_new/mixins/all_versions.js @@ -0,0 +1,59 @@ +import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql'; +import { findVersionId } from '../utils/design_management_utils'; + +export default { + apollo: { + allVersions: { + query: getDesignListQuery, + variables() { + return { + fullPath: this.projectPath, + iid: this.issueIid, + atVersion: null, + }; + }, + update: data => data.project.issue.designCollection.versions.edges, + }, + }, + inject: { + projectPath: { + default: '', + }, + issueIid: { + default: '', + }, + }, + computed: { + hasValidVersion() { + return ( + this.$route.query.version && + this.allVersions && + this.allVersions.some(version => version.node.id.endsWith(this.$route.query.version)) + ); + }, + designsVersion() { + return this.hasValidVersion + ? `gid://gitlab/DesignManagement::Version/${this.$route.query.version}` + : null; + }, + latestVersionId() { + const latestVersion = this.allVersions[0]; + return latestVersion && findVersionId(latestVersion.node.id); + }, + isLatestVersion() { + if (this.allVersions.length > 0) { + return ( + !this.$route.query.version || + !this.latestVersionId || + this.$route.query.version === this.latestVersionId + ); + } + return true; + }, + }, + data() { + return { + allVersions: [], + }; + }, +}; diff --git a/app/assets/javascripts/design_management_new/pages/design/index.vue b/app/assets/javascripts/design_management_new/pages/design/index.vue new file mode 100644 index 00000000000..47f5e3a786f --- /dev/null +++ b/app/assets/javascripts/design_management_new/pages/design/index.vue @@ -0,0 +1,367 @@ +<script> +import Mousetrap from 'mousetrap'; +import { GlLoadingIcon, GlAlert } from '@gitlab/ui'; +import { ApolloMutation } from 'vue-apollo'; +import createFlash from '~/flash'; +import { fetchPolicies } from '~/lib/graphql'; +import allVersionsMixin from '../../mixins/all_versions'; +import Toolbar from '../../components/toolbar/index.vue'; +import DesignDestroyer from '../../components/design_destroyer.vue'; +import DesignScaler from '../../components/design_scaler.vue'; +import DesignPresentation from '../../components/design_presentation.vue'; +import DesignReplyForm from '../../components/design_notes/design_reply_form.vue'; +import DesignSidebar from '../../components/design_sidebar.vue'; +import getDesignQuery from '../../graphql/queries/get_design.query.graphql'; +import createImageDiffNoteMutation from '../../graphql/mutations/create_image_diff_note.mutation.graphql'; +import updateImageDiffNoteMutation from '../../graphql/mutations/update_image_diff_note.mutation.graphql'; +import updateActiveDiscussionMutation from '../../graphql/mutations/update_active_discussion.mutation.graphql'; +import { + extractDiscussions, + extractDesign, + updateImageDiffNoteOptimisticResponse, +} from '../../utils/design_management_utils'; +import { + updateStoreAfterAddImageDiffNote, + updateStoreAfterUpdateImageDiffNote, +} from '../../utils/cache_update'; +import { + ADD_DISCUSSION_COMMENT_ERROR, + ADD_IMAGE_DIFF_NOTE_ERROR, + UPDATE_IMAGE_DIFF_NOTE_ERROR, + DESIGN_NOT_FOUND_ERROR, + DESIGN_VERSION_NOT_EXIST_ERROR, + UPDATE_NOTE_ERROR, + designDeletionError, +} from '../../utils/error_messages'; +import { trackDesignDetailView } from '../../utils/tracking'; +import { DESIGNS_ROUTE_NAME } from '../../router/constants'; +import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants'; + +export default { + components: { + ApolloMutation, + DesignReplyForm, + DesignPresentation, + DesignScaler, + DesignDestroyer, + Toolbar, + GlLoadingIcon, + GlAlert, + DesignSidebar, + }, + mixins: [allVersionsMixin], + props: { + id: { + type: String, + required: true, + }, + }, + data() { + return { + design: {}, + comment: '', + annotationCoordinates: null, + errorMessage: '', + scale: 1, + resolvedDiscussionsExpanded: false, + }; + }, + apollo: { + design: { + query: getDesignQuery, + // We want to see cached design version if we have one, and fetch newer version on the background to update discussions + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + variables() { + return this.designVariables; + }, + update: data => extractDesign(data), + result(res) { + this.onDesignQueryResult(res); + }, + error() { + this.onQueryError(DESIGN_NOT_FOUND_ERROR); + }, + }, + }, + computed: { + isFirstLoading() { + // We only want to show spinner on initial design load (when opened from a deep link to design) + // If we already have cached a design, loading shouldn't be indicated to user + return this.$apollo.queries.design.loading && !this.design.filename; + }, + discussions() { + if (!this.design.discussions) { + return []; + } + return extractDiscussions(this.design.discussions); + }, + markdownPreviewPath() { + return `/${this.projectPath}/preview_markdown?target_type=Issue`; + }, + isSubmitButtonDisabled() { + return this.comment.trim().length === 0; + }, + designVariables() { + return { + fullPath: this.projectPath, + iid: this.issueIid, + filenames: [this.$route.params.id], + atVersion: this.designsVersion, + }; + }, + mutationPayload() { + const { x, y, width, height } = this.annotationCoordinates; + return { + noteableId: this.design.id, + body: this.comment, + position: { + headSha: this.design.diffRefs.headSha, + baseSha: this.design.diffRefs.baseSha, + startSha: this.design.diffRefs.startSha, + x, + y, + width, + height, + paths: { + newPath: this.design.fullPath, + }, + }, + }; + }, + isAnnotating() { + return Boolean(this.annotationCoordinates); + }, + resolvedDiscussions() { + return this.discussions.filter(discussion => discussion.resolved); + }, + }, + watch: { + resolvedDiscussions(val) { + if (!val.length) { + this.resolvedDiscussionsExpanded = false; + } + }, + }, + mounted() { + Mousetrap.bind('esc', this.closeDesign); + this.trackEvent(); + // We need to reset the active discussion when opening a new design + this.updateActiveDiscussion(); + }, + beforeDestroy() { + Mousetrap.unbind('esc', this.closeDesign); + }, + methods: { + addImageDiffNoteToStore( + store, + { + data: { createImageDiffNote }, + }, + ) { + updateStoreAfterAddImageDiffNote( + store, + createImageDiffNote, + getDesignQuery, + this.designVariables, + ); + }, + updateImageDiffNoteInStore( + store, + { + data: { updateImageDiffNote }, + }, + ) { + return updateStoreAfterUpdateImageDiffNote( + store, + updateImageDiffNote, + getDesignQuery, + this.designVariables, + ); + }, + onMoveNote({ noteId, discussionId, position }) { + const discussion = this.discussions.find(({ id }) => id === discussionId); + const note = discussion.notes.find( + ({ discussion: noteDiscussion }) => noteDiscussion.id === discussionId, + ); + + const mutationPayload = { + optimisticResponse: updateImageDiffNoteOptimisticResponse(note, { + position, + }), + variables: { + input: { + id: noteId, + position, + }, + }, + mutation: updateImageDiffNoteMutation, + update: this.updateImageDiffNoteInStore, + }; + + return this.$apollo.mutate(mutationPayload).catch(e => this.onUpdateImageDiffNoteError(e)); + }, + onDesignQueryResult({ data, loading }) { + // On the initial load with cache-and-network policy data is undefined while loading is true + // To prevent throwing an error, we don't perform any logic until loading is false + if (loading) { + return; + } + + if (!data || !extractDesign(data)) { + this.onQueryError(DESIGN_NOT_FOUND_ERROR); + } else if (this.$route.query.version && !this.hasValidVersion) { + this.onQueryError(DESIGN_VERSION_NOT_EXIST_ERROR); + } + }, + onQueryError(message) { + // because we redirect user to /designs (the issue page), + // we want to create these flashes on the issue page + createFlash(message); + this.$router.push({ name: this.$options.DESIGNS_ROUTE_NAME }); + }, + onError(message, e) { + this.errorMessage = message; + throw e; + }, + onCreateImageDiffNoteError(e) { + this.onError(ADD_IMAGE_DIFF_NOTE_ERROR, e); + }, + onUpdateNoteError(e) { + this.onError(UPDATE_NOTE_ERROR, e); + }, + onDesignDiscussionError(e) { + this.onError(ADD_DISCUSSION_COMMENT_ERROR, e); + }, + onUpdateImageDiffNoteError(e) { + this.onError(UPDATE_IMAGE_DIFF_NOTE_ERROR, e); + }, + onDesignDeleteError(e) { + this.onError(designDeletionError({ singular: true }), e); + }, + onResolveDiscussionError(e) { + this.onError(UPDATE_IMAGE_DIFF_NOTE_ERROR, e); + }, + openCommentForm(annotationCoordinates) { + this.annotationCoordinates = annotationCoordinates; + if (this.$refs.newDiscussionForm) { + this.$refs.newDiscussionForm.focusInput(); + } + }, + closeCommentForm() { + this.comment = ''; + this.annotationCoordinates = null; + }, + closeDesign() { + this.$router.push({ + name: this.$options.DESIGNS_ROUTE_NAME, + query: this.$route.query, + }); + }, + trackEvent() { + // TODO: This needs to be made aware of referers, or if it's rendered in a different context than a Issue + trackDesignDetailView( + 'issue-design-collection', + 'issue', + this.$route.query.version || this.latestVersionId, + this.isLatestVersion, + ); + }, + updateActiveDiscussion(id) { + this.$apollo.mutate({ + mutation: updateActiveDiscussionMutation, + variables: { + id, + source: ACTIVE_DISCUSSION_SOURCE_TYPES.discussion, + }, + }); + }, + toggleResolvedComments() { + this.resolvedDiscussionsExpanded = !this.resolvedDiscussionsExpanded; + }, + }, + createImageDiffNoteMutation, + DESIGNS_ROUTE_NAME, +}; +</script> + +<template> + <div + class="design-detail js-design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row" + > + <gl-loading-icon v-if="isFirstLoading" size="xl" class="align-self-center" /> + <template v-else> + <div class="d-flex overflow-hidden flex-grow-1 flex-column position-relative"> + <design-destroyer + :filenames="[design.filename]" + :project-path="projectPath" + :iid="issueIid" + @done="$router.push({ name: $options.DESIGNS_ROUTE_NAME })" + @error="onDesignDeleteError" + > + <template #default="{ mutate, loading }"> + <toolbar + :id="id" + :is-deleting="loading" + :is-latest-version="isLatestVersion" + v-bind="design" + @delete="mutate" + /> + </template> + </design-destroyer> + + <div v-if="errorMessage" class="p-3"> + <gl-alert variant="danger" @dismiss="errorMessage = null"> + {{ errorMessage }} + </gl-alert> + </div> + <design-presentation + :image="design.image" + :image-name="design.filename" + :discussions="discussions" + :is-annotating="isAnnotating" + :scale="scale" + :resolved-discussions-expanded="resolvedDiscussionsExpanded" + @openCommentForm="openCommentForm" + @closeCommentForm="closeCommentForm" + @moveNote="onMoveNote" + /> + + <div class="design-scaler-wrapper position-absolute mb-4 d-flex-center"> + <design-scaler @scale="scale = $event" /> + </div> + </div> + <design-sidebar + :design="design" + :resolved-discussions-expanded="resolvedDiscussionsExpanded" + :markdown-preview-path="markdownPreviewPath" + @onDesignDiscussionError="onDesignDiscussionError" + @onCreateImageDiffNoteError="onCreateImageDiffNoteError" + @updateNoteError="onUpdateNoteError" + @resolveDiscussionError="onResolveDiscussionError" + @toggleResolvedComments="toggleResolvedComments" + > + <template #replyForm> + <apollo-mutation + v-if="isAnnotating" + #default="{ mutate, loading }" + :mutation="$options.createImageDiffNoteMutation" + :variables="{ + input: mutationPayload, + }" + :update="addImageDiffNoteToStore" + @done="closeCommentForm" + @error="onCreateImageDiffNoteError" + > + <design-reply-form + ref="newDiscussionForm" + v-model="comment" + :is-saving="loading" + :markdown-preview-path="markdownPreviewPath" + @submitForm="mutate" + @cancelForm="closeCommentForm" + /> </apollo-mutation + ></template> + </design-sidebar> + </template> + </div> +</template> diff --git a/app/assets/javascripts/design_management_new/pages/index.vue b/app/assets/javascripts/design_management_new/pages/index.vue new file mode 100644 index 00000000000..2a100fae280 --- /dev/null +++ b/app/assets/javascripts/design_management_new/pages/index.vue @@ -0,0 +1,346 @@ +<script> +import { GlLoadingIcon, GlButton, GlAlert } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { s__, sprintf } from '~/locale'; +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 '../components/upload/design_dropzone.vue'; +import uploadDesignMutation from '../graphql/mutations/upload_design.mutation.graphql'; +import permissionsQuery from '../graphql/queries/design_permissions.query.graphql'; +import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql'; +import allDesignsMixin from '../mixins/all_designs'; +import { + UPLOAD_DESIGN_ERROR, + EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE, + EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE, + designUploadSkippedWarning, + designDeletionError, +} from '../utils/error_messages'; +import { updateStoreAfterUploadDesign } from '../utils/cache_update'; +import { + designUploadOptimisticResponse, + isValidDesignFile, +} from '../utils/design_management_utils'; +import { getFilename } from '~/lib/utils/file_upload'; +import { DESIGNS_ROUTE_NAME } from '../router/constants'; + +const MAXIMUM_FILE_UPLOAD_LIMIT = 10; + +export default { + components: { + GlLoadingIcon, + GlAlert, + GlButton, + UploadButton, + Design, + DesignDestroyer, + DesignVersionDropdown, + DeleteButton, + DesignDropzone, + }, + mixins: [allDesignsMixin], + apollo: { + permissions: { + query: permissionsQuery, + variables() { + return { + fullPath: this.projectPath, + iid: this.issueIid, + }; + }, + update: data => data.project.issue.userPermissions, + }, + }, + data() { + return { + permissions: { + createDesign: false, + }, + filesToBeSaved: [], + selectedDesigns: [], + }; + }, + computed: { + isLoading() { + return this.$apollo.queries.designs.loading || this.$apollo.queries.permissions.loading; + }, + isSaving() { + return this.filesToBeSaved.length > 0; + }, + canCreateDesign() { + return this.permissions.createDesign; + }, + showToolbar() { + return this.canCreateDesign && this.allVersions.length > 0; + }, + hasDesigns() { + return this.designs.length > 0; + }, + hasSelectedDesigns() { + return this.selectedDesigns.length > 0; + }, + canDeleteDesigns() { + return this.isLatestVersion && this.hasSelectedDesigns; + }, + projectQueryBody() { + return { + query: getDesignListQuery, + variables: { fullPath: this.projectPath, iid: this.issueIid, atVersion: null }, + }; + }, + selectAllButtonText() { + return this.hasSelectedDesigns + ? s__('DesignManagement|Deselect all') + : s__('DesignManagement|Select all'); + }, + isDesignListEmpty() { + return !this.isSaving && !this.hasDesigns; + }, + designDropzoneWrapperClass() { + return this.isDesignListEmpty + ? 'col-12' + : 'gl-flex-direction-column col-md-6 col-lg-3 gl-mb-3'; + }, + }, + mounted() { + this.toggleOnPasteListener(this.$route.name); + }, + methods: { + resetFilesToBeSaved() { + this.filesToBeSaved = []; + }, + /** + * Determine if a design upload is valid, given [files] + * @param {Array<File>} files + */ + isValidDesignUpload(files) { + if (!this.canCreateDesign) return false; + + if (files.length > MAXIMUM_FILE_UPLOAD_LIMIT) { + createFlash( + sprintf( + s__( + 'DesignManagement|The maximum number of designs allowed to be uploaded is %{upload_limit}. Please try again.', + ), + { + upload_limit: MAXIMUM_FILE_UPLOAD_LIMIT, + }, + ), + ); + + return false; + } + return true; + }, + onUploadDesign(files) { + // convert to Array so that we have Array methods (.map, .some, etc.) + this.filesToBeSaved = Array.from(files); + if (!this.isValidDesignUpload(this.filesToBeSaved)) return null; + + const mutationPayload = { + optimisticResponse: designUploadOptimisticResponse(this.filesToBeSaved), + variables: { + files: this.filesToBeSaved, + projectPath: this.projectPath, + iid: this.issueIid, + }, + context: { + hasUpload: true, + }, + mutation: uploadDesignMutation, + update: this.afterUploadDesign, + }; + + return this.$apollo + .mutate(mutationPayload) + .then(res => this.onUploadDesignDone(res)) + .catch(() => this.onUploadDesignError()); + }, + afterUploadDesign( + store, + { + data: { designManagementUpload }, + }, + ) { + updateStoreAfterUploadDesign(store, designManagementUpload, this.projectQueryBody); + }, + onUploadDesignDone(res) { + const skippedFiles = res?.data?.designManagementUpload?.skippedDesigns || []; + const skippedWarningMessage = designUploadSkippedWarning(this.filesToBeSaved, skippedFiles); + if (skippedWarningMessage) { + createFlash(skippedWarningMessage, 'warning'); + } + + // if this upload resulted in a new version being created, redirect user to the latest version + if (!this.isLatestVersion) { + this.$router.push({ name: DESIGNS_ROUTE_NAME }); + } + this.resetFilesToBeSaved(); + }, + onUploadDesignError() { + this.resetFilesToBeSaved(); + createFlash(UPLOAD_DESIGN_ERROR); + }, + changeSelectedDesigns(filename) { + if (this.isDesignSelected(filename)) { + this.selectedDesigns = this.selectedDesigns.filter(design => design !== filename); + } else { + this.selectedDesigns.push(filename); + } + }, + toggleDesignsSelection() { + if (this.hasSelectedDesigns) { + this.selectedDesigns = []; + } else { + this.selectedDesigns = this.designs.map(design => design.filename); + } + }, + isDesignSelected(filename) { + return this.selectedDesigns.includes(filename); + }, + isDesignToBeSaved(filename) { + return this.filesToBeSaved.some(file => file.name === filename); + }, + canSelectDesign(filename) { + return this.isLatestVersion && this.canCreateDesign && !this.isDesignToBeSaved(filename); + }, + onDesignDelete() { + this.selectedDesigns = []; + if (this.$route.query.version) this.$router.push({ name: DESIGNS_ROUTE_NAME }); + }, + onDesignDeleteError() { + const errorMessage = designDeletionError({ singular: this.selectedDesigns.length === 1 }); + createFlash(errorMessage); + }, + onExistingDesignDropzoneChange(files, existingDesignFilename) { + const filesArr = Array.from(files); + + if (filesArr.length > 1) { + createFlash(EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE); + return; + } + + if (!filesArr.some(({ name }) => existingDesignFilename === name)) { + createFlash(EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE); + return; + } + + this.onUploadDesign(files); + }, + onDesignPaste(event) { + const { clipboardData } = event; + const files = Array.from(clipboardData.files); + if (clipboardData && files.length > 0) { + if (!files.some(isValidDesignFile)) { + return; + } + event.preventDefault(); + let filename = getFilename(event); + if (!filename || filename === 'image.png') { + filename = `design_${Date.now()}.png`; + } + const newFile = new File([files[0]], filename); + this.onUploadDesign([newFile]); + } + }, + toggleOnPasteListener(route) { + if (route === DESIGNS_ROUTE_NAME) { + document.addEventListener('paste', this.onDesignPaste); + } else { + document.removeEventListener('paste', this.onDesignPaste); + } + }, + }, + beforeRouteUpdate(to, from, next) { + this.toggleOnPasteListener(to.name); + this.selectedDesigns = []; + next(); + }, + beforeRouteLeave(to, from, next) { + this.toggleOnPasteListener(to.name); + next(); + }, +}; +</script> + +<template> + <div data-testid="designs-root" class="gl-mt-5"> + <header v-if="showToolbar" class="row-content-block border-top-0 p-2 d-flex"> + <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-w-full"> + <div> + <span class="gl-font-weight-bold gl-mr-3">{{ s__('DesignManagement|Designs') }}</span> + <design-version-dropdown /> + </div> + <div v-show="hasDesigns" class="qa-selector-toolbar gl-display-flex"> + <gl-button + v-if="isLatestVersion" + variant="link" + size="small" + class="gl-mr-2 js-select-all" + @click="toggleDesignsSelection" + >{{ selectAllButtonText }} + </gl-button> + <design-destroyer + #default="{ mutate, loading }" + :filenames="selectedDesigns" + @done="onDesignDelete" + @error="onDesignDeleteError" + > + <delete-button + v-if="isLatestVersion" + :is-deleting="loading" + button-variant="danger" + button-class="gl-mr-4" + button-size="small" + :has-selected-designs="hasSelectedDesigns" + @deleteSelectedDesigns="mutate()" + > + {{ s__('DesignManagement|Delete selected') }} + <gl-loading-icon v-if="loading" inline class="ml-1" /> + </delete-button> + </design-destroyer> + <upload-button v-if="canCreateDesign" :is-saving="isSaving" @upload="onUploadDesign" /> + </div> + </div> + </header> + <div class="mt-4"> + <gl-loading-icon v-if="isLoading" size="md" /> + <gl-alert v-else-if="error" variant="danger" :dismissible="false"> + {{ __('An error occurred while loading designs. Please try again.') }} + </gl-alert> + <ol v-else class="list-unstyled row"> + <span + v-if="isDesignListEmpty && !allVersions.length" + class="gl-font-weight-bold gl-font-weight-bold gl-ml-5 gl-mb-4" + >{{ s__('DesignManagement|Designs') }}</span + > + <li :class="designDropzoneWrapperClass" data-testid="design-dropzone-wrapper"> + <design-dropzone + :class="{ 'design-list-item design-list-item-new': !isDesignListEmpty }" + :has-designs="hasDesigns" + @change="onUploadDesign" + /> + </li> + <li v-for="design in designs" :key="design.id" class="col-md-6 col-lg-3 gl-mb-3"> + <design-dropzone + :has-designs="hasDesigns" + @change="onExistingDesignDropzoneChange($event, design.filename)" + ><design v-bind="design" :is-uploading="isDesignToBeSaved(design.filename)" + /></design-dropzone> + + <input + v-if="canSelectDesign(design.filename)" + :checked="isDesignSelected(design.filename)" + type="checkbox" + class="design-checkbox" + @change="changeSelectedDesigns(design.filename)" + /> + </li> + </ol> + </div> + <router-view :key="$route.fullPath" /> + </div> +</template> diff --git a/app/assets/javascripts/design_management_new/router/constants.js b/app/assets/javascripts/design_management_new/router/constants.js new file mode 100644 index 00000000000..dd2ee8d8689 --- /dev/null +++ b/app/assets/javascripts/design_management_new/router/constants.js @@ -0,0 +1,2 @@ +export const DESIGNS_ROUTE_NAME = 'designs'; +export const DESIGN_ROUTE_NAME = 'design'; diff --git a/app/assets/javascripts/design_management_new/router/index.js b/app/assets/javascripts/design_management_new/router/index.js new file mode 100644 index 00000000000..40e2d35bc40 --- /dev/null +++ b/app/assets/javascripts/design_management_new/router/index.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import routes from './routes'; +import { DESIGN_ROUTE_NAME } from './constants'; +import { getPageLayoutElement } from '~/design_management_new/utils/design_management_utils'; +import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '../constants'; + +Vue.use(VueRouter); + +export default function createRouter(base) { + const router = new VueRouter({ + base, + mode: 'history', + routes, + }); + const pageEl = getPageLayoutElement(); + + router.beforeEach(({ name }, _, next) => { + // apply a fullscreen layout style in Design View (a.k.a design detail) + if (pageEl) { + if (name === DESIGN_ROUTE_NAME) { + pageEl.classList.add(...DESIGN_DETAIL_LAYOUT_CLASSLIST); + } else { + pageEl.classList.remove(...DESIGN_DETAIL_LAYOUT_CLASSLIST); + } + } + + next(); + }); + + return router; +} diff --git a/app/assets/javascripts/design_management_new/router/routes.js b/app/assets/javascripts/design_management_new/router/routes.js new file mode 100644 index 00000000000..d888b856611 --- /dev/null +++ b/app/assets/javascripts/design_management_new/router/routes.js @@ -0,0 +1,29 @@ +import Home from '../pages/index.vue'; +import DesignDetail from '../pages/design/index.vue'; +import { DESIGNS_ROUTE_NAME, DESIGN_ROUTE_NAME } from './constants'; + +export default [ + { + name: DESIGNS_ROUTE_NAME, + path: '/', + component: Home, + alias: '/designs', + }, + { + name: DESIGN_ROUTE_NAME, + path: '/designs/:id', + component: DesignDetail, + beforeEnter( + { + params: { id }, + }, + _, + next, + ) { + if (typeof id === 'string') { + next(); + } + }, + props: ({ params: { id } }) => ({ id }), + }, +]; diff --git a/app/assets/javascripts/design_management_new/utils/cache_update.js b/app/assets/javascripts/design_management_new/utils/cache_update.js new file mode 100644 index 00000000000..24b374b79fd --- /dev/null +++ b/app/assets/javascripts/design_management_new/utils/cache_update.js @@ -0,0 +1,276 @@ +/* eslint-disable @gitlab/require-i18n-strings */ + +import createFlash from '~/flash'; +import { extractCurrentDiscussion, extractDesign } from './design_management_utils'; +import { + ADD_IMAGE_DIFF_NOTE_ERROR, + UPDATE_IMAGE_DIFF_NOTE_ERROR, + ADD_DISCUSSION_COMMENT_ERROR, + designDeletionError, +} from './error_messages'; + +const deleteDesignsFromStore = (store, query, selectedDesigns) => { + const data = store.readQuery(query); + + const changedDesigns = data.project.issue.designCollection.designs.edges.filter( + ({ node }) => !selectedDesigns.includes(node.filename), + ); + data.project.issue.designCollection.designs.edges = [...changedDesigns]; + + store.writeQuery({ + ...query, + data, + }); +}; + +/** + * Adds a new version of designs to store + * + * @param {Object} store + * @param {Object} query + * @param {Object} version + */ +const addNewVersionToStore = (store, query, version) => { + if (!version) return; + + const data = store.readQuery(query); + const newEdge = { node: version, __typename: 'DesignVersionEdge' }; + + data.project.issue.designCollection.versions.edges = [ + newEdge, + ...data.project.issue.designCollection.versions.edges, + ]; + + store.writeQuery({ + ...query, + data, + }); +}; + +const addDiscussionCommentToStore = (store, createNote, query, queryVariables, discussionId) => { + const data = store.readQuery({ + query, + variables: queryVariables, + }); + + const design = extractDesign(data); + const currentDiscussion = extractCurrentDiscussion(design.discussions, discussionId); + currentDiscussion.notes.nodes = [...currentDiscussion.notes.nodes, createNote.note]; + + design.notesCount += 1; + if ( + !design.issue.participants.edges.some( + participant => participant.node.username === createNote.note.author.username, + ) + ) { + design.issue.participants.edges = [ + ...design.issue.participants.edges, + { + __typename: 'UserEdge', + node: { + __typename: 'User', + ...createNote.note.author, + }, + }, + ]; + } + store.writeQuery({ + query, + variables: queryVariables, + data: { + ...data, + design: { + ...design, + }, + }, + }); +}; + +const addImageDiffNoteToStore = (store, createImageDiffNote, query, variables) => { + const data = store.readQuery({ + query, + variables, + }); + const newDiscussion = { + __typename: 'Discussion', + id: createImageDiffNote.note.discussion.id, + replyId: createImageDiffNote.note.discussion.replyId, + resolvable: true, + resolved: false, + resolvedAt: null, + resolvedBy: null, + notes: { + __typename: 'NoteConnection', + nodes: [createImageDiffNote.note], + }, + }; + const design = extractDesign(data); + const notesCount = design.notesCount + 1; + design.discussions.nodes = [...design.discussions.nodes, newDiscussion]; + if ( + !design.issue.participants.edges.some( + participant => participant.node.username === createImageDiffNote.note.author.username, + ) + ) { + design.issue.participants.edges = [ + ...design.issue.participants.edges, + { + __typename: 'UserEdge', + node: { + __typename: 'User', + ...createImageDiffNote.note.author, + }, + }, + ]; + } + store.writeQuery({ + query, + variables, + data: { + ...data, + design: { + ...design, + notesCount, + }, + }, + }); +}; + +const updateImageDiffNoteInStore = (store, updateImageDiffNote, query, variables) => { + const data = store.readQuery({ + query, + variables, + }); + + const design = extractDesign(data); + const discussion = extractCurrentDiscussion( + design.discussions, + updateImageDiffNote.note.discussion.id, + ); + + discussion.notes = { + ...discussion.notes, + nodes: [updateImageDiffNote.note, ...discussion.notes.nodes.slice(1)], + }; + + store.writeQuery({ + query, + variables, + data: { + ...data, + design, + }, + }); +}; + +const addNewDesignToStore = (store, designManagementUpload, query) => { + const data = store.readQuery(query); + + const newDesigns = data.project.issue.designCollection.designs.edges.reduce((acc, design) => { + if (!acc.find(d => d.filename === design.node.filename)) { + acc.push(design.node); + } + + return acc; + }, designManagementUpload.designs); + + let newVersionNode; + const findNewVersions = designManagementUpload.designs.find(design => design.versions); + + if (findNewVersions) { + const findNewVersionsEdges = findNewVersions.versions.edges; + + if (findNewVersionsEdges && findNewVersionsEdges.length) { + newVersionNode = [findNewVersionsEdges[0]]; + } + } + + const newVersions = [ + ...(newVersionNode || []), + ...data.project.issue.designCollection.versions.edges, + ]; + + const updatedDesigns = { + __typename: 'DesignCollection', + designs: { + __typename: 'DesignConnection', + edges: newDesigns.map(design => ({ + __typename: 'DesignEdge', + node: design, + })), + }, + versions: { + __typename: 'DesignVersionConnection', + edges: newVersions, + }, + }; + + data.project.issue.designCollection = updatedDesigns; + + store.writeQuery({ + ...query, + data, + }); +}; + +const onError = (data, message) => { + createFlash(message); + throw new Error(data.errors); +}; + +export const hasErrors = ({ errors = [] }) => errors?.length; + +/** + * Updates a store after design deletion + * + * @param {Object} store + * @param {Object} data + * @param {Object} query + * @param {Array} designs + */ +export const updateStoreAfterDesignsDelete = (store, data, query, designs) => { + if (hasErrors(data)) { + onError(data, designDeletionError({ singular: designs.length === 1 })); + } else { + deleteDesignsFromStore(store, query, designs); + addNewVersionToStore(store, query, data.version); + } +}; + +export const updateStoreAfterAddDiscussionComment = ( + store, + data, + query, + queryVariables, + discussionId, +) => { + if (hasErrors(data)) { + onError(data, ADD_DISCUSSION_COMMENT_ERROR); + } else { + addDiscussionCommentToStore(store, data, query, queryVariables, discussionId); + } +}; + +export const updateStoreAfterAddImageDiffNote = (store, data, query, queryVariables) => { + if (hasErrors(data)) { + onError(data, ADD_IMAGE_DIFF_NOTE_ERROR); + } else { + addImageDiffNoteToStore(store, data, query, queryVariables); + } +}; + +export const updateStoreAfterUpdateImageDiffNote = (store, data, query, queryVariables) => { + if (hasErrors(data)) { + onError(data, UPDATE_IMAGE_DIFF_NOTE_ERROR); + } else { + updateImageDiffNoteInStore(store, data, query, queryVariables); + } +}; + +export const updateStoreAfterUploadDesign = (store, data, query) => { + if (hasErrors(data)) { + onError(data, data.errors[0]); + } else { + addNewDesignToStore(store, data, query); + } +}; diff --git a/app/assets/javascripts/design_management_new/utils/design_management_utils.js b/app/assets/javascripts/design_management_new/utils/design_management_utils.js new file mode 100644 index 00000000000..22705cf67a1 --- /dev/null +++ b/app/assets/javascripts/design_management_new/utils/design_management_utils.js @@ -0,0 +1,128 @@ +import { uniqueId } from 'lodash'; +import { VALID_DESIGN_FILE_MIMETYPE } from '../constants'; + +export const isValidDesignFile = ({ type }) => + (type.match(VALID_DESIGN_FILE_MIMETYPE.regex) || []).length > 0; + +/** + * Returns formatted array that doesn't contain + * `edges`->`node` nesting + * + * @param {Array} elements + */ + +export const extractNodes = elements => elements.edges.map(({ node }) => node); + +/** + * Returns formatted array of discussions that doesn't contain + * `edges`->`node` nesting for child notes + * + * @param {Array} discussions + */ + +export const extractDiscussions = discussions => + discussions.nodes.map((discussion, index) => ({ + ...discussion, + index: index + 1, + notes: discussion.notes.nodes, + })); + +/** + * Returns a discussion with the given id from discussions array + * + * @param {Array} discussions + */ + +export const extractCurrentDiscussion = (discussions, id) => + discussions.nodes.find(discussion => discussion.id === id); + +export const findVersionId = id => (id.match('::Version/(.+$)') || [])[1]; + +export const findNoteId = id => (id.match('DiffNote/(.+$)') || [])[1]; + +export const extractDesigns = data => data.project.issue.designCollection.designs.edges; + +export const extractDesign = data => (extractDesigns(data) || [])[0]?.node; + +/** + * Generates optimistic response for a design upload mutation + * @param {Array<File>} files + */ +export const designUploadOptimisticResponse = files => { + const designs = files.map(file => ({ + // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26 + // eslint-disable-next-line @gitlab/require-i18n-strings + __typename: 'Design', + id: -uniqueId(), + image: '', + imageV432x230: '', + filename: file.name, + fullPath: '', + notesCount: 0, + event: 'NONE', + diffRefs: { + __typename: 'DiffRefs', + baseSha: '', + startSha: '', + headSha: '', + }, + discussions: { + __typename: 'DesignDiscussion', + nodes: [], + }, + versions: { + __typename: 'DesignVersionConnection', + edges: { + __typename: 'DesignVersionEdge', + node: { + __typename: 'DesignVersion', + id: -uniqueId(), + sha: -uniqueId(), + }, + }, + }, + })); + + return { + // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26 + // eslint-disable-next-line @gitlab/require-i18n-strings + __typename: 'Mutation', + designManagementUpload: { + __typename: 'DesignManagementUploadPayload', + designs, + skippedDesigns: [], + errors: [], + }, + }; +}; + +/** + * Generates optimistic response for a design upload mutation + * @param {Array<File>} files + */ +export const updateImageDiffNoteOptimisticResponse = (note, { position }) => ({ + // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26 + // eslint-disable-next-line @gitlab/require-i18n-strings + __typename: 'Mutation', + updateImageDiffNote: { + __typename: 'UpdateImageDiffNotePayload', + note: { + ...note, + position: { + ...note.position, + ...position, + }, + }, + errors: [], + }, +}); + +const normalizeAuthor = author => ({ + ...author, + web_url: author.webUrl, + avatar_url: author.avatarUrl, +}); + +export const extractParticipants = users => users.edges.map(({ node }) => normalizeAuthor(node)); + +export const getPageLayoutElement = () => document.querySelector('.layout-page'); diff --git a/app/assets/javascripts/design_management_new/utils/error_messages.js b/app/assets/javascripts/design_management_new/utils/error_messages.js new file mode 100644 index 00000000000..7666c726c2f --- /dev/null +++ b/app/assets/javascripts/design_management_new/utils/error_messages.js @@ -0,0 +1,95 @@ +import { __, s__, n__, sprintf } from '~/locale'; + +export const ADD_DISCUSSION_COMMENT_ERROR = s__( + 'DesignManagement|Could not add a new comment. Please try again.', +); + +export const ADD_IMAGE_DIFF_NOTE_ERROR = s__( + 'DesignManagement|Could not create new discussion. Please try again.', +); + +export const UPDATE_IMAGE_DIFF_NOTE_ERROR = s__( + 'DesignManagement|Could not update discussion. Please try again.', +); + +export const UPDATE_NOTE_ERROR = s__('DesignManagement|Could not update note. Please try again.'); + +export const UPLOAD_DESIGN_ERROR = s__( + 'DesignManagement|Error uploading a new design. Please try again.', +); + +export const UPLOAD_DESIGN_INVALID_FILETYPE_ERROR = __( + 'Could not upload your designs as one or more files uploaded are not supported.', +); + +export const DESIGN_NOT_FOUND_ERROR = __('Could not find design.'); + +export const DESIGN_VERSION_NOT_EXIST_ERROR = __('Requested design version does not exist.'); + +const DESIGN_UPLOAD_SKIPPED_MESSAGE = s__('DesignManagement|Upload skipped.'); + +const ALL_DESIGNS_SKIPPED_MESSAGE = `${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${s__( + 'The designs you tried uploading did not change.', +)}`; + +export const EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE = __( + 'You can only upload one design when dropping onto an existing design.', +); + +export const EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE = __( + 'You must upload a file with the same file name when dropping onto an existing design.', +); + +const MAX_SKIPPED_FILES_LISTINGS = 5; + +const oneDesignSkippedMessage = filename => + `${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${sprintf(s__('DesignManagement|%{filename} did not change.'), { + filename, + })}`; + +/** + * Return warning message indicating that some (but not all) uploaded + * files were skipped. + * @param {Array<{ filename }>} skippedFiles + */ +const someDesignsSkippedMessage = skippedFiles => { + const designsSkippedMessage = `${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${s__( + 'Some of the designs you tried uploading did not change:', + )}`; + + const moreText = sprintf(s__(`DesignManagement|and %{moreCount} more.`), { + moreCount: skippedFiles.length - MAX_SKIPPED_FILES_LISTINGS, + }); + + return `${designsSkippedMessage} ${skippedFiles + .slice(0, MAX_SKIPPED_FILES_LISTINGS) + .map(({ filename }) => filename) + .join(', ')}${skippedFiles.length > MAX_SKIPPED_FILES_LISTINGS ? `, ${moreText}` : '.'}`; +}; + +export const designDeletionError = ({ singular = true } = {}) => { + const design = singular ? __('a design') : __('designs'); + return sprintf(s__('Could not delete %{design}. Please try again.'), { + design, + }); +}; + +/** + * Return warning message, if applicable, that one, some or all uploaded + * files were skipped. + * @param {Array<{ filename }>} uploadedDesigns + * @param {Array<{ filename }>} skippedFiles + */ +export const designUploadSkippedWarning = (uploadedDesigns, skippedFiles) => { + if (skippedFiles.length === 0) { + return null; + } + + if (skippedFiles.length === uploadedDesigns.length) { + const { filename } = skippedFiles[0]; + + return n__(oneDesignSkippedMessage(filename), ALL_DESIGNS_SKIPPED_MESSAGE, skippedFiles.length); + } + + return someDesignsSkippedMessage(skippedFiles); +}; diff --git a/app/assets/javascripts/design_management_new/utils/tracking.js b/app/assets/javascripts/design_management_new/utils/tracking.js new file mode 100644 index 00000000000..b3ecc1453a6 --- /dev/null +++ b/app/assets/javascripts/design_management_new/utils/tracking.js @@ -0,0 +1,27 @@ +import Tracking from '~/tracking'; + +// Tracking Constants +const DESIGN_TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/design_management_context/jsonschema/1-0-0'; +const DESIGN_TRACKING_PAGE_NAME = 'projects:issues:design'; +const DESIGN_TRACKING_EVENT_NAME = 'view_design'; + +// eslint-disable-next-line import/prefer-default-export +export function trackDesignDetailView( + referer = '', + owner = '', + designVersion = 1, + latestVersion = false, +) { + Tracking.event(DESIGN_TRACKING_PAGE_NAME, DESIGN_TRACKING_EVENT_NAME, { + label: DESIGN_TRACKING_EVENT_NAME, + context: { + schema: DESIGN_TRACKING_CONTEXT_SCHEMA, + data: { + 'design-version-number': designVersion, + 'design-is-current-version': latestVersion, + 'internal-object-referrer': referer, + 'design-collection-owner': owner, + }, + }, + }); +} diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 941365d9d1d..1e524882d5f 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -1,6 +1,6 @@ <script> import { mapState, mapGetters, mapActions } from 'vuex'; -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlButtonGroup, GlButton } from '@gitlab/ui'; import Mousetrap from 'mousetrap'; import { __ } from '~/locale'; import createFlash from '~/flash'; @@ -36,6 +36,8 @@ export default { TreeList, GlLoadingIcon, PanelResizer, + GlButtonGroup, + GlButton, }, mixins: [glFeatureFlagsMixin()], props: { @@ -94,6 +96,11 @@ export default { required: false, default: false, }, + viewDiffsFileByFile: { + type: Boolean, + required: false, + default: false, + }, }, data() { const treeWidth = @@ -120,9 +127,18 @@ export default { emailPatchPath: state => state.diffs.emailPatchPath, retrievingBatches: state => state.diffs.retrievingBatches, }), - ...mapState('diffs', ['showTreeList', 'isLoading', 'startVersion']), + ...mapState('diffs', ['showTreeList', 'isLoading', 'startVersion', 'currentDiffFileId']), ...mapGetters('diffs', ['isParallelView', 'currentDiffIndex']), ...mapGetters(['isNotesFetched', 'getNoteableData']), + diffs() { + if (!this.viewDiffsFileByFile) { + return this.diffFiles; + } + + return this.diffFiles.filter((file, i) => { + return file.file_hash === this.currentDiffFileId || (i === 0 && !this.currentDiffFileId); + }); + }, canCurrentUserFork() { return this.currentUser.can_fork === true && this.currentUser.can_create_merge_request; }, @@ -183,16 +199,22 @@ export default { dismissEndpoint: this.dismissEndpoint, showSuggestPopover: this.showSuggestPopover, useSingleDiffStyle: this.glFeatures.singleMrDiffView, + viewDiffsFileByFile: this.viewDiffsFileByFile, }); if (this.shouldShow) { this.fetchData(); } - const id = window && window.location && window.location.hash; + const id = window?.location?.hash; - if (id) { - this.setHighlightedRow(id.slice(1)); + if (id && id.indexOf('#note') !== 0) { + this.setHighlightedRow( + id + .split('diff-content') + .pop() + .slice(1), + ); } }, created() { @@ -236,6 +258,7 @@ export default { 'cacheTreeListWidth', 'scrollToFile', 'toggleShowTreeList', + 'navigateToDiffFileIndex', ]), refetchDiffData() { this.fetchData(false); @@ -398,7 +421,7 @@ export default { class="files d-flex" > <div - v-show="showTreeList" + v-if="showTreeList" :style="{ width: `${treeWidth}px` }" class="diff-tree-list js-diff-tree-list mr-3" > @@ -422,12 +445,31 @@ export default { <div v-if="isBatchLoading" class="loading"><gl-loading-icon size="lg" /></div> <template v-else-if="renderDiffFiles"> <diff-file - v-for="file in diffFiles" + v-for="file in diffs" :key="file.newPath" :file="file" :help-page-path="helpPagePath" :can-current-user-fork="canCurrentUserFork" + :view-diffs-file-by-file="viewDiffsFileByFile" /> + <div v-if="viewDiffsFileByFile" class="d-flex gl-justify-content-center"> + <gl-button-group> + <gl-button + :disabled="currentDiffIndex === 0" + data-testid="singleFilePrevious" + @click="navigateToDiffFileIndex(currentDiffIndex - 1)" + > + {{ __('Prev') }} + </gl-button> + <gl-button + :disabled="currentDiffIndex === diffFiles.length - 1" + data-testid="singleFileNext" + @click="navigateToDiffFileIndex(currentDiffIndex + 1)" + > + {{ __('Next') }} + </gl-button> + </gl-button-group> + </div> </template> <no-changes v-else :changes-empty-state-illustration="changesEmptyStateIllustration" /> </div> diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index 54852b113ae..00d36c0b978 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -30,6 +30,10 @@ export default { required: false, default: '', }, + viewDiffsFileByFile: { + type: Boolean, + required: true, + }, }, data() { return { @@ -154,6 +158,7 @@ export default { :collapsible="true" :expanded="!isCollapsed" :add-merge-request-buttons="true" + :view-diffs-file-by-file="viewDiffsFileByFile" class="js-file-title file-title" @toggleFile="handleToggle" @showForkMessage="showForkMessage" diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index 61bbf13aa53..5727fbaaf68 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -2,7 +2,6 @@ import { escape } from 'lodash'; import { mapActions, mapGetters } from 'vuex'; import { GlDeprecatedButton, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui'; -import { polyfillSticky } from '~/lib/utils/sticky'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import Icon from '~/vue_shared/components/icon.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; @@ -55,6 +54,11 @@ export default { type: Boolean, required: true, }, + viewDiffsFileByFile: { + type: Boolean, + required: false, + default: false, + }, }, computed: { ...mapGetters('diffs', ['diffHasExpandedDiscussions', 'diffHasDiscussions']), @@ -124,9 +128,6 @@ export default { return s__('MRDiff|Show full file'); }, }, - mounted() { - polyfillSticky(this.$refs.header); - }, methods: { ...mapActions('diffs', [ 'toggleFileDiscussions', @@ -167,22 +168,17 @@ export default { :name="collapseIcon" :size="16" aria-hidden="true" - class="diff-toggle-caret append-right-5" + class="diff-toggle-caret gl-mr-2" @click.stop="handleToggleFile" /> <a - v-once ref="titleWrapper" - class="append-right-4" + :v-once="!viewDiffsFileByFile" + class="gl-mr-2" :href="titleLink" @click="handleFileNameClick" > - <file-icon - :file-name="filePath" - :size="18" - aria-hidden="true" - css-classes="append-right-5" - /> + <file-icon :file-name="filePath" :size="18" aria-hidden="true" css-classes="gl-mr-2" /> <span v-if="isFileRenamed"> <strong v-gl-tooltip @@ -218,7 +214,7 @@ export default { {{ diffFile.a_mode }} → {{ diffFile.b_mode }} </small> - <span v-if="isUsingLfs" class="label label-lfs append-right-5"> {{ __('LFS') }} </span> + <span v-if="isUsingLfs" class="label label-lfs gl-mr-2"> {{ __('LFS') }} </span> </div> <div diff --git a/app/assets/javascripts/diffs/components/diff_file_row.vue b/app/assets/javascripts/diffs/components/diff_file_row.vue index c8ba8d6040e..43b669625f4 100644 --- a/app/assets/javascripts/diffs/components/diff_file_row.vue +++ b/app/assets/javascripts/diffs/components/diff_file_row.vue @@ -23,6 +23,11 @@ export default { type: Boolean, required: true, }, + currentDiffFileId: { + type: String, + required: false, + default: null, + }, }, computed: { showFileRowStats() { @@ -33,7 +38,13 @@ export default { </script> <template> - <file-row :file="file" v-bind="$attrs" v-on="$listeners"> + <file-row + :file="file" + v-bind="$attrs" + :class="{ 'is-active': currentDiffFileId === file.fileHash }" + class="diff-file-row" + v-on="$listeners" + > <file-row-stats v-if="showFileRowStats" :file="file" class="mr-1" /> <changed-file-icon :file="file" :size="16" :show-tooltip="true" /> </file-row> 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 74305ee69bc..d2f49bd0020 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -8,7 +8,10 @@ import MultilineCommentForm from '../../notes/components/multiline_comment_form. import autosave from '../../notes/mixins/autosave'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import { DIFF_NOTE_TYPE } from '../constants'; -import { commentLineOptions } from '../../notes/components/multiline_comment_utils'; +import { + commentLineOptions, + formatLineRange, +} from '../../notes/components/multiline_comment_utils'; export default { components: { @@ -44,8 +47,10 @@ export default { data() { return { commentLineStart: { - lineCode: this.line.line_code, + line_code: this.line.line_code, type: this.line.type, + old_line: this.line.old_line, + new_line: this.line.new_line, }, }; }, @@ -74,19 +79,26 @@ export default { diffViewType: this.diffViewType, diffFile: this.diffFile, linePosition: this.linePosition, - lineRange: { - start_line_code: this.commentLineStart.lineCode, - start_line_type: this.commentLineStart.type, - end_line_code: this.line.line_code, - end_line_type: this.line.type, - }, + lineRange: formatLineRange(this.commentLineStart, this.line), }; }, diffFile() { return this.getDiffFileByHash(this.diffFileHash); }, commentLineOptions() { - return commentLineOptions(this.diffFile.highlighted_diff_lines, this.line.line_code); + const combineSides = (acc, { left, right }) => { + // ignore null values match lines + if (left && left.type !== 'match') acc.push(left); + // if the line_codes are identically, return to avoid duplicates + if (left?.line_code === right?.line_code) return acc; + if (right && right.type !== 'match') acc.push(right); + return acc; + }; + const side = this.line.type === 'new' ? 'right' : 'left'; + const lines = this.diffFile.highlighted_diff_lines.length + ? this.diffFile.highlighted_diff_lines + : this.diffFile.parallel_diff_lines.reduce(combineSides, []); + return commentLineOptions(lines, this.line, this.line.line_code, side); }, }, mounted() { @@ -136,10 +148,7 @@ export default { <template> <div class="content discussion-form discussion-form-container discussion-notes"> - <div - v-if="glFeatures.multilineComments" - class="gl-mb-3 gl-text-gray-700 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-pb-3" - > + <div v-if="glFeatures.multilineComments" class="gl-mb-3 gl-text-gray-700 gl-pb-3"> <multiline-comment-form v-model="commentLineStart" :line="line" diff --git a/app/assets/javascripts/diffs/components/diff_table_cell.vue b/app/assets/javascripts/diffs/components/diff_table_cell.vue index 514d26862a3..198113e330a 100644 --- a/app/assets/javascripts/diffs/components/diff_table_cell.vue +++ b/app/assets/javascripts/diffs/components/diff_table_cell.vue @@ -4,15 +4,13 @@ import { GlIcon } from '@gitlab/ui'; import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils'; import DiffGutterAvatars from './diff_gutter_avatars.vue'; import { - MATCH_LINE_TYPE, CONTEXT_LINE_TYPE, LINE_POSITION_RIGHT, EMPTY_CELL_TYPE, - OLD_LINE_TYPE, OLD_NO_NEW_LINE_TYPE, + OLD_LINE_TYPE, NEW_NO_NEW_LINE_TYPE, LINE_HOVER_CLASS_NAME, - LINE_UNFOLD_CLASS_NAME, } from '../constants'; export default { @@ -29,10 +27,6 @@ export default { type: String, required: true, }, - contextLinesPath: { - type: String, - required: true, - }, isHighlighted: { type: Boolean, required: true, @@ -52,11 +46,6 @@ export default { required: false, default: '', }, - isContentLine: { - type: Boolean, - required: false, - default: false, - }, isBottom: { type: Boolean, required: false, @@ -68,6 +57,11 @@ export default { default: false, }, }, + data() { + return { + isCommentButtonRendered: false, + }; + }, computed: { ...mapGetters(['isLoggedIn']), lineCode() { @@ -81,13 +75,7 @@ export default { return `#${this.line.line_code || ''}`; }, shouldShowCommentButton() { - return ( - this.isHover && - !this.isMatchLine && - !this.isContextLine && - !this.isMetaLine && - !this.hasDiscussions - ); + return this.isHover && !this.isContextLine && !this.isMetaLine && !this.hasDiscussions; }, hasDiscussions() { return this.line.discussions && this.line.discussions.length > 0; @@ -99,6 +87,10 @@ export default { return this.showCommentButton && this.hasDiscussions; }, shouldRenderCommentButton() { + if (!this.isCommentButtonRendered) { + return false; + } + if (this.isLoggedIn && this.showCommentButton) { const isDiffHead = parseBoolean(getParameterByName('diff_head')); return !isDiffHead || gon.features?.mergeRefHeadComments; @@ -106,9 +98,6 @@ export default { return false; }, - isMatchLine() { - return this.line.type === MATCH_LINE_TYPE; - }, isContextLine() { return this.line.type === CONTEXT_LINE_TYPE; }, @@ -126,13 +115,8 @@ export default { type, { hll: this.isHighlighted, - [LINE_UNFOLD_CLASS_NAME]: this.isMatchLine, [LINE_HOVER_CLASS_NAME]: - this.isLoggedIn && - this.isHover && - !this.isMatchLine && - !this.isContextLine && - !this.isMetaLine, + this.isLoggedIn && this.isHover && !this.isContextLine && !this.isMetaLine, }, ]; }, @@ -140,6 +124,17 @@ export default { return this.lineType === OLD_LINE_TYPE ? this.line.old_line : this.line.new_line; }, }, + mounted() { + this.unwatchShouldShowCommentButton = this.$watch('shouldShowCommentButton', newVal => { + if (newVal) { + this.isCommentButtonRendered = true; + this.unwatchShouldShowCommentButton(); + } + }); + }, + beforeDestroy() { + this.unwatchShouldShowCommentButton(); + }, methods: { ...mapActions('diffs', ['showCommentForm', 'setHighlightedRow', 'toggleLineDiscussions']), handleCommentButton() { @@ -151,34 +146,32 @@ export default { <template> <td ref="td" :class="classNameMap"> - <div> - <button - v-if="shouldRenderCommentButton" - v-show="shouldShowCommentButton" - ref="addDiffNoteButton" - type="button" - class="add-diff-note js-add-diff-note-button qa-diff-comment" - title="Add a comment to this line" - @click="handleCommentButton" - > - <gl-icon :size="12" name="comment" /> - </button> - <a - v-if="lineNumber" - ref="lineNumberRef" - :data-linenumber="lineNumber" - :href="lineHref" - @click="setHighlightedRow(lineCode)" - > - </a> - <diff-gutter-avatars - v-if="shouldShowAvatarsOnGutter" - :discussions="line.discussions" - :discussions-expanded="line.discussionsExpanded" - @toggleLineDiscussions=" - toggleLineDiscussions({ lineCode, fileHash, expanded: !line.discussionsExpanded }) - " - /> - </div> + <button + v-if="shouldRenderCommentButton" + v-show="shouldShowCommentButton" + ref="addDiffNoteButton" + type="button" + class="add-diff-note js-add-diff-note-button qa-diff-comment" + title="Add a comment to this line" + @click="handleCommentButton" + > + <gl-icon :size="12" name="comment" /> + </button> + <a + v-if="lineNumber" + ref="lineNumberRef" + :data-linenumber="lineNumber" + :href="lineHref" + @click="setHighlightedRow(lineCode)" + > + </a> + <diff-gutter-avatars + v-if="shouldShowAvatarsOnGutter" + :discussions="line.discussions" + :discussions-expanded="line.discussionsExpanded" + @toggleLineDiscussions=" + toggleLineDiscussions({ lineCode, fileHash, expanded: !line.discussionsExpanded }) + " + /> </td> </template> diff --git a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue index bd99fcb71b8..168e8c6c14e 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue @@ -28,10 +28,6 @@ export default { type: String, required: true, }, - contextLinesPath: { - type: String, - required: true, - }, line: { type: Object, required: true, @@ -41,6 +37,11 @@ export default { required: false, default: false, }, + isCommented: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -51,7 +52,10 @@ export default { ...mapGetters('diffs', ['fileLineCoverage']), ...mapState({ isHighlighted(state) { - return this.line.line_code !== null && this.line.line_code === state.diffs.highlightedRow; + if (this.isCommented) return true; + + const lineCode = this.line.line_code; + return lineCode ? lineCode === state.diffs.highlightedRow : false; }, }), isContextLine() { @@ -106,7 +110,6 @@ export default { > <diff-table-cell :file-hash="fileHash" - :context-lines-path="contextLinesPath" :line="line" :line-type="oldLineType" :is-bottom="isBottom" @@ -117,7 +120,6 @@ export default { /> <diff-table-cell :file-hash="fileHash" - :context-lines-path="contextLinesPath" :line="line" :line-type="newLineType" :is-bottom="isBottom" diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue index ad72016f03b..e82d06ee385 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_view.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue @@ -1,10 +1,11 @@ <script> -import { mapGetters } from 'vuex'; +import { mapGetters, mapState } from 'vuex'; import draftCommentsMixin from '~/diffs/mixins/draft_comments'; import InlineDraftCommentRow from '~/batch_comments/components/inline_draft_comment_row.vue'; import inlineDiffTableRow from './inline_diff_table_row.vue'; import inlineDiffCommentRow from './inline_diff_comment_row.vue'; import inlineDiffExpansionRow from './inline_diff_expansion_row.vue'; +import { getCommentedLines } from '~/notes/components/multiline_comment_utils'; export default { components: { @@ -31,9 +32,19 @@ export default { }, computed: { ...mapGetters('diffs', ['commitId']), + ...mapState({ + selectedCommentPosition: ({ notes }) => notes.selectedCommentPosition, + selectedCommentPositionHover: ({ notes }) => notes.selectedCommentPositionHover, + }), diffLinesLength() { return this.diffLines.length; }, + commentedLines() { + return getCommentedLines( + this.selectedCommentPosition || this.selectedCommentPositionHover, + this.diffLines, + ); + }, }, userColorScheme: window.gon.user_color_scheme, }; @@ -65,9 +76,9 @@ export default { :key="`${line.line_code || index}`" :file-hash="diffFile.file_hash" :file-path="diffFile.file_path" - :context-lines-path="diffFile.context_lines_path" :line="line" :is-bottom="index + 1 === diffLinesLength" + :is-commented="index >= commentedLines.startLine && index <= commentedLines.endLine" /> <inline-diff-comment-row :key="`icr-${line.line_code || index}`" diff --git a/app/assets/javascripts/diffs/components/no_changes.vue b/app/assets/javascripts/diffs/components/no_changes.vue index 94c2695a945..93afa978862 100644 --- a/app/assets/javascripts/diffs/components/no_changes.vue +++ b/app/assets/javascripts/diffs/components/no_changes.vue @@ -1,12 +1,12 @@ <script> import { mapGetters } from 'vuex'; import { escape } from 'lodash'; -import { GlDeprecatedButton } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; export default { components: { - GlDeprecatedButton, + GlButton, }, props: { changesEmptyStateIllustration: { @@ -43,9 +43,9 @@ export default { <div class="text-content text-center"> <span v-html="emptyStateText"></span> <div class="text-center"> - <gl-deprecated-button :href="getNoteableData.new_blob_path" variant="success">{{ + <gl-button :href="getNoteableData.new_blob_path" variant="success" category="primary">{{ __('Create commit') - }}</gl-deprecated-button> + }}</gl-button> </div> </div> </div> diff --git a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue index 83d803f42b1..ccb32a2a745 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue @@ -31,10 +31,6 @@ export default { type: String, required: true, }, - contextLinesPath: { - type: String, - required: true, - }, line: { type: Object, required: true, @@ -44,6 +40,11 @@ export default { required: false, default: false, }, + isCommented: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -55,6 +56,8 @@ export default { ...mapGetters('diffs', ['fileLineCoverage']), ...mapState({ isHighlighted(state) { + if (this.isCommented) return true; + const lineCode = (this.line.left && this.line.left.line_code) || (this.line.right && this.line.right.line_code); @@ -144,7 +147,6 @@ export default { <template v-if="line.left && !isMatchLineLeft"> <diff-table-cell :file-hash="fileHash" - :context-lines-path="contextLinesPath" :line="line.left" :line-type="oldLineType" :is-bottom="isBottom" @@ -172,7 +174,6 @@ export default { <template v-if="line.right && !isMatchLineRight"> <diff-table-cell :file-hash="fileHash" - :context-lines-path="contextLinesPath" :line="line.right" :line-type="newLineType" :is-bottom="isBottom" diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue index b5fcc50ce26..46a691ad22d 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_view.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue @@ -1,10 +1,11 @@ <script> -import { mapGetters } from 'vuex'; +import { mapGetters, mapState } from 'vuex'; import draftCommentsMixin from '~/diffs/mixins/draft_comments'; import ParallelDraftCommentRow from '~/batch_comments/components/parallel_draft_comment_row.vue'; import parallelDiffTableRow from './parallel_diff_table_row.vue'; import parallelDiffCommentRow from './parallel_diff_comment_row.vue'; import parallelDiffExpansionRow from './parallel_diff_expansion_row.vue'; +import { getCommentedLines } from '~/notes/components/multiline_comment_utils'; export default { components: { @@ -31,9 +32,19 @@ export default { }, computed: { ...mapGetters('diffs', ['commitId']), + ...mapState({ + selectedCommentPosition: ({ notes }) => notes.selectedCommentPosition, + selectedCommentPositionHover: ({ notes }) => notes.selectedCommentPositionHover, + }), diffLinesLength() { return this.diffLines.length; }, + commentedLines() { + return getCommentedLines( + this.selectedCommentPosition || this.selectedCommentPositionHover, + this.diffLines, + ); + }, }, userColorScheme: window.gon.user_color_scheme, }; @@ -67,9 +78,9 @@ export default { :key="line.line_code" :file-hash="diffFile.file_hash" :file-path="diffFile.file_path" - :context-lines-path="diffFile.context_lines_path" :line="line" :is-bottom="index + 1 === diffLinesLength" + :is-commented="index >= commentedLines.startLine && index <= commentedLines.endLine" /> <parallel-diff-comment-row :key="`dcr-${line.line_code || index}`" diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue index 52611f3c82a..38fbd8e61d4 100644 --- a/app/assets/javascripts/diffs/components/tree_list.vue +++ b/app/assets/javascripts/diffs/components/tree_list.vue @@ -26,7 +26,7 @@ export default { }; }, computed: { - ...mapState('diffs', ['tree', 'renderTreeList']), + ...mapState('diffs', ['tree', 'renderTreeList', 'currentDiffFileId']), ...mapGetters('diffs', ['allBlobs']), filteredTreeList() { const search = this.search.toLowerCase().trim(); @@ -96,6 +96,7 @@ export default { :level="0" :hide-file-stats="hideFileStats" :file-row-component="$options.DiffFileRow" + :current-diff-file-id="currentDiffFileId" @toggleTreeOpen="toggleTreeOpen" @clickFile="scrollToFile" /> diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js index 9269dacd582..e3dd882f3dc 100644 --- a/app/assets/javascripts/diffs/constants.js +++ b/app/assets/javascripts/diffs/constants.js @@ -1,3 +1,7 @@ +// The backend actually uses "hide_whitespace" while the frontend +// uses "show whitspace" so these values are opposite what you might expect +export const NO_SHOW_WHITESPACE = '1'; +export const SHOW_WHITESPACE = '0'; export const INLINE_DIFF_VIEW_TYPE = 'inline'; export const PARALLEL_DIFF_VIEW_TYPE = 'parallel'; export const MATCH_LINE_TYPE = 'match'; @@ -20,6 +24,7 @@ export const LINE_SIDE_LEFT = 'left-side'; export const LINE_SIDE_RIGHT = 'right-side'; export const DIFF_VIEW_COOKIE_NAME = 'diff_view'; +export const DIFF_WHITESPACE_COOKIE_NAME = 'diff_whitespace'; export const LINE_HOVER_CLASS_NAME = 'is-over'; export const LINE_UNFOLD_CLASS_NAME = 'unfold js-unfold'; export const CONTEXT_LINE_CLASS_NAME = 'diff-expanded'; @@ -35,7 +40,6 @@ export const MR_TREE_SHOW_KEY = 'mr_tree_show'; export const TREE_TYPE = 'tree'; export const TREE_LIST_STORAGE_KEY = 'mr_diff_tree_list'; -export const WHITESPACE_STORAGE_KEY = 'mr_show_whitespace'; export const TREE_LIST_WIDTH_STORAGE_KEY = 'mr_tree_list_width'; export const INITIAL_TREE_WIDTH = 320; diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index ce48e36bfd7..76ff67ab861 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -1,11 +1,11 @@ import Vue from 'vue'; import { mapActions, mapState, mapGetters } from 'vuex'; import { parseBoolean } from '~/lib/utils/common_utils'; -import { getParameterValues } from '~/lib/utils/url_utility'; import FindFile from '~/vue_shared/components/file_finder/index.vue'; import eventHub from '../notes/event_hub'; import diffsApp from './components/app.vue'; -import { TREE_LIST_STORAGE_KEY } from './constants'; +import { TREE_LIST_STORAGE_KEY, DIFF_WHITESPACE_COOKIE_NAME } from './constants'; +import Cookies from 'js-cookie'; export default function initDiffsApp(store) { const fileFinderEl = document.getElementById('js-diff-file-finder'); @@ -78,6 +78,7 @@ export default function initDiffsApp(store) { dismissEndpoint: dataset.dismissEndpoint, showSuggestPopover: parseBoolean(dataset.showSuggestPopover), showWhitespaceDefault: parseBoolean(dataset.showWhitespaceDefault), + viewDiffsFileByFile: parseBoolean(dataset.fileByFileDefault), }; }, computed: { @@ -86,15 +87,16 @@ export default function initDiffsApp(store) { }), }, created() { - let hideWhitespace = getParameterValues('w')[0]; const treeListStored = localStorage.getItem(TREE_LIST_STORAGE_KEY); const renderTreeList = treeListStored !== null ? parseBoolean(treeListStored) : true; this.setRenderTreeList(renderTreeList); - if (!hideWhitespace) { - hideWhitespace = this.showWhitespaceDefault ? '0' : '1'; + + // Set whitespace default as per user preferences unless cookie is already set + if (!Cookies.get(DIFF_WHITESPACE_COOKIE_NAME)) { + const hideWhitespace = this.showWhitespaceDefault ? '0' : '1'; + this.setShowWhitespace({ showWhitespace: hideWhitespace !== '1' }); } - this.setShowWhitespace({ showWhitespace: hideWhitespace !== '1' }); }, methods: { ...mapActions('diffs', ['setRenderTreeList', 'setShowWhitespace']), @@ -114,7 +116,7 @@ export default function initDiffsApp(store) { isFluidLayout: this.isFluidLayout, dismissEndpoint: this.dismissEndpoint, showSuggestPopover: this.showSuggestPopover, - showWhitespaceDefault: this.showWhitespaceDefault, + viewDiffsFileByFile: this.viewDiffsFileByFile, }, }); }, diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index a8d348e1836..d469ed8ee82 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -25,7 +25,6 @@ import { DIFF_VIEW_COOKIE_NAME, MR_TREE_SHOW_KEY, TREE_LIST_STORAGE_KEY, - WHITESPACE_STORAGE_KEY, TREE_LIST_WIDTH_STORAGE_KEY, OLD_LINE_KEY, NEW_LINE_KEY, @@ -38,6 +37,9 @@ import { INLINE_DIFF_LINES_KEY, PARALLEL_DIFF_LINES_KEY, DIFFS_PER_PAGE, + DIFF_WHITESPACE_COOKIE_NAME, + SHOW_WHITESPACE, + NO_SHOW_WHITESPACE, } from '../constants'; import { diffViewerModes } from '~/ide/constants'; @@ -103,7 +105,9 @@ export const fetchDiffFiles = ({ state, commit }) => { .catch(() => worker.terminate()); }; -export const fetchDiffFilesBatch = ({ commit, state }) => { +export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { + const id = window?.location?.hash; + const isNoteLink = id.indexOf('#note') === 0; const urlParams = { per_page: DIFFS_PER_PAGE, w: state.showWhitespace ? '0' : '1', @@ -123,16 +127,36 @@ export const fetchDiffFilesBatch = ({ commit, state }) => { commit(types.SET_DIFF_DATA_BATCH, { diff_files }); commit(types.SET_BATCH_LOADING, false); + if (!isNoteLink && !state.currentDiffFileId) { + commit(types.UPDATE_CURRENT_DIFF_FILE_ID, diff_files[0].file_hash); + } + + if (isNoteLink) { + dispatch('setCurrentDiffFileIdFromNote', id.split('_').pop()); + } + if (!pagination.next_page) { commit(types.SET_RETRIEVING_BATCHES, false); + + // We need to check that the currentDiffFileId points to a file that exists + if ( + state.currentDiffFileId && + !state.diffFiles.some(f => f.file_hash === state.currentDiffFileId) && + !isNoteLink + ) { + commit(types.UPDATE_CURRENT_DIFF_FILE_ID, state.diffFiles[0].file_hash); + } + if (gon.features?.codeNavigation) { // eslint-disable-next-line promise/catch-or-return,promise/no-nesting import('~/code_navigation').then(m => m.default({ - blobs: state.diffFiles.map(f => ({ - path: f.new_path, - codeNavigationPath: f.code_navigation_path, - })), + blobs: state.diffFiles + .filter(f => f.code_navigation_path) + .map(f => ({ + path: f.new_path, + codeNavigationPath: f.code_navigation_path, + })), definitionPathPrefix: state.definitionPathPrefix, }), ); @@ -211,9 +235,11 @@ export const setHighlightedRow = ({ commit }, lineCode) => { // This is adding line discussions to the actual lines in the diff tree // once for parallel and once for inline mode export const assignDiscussionsToDiff = ( - { commit, state, rootState }, + { commit, state, rootState, dispatch }, discussions = rootState.notes.discussions, ) => { + const id = window?.location?.hash; + const isNoteLink = id.indexOf('#note') === 0; const diffPositionByLineCode = getDiffPositionByLineCode( state.diffFiles, state.useSingleDiffStyle, @@ -230,6 +256,10 @@ export const assignDiscussionsToDiff = ( }); }); + if (isNoteLink) { + dispatch('setCurrentDiffFileIdFromNote', id.split('_').pop()); + } + Vue.nextTick(() => { eventHub.$emit('scrollToDiscussion'); }); @@ -448,6 +478,8 @@ export const toggleTreeOpen = ({ commit }, path) => { }; export const scrollToFile = ({ state, commit }, path) => { + if (!state.treeEntries[path]) return; + const { fileHash } = state.treeEntries[path]; document.location.hash = fileHash; @@ -484,11 +516,12 @@ export const setRenderTreeList = ({ commit }, renderTreeList) => { export const setShowWhitespace = ({ commit }, { showWhitespace, pushState = false }) => { commit(types.SET_SHOW_WHITESPACE, showWhitespace); + const w = showWhitespace ? SHOW_WHITESPACE : NO_SHOW_WHITESPACE; - localStorage.setItem(WHITESPACE_STORAGE_KEY, showWhitespace); + Cookies.set(DIFF_WHITESPACE_COOKIE_NAME, w); if (pushState) { - historyPushState(mergeUrlParams({ w: showWhitespace ? '0' : '1' }, window.location.href)); + historyPushState(mergeUrlParams({ w }, window.location.href)); } eventHub.$emit('refetchDiffData'); @@ -710,5 +743,22 @@ export function moveToNeighboringCommit({ dispatch, state }, { direction }) { } } +export const setCurrentDiffFileIdFromNote = ({ commit, rootGetters }, noteId) => { + const note = rootGetters.notesById[noteId]; + + if (!note) return; + + const fileHash = rootGetters.getDiscussion(note.discussion_id).diff_file.file_hash; + + commit(types.UPDATE_CURRENT_DIFF_FILE_ID, fileHash); +}; + +export const navigateToDiffFileIndex = ({ commit, state }, index) => { + const fileHash = state.diffFiles[index].file_hash; + document.location.hash = fileHash; + + commit(types.UPDATE_CURRENT_DIFF_FILE_ID, fileHash); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js index 87938ababed..1f165dd4971 100644 --- a/app/assets/javascripts/diffs/store/modules/diff_state.js +++ b/app/assets/javascripts/diffs/store/modules/diff_state.js @@ -1,10 +1,17 @@ import Cookies from 'js-cookie'; import { getParameterValues } from '~/lib/utils/url_utility'; -import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME } from '../../constants'; +import { + INLINE_DIFF_VIEW_TYPE, + DIFF_VIEW_COOKIE_NAME, + DIFF_WHITESPACE_COOKIE_NAME, +} from '../../constants'; +import { getDefaultWhitespace } from '../utils'; const viewTypeFromQueryString = getParameterValues('view')[0]; const viewTypeFromCookie = Cookies.get(DIFF_VIEW_COOKIE_NAME); const defaultViewType = INLINE_DIFF_VIEW_TYPE; +const whiteSpaceFromQueryString = getParameterValues('w')[0]; +const whiteSpaceFromCookie = Cookies.get(DIFF_WHITESPACE_COOKIE_NAME); export default () => ({ isLoading: true, @@ -29,7 +36,7 @@ export default () => ({ commentForms: [], highlightedRow: null, renderTreeList: true, - showWhitespace: true, + showWhitespace: getDefaultWhitespace(whiteSpaceFromQueryString, whiteSpaceFromCookie), fileFinderVisible: false, dismissEndpoint: '', showSuggestPopover: true, diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index d261be1b550..bc85dd0a1d4 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -15,6 +15,8 @@ import { TREE_TYPE, INLINE_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE, + SHOW_WHITESPACE, + NO_SHOW_WHITESPACE, } from '../constants'; export function findDiffFile(files, match, matchKey = 'file_hash') { @@ -701,3 +703,10 @@ export const allDiscussionWrappersExpanded = diff => { return discussionsExpanded; }; + +export const getDefaultWhitespace = (queryString, cookie) => { + // Querystring should override stored cookie value + if (queryString) return queryString === SHOW_WHITESPACE; + if (cookie === NO_SHOW_WHITESPACE) return false; + return true; +}; diff --git a/app/assets/javascripts/editor/editor_lite.js b/app/assets/javascripts/editor/editor_lite.js index 020ed6dc867..551ffbabaef 100644 --- a/app/assets/javascripts/editor/editor_lite.js +++ b/app/assets/javascripts/editor/editor_lite.js @@ -1,4 +1,4 @@ -import { editor as monacoEditor, languages as monacoLanguages, Uri } from 'monaco-editor'; +import { editor as monacoEditor, languages as monacoLanguages, Position, Uri } from 'monaco-editor'; import { DEFAULT_THEME, themes } from '~/ide/lib/themes'; import languages from '~/ide/lib/languages'; import { defaultEditorOptions } from '~/ide/lib/editor_options'; @@ -70,6 +70,27 @@ export default class Editor { } getValue() { - return this.model.getValue(); + return this.instance.getValue(); + } + + setValue(val) { + this.instance.setValue(val); + } + + focus() { + this.instance.focus(); + } + + navigateFileStart() { + this.instance.setPosition(new Position(1, 1)); + } + + updateOptions(options = {}) { + this.instance.updateOptions(options); + } + + use(exts = []) { + const extensions = Array.isArray(exts) ? exts : [exts]; + Object.assign(this, ...extensions); } } diff --git a/app/assets/javascripts/editor/editor_markdown_ext.js b/app/assets/javascripts/editor/editor_markdown_ext.js new file mode 100644 index 00000000000..9d09663e643 --- /dev/null +++ b/app/assets/javascripts/editor/editor_markdown_ext.js @@ -0,0 +1,99 @@ +export default { + getSelectedText(selection = this.getSelection()) { + const { startLineNumber, endLineNumber, startColumn, endColumn } = selection; + const valArray = this.instance.getValue().split('\n'); + let text = ''; + if (startLineNumber === endLineNumber) { + text = valArray[startLineNumber - 1].slice(startColumn - 1, endColumn - 1); + } else { + const startLineText = valArray[startLineNumber - 1].slice(startColumn - 1); + const endLineText = valArray[endLineNumber - 1].slice(0, endColumn - 1); + + for (let i = startLineNumber, k = endLineNumber - 1; i < k; i += 1) { + text += `${valArray[i]}`; + if (i !== k - 1) text += `\n`; + } + text = text + ? [startLineText, text, endLineText].join('\n') + : [startLineText, endLineText].join('\n'); + } + return text; + }, + + getSelection() { + return this.instance.getSelection(); + }, + + replaceSelectedText(text, select = undefined) { + const forceMoveMarkers = !select; + this.instance.executeEdits('', [{ range: this.getSelection(), text, forceMoveMarkers }]); + }, + + moveCursor(dx = 0, dy = 0) { + const pos = this.instance.getPosition(); + pos.column += dx; + pos.lineNumber += dy; + this.instance.setPosition(pos); + }, + + /** + * Adjust existing selection to select text within the original selection. + * - If `selectedText` is not supplied, we fetch selected text with + * + * ALGORITHM: + * + * MULTI-LINE SELECTION + * 1. Find line that contains `toSelect` text. + * 2. Using the index of this line and the position of `toSelect` text in it, + * construct: + * * newStartLineNumber + * * newStartColumn + * + * SINGLE-LINE SELECTION + * 1. Use `startLineNumber` from the current selection as `newStartLineNumber` + * 2. Find the position of `toSelect` text in it to get `newStartColumn` + * + * 3. `newEndLineNumber` — Since this method is supposed to be used with + * markdown decorators that are pretty short, the `newEndLineNumber` is + * suggested to be assumed the same as the startLine. + * 4. `newEndColumn` — pretty obvious + * 5. Adjust the start and end positions of the current selection + * 6. Re-set selection on the instance + * + * @param {string} toSelect - New text to select within current selection. + * @param {string} selectedText - Currently selected text. It's just a + * shortcut: If it's not supplied, we fetch selected text from the instance + */ + selectWithinSelection(toSelect, selectedText) { + const currentSelection = this.getSelection(); + if (currentSelection.isEmpty() || !toSelect) { + return; + } + const text = selectedText || this.getSelectedText(currentSelection); + let lineShift; + let newStartLineNumber; + let newStartColumn; + + const textLines = text.split('\n'); + + if (textLines.length > 1) { + // Multi-line selection + lineShift = textLines.findIndex(line => line.indexOf(toSelect) !== -1); + newStartLineNumber = currentSelection.startLineNumber + lineShift; + newStartColumn = textLines[lineShift].indexOf(toSelect) + 1; + } else { + // Single-line selection + newStartLineNumber = currentSelection.startLineNumber; + newStartColumn = currentSelection.startColumn + text.indexOf(toSelect); + } + + const newEndLineNumber = newStartLineNumber; + const newEndColumn = newStartColumn + toSelect.length; + + const newSelection = currentSelection + .setStartPosition(newStartLineNumber, newStartColumn) + .setEndPosition(newEndLineNumber, newEndColumn); + + this.instance.setSelection(newSelection); + }, +}; diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js index 27dff8cf9aa..4567c807c40 100644 --- a/app/assets/javascripts/emoji/index.js +++ b/app/assets/javascripts/emoji/index.js @@ -1,13 +1,63 @@ import { uniq } from 'lodash'; -import emojiMap from 'emojis/digests.json'; import emojiAliases from 'emojis/aliases.json'; +import axios from '../lib/utils/axios_utils'; -export const validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)]; +import AccessorUtilities from '../lib/utils/accessor'; + +let emojiMap = null; +let emojiPromise = null; +let validEmojiNames = null; + +export const EMOJI_VERSION = '1'; + +const isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); + +export function initEmojiMap() { + emojiPromise = + emojiPromise || + new Promise((resolve, reject) => { + if (emojiMap) { + resolve(emojiMap); + } else if ( + isLocalStorageAvailable && + window.localStorage.getItem('gl-emoji-map-version') === EMOJI_VERSION && + window.localStorage.getItem('gl-emoji-map') + ) { + emojiMap = JSON.parse(window.localStorage.getItem('gl-emoji-map')); + validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)]; + resolve(emojiMap); + } else { + // We load the JSON file direct from the server + // because it can't be loaded from a CDN due to + // cross domain problems with JSON + axios + .get(`${gon.relative_url_root || ''}/-/emojis/${EMOJI_VERSION}/emojis.json`) + .then(({ data }) => { + emojiMap = data; + validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)]; + resolve(emojiMap); + if (isLocalStorageAvailable) { + window.localStorage.setItem('gl-emoji-map-version', EMOJI_VERSION); + window.localStorage.setItem('gl-emoji-map', JSON.stringify(emojiMap)); + } + }) + .catch(err => { + reject(err); + }); + } + }); + + return emojiPromise; +} export function normalizeEmojiName(name) { return Object.prototype.hasOwnProperty.call(emojiAliases, name) ? emojiAliases[name] : name; } +export function getValidEmojiNames() { + return validEmojiNames; +} + export function isEmojiNameValid(name) { return validEmojiNames.indexOf(name) >= 0; } @@ -36,8 +86,8 @@ export function getEmojiCategoryMap() { }; Object.keys(emojiMap).forEach(name => { const emoji = emojiMap[name]; - if (emojiCategoryMap[emoji.category]) { - emojiCategoryMap[emoji.category].push(name); + if (emojiCategoryMap[emoji.c]) { + emojiCategoryMap[emoji.c].push(name); } }); } @@ -58,8 +108,9 @@ export function getEmojiInfo(query) { } export function emojiFallbackImageSrc(inputName) { - const { name, digest } = getEmojiInfo(inputName); - return `${gon.asset_host || ''}${gon.relative_url_root || ''}/assets/emoji/${name}-${digest}.png`; + const { name } = getEmojiInfo(inputName); + return `${gon.asset_host || ''}${gon.relative_url_root || + ''}/-/emojis/${EMOJI_VERSION}/${name}.png`; } export function emojiImageTag(name, src) { @@ -67,36 +118,17 @@ export function emojiImageTag(name, src) { } export function glEmojiTag(inputName, options) { - const opts = { sprite: false, forceFallback: false, ...options }; - const { name, ...emojiInfo } = getEmojiInfo(inputName); - - const fallbackImageSrc = emojiFallbackImageSrc(name); + const opts = { sprite: false, ...options }; + const name = normalizeEmojiName(inputName); const fallbackSpriteClass = `emoji-${name}`; - const classList = []; - if (opts.forceFallback && opts.sprite) { - classList.push('emoji-icon'); - classList.push(fallbackSpriteClass); - } - const classAttribute = classList.length > 0 ? `class="${classList.join(' ')}"` : ''; const fallbackSpriteAttribute = opts.sprite ? `data-fallback-sprite-class="${fallbackSpriteClass}"` : ''; - let contents = emojiInfo.moji; - if (opts.forceFallback && !opts.sprite) { - contents = emojiImageTag(name, fallbackImageSrc); - } return ` <gl-emoji - ${classAttribute} - data-name="${name}" - data-fallback-src="${fallbackImageSrc}" ${fallbackSpriteAttribute} - data-unicode-version="${emojiInfo.unicodeVersion}" - title="${emojiInfo.description}" - > - ${contents} - </gl-emoji> + data-name="${name}"></gl-emoji> `; } diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue index 899d7ec8521..4c6d233c4d2 100644 --- a/app/assets/javascripts/environments/components/container.vue +++ b/app/assets/javascripts/environments/components/container.vue @@ -67,12 +67,7 @@ export default { <template> <div class="environments-container"> - <gl-loading-icon - v-if="isLoading" - size="md" - class="prepend-top-default" - label="Loading environments" - /> + <gl-loading-icon v-if="isLoading" size="md" class="gl-mt-3" label="Loading environments" /> <slot name="emptyState"></slot> diff --git a/app/assets/javascripts/environments/components/empty_state.vue b/app/assets/javascripts/environments/components/empty_state.vue index ca2ac4c3c53..977da12e8a9 100644 --- a/app/assets/javascripts/environments/components/empty_state.vue +++ b/app/assets/javascripts/environments/components/empty_state.vue @@ -2,14 +2,6 @@ export default { name: 'EnvironmentsEmptyState', props: { - newPath: { - type: String, - required: true, - }, - canCreateEnvironment: { - type: Boolean, - required: true, - }, helpPath: { type: String, required: true, @@ -28,18 +20,8 @@ export default { s__(`Environments|Environments are places where code gets deployed, such as staging or production.`) }} - <a :href="helpPath"> {{ s__('Environments|Read more about environments') }} </a> + <a :href="helpPath"> {{ s__('Environments|More information') }} </a> </p> - - <div class="text-center"> - <a - v-if="canCreateEnvironment" - :href="newPath" - class="btn btn-success js-new-environment-button" - > - {{ s__('Environments|New environment') }} - </a> - </div> </div> </div> </template> diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index b8bcca814cd..d26bd14a937 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -159,11 +159,7 @@ export default { @onChangePage="onChangePage" > <template v-if="!isLoading && state.environments.length === 0" #emptyState> - <empty-state - :new-path="newEnvironmentPath" - :help-path="helpPagePath" - :can-create-environment="canCreateEnvironment" - /> + <empty-state :help-path="helpPagePath" /> </template> </container> </div> diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index 380e16c7b71..ab1818e61fa 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -188,7 +188,7 @@ export default { <template v-if="shouldRenderFolderContent(model)"> <div v-if="model.isLoadingFolderContent" :key="`loading-item-${i}`"> - <gl-loading-icon size="md" class="prepend-top-16" /> + <gl-loading-icon size="md" class="gl-mt-5" /> </div> <template v-else> diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js index 73dc8c02485..f2b464464e9 100644 --- a/app/assets/javascripts/environments/mixins/environments_mixin.js +++ b/app/assets/javascripts/environments/mixins/environments_mixin.js @@ -145,7 +145,7 @@ export default { deleteEnvironment(environment) { const endpoint = environment.delete_path; - const mountedToShow = environment.mounted_to_show; + const { onSingleEnvironmentPage } = environment; const errorMessage = s__( 'Environments|An error occurred while deleting the environment. Check if the environment stopped; if not, stop it and try again.', ); @@ -153,7 +153,7 @@ export default { this.service .deleteAction(endpoint) .then(() => { - if (!mountedToShow) { + if (!onSingleEnvironmentPage) { // Reload as a first solution to bust the ETag cache window.location.reload(); return; diff --git a/app/assets/javascripts/environments/mount_show.js b/app/assets/javascripts/environments/mount_show.js index 1929ed080a1..d0b68b0c14f 100644 --- a/app/assets/javascripts/environments/mount_show.js +++ b/app/assets/javascripts/environments/mount_show.js @@ -15,7 +15,7 @@ export default () => { data() { const environment = JSON.parse(JSON.stringify(container.dataset)); environment.delete_path = environment.deletePath; - environment.mounted_to_show = true; + environment.onSingleEnvironmentPage = true; return { environment, diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue index 1e8f5a26125..52444d2c493 100644 --- a/app/assets/javascripts/error_tracking/components/error_details.vue +++ b/app/assets/javascripts/error_tracking/components/error_details.vue @@ -2,7 +2,7 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import createFlash from '~/flash'; import { - GlDeprecatedButton, + GlButton, GlFormInput, GlLink, GlLoadingIcon, @@ -33,7 +33,7 @@ const SENTRY_TIMEOUT = 10000; export default { components: { - GlDeprecatedButton, + GlButton, GlFormInput, GlLink, GlLoadingIcon, @@ -106,6 +106,7 @@ export default { errorPollTimeout: 0, issueCreationInProgress: false, isAlertVisible: false, + isStacktraceEmptyAlertVisible: true, closedIssueId: null, }; }, @@ -119,10 +120,10 @@ export default { ]), ...mapGetters('details', ['stacktrace']), firstReleaseLink() { - return `${this.error.externalBaseUrl}/releases/${this.error.firstReleaseShortVersion}`; + return `${this.error.externalBaseUrl}/releases/${this.error.firstReleaseVersion}`; }, lastReleaseLink() { - return `${this.error.externalBaseUrl}/releases/${this.error.lastReleaseShortVersion}`; + return `${this.error.externalBaseUrl}/releases/${this.error.lastReleaseVersion}`; }, showStacktrace() { return Boolean(this.stacktrace?.length); @@ -167,6 +168,9 @@ export default { resolveBtnLabel() { return this.errorStatus !== errorStatus.RESOLVED ? __('Resolve') : __('Unresolve'); }, + showEmptyStacktraceAlert() { + return !this.loadingStacktrace && !this.showStacktrace && this.isStacktraceEmptyAlertVisible; + }, }, watch: { error(val) { @@ -254,6 +258,10 @@ export default { </gl-sprintf> </gl-alert> + <gl-alert v-if="showEmptyStacktraceAlert" @dismiss="isStacktraceEmptyAlertVisible = false"> + {{ __('No stack trace for this error') }} + </gl-alert> + <div class="error-details-header d-flex py-2 justify-content-between"> <div v-if="!loadingStacktrace && stacktrace" @@ -271,22 +279,24 @@ export default { </div> <div class="error-details-actions"> <div class="d-inline-flex bv-d-sm-down-none"> - <gl-deprecated-button + <gl-button :loading="updatingIgnoreStatus" data-testid="update-ignore-status-btn" @click="onIgnoreStatusUpdate" > {{ ignoreBtnLabel }} - </gl-deprecated-button> - <gl-deprecated-button - class="btn-outline-info ml-2" + </gl-button> + <gl-button + class="ml-2" + category="secondary" + variant="info" :loading="updatingResolveStatus" data-testid="update-resolve-status-btn" @click="onResolveStatusUpdate" > {{ resolveBtnLabel }} - </gl-deprecated-button> - <gl-deprecated-button + </gl-button> + <gl-button v-if="error.gitlabIssuePath" class="ml-2" data-testid="view_issue_button" @@ -294,7 +304,7 @@ export default { variant="success" > {{ __('View issue') }} - </gl-deprecated-button> + </gl-button> <form ref="sentryIssueForm" :action="projectIssuesPath" @@ -309,15 +319,16 @@ export default { name="issue[sentry_issue_attributes][sentry_issue_identifier]" /> <gl-form-input :value="csrfToken" class="hidden" name="authenticity_token" /> - <gl-deprecated-button + <gl-button v-if="!error.gitlabIssuePath" - class="btn-success" + category="primary" + variant="success" :loading="issueCreationInProgress" data-qa-selector="create_issue_button" @click="createIssue" > {{ __('Create issue') }} - </gl-deprecated-button> + </gl-button> </form> </div> <gl-dropdown @@ -389,18 +400,18 @@ export default { <icon name="external-link" class="ml-1 flex-shrink-0" /> </gl-link> </li> - <li v-if="error.firstReleaseShortVersion"> + <li v-if="error.firstReleaseVersion"> <strong class="bold">{{ __('First seen') }}:</strong> <time-ago-tooltip :time="error.firstSeen" /> <gl-link :href="firstReleaseLink" target="_blank"> - <span>{{ __('Release') }}: {{ error.firstReleaseShortVersion.substr(0, 10) }}</span> + <span>{{ __('Release') }}: {{ error.firstReleaseVersion }}</span> </gl-link> </li> - <li v-if="error.lastReleaseShortVersion"> + <li v-if="error.lastReleaseVersion"> <strong class="bold">{{ __('Last seen') }}:</strong> <time-ago-tooltip :time="error.lastSeen" /> <gl-link :href="lastReleaseLink" target="_blank"> - <span>{{ __('Release') }}: {{ error.lastReleaseShortVersion.substr(0, 10) }}</span> + <span>{{ __('Release') }}: {{ error.lastReleaseVersion }}</span> </gl-link> </li> <li> diff --git a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue index d806c6934a3..c22f34b5a8d 100644 --- a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue +++ b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue @@ -80,14 +80,9 @@ export default { <div ref="header" class="file-title file-title-flex-parent"> <div class="file-header-content d-flex align-content-center"> <div v-if="hasCode" class="d-inline-block cursor-pointer" @click="toggle()"> - <icon :name="collapseIcon" :size="16" aria-hidden="true" class="append-right-5" /> + <icon :name="collapseIcon" :size="16" aria-hidden="true" class="gl-mr-2" /> </div> - <file-icon - :file-name="filePath" - :size="18" - aria-hidden="true" - css-classes="append-right-5" - /> + <file-icon :file-name="filePath" :size="18" aria-hidden="true" css-classes="gl-mr-2" /> <strong v-gl-tooltip :title="filePath" diff --git a/app/assets/javascripts/error_tracking/queries/details.query.graphql b/app/assets/javascripts/error_tracking/queries/details.query.graphql index fa579c94257..593cbf2ae52 100644 --- a/app/assets/javascripts/error_tracking/queries/details.query.graphql +++ b/app/assets/javascripts/error_tracking/queries/details.query.graphql @@ -1,29 +1,29 @@ query errorDetails($fullPath: ID!, $errorId: ID!) { - project(fullPath: $fullPath) { - sentryErrors { - detailedError(id: $errorId) { - id - sentryId - title - userCount - count - status - firstSeen - lastSeen - message - culprit - tags { - level - logger - } - externalUrl - externalBaseUrl - firstReleaseShortVersion - lastReleaseShortVersion - gitlabCommit - gitlabCommitPath - gitlabIssuePath - } + project(fullPath: $fullPath) { + sentryErrors { + detailedError(id: $errorId) { + id + sentryId + title + userCount + count + status + firstSeen + lastSeen + message + culprit + tags { + level + logger } + externalUrl + externalBaseUrl + firstReleaseVersion + lastReleaseVersion + gitlabCommit + gitlabCommitPath + gitlabIssuePath + } } + } } diff --git a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue index 0be42519092..0de67a8bcc7 100644 --- a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue +++ b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue @@ -59,14 +59,14 @@ export default { </div> <div class="col-4 col-md-3 gl-pl-0"> <loading-button - class="js-error-tracking-connect prepend-left-5 d-inline-flex" + class="js-error-tracking-connect gl-ml-2 d-inline-flex" :label="isLoadingProjects ? __('Connecting') : __('Connect')" :loading="isLoadingProjects" @click="fetchProjects" /> <icon v-show="connectSuccessful" - class="js-error-tracking-connect-success prepend-left-5 text-success align-middle" + class="js-error-tracking-connect-success gl-ml-2 text-success align-middle" :aria-label="__('Projects Successfully Retrieved')" name="check-circle" /> diff --git a/app/assets/javascripts/filtered_search/constants.js b/app/assets/javascripts/filtered_search/constants.js index 7e7a2588951..0b9fe969da1 100644 --- a/app/assets/javascripts/filtered_search/constants.js +++ b/app/assets/javascripts/filtered_search/constants.js @@ -9,3 +9,5 @@ export const FILTER_TYPE = { none: 'none', any: 'any', }; + +export const MAX_HISTORY_SIZE = 5; diff --git a/app/assets/javascripts/filtered_search/dropdown_emoji.js b/app/assets/javascripts/filtered_search/dropdown_emoji.js index dad188f6f98..adeea0ed5f6 100644 --- a/app/assets/javascripts/filtered_search/dropdown_emoji.js +++ b/app/assets/javascripts/filtered_search/dropdown_emoji.js @@ -10,7 +10,7 @@ export default class DropdownEmoji extends FilteredSearchDropdown { super(options); this.config = { Ajax: { - endpoint: `${gon.relative_url_root || ''}/autocomplete/award_emojis`, + endpoint: `${gon.relative_url_root || ''}/-/autocomplete/award_emojis`, method: 'setData', loadingTemplate: this.loadingTemplate, onError() { diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js b/app/assets/javascripts/filtered_search/dropdown_user.js index a65c0012b4d..0fb1828fc98 100644 --- a/app/assets/javascripts/filtered_search/dropdown_user.js +++ b/app/assets/javascripts/filtered_search/dropdown_user.js @@ -5,7 +5,7 @@ export default class DropdownUser extends DropdownAjaxFilter { constructor(options = {}) { super({ ...options, - endpoint: '/autocomplete/users.json', + endpoint: '/-/autocomplete/users.json', symbol: '@', }); } diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 55a0e91b0f3..108cc8d3a78 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -32,6 +32,7 @@ export default class FilteredSearchManager { filteredSearchTokenKeys = IssuableFilteredSearchTokenKeys, stateFiltersSelector = '.issues-state-filters', placeholder = __('Search or filter results...'), + anchor = null, }) { this.isGroup = isGroup; this.isGroupAncestor = isGroupAncestor; @@ -47,6 +48,7 @@ export default class FilteredSearchManager { this.filteredSearchTokenKeys = filteredSearchTokenKeys; this.stateFiltersSelector = stateFiltersSelector; this.placeholder = placeholder; + this.anchor = anchor; const { multipleAssignees } = this.filteredSearchInput.dataset; if (multipleAssignees && this.filteredSearchTokenKeys.enableMultipleAssignees) { @@ -779,7 +781,11 @@ export default class FilteredSearchManager { paths.push(`search=${sanitized}`); } - const parameterizedUrl = `?scope=all&utf8=%E2%9C%93&${paths.join('&')}`; + let parameterizedUrl = `?scope=all&utf8=%E2%9C%93&${paths.join('&')}`; + + if (this.anchor) { + parameterizedUrl += `#${this.anchor}`; + } if (this.updateObject) { this.updateObject(parameterizedUrl); diff --git a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js index 963e8fe5df5..be0fb5cac13 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js +++ b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js @@ -6,7 +6,7 @@ export default class FilteredSearchTokenizer { // Values that start with a double quote must end in a double quote (same for single) const tokenRegex = new RegExp( - `(${allowedKeys.join('|')}):(=|!=)?([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`, + `(${allowedKeys.join('|')}):(=|!=)?([~%@&]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`, 'g', ); const tokens = []; @@ -15,17 +15,19 @@ export default class FilteredSearchTokenizer { const searchToken = input .replace(tokenRegex, (match, key, operator, symbol, v1, v2, v3) => { + const prefixedTokens = ['~', '%', '@', '&']; + const comparisonTokens = ['!=', '=']; let tokenValue = v1 || v2 || v3; let tokenSymbol = symbol; let tokenIndex = ''; let tokenOperator = operator; - if (tokenValue === '~' || tokenValue === '%' || tokenValue === '@') { + if (prefixedTokens.includes(tokenValue)) { tokenSymbol = tokenValue; tokenValue = ''; } - if (tokenValue === '!=' || tokenValue === '=') { + if (comparisonTokens.includes(tokenValue)) { tokenOperator = tokenValue; tokenValue = ''; } diff --git a/app/assets/javascripts/filtered_search/stores/recent_searches_store.js b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js index cdbc9ec84bd..423f123f71c 100644 --- a/app/assets/javascripts/filtered_search/stores/recent_searches_store.js +++ b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js @@ -1,4 +1,6 @@ -import { uniq } from 'lodash'; +import { uniqWith, isEqual } from 'lodash'; + +import { MAX_HISTORY_SIZE } from '../constants'; class RecentSearchesStore { constructor(initialState = {}, allowedKeys) { @@ -17,8 +19,12 @@ class RecentSearchesStore { } setRecentSearches(searches = []) { - const trimmedSearches = searches.map(search => search.trim()); - this.state.recentSearches = uniq(trimmedSearches).slice(0, 5); + const trimmedSearches = searches.map(search => + typeof search === 'string' ? search.trim() : search, + ); + + // Do object equality check to remove duplicates. + this.state.recentSearches = uniqWith(trimmedSearches, isEqual).slice(0, MAX_HISTORY_SIZE); return this.state.recentSearches; } } diff --git a/app/assets/javascripts/filtered_search/visual_token_value.js b/app/assets/javascripts/filtered_search/visual_token_value.js index 02caf0851af..b1e6c4142e9 100644 --- a/app/assets/javascripts/filtered_search/visual_token_value.js +++ b/app/assets/javascripts/filtered_search/visual_token_value.js @@ -7,6 +7,7 @@ import DropdownUtils from '~/filtered_search/dropdown_utils'; import Flash from '~/flash'; import UsersCache from '~/lib/utils/users_cache'; import { __ } from '~/locale'; +import * as Emoji from '~/emoji'; export default class VisualTokenValue { constructor(tokenValue, tokenType, tokenOperator) { @@ -137,18 +138,13 @@ export default class VisualTokenValue { const element = tokenValueElement; const value = this.tokenValue; - return ( - import(/* webpackChunkName: 'emoji' */ '../emoji') - .then(Emoji => { - if (!Emoji.isEmojiNameValid(value)) { - return; - } + return Emoji.initEmojiMap().then(() => { + if (!Emoji.isEmojiNameValid(value)) { + return; + } - container.dataset.originalValue = value; - element.innerHTML = Emoji.glEmojiTag(value); - }) - // ignore error and leave emoji name in the search bar - .catch(() => {}) - ); + container.dataset.originalValue = value; + element.innerHTML = Emoji.glEmojiTag(value); + }); } } diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index f3ce30c942f..36c586ddfd2 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -5,6 +5,7 @@ import SidebarMediator from '~/sidebar/sidebar_mediator'; import glRegexp from './lib/utils/regexp'; import AjaxCache from './lib/utils/ajax_cache'; import { spriteIcon } from './lib/utils/common_utils'; +import * as Emoji from '~/emoji'; function sanitize(str) { return str.replace(/<(?:.|\n)*?>/gm, ''); @@ -29,7 +30,7 @@ export function membersBeforeSave(members) { const imgAvatar = `<img src="${member.avatar_url}" alt="${member.username}" class="avatar ${rectAvatarClass} avatar-inline center s26"/>`; const txtAvatar = `<div class="avatar ${rectAvatarClass} center avatar-inline s26">${autoCompleteAvatar}</div>`; const avatarIcon = member.mentionsDisabled - ? spriteIcon('notifications-off', 's16 vertical-align-middle prepend-left-5') + ? spriteIcon('notifications-off', 's16 vertical-align-middle gl-ml-2') : ''; return { @@ -88,7 +89,6 @@ class GfmAutoComplete { if (this.enableMap.labels) this.setupLabels($input); if (this.enableMap.snippets) this.setupSnippets($input); - // We don't instantiate the quick actions autocomplete for note and issue/MR edit forms $input.filter('[data-supports-quick-actions="true"]').atwho({ at: '/', alias: 'commands', @@ -109,8 +109,10 @@ class GfmAutoComplete { tpl += ' <small class="params"><%- params.join(" ") %></small>'; } if (value.warning && value.icon && value.icon === 'confidential') { - tpl += - '<small class="description"><em><i class="fa fa-eye-slash" aria-hidden="true"/><%- warning %></em></small>'; + tpl += `<small class="description gl-display-flex gl-align-items-center">${spriteIcon( + 'eye-slash', + 's16 gl-mr-2', + )}<em><%- warning %></em></small>`; } else if (value.warning) { tpl += '<small class="description"><em><%- warning %></em></small>'; } else if (value.description !== '') { @@ -587,14 +589,12 @@ class GfmAutoComplete { if (this.cachedData[at]) { this.loadData($input, at, this.cachedData[at]); } else if (GfmAutoComplete.atTypeMap[at] === 'emojis') { - import(/* webpackChunkName: 'emoji' */ './emoji') - .then(({ validEmojiNames, glEmojiTag }) => { - this.loadData($input, at, validEmojiNames); - GfmAutoComplete.glEmojiTag = glEmojiTag; + Emoji.initEmojiMap() + .then(() => { + this.loadData($input, at, Emoji.getValidEmojiNames()); + GfmAutoComplete.glEmojiTag = Emoji.glEmojiTag; }) - .catch(() => { - this.isLoadingData[at] = false; - }); + .catch(() => {}); } else if (dataSource) { AjaxCache.retrieve(dataSource, true) .then(data => { diff --git a/app/assets/javascripts/gl_field_error.js b/app/assets/javascripts/gl_field_error.js index 04301c9ce12..ac4c8d28ee4 100644 --- a/app/assets/javascripts/gl_field_error.js +++ b/app/assets/javascripts/gl_field_error.js @@ -114,7 +114,7 @@ export default class GlFieldError { this.state.empty = currentValue === ''; this.state.submitted = true; this.renderValidity(); - this.form.focusOnFirstInvalid.apply(this.form); + this.form.focusInvalid.apply(this.form); // For UX, wait til after first invalid submission to check each keyup this.inputElement diff --git a/app/assets/javascripts/gl_field_errors.js b/app/assets/javascripts/gl_field_errors.js index c4fd719c8d0..ad79483d5ec 100644 --- a/app/assets/javascripts/gl_field_errors.js +++ b/app/assets/javascripts/gl_field_errors.js @@ -52,10 +52,23 @@ export default class GlFieldErrors { }); } - focusOnFirstInvalid() { - const firstInvalid = this.state.inputs.filter( - input => !input.inputDomElement.validity.valid, - )[0]; - firstInvalid.inputElement.focus(); + get invalidInputs() { + return this.state.inputs.filter( + ({ + inputDomElement: { + validity: { valid }, + }, + }) => !valid, + ); + } + + get focusedInvalidInput() { + return this.invalidInputs.find(({ inputElement }) => inputElement.is(':focus')); + } + + focusInvalid() { + if (this.focusedInvalidInput) return; + + this.invalidInputs[0].inputElement.focus(); } } diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index 0b7735a7db9..0a1e5490237 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -3,19 +3,22 @@ import autosize from 'autosize'; import GfmAutoComplete, { defaultAutocompleteConfig } from 'ee_else_ce/gfm_auto_complete'; import dropzoneInput from './dropzone_input'; import { addMarkdownListeners, removeMarkdownListeners } from './lib/utils/text_markdown'; +import { disableButtonIfEmptyField } from '~/lib/utils/common_utils'; export default class GLForm { constructor(form, enableGFM = {}) { this.form = form; this.textarea = this.form.find('textarea.js-gfm-input'); this.enableGFM = { ...defaultAutocompleteConfig, ...enableGFM }; + // Disable autocomplete for keywords which do not have dataSources available const dataSources = (gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources) || {}; Object.keys(this.enableGFM).forEach(item => { - if (item !== 'emojis') { - this.enableGFM[item] = Boolean(dataSources[item]); + if (item !== 'emojis' && !dataSources[item]) { + this.enableGFM[item] = false; } }); + // Before we start, we should clean up any previous data for this form this.destroy(); // Set up the form @@ -43,7 +46,7 @@ export default class GLForm { this.form.find('.div-dropzone').remove(); this.form.addClass('gfm-form'); // remove notify commit author checkbox for non-commit notes - gl.utils.disableButtonIfEmptyField( + disableButtonIfEmptyField( this.form.find('.js-note-text'), this.form.find('.js-comment-button, .js-note-new-discussion'), ); @@ -104,4 +107,8 @@ export default class GLForm { .removeClass('is-focused'); }); } + + get supportsQuickActions() { + return Boolean(this.textarea.data('supports-quick-actions')); + } } diff --git a/app/assets/javascripts/graphql_shared/fragments/user.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/user.fragment.graphql new file mode 100644 index 00000000000..096bb77ee49 --- /dev/null +++ b/app/assets/javascripts/graphql_shared/fragments/user.fragment.graphql @@ -0,0 +1,7 @@ +fragment User on User { + id + avatarUrl + name + username + webUrl +} diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index 6b9748bb725..be90ba12678 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -104,7 +104,7 @@ export default { :class="{ 'project-row-contents': !isGroup }" class="group-row-contents d-flex align-items-center py-2 pr-3" > - <div class="folder-toggle-wrap append-right-4 d-flex align-items-center"> + <div class="folder-toggle-wrap gl-mr-2 d-flex align-items-center"> <item-caret :is-group-open="group.isOpen" /> <item-type-icon :item-type="group.type" :is-group-open="group.isOpen" /> </div> @@ -140,7 +140,7 @@ export default { <item-stats-value :icon-name="visibilityIcon" :title="visibilityTooltip" - css-class="item-visibility d-inline-flex align-items-center gl-mt-3 append-right-4 text-secondary" + css-class="item-visibility d-inline-flex align-items-center gl-mt-3 gl-mr-2 text-secondary" /> <span v-if="group.permission" class="user-access-role gl-mt-3"> {{ group.permission }} diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue index c7acc21378b..c7713cbfafc 100644 --- a/app/assets/javascripts/groups/components/groups.vue +++ b/app/assets/javascripts/groups/components/groups.vue @@ -49,7 +49,7 @@ export default { <pagination-links :change="change" :page-info="pageInfo" - class="d-flex justify-content-center prepend-top-default" + class="d-flex justify-content-center gl-mt-3" /> </template> </div> diff --git a/app/assets/javascripts/groups/components/item_caret.vue b/app/assets/javascripts/groups/components/item_caret.vue index 18ea4819878..cd3e3de4cb4 100644 --- a/app/assets/javascripts/groups/components/item_caret.vue +++ b/app/assets/javascripts/groups/components/item_caret.vue @@ -21,5 +21,5 @@ export default { </script> <template> - <span class="folder-caret append-right-4"> <icon :size="10" :name="iconClass" /> </span> + <span class="folder-caret gl-mr-2"> <icon :size="10" :name="iconClass" /> </span> </template> diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js index d151cecf5be..3f9163e924d 100644 --- a/app/assets/javascripts/header.js +++ b/app/assets/javascripts/header.js @@ -16,10 +16,11 @@ import Tracking from '~/tracking'; */ export default function initTodoToggle() { $(document).on('todo:toggle', (e, count) => { + const updatedCount = count || e?.detail?.count || 0; const $todoPendingCount = $('.todos-count'); - $todoPendingCount.text(highCountTrim(count)); - $todoPendingCount.toggleClass('hidden', count === 0); + $todoPendingCount.text(highCountTrim(updatedCount)); + $todoPendingCount.toggleClass('hidden', updatedCount === 0); }); } diff --git a/app/assets/javascripts/helpers/event_hub_factory.js b/app/assets/javascripts/helpers/event_hub_factory.js index 4d7f7550a94..a9c301e3a93 100644 --- a/app/assets/javascripts/helpers/event_hub_factory.js +++ b/app/assets/javascripts/helpers/event_hub_factory.js @@ -1,20 +1,101 @@ -import mitt from 'mitt'; +/** + * An event hub with a Vue instance like API + * + * NOTE: There's an [issue open][4] to eventually remove this when some + * coupling in our codebase has been fixed. + * + * NOTE: This is a derivative work from [mitt][1] v1.2.0 which is licensed by + * [MIT License][2] © [Jason Miller][3] + * + * [1]: https://github.com/developit/mitt + * [2]: https://opensource.org/licenses/MIT + * [3]: https://jasonformat.com/ + * [4]: https://gitlab.com/gitlab-org/gitlab/-/issues/223864 + */ +class EventHub { + constructor() { + this.$_all = new Map(); + } -export default () => { - const emitter = mitt(); + dispose() { + this.$_all.clear(); + } + + /** + * Register an event handler for the given type. + * + * @param {string|symbol} type Type of event to listen for + * @param {Function} handler Function to call in response to given event + */ + $on(type, handler) { + const handlers = this.$_all.get(type); + const added = handlers && handlers.push(handler); + + if (!added) { + this.$_all.set(type, [handler]); + } + } + + /** + * Remove an event handler or all handlers for the given type. + * + * @param {string|symbol} type Type of event to unregister `handler` + * @param {Function} handler Handler function to remove + */ + $off(type, handler) { + const handlers = this.$_all.get(type) || []; - emitter.once = (event, handler) => { - const wrappedHandler = evt => { - handler(evt); - emitter.off(event, wrappedHandler); + const newHandlers = handler ? handlers.filter(x => x !== handler) : []; + + if (newHandlers.length) { + this.$_all.set(type, newHandlers); + } else { + this.$_all.delete(type); + } + } + + /** + * Add an event listener to type but only trigger it once + * + * @param {string|symbol} type Type of event to listen for + * @param {Function} handler Handler function to call in response to event + */ + $once(type, handler) { + const wrapHandler = (...args) => { + this.$off(type, wrapHandler); + handler(...args); }; - emitter.on(event, wrappedHandler); - }; + this.$on(type, wrapHandler); + } - emitter.$on = emitter.on; - emitter.$once = emitter.once; - emitter.$off = emitter.off; - emitter.$emit = emitter.emit; + /** + * Invoke all handlers for the given type. + * + * @param {string|symbol} type The event type to invoke + * @param {Any} [evt] Any value passed to each handler + */ + $emit(type, ...args) { + const handlers = this.$_all.get(type) || []; - return emitter; + handlers.forEach(handler => { + handler(...args); + }); + } +} + +/** + * Return a Vue like event hub + * + * - $on + * - $off + * - $once + * - $emit + * + * Please note, this was once implemented with `mitt`, but since then has been reverted + * because of some API issues. https://gitlab.com/gitlab-org/gitlab/-/merge_requests/35074 + * + * We'd like to shy away from using a full fledged Vue instance from this in the future. + */ +export default () => { + return new EventHub(); }; diff --git a/app/assets/javascripts/helpers/monitor_helper.js b/app/assets/javascripts/helpers/monitor_helper.js index 94a0d38f05f..5f85ee58779 100644 --- a/app/assets/javascripts/helpers/monitor_helper.js +++ b/app/assets/javascripts/helpers/monitor_helper.js @@ -65,18 +65,10 @@ const getSeriesLabel = (queryLabel, metricAttributes) => { */ // eslint-disable-next-line import/prefer-default-export export const makeDataSeries = (queryResults, defaultConfig) => - queryResults - .map(result => { - // NaN values may disrupt avg., max. & min. calculations in the legend, filter them out - const data = result.values.filter(([, value]) => !Number.isNaN(value)); - if (!data.length) { - return null; - } - const series = { data }; - return { - ...defaultConfig, - ...series, - name: getSeriesLabel(defaultConfig.name, result.metric), - }; - }) - .filter(series => series !== null); + queryResults.map(result => { + return { + ...defaultConfig, + data: result.values, + name: getSeriesLabel(defaultConfig.name, result.metric), + }; + }); diff --git a/app/assets/javascripts/ide/components/branches/item.vue b/app/assets/javascripts/ide/components/branches/item.vue index e7f4cd796b5..49744d573da 100644 --- a/app/assets/javascripts/ide/components/branches/item.vue +++ b/app/assets/javascripts/ide/components/branches/item.vue @@ -33,7 +33,7 @@ export default { <template> <a :href="branchHref" class="btn-link d-flex align-items-center"> - <span class="d-flex append-right-default ide-search-list-current-icon"> + <span class="d-flex gl-mr-3 ide-search-list-current-icon"> <icon v-if="isActive" :size="18" name="mobile-issue-close" /> </span> <span> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue index 6c563776533..407e4c57cd8 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue @@ -70,7 +70,7 @@ export default { </script> <template> - <div class="append-bottom-15 ide-commit-options"> + <div class="gl-mb-5 ide-commit-options"> <radio-group :value="$options.commitToCurrentBranch" :disabled="!canPushToBranch" diff --git a/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue b/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue index a13ca0cd138..3ffbcbf99e8 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue @@ -12,7 +12,7 @@ export default { <div v-if="!lastCommitMsg" class="multi-file-commit-panel-section ide-commit-empty-state"> <div class="ide-commit-empty-state-container"> <div class="svg-content svg-80"><img :src="noChangesStateSvgPath" /></div> - <div class="append-right-default prepend-left-default"> + <div class="gl-mr-3 gl-ml-3"> <div class="text-content text-center"> <h4>{{ __('No changes') }}</h4> <p>{{ __('Edit files in the editor and commit changes here') }}</p> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue index b6fc567f8cc..03304337839 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue @@ -75,7 +75,7 @@ export default { :title="titleTooltip" data-container="body" data-placement="left" - class="append-bottom-15" + class="gl-mb-5" > <icon v-once :name="iconName" :size="18" /> </div> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue index 6b0aa5b2b2b..b37c7280a30 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue @@ -83,7 +83,7 @@ export default { <ul class="nav-links"> <li> {{ __('Commit Message') }} - <span v-popover="$options.popoverOptions" class="form-text text-muted prepend-left-10"> + <span v-popover="$options.popoverOptions" class="form-text text-muted gl-ml-3"> <icon name="question" /> </span> </li> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue b/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue index 0812599c25c..cdf49866982 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue @@ -44,7 +44,7 @@ export default { data-qa-selector="start_new_mr_checkbox" @change="toggleShouldCreateMR" /> - <span class="prepend-left-10 ide-option-label"> + <span class="gl-ml-3 ide-option-label"> {{ __('Start a new merge request') }} </span> </label> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue index a9591805261..aed7b792902 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue @@ -66,7 +66,7 @@ export default { name="commit-action" @change="updateCommitAction($event.target.value)" /> - <span class="prepend-left-10"> + <span class="gl-ml-3"> <span v-if="label" class="ide-option-label"> {{ label }} </span> <slot v-else></slot> </span> </label> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue b/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue index 137f8bb18c7..327b0b8172f 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue @@ -13,7 +13,7 @@ export default { <div class="svg-content svg-80"> <img :src="committedStateSvgPath" :alt="s__('IDE|Successful commit')" /> </div> - <div class="append-right-default prepend-left-default"> + <div class="gl-mr-3 gl-ml-3"> <div class="text-content text-center"> <h4>{{ __('All changes are committed') }}</h4> <p v-html="lastCommitMsg"></p> diff --git a/app/assets/javascripts/ide/components/file_row_extra.vue b/app/assets/javascripts/ide/components/file_row_extra.vue index 51509cd5fe6..f7cf7a5b251 100644 --- a/app/assets/javascripts/ide/components/file_row_extra.vue +++ b/app/assets/javascripts/ide/components/file_row_extra.vue @@ -76,7 +76,7 @@ export default { data-container="body" data-placement="right" name="file-modified" - class="prepend-left-5 ide-file-modified" + class="gl-ml-2 ide-file-modified" /> </span> <changed-file-icon diff --git a/app/assets/javascripts/ide/components/file_templates/bar.vue b/app/assets/javascripts/ide/components/file_templates/bar.vue index d459e3b43d3..b6a57d1b6e6 100644 --- a/app/assets/javascripts/ide/components/file_templates/bar.vue +++ b/app/assets/javascripts/ide/components/file_templates/bar.vue @@ -48,7 +48,7 @@ export default { <template> <div class="d-flex align-items-center ide-file-templates qa-file-templates-bar"> - <strong class="append-right-default"> {{ __('File templates') }} </strong> + <strong class="gl-mr-3"> {{ __('File templates') }} </strong> <dropdown :data="templateTypes" :label="selectedTemplateType.name || __('Choose a type...')" diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index e9f84eb8648..55b3eaf9737 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -1,6 +1,6 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; -import { GlDeprecatedButton, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import { modalTypes } from '../constants'; import FindFile from '~/vue_shared/components/file_finder/index.vue'; @@ -24,7 +24,7 @@ export default { FindFile, ErrorMessage, CommitEditorHeader, - GlDeprecatedButton, + GlButton, GlLoadingIcon, RightPane, }, @@ -121,15 +121,16 @@ export default { ) }} </p> - <gl-deprecated-button + <gl-button variant="success" + category="primary" :title="__('New file')" :aria-label="__('New file')" data-qa-selector="first_file_button" @click="createNewFile()" > {{ __('New file') }} - </gl-deprecated-button> + </gl-button> </template> <gl-loading-icon v-else-if="!currentTree || currentTree.loading" size="md" /> <p v-else> diff --git a/app/assets/javascripts/ide/components/ide_review.vue b/app/assets/javascripts/ide/components/ide_review.vue index 62dbfea2088..95348711e1d 100644 --- a/app/assets/javascripts/ide/components/ide_review.vue +++ b/app/assets/javascripts/ide/components/ide_review.vue @@ -53,7 +53,7 @@ export default { @click="updateViewer" /> </div> - <div class="prepend-top-5 ide-review-sub-header"> + <div class="gl-mt-2 ide-review-sub-header"> <template v-if="showLatestChangesText"> {{ __('Latest changes') }} </template> diff --git a/app/assets/javascripts/ide/components/ide_status_list.vue b/app/assets/javascripts/ide/components/ide_status_list.vue index 92d25709bd5..1354fdc3d98 100644 --- a/app/assets/javascripts/ide/components/ide_status_list.vue +++ b/app/assets/javascripts/ide/components/ide_status_list.vue @@ -1,12 +1,17 @@ <script> import { mapGetters } from 'vuex'; +import { GlLink, GlTooltipDirective } from '@gitlab/ui'; import TerminalSyncStatusSafe from './terminal_sync/terminal_sync_status_safe.vue'; import { getFileEOL } from '../utils'; export default { components: { + GlLink, TerminalSyncStatusSafe, }, + directives: { + GlTooltip: GlTooltipDirective, + }, computed: { ...mapGetters(['activeFile']), activeFileEOL() { @@ -19,12 +24,14 @@ export default { <template> <div class="ide-status-list d-flex"> <template v-if="activeFile"> - <div class="ide-status-file">{{ activeFile.name }}</div> - <div class="ide-status-file">{{ activeFileEOL }}</div> - <div v-if="!activeFile.binary" class="ide-status-file"> - {{ activeFile.editorRow }}:{{ activeFile.editorColumn }} + <div> + <gl-link v-gl-tooltip.hover :href="activeFile.permalink" :title="__('Open in file view')"> + {{ activeFile.name }} + </gl-link> </div> - <div class="ide-status-file">{{ activeFile.fileLanguage }}</div> + <div>{{ activeFileEOL }}</div> + <div v-if="!activeFile.binary">{{ activeFile.editorRow }}:{{ activeFile.editorColumn }}</div> + <div>{{ activeFile.fileLanguage }}</div> </template> <terminal-sync-status-safe /> </div> diff --git a/app/assets/javascripts/ide/components/jobs/item.vue b/app/assets/javascripts/ide/components/jobs/item.vue index be8bf77bba0..db3630bc1d1 100644 --- a/app/assets/javascripts/ide/components/jobs/item.vue +++ b/app/assets/javascripts/ide/components/jobs/item.vue @@ -26,7 +26,7 @@ export default { <template> <div class="ide-job-item"> - <job-description :job="job" class="append-right-default" /> + <job-description :job="job" class="gl-mr-3" /> <div class="ml-auto align-self-center"> <button v-if="job.started" type="button" class="btn btn-default btn-sm" @click="clickViewLog"> {{ __('View log') }} diff --git a/app/assets/javascripts/ide/components/jobs/list.vue b/app/assets/javascripts/ide/components/jobs/list.vue index b97b7289886..4e0912f3f44 100644 --- a/app/assets/javascripts/ide/components/jobs/list.vue +++ b/app/assets/javascripts/ide/components/jobs/list.vue @@ -26,7 +26,7 @@ export default { <template> <div> - <gl-loading-icon v-if="loading && !stages.length" size="lg" class="prepend-top-default" /> + <gl-loading-icon v-if="loading && !stages.length" size="lg" class="gl-mt-3" /> <template v-else> <stage v-for="stage in stages" diff --git a/app/assets/javascripts/ide/components/jobs/stage.vue b/app/assets/javascripts/ide/components/jobs/stage.vue index 169a948c2da..75441e8c1c8 100644 --- a/app/assets/javascripts/ide/components/jobs/stage.vue +++ b/app/assets/javascripts/ide/components/jobs/stage.vue @@ -56,7 +56,7 @@ export default { </script> <template> - <div class="ide-stage card prepend-top-default"> + <div class="ide-stage card gl-mt-3"> <div ref="cardHeader" :class="{ diff --git a/app/assets/javascripts/ide/components/merge_requests/item.vue b/app/assets/javascripts/ide/components/merge_requests/item.vue index 3f060392686..8b7b8d5a91c 100644 --- a/app/assets/javascripts/ide/components/merge_requests/item.vue +++ b/app/assets/javascripts/ide/components/merge_requests/item.vue @@ -40,7 +40,7 @@ export default { <template> <a :href="mergeRequestHref" class="btn-link d-flex align-items-center"> - <span class="d-flex append-right-default ide-search-list-current-icon"> + <span class="d-flex gl-mr-3 ide-search-list-current-icon"> <icon v-if="isActive" :size="18" name="mobile-issue-close" /> </span> <span> diff --git a/app/assets/javascripts/ide/components/merge_requests/list.vue b/app/assets/javascripts/ide/components/merge_requests/list.vue index bf2a33be653..af45d88b84a 100644 --- a/app/assets/javascripts/ide/components/merge_requests/list.vue +++ b/app/assets/javascripts/ide/components/merge_requests/list.vue @@ -102,7 +102,7 @@ export default { class="btn-link d-flex align-items-center" @click.stop="setSearchType(searchType)" > - <span class="d-flex append-right-default ide-search-list-current-icon"> + <span class="d-flex gl-mr-3 ide-search-list-current-icon"> <icon :size="18" name="search" /> </span> <span>{{ searchType.label }}</span> diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue index 2798ede5341..b656e35f150 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/index.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue @@ -64,6 +64,7 @@ export default { :aria-label="__('Create new file or directory')" type="button" class="rounded border-0 d-flex ide-entry-dropdown-toggle" + data-qa-selector="dropdown_button" @click.stop="openDropdown()" > <icon name="ellipsis_v" /> <icon name="chevron-down" /> @@ -97,6 +98,7 @@ export default { class="d-flex" icon="pencil" icon-classes="mr-2" + data-qa-selector="rename_move_button" @click="createNewItem($options.modalTypes.rename)" /> </li> diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index 586d6867ab4..fe0167942b8 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -154,10 +154,7 @@ export default { data-qa-selector="file_name_field" :placeholder="placeholder" /> - <ul - v-if="isCreatingNewFile" - class="file-templates prepend-top-default list-inline qa-template-list" - > + <ul v-if="isCreatingNewFile" class="file-templates gl-mt-3 list-inline qa-template-list"> <li v-for="(template, index) in templateTypes" :key="index" class="list-inline-item"> <button type="button" diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue index 6958a5d2526..6038e92f254 100644 --- a/app/assets/javascripts/ide/components/pipelines/list.vue +++ b/app/assets/javascripts/ide/components/pipelines/list.vue @@ -7,7 +7,7 @@ import Icon from '../../../vue_shared/components/icon.vue'; import CiIcon from '../../../vue_shared/components/ci_icon.vue'; import Tabs from '../../../vue_shared/components/tabs/tabs'; import Tab from '../../../vue_shared/components/tabs/tab.vue'; -import EmptyState from '../../../pipelines/components/empty_state.vue'; +import EmptyState from '../../../pipelines/components/pipelines_list/empty_state.vue'; import JobsList from '../jobs/list.vue'; import IDEServices from '~/ide/services'; @@ -59,7 +59,7 @@ export default { <template> <div class="ide-pipeline"> - <gl-loading-icon v-if="showLoadingIcon" size="lg" class="prepend-top-default" /> + <gl-loading-icon v-if="showLoadingIcon" size="lg" class="gl-mt-3" /> <template v-else-if="hasLoadedPipeline"> <header v-if="latestPipeline" class="ide-tree-header ide-pipeline-header"> <ci-icon :status="latestPipeline.details.status" :size="24" class="d-flex" /> diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index a7646083428..ac445a1d9f1 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -185,7 +185,6 @@ export default { 'setFileLanguage', 'setEditorPosition', 'setFileViewMode', - 'updateViewer', 'removePendingTab', 'triggerFilesChange', 'addTempImage', @@ -241,7 +240,7 @@ export default { }); }, setupEditor() { - if (!this.file || !this.editor.instance) return; + if (!this.file || !this.editor.instance || this.file.loading) return; const head = this.getStagedFile(this.file.path); diff --git a/app/assets/javascripts/ide/components/terminal/empty_state.vue b/app/assets/javascripts/ide/components/terminal/empty_state.vue index 9841f1ece48..5dd12e62820 100644 --- a/app/assets/javascripts/ide/components/terminal/empty_state.vue +++ b/app/assets/javascripts/ide/components/terminal/empty_state.vue @@ -43,7 +43,7 @@ export default { <div class="text-center p-3"> <div v-if="illustrationPath" class="svg-content svg-130"><img :src="illustrationPath" /></div> <h4>{{ __('Web Terminal') }}</h4> - <gl-loading-icon v-if="isLoading" size="lg" class="prepend-top-default" /> + <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3" /> <template v-else> <p>{{ __('Run tests against your code live using the Web Terminal') }}</p> <p> diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js index 4dfc27117c0..6e90968f008 100644 --- a/app/assets/javascripts/ide/lib/editor.js +++ b/app/assets/javascripts/ide/lib/editor.js @@ -8,9 +8,10 @@ import ModelManager from './common/model_manager'; import { editorOptions, defaultEditorOptions, defaultDiffEditorOptions } from './editor_options'; import { themes } from './themes'; import languages from './languages'; +import schemas from './schemas'; import keymap from './keymap.json'; import { clearDomElement } from '~/editor/utils'; -import { registerLanguages } from '../utils'; +import { registerLanguages, registerSchemas } from '../utils'; function setupThemes() { themes.forEach(theme => { @@ -45,6 +46,10 @@ export default class Editor { setupThemes(); registerLanguages(...languages); + if (gon.features?.schemaLinting) { + registerSchemas(...schemas); + } + this.debouncedUpdate = debounce(() => { this.updateDimensions(); }, 200); diff --git a/app/assets/javascripts/ide/lib/schemas/index.js b/app/assets/javascripts/ide/lib/schemas/index.js new file mode 100644 index 00000000000..38a2f81921b --- /dev/null +++ b/app/assets/javascripts/ide/lib/schemas/index.js @@ -0,0 +1,4 @@ +import json from './json'; +import yaml from './yaml'; + +export default [json, yaml]; diff --git a/app/assets/javascripts/ide/lib/schemas/json/index.js b/app/assets/javascripts/ide/lib/schemas/json/index.js new file mode 100644 index 00000000000..900d5442bec --- /dev/null +++ b/app/assets/javascripts/ide/lib/schemas/json/index.js @@ -0,0 +1,8 @@ +export default { + language: 'json', + options: { + validate: true, + enableSchemaRequest: true, + schemas: [], + }, +}; diff --git a/app/assets/javascripts/ide/lib/schemas/yaml/gitlab_ci.js b/app/assets/javascripts/ide/lib/schemas/yaml/gitlab_ci.js new file mode 100644 index 00000000000..af20744abb3 --- /dev/null +++ b/app/assets/javascripts/ide/lib/schemas/yaml/gitlab_ci.js @@ -0,0 +1,4 @@ +export default { + uri: 'https://json.schemastore.org/gitlab-ci', + fileMatch: ['*.gitlab-ci.yml'], +}; diff --git a/app/assets/javascripts/ide/lib/schemas/yaml/index.js b/app/assets/javascripts/ide/lib/schemas/yaml/index.js new file mode 100644 index 00000000000..e3fc406df4b --- /dev/null +++ b/app/assets/javascripts/ide/lib/schemas/yaml/index.js @@ -0,0 +1,12 @@ +import gitlabCi from './gitlab_ci'; + +export default { + language: 'yaml', + options: { + validate: true, + enableSchemaRequest: true, + hover: true, + completion: true, + schemas: [gitlabCi], + }, +}; diff --git a/app/assets/javascripts/ide/queries/getUserPermissions.query.graphql b/app/assets/javascripts/ide/queries/getUserPermissions.query.graphql index 2c9013ffa9c..f0b50793226 100644 --- a/app/assets/javascripts/ide/queries/getUserPermissions.query.graphql +++ b/app/assets/javascripts/ide/queries/getUserPermissions.query.graphql @@ -1,8 +1,8 @@ query getUserPermissions($projectPath: ID!) { project(fullPath: $projectPath) { userPermissions { - createMergeRequestIn, - readMergeRequest, + createMergeRequestIn + readMergeRequest pushCode } } diff --git a/app/assets/javascripts/ide/services/gql.js b/app/assets/javascripts/ide/services/gql.js index 8a7f27328ba..211cc78bd99 100644 --- a/app/assets/javascripts/ide/services/gql.js +++ b/app/assets/javascripts/ide/services/gql.js @@ -1,8 +1,21 @@ +import { memoize } from 'lodash'; import createGqClient, { fetchPolicies } from '~/lib/graphql'; -export default createGqClient( - {}, - { - fetchPolicy: fetchPolicies.NO_CACHE, - }, +/** + * Returns a memoized client + * + * We defer creating the client so that importing this module does not cause any side-effects. + * Creating the client immediately caused issues with miragejs where the gql client uses the + * real fetch() instead of the shimmed one. + */ +const getClient = memoize(() => + createGqClient( + {}, + { + fetchPolicy: fetchPolicies.NO_CACHE, + }, + ), ); + +// eslint-disable-next-line import/prefer-default-export +export const query = (...args) => getClient().query(...args); diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js index 1767d961259..ae4a1ba3db5 100644 --- a/app/assets/javascripts/ide/services/index.js +++ b/app/assets/javascripts/ide/services/index.js @@ -2,17 +2,15 @@ import axios from '~/lib/utils/axios_utils'; import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility'; import Api from '~/api'; import getUserPermissions from '../queries/getUserPermissions.query.graphql'; -import gqClient from './gql'; +import { query } from './gql'; const fetchApiProjectData = projectPath => Api.project(projectPath).then(({ data }) => data); const fetchGqlProjectData = projectPath => - gqClient - .query({ - query: getUserPermissions, - variables: { projectPath }, - }) - .then(({ data }) => data.project); + query({ + query: getUserPermissions, + variables: { projectPath }, + }).then(({ data }) => data.project); export default { getFileData(endpoint) { diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index 47f9337a288..c0cb924e749 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -65,7 +65,7 @@ export const getFileData = ( if (file.raw || (file.tempFile && !file.prevPath && !fileDeletedAndReadded)) return Promise.resolve(); - commit(types.TOGGLE_LOADING, { entry: file }); + commit(types.TOGGLE_LOADING, { entry: file, forceValue: true }); const url = joinPaths( gon.relative_url_root || '/', @@ -79,15 +79,15 @@ export const getFileData = ( return service .getFileData(url) .then(({ data }) => { - setPageTitleForFile(state, file); - if (data) commit(types.SET_FILE_DATA, { data, file }); if (openFile) commit(types.TOGGLE_FILE_OPEN, path); - if (makeFileActive) dispatch('setFileActive', path); - commit(types.TOGGLE_LOADING, { entry: file }); + + if (makeFileActive) { + setPageTitleForFile(state, file); + dispatch('setFileActive', path); + } }) .catch(() => { - commit(types.TOGGLE_LOADING, { entry: file }); dispatch('setErrorMessage', { text: __('An error occurred while loading the file.'), action: payload => @@ -95,6 +95,9 @@ export const getFileData = ( actionText: __('Please try again'), actionPayload: { path, makeFileActive }, }); + }) + .finally(() => { + commit(types.TOGGLE_LOADING, { entry: file, forceValue: false }); }); }; @@ -106,45 +109,41 @@ export const getRawFileData = ({ state, commit, dispatch, getters }, { path }) = const file = state.entries[path]; const stagedFile = state.stagedFiles.find(f => f.path === path); - return new Promise((resolve, reject) => { - const fileDeletedAndReadded = getters.isFileDeletedAndReadded(path); - service - .getRawFileData(fileDeletedAndReadded ? stagedFile : file) - .then(raw => { - if (!(file.tempFile && !file.prevPath && !fileDeletedAndReadded)) - commit(types.SET_FILE_RAW_DATA, { file, raw, fileDeletedAndReadded }); - - if (file.mrChange && file.mrChange.new_file === false) { - const baseSha = - (getters.currentMergeRequest && getters.currentMergeRequest.baseCommitSha) || ''; - - service - .getBaseRawFileData(file, baseSha) - .then(baseRaw => { - commit(types.SET_FILE_BASE_RAW_DATA, { - file, - baseRaw, - }); - resolve(raw); - }) - .catch(e => { - reject(e); - }); - } else { - resolve(raw); - } - }) - .catch(() => { - dispatch('setErrorMessage', { - text: __('An error occurred while loading the file content.'), - action: payload => - dispatch('getRawFileData', payload).then(() => dispatch('setErrorMessage', null)), - actionText: __('Please try again'), - actionPayload: { path }, + const fileDeletedAndReadded = getters.isFileDeletedAndReadded(path); + commit(types.TOGGLE_LOADING, { entry: file, forceValue: true }); + return service + .getRawFileData(fileDeletedAndReadded ? stagedFile : file) + .then(raw => { + if (!(file.tempFile && !file.prevPath && !fileDeletedAndReadded)) + commit(types.SET_FILE_RAW_DATA, { file, raw, fileDeletedAndReadded }); + + if (file.mrChange && file.mrChange.new_file === false) { + const baseSha = + (getters.currentMergeRequest && getters.currentMergeRequest.baseCommitSha) || ''; + + return service.getBaseRawFileData(file, baseSha).then(baseRaw => { + commit(types.SET_FILE_BASE_RAW_DATA, { + file, + baseRaw, + }); + return raw; }); - reject(); + } + return raw; + }) + .catch(e => { + dispatch('setErrorMessage', { + text: __('An error occurred while loading the file content.'), + action: payload => + dispatch('getRawFileData', payload).then(() => dispatch('setErrorMessage', null)), + actionText: __('Please try again'), + actionPayload: { path }, }); - }); + throw e; + }) + .finally(() => { + commit(types.TOGGLE_LOADING, { entry: file, forceValue: false }); + }); }; export const changeFileContent = ({ commit, state, getters }, { path, content }) => { diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js index fcaf060ef09..3fdfdc5422b 100644 --- a/app/assets/javascripts/ide/stores/actions/merge_request.js +++ b/app/assets/javascripts/ide/stores/actions/merge_request.js @@ -16,6 +16,7 @@ export const getMergeRequestsForBranch = ( .getProjectMergeRequests(`${projectId}`, { source_branch: branchId, source_project_id: state.projects[projectId].id, + state: 'opened', order_by: 'created_at', per_page: 1, }) diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index d94adc3760f..ae119c2b1fd 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -1,6 +1,5 @@ export const SET_INITIAL_DATA = 'SET_INITIAL_DATA'; export const TOGGLE_LOADING = 'TOGGLE_LOADING'; -export const SET_LAST_COMMIT_DATA = 'SET_LAST_COMMIT_DATA'; export const SET_LAST_COMMIT_MSG = 'SET_LAST_COMMIT_MSG'; export const SET_RESIZING_STATUS = 'SET_RESIZING_STATUS'; export const SET_EMPTY_STATE_SVGS = 'SET_EMPTY_STATE_SVGS'; diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index e827aacac13..c64839e5019 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -34,15 +34,6 @@ export default { panelResizing: resizing, }); }, - [types.SET_LAST_COMMIT_DATA](state, { entry, lastCommit }) { - Object.assign(entry.lastCommit, { - id: lastCommit.commit.id, - url: lastCommit.commit_path, - message: lastCommit.commit.message, - author: lastCommit.commit.author_name, - updatedAt: lastCommit.commit.authored_date, - }); - }, [types.SET_LAST_COMMIT_MSG](state, lastCommitMsg) { Object.assign(state, { lastCommitMsg, diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index 1c5fe9fe9a5..f074e6880d0 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -25,13 +25,6 @@ export const dataStructure = () => ({ changed: false, staged: false, lastCommitSha: '', - lastCommit: { - id: '', - url: '', - message: '', - updatedAt: '', - author: '', - }, rawPath: '', binary: false, raw: '', diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js index c28a2bd9f1d..9ec7b2c06ce 100644 --- a/app/assets/javascripts/ide/utils.js +++ b/app/assets/javascripts/ide/utils.js @@ -66,7 +66,7 @@ export const trimPathComponents = path => .join('/'); export function registerLanguages(def, ...defs) { - if (defs.length) defs.forEach(lang => registerLanguages(lang)); + defs.forEach(lang => registerLanguages(lang)); const languageId = def.id; @@ -75,6 +75,19 @@ export function registerLanguages(def, ...defs) { languages.setLanguageConfiguration(languageId, def.conf); } +export function registerSchemas({ language, options }, ...schemas) { + schemas.forEach(schema => registerSchemas(schema)); + + const defaults = { + json: languages.json.jsonDefaults, + yaml: languages.yaml.yamlDefaults, + }; + + if (defaults[language]) { + defaults[language].setDiagnosticsOptions(options); + } +} + export const otherSide = side => (side === SIDE_RIGHT ? SIDE_LEFT : SIDE_RIGHT); export function trimTrailingWhitespace(content) { diff --git a/app/assets/javascripts/import_projects/components/bitbucket_status_table.vue b/app/assets/javascripts/import_projects/components/bitbucket_status_table.vue index 1a9974db727..f673a0e42dc 100644 --- a/app/assets/javascripts/import_projects/components/bitbucket_status_table.vue +++ b/app/assets/javascripts/import_projects/components/bitbucket_status_table.vue @@ -28,7 +28,7 @@ export default { }; </script> <template> - <import-projects-table provider-title="providerTitle"> + <import-projects-table :provider-title="providerTitle"> <template #actions> <slot name="actions"></slot> </template> diff --git a/app/assets/javascripts/import_projects/store/actions.js b/app/assets/javascripts/import_projects/store/actions.js index 2422a1ed2e4..8d8d33f5972 100644 --- a/app/assets/javascripts/import_projects/store/actions.js +++ b/app/assets/javascripts/import_projects/store/actions.js @@ -70,8 +70,19 @@ export const fetchImport = ({ state, commit }, { newName, targetNamespace, repo repoId: repo.id, }), ) - .catch(() => { - createFlash(s__('ImportProjects|Importing the project failed')); + .catch(e => { + const serverErrorMessage = e?.response?.data?.errors; + const flashMessage = serverErrorMessage + ? sprintf( + s__('ImportProjects|Importing the project failed: %{reason}'), + { + reason: serverErrorMessage, + }, + false, + ) + : s__('ImportProjects|Importing the project failed'); + + createFlash(flashMessage); commit(types.RECEIVE_IMPORT_ERROR, repo.id); }); diff --git a/app/assets/javascripts/importer_status.js b/app/assets/javascripts/importer_status.js index d6b519f7eac..f44c5c3d289 100644 --- a/app/assets/javascripts/importer_status.js +++ b/app/assets/javascripts/importer_status.js @@ -35,8 +35,8 @@ class ImporterStatus { const $tr = $btn.closest('tr'); const $targetField = $tr.find('.import-target'); const $namespaceInput = $targetField.find('.js-select-namespace option:selected'); - const id = $tr.attr('id').replace('repo_', ''); const repoData = $tr.data(); + const id = repoData.id || $tr.attr('id').replace('repo_', ''); let targetNamespace; let newName; @@ -63,7 +63,7 @@ class ImporterStatus { return axios .post(this.importUrl, attributes) .then(({ data }) => { - const job = $(`tr#repo_${id}`); + const job = $tr; job.attr('id', `project_${data.id}`); job.find('.import-target').html(`<a href="${data.full_path}">${data.full_path}</a>`); @@ -86,7 +86,7 @@ class ImporterStatus { .catch(error => { let details = error; - const $statusField = $(`#repo_${this.id} .job-status`); + const $statusField = $tr.find('.job-status'); $statusField.text(__('Failed')); if (error.response && error.response.data && error.response.data.errors) { diff --git a/app/assets/javascripts/incidents_settings/components/alerts_form.vue b/app/assets/javascripts/incidents_settings/components/alerts_form.vue new file mode 100644 index 00000000000..a394f404ee1 --- /dev/null +++ b/app/assets/javascripts/incidents_settings/components/alerts_form.vue @@ -0,0 +1,139 @@ +<script> +import { + GlButton, + GlSprintf, + GlLink, + GlIcon, + GlFormGroup, + GlFormCheckbox, + GlNewDropdown, + GlNewDropdownItem, +} from '@gitlab/ui'; +import { + I18N_ALERT_SETTINGS_FORM, + NO_ISSUE_TEMPLATE_SELECTED, + TAKING_INCIDENT_ACTION_DOCS_LINK, + ISSUE_TEMPLATES_DOCS_LINK, +} from '../constants'; + +export default { + components: { + GlButton, + GlSprintf, + GlLink, + GlFormGroup, + GlIcon, + GlFormCheckbox, + GlNewDropdown, + GlNewDropdownItem, + }, + inject: ['service', 'alertSettings'], + data() { + return { + templates: [NO_ISSUE_TEMPLATE_SELECTED, ...this.alertSettings.templates], + createIssueEnabled: this.alertSettings.createIssue, + issueTemplate: this.alertSettings.issueTemplateKey, + sendEmailEnabled: this.alertSettings.sendEmail, + loading: false, + }; + }, + i18n: I18N_ALERT_SETTINGS_FORM, + TAKING_INCIDENT_ACTION_DOCS_LINK, + ISSUE_TEMPLATES_DOCS_LINK, + computed: { + issueTemplateHeader() { + return this.issueTemplate || NO_ISSUE_TEMPLATE_SELECTED.name; + }, + formData() { + return { + create_issue: this.createIssueEnabled, + issue_template_key: this.issueTemplate, + send_email: this.sendEmailEnabled, + }; + }, + }, + methods: { + selectIssueTemplate(templateKey) { + this.issueTemplate = templateKey; + }, + isTemplateSelected(templateKey) { + return templateKey === this.issueTemplate; + }, + updateAlertsIntegrationSettings() { + this.loading = true; + + this.service.updateSettings(this.formData).catch(() => { + this.loading = false; + }); + }, + }, +}; +</script> + +<template> + <div> + <p> + <gl-sprintf :message="$options.i18n.introText"> + <template #docsLink> + <gl-link :href="$options.TAKING_INCIDENT_ACTION_DOCS_LINK" target="_blank"> + <span>{{ $options.i18n.introLinkText }}</span> + </gl-link> + </template> + </gl-sprintf> + </p> + <form ref="settingsForm" @submit.prevent="updateAlertsIntegrationSettings"> + <gl-form-group class="gl-pl-0"> + <gl-form-checkbox v-model="createIssueEnabled" data-qa-selector="create_issue_checkbox"> + <span>{{ $options.i18n.createIssue.label }}</span> + </gl-form-checkbox> + </gl-form-group> + + <gl-form-group + label-size="sm" + label-for="alert-integration-settings-issue-template" + class="col-8 col-md-9 gl-px-6" + > + <label class="gl-display-inline-flex" for="alert-integration-settings-issue-template"> + {{ $options.i18n.issueTemplate.label }} + <gl-link :href="$options.ISSUE_TEMPLATES_DOCS_LINK" target="_blank"> + <gl-icon name="question" :size="12" /> + </gl-link> + </label> + <gl-new-dropdown + id="alert-integration-settings-issue-template" + data-qa-selector="incident_templates_dropdown" + :text="issueTemplateHeader" + :block="true" + > + <gl-new-dropdown-item + v-for="template in templates" + :key="template.key" + data-qa-selector="incident_templates_item" + :is-check-item="true" + :is-checked="isTemplateSelected(template.key)" + @click="selectIssueTemplate(template.key)" + > + {{ template.name }} + </gl-new-dropdown-item> + </gl-new-dropdown> + </gl-form-group> + + <gl-form-group class="gl-pl-0 gl-mb-5"> + <gl-form-checkbox v-model="sendEmailEnabled"> + <span>{{ $options.i18n.sendEmail.label }}</span> + </gl-form-checkbox> + </gl-form-group> + + <gl-button + ref="submitBtn" + data-qa-selector="save_changes_button" + :disabled="loading" + variant="success" + type="submit" + class="js-no-auto-disable" + > + {{ $options.i18n.saveBtnLabel }} + </gl-button> + </form> + </div> +</template> diff --git a/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue b/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue new file mode 100644 index 00000000000..0623c275c5a --- /dev/null +++ b/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue @@ -0,0 +1,61 @@ +<script> +import { GlButton, GlTabs, GlTab } from '@gitlab/ui'; +import AlertsSettingsForm from './alerts_form.vue'; +import PagerDutySettingsForm from './pagerduty_form.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { INTEGRATION_TABS_CONFIG, I18N_INTEGRATION_TABS } from '../constants'; + +export default { + components: { + GlButton, + GlTabs, + GlTab, + AlertsSettingsForm, + PagerDutySettingsForm, + }, + mixins: [glFeatureFlagMixin()], + tabs: INTEGRATION_TABS_CONFIG, + i18n: I18N_INTEGRATION_TABS, + methods: { + isFeatureFlagEnabled(tab) { + if (tab.featureFlag) { + return this.glFeatures[tab.featureFlag]; + } + return true; + }, + }, +}; +</script> + +<template> + <section + id="incident-management-settings" + data-qa-selector="incidents_settings_content" + class="settings no-animate qa-incident-management-settings" + > + <div class="settings-header"> + <h3 ref="sectionHeader" class="h4"> + {{ $options.i18n.headerText }} + </h3> + <gl-button ref="toggleBtn" class="js-settings-toggle">{{ + $options.i18n.expandBtnLabel + }}</gl-button> + <p ref="sectionSubHeader"> + {{ $options.i18n.subHeaderText }} + </p> + </div> + + <div class="settings-content"> + <gl-tabs> + <gl-tab + v-for="(tab, index) in $options.tabs" + v-if="tab.active && isFeatureFlagEnabled(tab)" + :key="`${tab.title}_${index}`" + :title="tab.title" + > + <component :is="tab.component" class="gl-pt-3" :data-testid="`${tab.component}-tab`" /> + </gl-tab> + </gl-tabs> + </div> + </section> +</template> diff --git a/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue b/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue new file mode 100644 index 00000000000..027848db6e9 --- /dev/null +++ b/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue @@ -0,0 +1,183 @@ +<script> +import { + GlAlert, + GlButton, + GlSprintf, + GlLink, + GlIcon, + GlFormGroup, + GlFormInputGroup, + GlToggle, + GlModal, + GlModalDirective, +} from '@gitlab/ui'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { I18N_PAGERDUTY_SETTINGS_FORM, CONFIGURE_PAGERDUTY_WEBHOOK_DOCS_LINK } from '../constants'; +import { isEqual } from 'lodash'; + +export default { + components: { + GlAlert, + GlButton, + GlSprintf, + GlLink, + GlIcon, + GlFormGroup, + GlFormInputGroup, + GlToggle, + GlModal, + ClipboardButton, + }, + directives: { + 'gl-modal': GlModalDirective, + }, + inject: ['service', 'pagerDutySettings'], + data() { + return { + active: this.pagerDutySettings.active, + webhookUrl: this.pagerDutySettings.webhookUrl, + loading: false, + resettingWebhook: false, + webhookUpdateFailed: false, + showAlert: false, + }; + }, + i18n: I18N_PAGERDUTY_SETTINGS_FORM, + CONFIGURE_PAGERDUTY_WEBHOOK_DOCS_LINK, + computed: { + formData() { + return { + pagerduty_active: this.active, + }; + }, + isFormUpdated() { + return isEqual(this.pagerDutySettings, { + active: this.active, + webhookUrl: this.webhookUrl, + }); + }, + isSaveDisabled() { + return this.isFormUpdated || this.loading || this.resettingWebhook; + }, + webhookUpdateAlertMsg() { + return this.webhookUpdateFailed + ? this.$options.i18n.webhookUrl.updateErrMsg + : this.$options.i18n.webhookUrl.updateSuccessMsg; + }, + webhookUpdateAlertVariant() { + return this.webhookUpdateFailed ? 'danger' : 'success'; + }, + }, + methods: { + updatePagerDutyIntegrationSettings() { + this.loading = true; + + this.service.updateSettings(this.formData).catch(() => { + this.loading = false; + }); + }, + resetWebhookUrl() { + this.resettingWebhook = true; + + this.service + .resetWebhookUrl() + .then(({ data: { pagerduty_webhook_url: url } }) => { + this.webhookUrl = url; + this.showAlert = true; + this.webhookUpdateFailed = false; + }) + .catch(() => { + this.showAlert = true; + this.webhookUpdateFailed = true; + }) + .finally(() => { + this.resettingWebhook = false; + }); + }, + }, +}; +</script> + +<template> + <div> + <gl-alert + v-if="showAlert" + class="gl-mb-3" + :variant="webhookUpdateAlertVariant" + @dismiss="showAlert = false" + > + {{ webhookUpdateAlertMsg }} + </gl-alert> + + <p>{{ $options.i18n.introText }}</p> + <form ref="settingsForm" @submit.prevent="updatePagerDutyIntegrationSettings"> + <gl-form-group class="col-8 col-md-9 gl-p-0"> + <gl-toggle + id="active" + v-model="active" + :is-loading="loading" + :label="$options.i18n.activeToggle.label" + /> + </gl-form-group> + + <gl-form-group + class="col-8 col-md-9 gl-p-0" + :label="$options.i18n.webhookUrl.label" + label-for="url" + label-class="label-bold" + > + <gl-form-input-group id="url" data-testid="webhook-url" readonly :value="webhookUrl"> + <template #append> + <clipboard-button + :text="webhookUrl" + :title="$options.i18n.webhookUrl.copyToClipboard" + /> + </template> + </gl-form-input-group> + + <div class="gl-text-gray-400 gl-pt-2"> + <gl-sprintf :message="$options.i18n.webhookUrl.helpText"> + <template #docsLink> + <gl-link + :href="$options.CONFIGURE_PAGERDUTY_WEBHOOK_DOCS_LINK" + target="_blank" + class="gl-display-inline-flex" + > + <span>{{ $options.i18n.webhookUrl.helpDocsLink }}</span> + <gl-icon name="external-link" /> + </gl-link> + </template> + </gl-sprintf> + </div> + <gl-button + v-gl-modal.resetWebhookModal + class="gl-mt-3" + :disabled="loading" + :loading="resettingWebhook" + data-testid="webhook-reset-btn" + > + {{ $options.i18n.webhookUrl.resetWebhookUrl }} + </gl-button> + <gl-modal + modal-id="resetWebhookModal" + :title="$options.i18n.webhookUrl.resetWebhookUrl" + :ok-title="$options.i18n.webhookUrl.resetWebhookUrl" + ok-variant="danger" + @ok="resetWebhookUrl" + > + {{ $options.i18n.webhookUrl.restKeyInfo }} + </gl-modal> + </gl-form-group> + + <gl-button + ref="submitBtn" + :disabled="isSaveDisabled" + variant="success" + type="submit" + class="js-no-auto-disable" + > + {{ $options.i18n.saveBtnLabel }} + </gl-button> + </form> + </div> +</template> diff --git a/app/assets/javascripts/incidents_settings/constants.js b/app/assets/javascripts/incidents_settings/constants.js new file mode 100644 index 00000000000..b443c237f0f --- /dev/null +++ b/app/assets/javascripts/incidents_settings/constants.js @@ -0,0 +1,83 @@ +import { __, s__ } from '~/locale'; + +/* Integration tabs constants */ +export const INTEGRATION_TABS_CONFIG = [ + { + title: s__('IncidentSettings|Alert integration'), + component: 'AlertsSettingsForm', + active: true, + }, + { + title: s__('IncidentSettings|PagerDuty integration'), + component: 'PagerDutySettingsForm', + active: true, + featureFlag: 'pagerdutyWebhook', + }, + { + title: s__('IncidentSettings|Grafana integration'), + component: '', + active: false, + }, +]; + +export const I18N_INTEGRATION_TABS = { + headerText: s__('IncidentSettings|Incidents'), + expandBtnLabel: __('Expand'), + subHeaderText: s__( + 'IncidentSettings|Set up integrations with external tools to help better manage incidents.', + ), +}; + +/* Alerts integration settings constants */ + +export const I18N_ALERT_SETTINGS_FORM = { + saveBtnLabel: __('Save changes'), + introText: __('Action to take when receiving an alert. %{docsLink}'), + introLinkText: __('More information.'), + createIssue: { + label: __('Create an issue. Issues are created for each alert triggered.'), + }, + issueTemplate: { + label: __('Issue template (optional)'), + }, + sendEmail: { + label: __('Send a separate email notification to Developers.'), + }, +}; + +export const NO_ISSUE_TEMPLATE_SELECTED = { key: '', name: __('No template selected') }; +export const TAKING_INCIDENT_ACTION_DOCS_LINK = + '/help/user/project/integrations/prometheus#taking-action-on-incidents-ultimate'; +export const ISSUE_TEMPLATES_DOCS_LINK = + '/help/user/project/description_templates#creating-issue-templates'; + +/* PagerDuty integration settings constants */ + +export const I18N_PAGERDUTY_SETTINGS_FORM = { + introText: s__( + 'PagerDutySettings|Setting up a webhook with PagerDuty will automatically create a GitLab issue for each PagerDuty incident.', + ), + activeToggle: { + label: s__('PagerDutySettings|Active'), + }, + webhookUrl: { + label: s__('PagerDutySettings|Webhook URL'), + helpText: s__( + 'PagerDutySettings|Create a GitLab issue for each PagerDuty incident by %{docsLink}', + ), + helpDocsLink: s__('PagerDutySettings|configuring a webhook in PagerDuty'), + resetWebhookUrl: s__('PagerDutySettings|Reset webhook URL'), + copyToClipboard: __('Copy'), + updateErrMsg: s__('PagerDutySettings|Failed to update Webhook URL'), + updateSuccessMsg: s__('PagerDutySettings|Webhook URL update was successful'), + restKeyInfo: s__( + "PagerDutySettings|Resetting the webhook URL for this project will require updating this integration's settings in PagerDuty.", + ), + }, + saveBtnLabel: __('Save changes'), +}; + +export const CONFIGURE_PAGERDUTY_WEBHOOK_DOCS_LINK = 'https://support.pagerduty.com/docs/webhooks'; + +/* common constants */ +export const ERROR_MSG = __('There was an error saving your changes.'); diff --git a/app/assets/javascripts/incidents_settings/incidents_settings_service.js b/app/assets/javascripts/incidents_settings/incidents_settings_service.js new file mode 100644 index 00000000000..bd4f5bb8820 --- /dev/null +++ b/app/assets/javascripts/incidents_settings/incidents_settings_service.js @@ -0,0 +1,32 @@ +import axios from '~/lib/utils/axios_utils'; +import { refreshCurrentPage } from '~/lib/utils/url_utility'; +import createFlash from '~/flash'; +import { ERROR_MSG } from './constants'; + +export default class IncidentsSettingsService { + constructor(settingsEndpoint, webhookUpdateEndpoint) { + this.settingsEndpoint = settingsEndpoint; + this.webhookUpdateEndpoint = webhookUpdateEndpoint; + } + + updateSettings(data) { + return axios + .patch(this.settingsEndpoint, { + project: { + incident_management_setting_attributes: data, + }, + }) + .then(() => { + refreshCurrentPage(); + }) + .catch(({ response }) => { + const message = response?.data?.message || ''; + + createFlash(`${ERROR_MSG} ${message}`, 'alert'); + }); + } + + resetWebhookUrl() { + return axios.post(this.webhookUpdateEndpoint); + } +} diff --git a/app/assets/javascripts/incidents_settings/index.js b/app/assets/javascripts/incidents_settings/index.js new file mode 100644 index 00000000000..80e7d07feca --- /dev/null +++ b/app/assets/javascripts/incidents_settings/index.js @@ -0,0 +1,46 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import SettingsTabs from './components/incidents_settings_tabs.vue'; +import IncidentsSettingsService from './incidents_settings_service'; + +export default () => { + const el = document.querySelector('.js-incidents-settings'); + + if (!el) { + return null; + } + + const { + dataset: { + operationsSettingsEndpoint, + templates, + createIssue, + issueTemplateKey, + sendEmail, + pagerdutyActive, + pagerdutyWebhookUrl, + pagerdutyResetKeyPath, + }, + } = el; + + const service = new IncidentsSettingsService(operationsSettingsEndpoint, pagerdutyResetKeyPath); + return new Vue({ + el, + provide: { + service, + alertSettings: { + templates: JSON.parse(templates), + createIssue: parseBoolean(createIssue), + issueTemplateKey, + sendEmail: parseBoolean(sendEmail), + }, + pagerDutySettings: { + active: parseBoolean(pagerdutyActive), + webhookUrl: pagerdutyWebhookUrl, + }, + }, + render(createElement) { + return createElement(SettingsTabs); + }, + }); +}; diff --git a/app/assets/javascripts/integrations/edit/components/active_toggle.vue b/app/assets/javascripts/integrations/edit/components/active_toggle.vue index dc89e139320..a3087c8958e 100644 --- a/app/assets/javascripts/integrations/edit/components/active_toggle.vue +++ b/app/assets/javascripts/integrations/edit/components/active_toggle.vue @@ -1,4 +1,5 @@ <script> +import { mapGetters } from 'vuex'; import eventHub from '../event_hub'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { GlFormGroup, GlToggle } from '@gitlab/ui'; @@ -21,6 +22,9 @@ export default { activated: this.initialActivated, }; }, + computed: { + ...mapGetters(['isInheriting']), + }, mounted() { // Initialize view this.$nextTick(() => { @@ -42,6 +46,7 @@ export default { v-model="activated" name="service[active]" class="gl-display-block gl-line-height-0" + :disabled="isInheriting" @change="onToggle" /> </gl-form-group> @@ -50,7 +55,12 @@ export default { <div class="form-group row" role="group"> <label for="service[active]" class="col-form-label col-sm-2">{{ __('Active') }}</label> <div class="col-sm-10 pt-1"> - <gl-toggle v-model="activated" name="service[active]" @change="onToggle" /> + <gl-toggle + v-model="activated" + name="service[active]" + :disabled="isInheriting" + @change="onToggle" + /> </div> </div> </div> diff --git a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue index 29318d6aaa8..6053d11e6da 100644 --- a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue +++ b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue @@ -1,4 +1,5 @@ <script> +import { mapGetters } from 'vuex'; import eventHub from '../event_hub'; import { capitalize, lowerCase, isEmpty } from 'lodash'; import { __, sprintf } from '~/locale'; @@ -59,6 +60,7 @@ export default { }; }, computed: { + ...mapGetters(['isInheriting']), isCheckbox() { return this.type === 'checkbox'; }, @@ -106,10 +108,12 @@ export default { return { id: this.fieldId, name: this.fieldName, + state: this.valid, + readonly: this.isInheriting, }; }, valid() { - return !this.required || !isEmpty(this.model) || !this.validated; + return !this.required || !isEmpty(this.model) || this.isNonEmptyPassword || !this.validated; }, }, created() { @@ -135,15 +139,21 @@ export default { :label-for="fieldId" :invalid-feedback="__('This field is required.')" :state="valid" - :description="help" > + <template #description> + <span v-html="help"></span> + </template> + <template v-if="isCheckbox"> - <input :name="fieldName" type="hidden" value="false" /> - <gl-form-checkbox v-model="model" v-bind="sharedProps"> + <input :name="fieldName" type="hidden" :value="model || false" /> + <gl-form-checkbox :id="fieldId" v-model="model" :disabled="isInheriting"> {{ humanizedTitle }} </gl-form-checkbox> </template> - <gl-form-select v-else-if="isSelect" v-model="model" v-bind="sharedProps" :options="options" /> + <template v-else-if="isSelect"> + <input type="hidden" :name="fieldName" :value="model" /> + <gl-form-select :id="fieldId" v-model="model" :options="options" :disabled="isInheriting" /> + </template> <gl-form-textarea v-else-if="isTextarea" v-model="model" @@ -159,6 +169,7 @@ export default { autocomplete="new-password" :placeholder="placeholder" :required="passwordRequired" + :data-qa-selector="`${fieldId}_field`" /> <gl-form-input v-else @@ -167,6 +178,7 @@ export default { :type="type" :placeholder="placeholder" :required="required" + :data-qa-selector="`${fieldId}_field`" /> </gl-form-group> </template> diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue index ef7a4d44b20..5088664c3bd 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue @@ -1,58 +1,74 @@ <script> +import { mapState, mapActions, mapGetters } from 'vuex'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; + +import OverrideDropdown from './override_dropdown.vue'; import ActiveToggle from './active_toggle.vue'; import JiraTriggerFields from './jira_trigger_fields.vue'; +import JiraIssuesFields from './jira_issues_fields.vue'; import TriggerFields from './trigger_fields.vue'; import DynamicField from './dynamic_field.vue'; export default { name: 'IntegrationForm', components: { + OverrideDropdown, ActiveToggle, JiraTriggerFields, + JiraIssuesFields, TriggerFields, DynamicField, }, - props: { - activeToggleProps: { - type: Object, - required: true, - }, - showActive: { - type: Boolean, - required: true, - }, - triggerFieldsProps: { - type: Object, - required: true, - }, - triggerEvents: { - type: Array, - required: false, - default: () => [], - }, - fields: { - type: Array, - required: false, - default: () => [], - }, - type: { - type: String, - required: true, - }, - }, + mixins: [glFeatureFlagsMixin()], computed: { + ...mapGetters(['currentKey', 'propsSource']), + ...mapState(['adminState', 'override']), isJira() { - return this.type === 'jira'; + return this.propsSource.type === 'jira'; }, + showJiraIssuesFields() { + return this.isJira && this.glFeatures.jiraIssuesIntegration; + }, + }, + methods: { + ...mapActions(['setOverride']), }, }; </script> <template> <div> - <active-toggle v-if="showActive" v-bind="activeToggleProps" /> - <jira-trigger-fields v-if="isJira" v-bind="triggerFieldsProps" /> - <trigger-fields v-else-if="triggerEvents.length" :events="triggerEvents" :type="type" /> - <dynamic-field v-for="field in fields" :key="field.name" v-bind="field" /> + <override-dropdown + v-if="adminState !== null" + :inherit-from-id="adminState.id" + :override="override" + @change="setOverride" + /> + <active-toggle + v-if="propsSource.showActive" + :key="`${currentKey}-active-toggle`" + v-bind="propsSource.activeToggleProps" + /> + <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> </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 new file mode 100644 index 00000000000..5444cd5a712 --- /dev/null +++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue @@ -0,0 +1,151 @@ +<script> +import eventHub from '../event_hub'; +import { + GlFormGroup, + GlFormCheckbox, + GlFormInput, + GlSprintf, + GlLink, + GlButton, + GlCard, +} from '@gitlab/ui'; + +export default { + name: 'JiraIssuesFields', + components: { + GlFormGroup, + GlFormCheckbox, + GlFormInput, + GlSprintf, + GlLink, + GlButton, + GlCard, + }, + props: { + showJiraIssuesIntegration: { + type: Boolean, + required: false, + default: false, + }, + initialEnableJiraIssues: { + type: Boolean, + required: false, + default: null, + }, + initialProjectKey: { + type: String, + required: false, + default: null, + }, + upgradePlanPath: { + type: String, + required: false, + default: null, + }, + editProjectPath: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + enableJiraIssues: this.initialEnableJiraIssues, + projectKey: this.initialProjectKey, + validated: false, + }; + }, + computed: { + validProjectKey() { + return !this.enableJiraIssues || Boolean(this.projectKey) || !this.validated; + }, + }, + created() { + eventHub.$on('validateForm', this.validateForm); + }, + beforeDestroy() { + eventHub.$off('validateForm', this.validateForm); + }, + methods: { + validateForm() { + this.validated = true; + }, + }, +}; +</script> + +<template> + <div> + <gl-form-group + :label="s__('JiraService|View Jira issues in GitLab')" + label-for="jira-issue-settings" + > + <div id="jira-issue-settings"> + <p> + {{ + s__( + 'JiraService|Work on Jira issues without leaving GitLab. Adds a Jira menu to access your list of Jira issues and view any issue as read-only.', + ) + }} + </p> + <template v-if="showJiraIssuesIntegration"> + <input name="service[issues_enabled]" type="hidden" :value="enableJiraIssues || false" /> + <gl-form-checkbox v-model="enableJiraIssues"> + {{ s__('JiraService|Enable Jira issues') }} + <template #help> + {{ + s__( + 'JiraService|Warning: All GitLab users that have access to this GitLab project will be able to view all issues from the Jira project specified below.', + ) + }} + </template> + </gl-form-checkbox> + </template> + <gl-card v-else class="gl-mt-7"> + <strong>{{ __('This is a Premium feature') }}</strong> + <p>{{ __('Upgrade your plan to enable this feature of the Jira Integration.') }}</p> + <gl-button + v-if="upgradePlanPath" + category="primary" + variant="info" + :href="upgradePlanPath" + target="_blank" + > + {{ __('Upgrade your plan') }} + </gl-button> + </gl-card> + </div> + </gl-form-group> + <template v-if="showJiraIssuesIntegration"> + <gl-form-group + :label="s__('JiraService|Jira project key')" + label-for="service_project_key" + :invalid-feedback="__('This field is required.')" + :state="validProjectKey" + > + <gl-form-input + id="service_project_key" + v-model="projectKey" + name="service[project_key]" + :placeholder="s__('JiraService|e.g. AB')" + :required="enableJiraIssues" + :state="validProjectKey" + :disabled="!enableJiraIssues" + /> + </gl-form-group> + <p> + <gl-sprintf + :message=" + s__( + 'JiraService|Displaying Jira issues while leaving the GitLab issue functionality enabled might be confusing. Consider %{linkStart}disabling GitLab issues%{linkEnd} if they won’t otherwise be used.', + ) + " + > + <template #link="{ content }"> + <gl-link :href="editProjectPath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + </template> + </div> +</template> diff --git a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue index 64e5789764f..1d3354c6651 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue @@ -1,5 +1,6 @@ <script> import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { mapGetters } from 'vuex'; import { s__ } from '~/locale'; import { GlFormGroup, GlFormCheckbox, GlFormRadio } from '@gitlab/ui'; @@ -55,6 +56,7 @@ export default { }; }, computed: { + ...mapGetters(['isInheriting']), showEnableComments() { return this.triggerCommit || this.triggerMergeRequest; }, @@ -73,13 +75,17 @@ export default { ) " > - <input name="service[commit_events]" type="hidden" value="false" /> - <gl-form-checkbox v-model="triggerCommit" name="service[commit_events]"> + <input name="service[commit_events]" type="hidden" :value="triggerCommit || false" /> + <gl-form-checkbox v-model="triggerCommit" :disabled="isInheriting"> {{ __('Commit') }} </gl-form-checkbox> - <input name="service[merge_requests_events]" type="hidden" value="false" /> - <gl-form-checkbox v-model="triggerMergeRequest" name="service[merge_requests_events]"> + <input + name="service[merge_requests_events]" + type="hidden" + :value="triggerMergeRequest || false" + /> + <gl-form-checkbox v-model="triggerMergeRequest" :disabled="isInheriting"> {{ __('Merge request') }} </gl-form-checkbox> </gl-form-group> @@ -89,8 +95,12 @@ export default { :label="s__('Integrations|Comment settings:')" data-testid="comment-settings" > - <input name="service[comment_on_event_enabled]" type="hidden" value="false" /> - <gl-form-checkbox v-model="enableComments" name="service[comment_on_event_enabled]"> + <input + name="service[comment_on_event_enabled]" + type="hidden" + :value="enableComments || false" + /> + <gl-form-checkbox v-model="enableComments" :disabled="isInheriting"> {{ s__('Integrations|Enable comments') }} </gl-form-checkbox> </gl-form-group> @@ -100,12 +110,18 @@ export default { :label="s__('Integrations|Comment detail:')" data-testid="comment-detail" > + <input + v-if="isInheriting" + name="service[comment_detail]" + type="hidden" + :value="commentDetail" + /> <gl-form-radio v-for="commentDetailOption in commentDetailOptions" :key="commentDetailOption.value" v-model="commentDetail" :value="commentDetailOption.value" - name="service[comment_detail]" + :disabled="isInheriting" > {{ commentDetailOption.label }} <template #help> @@ -126,13 +142,17 @@ export default { }} </label> - <input name="service[commit_events]" type="hidden" value="false" /> - <gl-form-checkbox v-model="triggerCommit" name="service[commit_events]"> + <input name="service[commit_events]" type="hidden" :value="triggerCommit || false" /> + <gl-form-checkbox v-model="triggerCommit" :disabled="isInheriting"> {{ __('Commit') }} </gl-form-checkbox> - <input name="service[merge_requests_events]" type="hidden" value="false" /> - <gl-form-checkbox v-model="triggerMergeRequest" name="service[merge_requests_events]"> + <input + name="service[merge_requests_events]" + type="hidden" + :value="triggerMergeRequest || false" + /> + <gl-form-checkbox v-model="triggerMergeRequest" :disabled="isInheriting"> {{ __('Merge request') }} </gl-form-checkbox> @@ -144,8 +164,12 @@ export default { <label> {{ s__('Integrations|Comment settings:') }} </label> - <input name="service[comment_on_event_enabled]" type="hidden" value="false" /> - <gl-form-checkbox v-model="enableComments" name="service[comment_on_event_enabled]"> + <input + name="service[comment_on_event_enabled]" + type="hidden" + :value="enableComments || false" + /> + <gl-form-checkbox v-model="enableComments" :disabled="isInheriting"> {{ s__('Integrations|Enable comments') }} </gl-form-checkbox> @@ -153,12 +177,18 @@ export default { <label> {{ s__('Integrations|Comment detail:') }} </label> + <input + v-if="isInheriting" + name="service[comment_detail]" + type="hidden" + :value="commentDetail" + /> <gl-form-radio v-for="commentDetailOption in commentDetailOptions" :key="commentDetailOption.value" v-model="commentDetail" :value="commentDetailOption.value" - name="service[comment_detail]" + :disabled="isInheriting" > {{ commentDetailOption.label }} <template #help> diff --git a/app/assets/javascripts/integrations/edit/components/override_dropdown.vue b/app/assets/javascripts/integrations/edit/components/override_dropdown.vue new file mode 100644 index 00000000000..0ae2f267434 --- /dev/null +++ b/app/assets/javascripts/integrations/edit/components/override_dropdown.vue @@ -0,0 +1,63 @@ +<script> +import { s__ } from '~/locale'; +import { GlNewDropdown, GlNewDropdownItem } from '@gitlab/ui'; + +const dropdownOptions = [ + { + value: false, + text: s__('Integrations|Use instance level settings'), + }, + { + value: true, + text: s__('Integrations|Use custom settings'), + }, +]; + +export default { + dropdownOptions, + name: 'OverrideDropdown', + components: { + GlNewDropdown, + GlNewDropdownItem, + }, + props: { + inheritFromId: { + type: Number, + required: true, + }, + override: { + type: Boolean, + required: true, + }, + }, + data() { + return { + selected: dropdownOptions.find(x => x.value === this.override), + }; + }, + methods: { + onClick(option) { + this.selected = option; + this.$emit('change', option.value); + }, + }, +}; +</script> + +<template> + <div + class="gl-display-flex gl-justify-content-space-between gl-align-items-baseline gl-py-4 gl-mt-5 gl-mb-6 gl-border-t-1 gl-border-t-solid gl-border-b-1 gl-border-b-solid gl-border-gray-100" + > + <span>{{ s__('Integrations|This integration has multiple settings available.') }}</span> + <input name="service[inherit_from_id]" :value="override ? '' : inheritFromId" type="hidden" /> + <gl-new-dropdown :text="selected.text"> + <gl-new-dropdown-item + v-for="option in $options.dropdownOptions" + :key="option.value" + @click="onClick(option)" + > + {{ option.text }} + </gl-new-dropdown-item> + </gl-new-dropdown> + </div> +</template> diff --git a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue index 531490ae40c..bb1e0d9d360 100644 --- a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue @@ -1,4 +1,5 @@ <script> +import { mapGetters } from 'vuex'; import { startCase } from 'lodash'; import { __ } from '~/locale'; import { GlFormGroup, GlFormCheckbox, GlFormInput } from '@gitlab/ui'; @@ -32,6 +33,7 @@ export default { }, }, computed: { + ...mapGetters(['isInheriting']), placeholder() { return placeholderForType[this.type]; }, @@ -57,8 +59,8 @@ export default { > <div id="trigger-fields" class="gl-pt-3"> <gl-form-group v-for="event in events" :key="event.title" :description="event.description"> - <input :name="checkboxName(event.name)" type="hidden" value="false" /> - <gl-form-checkbox v-model="event.value" :name="checkboxName(event.name)"> + <input :name="checkboxName(event.name)" type="hidden" :value="event.value || false" /> + <gl-form-checkbox v-model="event.value" :disabled="isInheriting"> {{ startCase(event.title) }} </gl-form-checkbox> <gl-form-input @@ -66,6 +68,7 @@ export default { v-model="event.field.value" :name="fieldName(event.field.name)" :placeholder="placeholder" + :readonly="isInheriting" /> </gl-form-group> </div> diff --git a/app/assets/javascripts/integrations/edit/index.js b/app/assets/javascripts/integrations/edit/index.js index 21b5ca17951..ea5463832ce 100644 --- a/app/assets/javascripts/integrations/edit/index.js +++ b/app/assets/javascripts/integrations/edit/index.js @@ -1,49 +1,86 @@ import Vue from 'vue'; +import { createStore } from './store'; import { parseBoolean } from '~/lib/utils/common_utils'; import IntegrationForm from './components/integration_form.vue'; -export default el => { - if (!el) { - return null; - } - - function parseBooleanInData(data) { - const result = {}; - Object.entries(data).forEach(([key, value]) => { - result[key] = parseBoolean(value); - }); - return result; - } +function parseBooleanInData(data) { + const result = {}; + Object.entries(data).forEach(([key, value]) => { + result[key] = parseBoolean(value); + }); + return result; +} - const { type, commentDetail, triggerEvents, fields, ...booleanAttributes } = el.dataset; +function parseDatasetToProps(data) { + const { + id, + type, + commentDetail, + projectKey, + upgradePlanPath, + editProjectPath, + triggerEvents, + fields, + inheritFromId, + ...booleanAttributes + } = data; const { showActive, activated, commitEvents, mergeRequestEvents, enableComments, + showJiraIssuesIntegration, + enableJiraIssues, } = parseBooleanInData(booleanAttributes); + return { + activeToggleProps: { + initialActivated: activated, + }, + showActive, + type, + triggerFieldsProps: { + initialTriggerCommit: commitEvents, + initialTriggerMergeRequest: mergeRequestEvents, + initialEnableComments: enableComments, + initialCommentDetail: commentDetail, + }, + jiraIssuesProps: { + showJiraIssuesIntegration, + initialEnableJiraIssues: enableJiraIssues, + initialProjectKey: projectKey, + upgradePlanPath, + editProjectPath, + }, + triggerEvents: JSON.parse(triggerEvents), + fields: JSON.parse(fields), + inheritFromId: parseInt(inheritFromId, 10), + id: parseInt(id, 10), + }; +} + +export default (el, adminEl) => { + if (!el) { + return null; + } + + const props = parseDatasetToProps(el.dataset); + + const initialState = { + adminState: null, + customState: props, + }; + + if (adminEl) { + initialState.adminState = Object.freeze(parseDatasetToProps(adminEl.dataset)); + } + return new Vue({ el, + store: createStore(initialState), render(createElement) { - return createElement(IntegrationForm, { - props: { - activeToggleProps: { - initialActivated: activated, - }, - showActive, - type, - triggerFieldsProps: { - initialTriggerCommit: commitEvents, - initialTriggerMergeRequest: mergeRequestEvents, - initialEnableComments: enableComments, - initialCommentDetail: commentDetail, - }, - triggerEvents: JSON.parse(triggerEvents), - fields: JSON.parse(fields), - }, - }); + return createElement(IntegrationForm); }, }); }; diff --git a/app/assets/javascripts/integrations/edit/store/actions.js b/app/assets/javascripts/integrations/edit/store/actions.js new file mode 100644 index 00000000000..3decdaab55d --- /dev/null +++ b/app/assets/javascripts/integrations/edit/store/actions.js @@ -0,0 +1,4 @@ +import * as types from './mutation_types'; + +// eslint-disable-next-line import/prefer-default-export +export const setOverride = ({ commit }, override) => commit(types.SET_OVERRIDE, override); diff --git a/app/assets/javascripts/integrations/edit/store/getters.js b/app/assets/javascripts/integrations/edit/store/getters.js new file mode 100644 index 00000000000..b68bd668980 --- /dev/null +++ b/app/assets/javascripts/integrations/edit/store/getters.js @@ -0,0 +1,6 @@ +export const isInheriting = state => (state.adminState === null ? false : !state.override); + +export const propsSource = (state, getters) => + getters.isInheriting ? state.adminState : state.customState; + +export const currentKey = (state, getters) => (getters.isInheriting ? 'admin' : 'custom'); diff --git a/app/assets/javascripts/integrations/edit/store/index.js b/app/assets/javascripts/integrations/edit/store/index.js new file mode 100644 index 00000000000..eea5e48780d --- /dev/null +++ b/app/assets/javascripts/integrations/edit/store/index.js @@ -0,0 +1,17 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; +import createState from './state'; + +Vue.use(Vuex); + +// eslint-disable-next-line import/prefer-default-export +export const createStore = (initialState = {}) => + new Vuex.Store({ + actions, + getters, + mutations, + state: createState(initialState), + }); diff --git a/app/assets/javascripts/integrations/edit/store/mutation_types.js b/app/assets/javascripts/integrations/edit/store/mutation_types.js new file mode 100644 index 00000000000..274afe3fb49 --- /dev/null +++ b/app/assets/javascripts/integrations/edit/store/mutation_types.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export +export const SET_OVERRIDE = 'SET_OVERRIDE'; diff --git a/app/assets/javascripts/integrations/edit/store/mutations.js b/app/assets/javascripts/integrations/edit/store/mutations.js new file mode 100644 index 00000000000..8757d415197 --- /dev/null +++ b/app/assets/javascripts/integrations/edit/store/mutations.js @@ -0,0 +1,7 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_OVERRIDE](state, override) { + state.override = override; + }, +}; diff --git a/app/assets/javascripts/integrations/edit/store/state.js b/app/assets/javascripts/integrations/edit/store/state.js new file mode 100644 index 00000000000..95c1a2be500 --- /dev/null +++ b/app/assets/javascripts/integrations/edit/store/state.js @@ -0,0 +1,9 @@ +export default ({ adminState = null, customState = {} } = {}) => { + const override = adminState !== null ? adminState.id !== customState.inheritFromId : false; + + return { + override, + adminState, + customState, + }; +}; diff --git a/app/assets/javascripts/integrations/integration_settings_form.js b/app/assets/javascripts/integrations/integration_settings_form.js index 8844cbebe85..837409a91ca 100644 --- a/app/assets/javascripts/integrations/integration_settings_form.js +++ b/app/assets/javascripts/integrations/integration_settings_form.js @@ -22,7 +22,10 @@ export default class IntegrationSettingsForm { init() { // Init Vue component - initForm(document.querySelector('.js-vue-integration-settings')); + initForm( + document.querySelector('.js-vue-integration-settings'), + document.querySelector('.js-vue-admin-integration-settings'), + ); eventHub.$on('toggle', active => { this.formActive = active; this.handleServiceToggle(); diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js index 01ea3eee16e..d968e9e5235 100644 --- a/app/assets/javascripts/issuable_bulk_update_actions.js +++ b/app/assets/javascripts/issuable_bulk_update_actions.js @@ -1,7 +1,5 @@ -/* eslint-disable consistent-return, func-names, array-callback-return */ - import $ from 'jquery'; -import { intersection } from 'lodash'; +import { difference, intersection, union } from 'lodash'; import axios from './lib/utils/axios_utils'; import Flash from './flash'; import { __ } from './locale'; @@ -36,43 +34,6 @@ export default { return new Flash(__('Issue update failed')); }, - getSelectedIssues() { - return this.issues.has('.selected-issuable:checked'); - }, - - getLabelsFromSelection() { - const labels = []; - this.getSelectedIssues().map(function() { - const labelsData = $(this).data('labels'); - if (labelsData) { - return labelsData.map(labelId => { - if (labels.indexOf(labelId) === -1) { - return labels.push(labelId); - } - }); - } - }); - return labels; - }, - - /** - * Will return only labels that were marked previously and the user has unmarked - * @return {Array} Label IDs - */ - - getUnmarkedIndeterminedLabels() { - const result = []; - const labelsToKeep = this.$labelDropdown.data('indeterminate'); - - this.getLabelsFromSelection().forEach(id => { - if (labelsToKeep.indexOf(id) === -1) { - result.push(id); - } - }); - - return result; - }, - /** * Simple form serialization, it will return just what we need * Returns key/value pairs from form data @@ -86,40 +47,44 @@ export default { milestone_id: this.form.find('input[name="update[milestone_id]"]').val(), issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(), 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(), add_label_ids: [], remove_label_ids: [], }, }; if (this.willUpdateLabels) { - formData.update.add_label_ids = this.$labelDropdown.data('marked'); - formData.update.remove_label_ids = this.$labelDropdown.data('unmarked'); + formData.update.add_label_ids = this.$labelDropdown.data('user-checked'); + formData.update.remove_label_ids = this.$labelDropdown.data('user-unchecked'); } return formData; }, setOriginalDropdownData() { const $labelSelect = $('.bulk-update .js-label-select'); - const dirtyLabelIds = $labelSelect.data('marked') || []; - const chosenLabelIds = [...this.getOriginalMarkedIds(), ...dirtyLabelIds]; - - $labelSelect.data('common', this.getOriginalCommonIds()); - $labelSelect.data('marked', chosenLabelIds); - $labelSelect.data('indeterminate', this.getOriginalIndeterminateIds()); + const userCheckedIds = $labelSelect.data('user-checked') || []; + const userUncheckedIds = $labelSelect.data('user-unchecked') || []; + + // Common labels plus user checked labels minus user unchecked labels + const checkedIdsToShow = difference( + union(this.getOriginalCommonIds(), userCheckedIds), + userUncheckedIds, + ); + + // Indeterminate labels minus user checked labels minus user unchecked labels + const indeterminateIdsToShow = difference( + this.getOriginalIndeterminateIds(), + userCheckedIds, + userUncheckedIds, + ); + + $labelSelect.data('marked', checkedIdsToShow); + $labelSelect.data('indeterminate', indeterminateIdsToShow); }, // From issuable's initial bulk selection getOriginalCommonIds() { const labelIds = []; - - this.getElement('.selected-issuable:checked').each((i, el) => { - labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels')); - }); - return intersection.apply(this, labelIds); - }, - - // From issuable's initial bulk selection - getOriginalMarkedIds() { - const labelIds = []; this.getElement('.selected-issuable:checked').each((i, el) => { labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels')); }); diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar.js index 50562688c53..85c2a370ff3 100644 --- a/app/assets/javascripts/issuable_bulk_update_sidebar.js +++ b/app/assets/javascripts/issuable_bulk_update_sidebar.js @@ -63,6 +63,22 @@ export default class IssuableBulkUpdateSidebar { new MilestoneSelect(); issueStatusSelect(); subscriptionSelect(); + + if (IS_EE) { + import('ee/vue_shared/components/sidebar/health_status_select/health_status_bundle') + .then(({ default: HealthStatusSelect }) => { + HealthStatusSelect(); + }) + .catch(() => {}); + } + + if (IS_EE) { + import('ee/vue_shared/components/sidebar/epics_select/epics_select_bundle') + .then(({ default: EpicSelect }) => { + EpicSelect(); + }) + .catch(() => {}); + } } setupBulkUpdateActions() { diff --git a/app/assets/javascripts/issuable_suggestions/components/app.vue b/app/assets/javascripts/issuable_suggestions/components/app.vue index 67d10b797fb..810ca7ac1bd 100644 --- a/app/assets/javascripts/issuable_suggestions/components/app.vue +++ b/app/assets/javascripts/issuable_suggestions/components/app.vue @@ -84,7 +84,7 @@ export default { v-for="(suggestion, index) in issues" :key="suggestion.id" :class="{ - 'append-bottom-default': index !== issues.length - 1, + 'gl-mb-3': index !== issues.length - 1, }" > <suggestion :suggestion="suggestion" /> diff --git a/app/assets/javascripts/issuable_suggestions/components/item.vue b/app/assets/javascripts/issuable_suggestions/components/item.vue index 51904c64085..dfadb9d2b24 100644 --- a/app/assets/javascripts/issuable_suggestions/components/item.vue +++ b/app/assets/javascripts/issuable_suggestions/components/item.vue @@ -75,7 +75,11 @@ export default { name="eye-slash" class="suggestion-help-hover mr-1 suggestion-confidential" /> - <gl-link :href="suggestion.webUrl" target="_blank" class="suggestion bold str-truncated-100"> + <gl-link + :href="suggestion.webUrl" + target="_blank" + class="suggestion bold str-truncated-100 gl-text-gray-900!" + > {{ suggestion.title }} </gl-link> </div> diff --git a/app/assets/javascripts/issuables_list/components/issuable.vue b/app/assets/javascripts/issuables_list/components/issuable.vue index 947c7518289..b7f4292a126 100644 --- a/app/assets/javascripts/issuables_list/components/issuable.vue +++ b/app/assets/javascripts/issuables_list/components/issuable.vue @@ -3,8 +3,11 @@ * This is tightly coupled to projects/issues/_issue.html.haml, * any changes done to the haml need to be reflected here. */ + +// TODO: need to move this component to graphql - https://gitlab.com/gitlab-org/gitlab/-/issues/221246 import { escape, isNumber } from 'lodash'; -import { GlLink, GlTooltipDirective as GlTooltip, GlSprintf } from '@gitlab/ui'; +import { GlLink, GlTooltipDirective as GlTooltip, GlSprintf, GlLabel, GlIcon } from '@gitlab/ui'; +import jiraLogo from '@gitlab/svgs/dist/illustrations/logos/jira.svg'; import { dateInWords, formatDate, @@ -16,22 +19,26 @@ import { import { sprintf, __ } from '~/locale'; import initUserPopovers from '~/user_popovers'; import { mergeUrlParams } from '~/lib/utils/url_utility'; -import Icon from '~/vue_shared/components/icon.vue'; import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; +import { isScopedLabel } from '~/lib/utils/common_utils'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { i18n: { openedAgo: __('opened %{timeAgoString} by %{user}'), + openedAgoJira: __('opened %{timeAgoString} by %{user} in Jira'), }, components: { - Icon, IssueAssignees, GlLink, + GlLabel, + GlIcon, GlSprintf, }, directives: { GlTooltip, }, + mixins: [glFeatureFlagsMixin()], props: { issuable: { type: Object, @@ -55,14 +62,19 @@ export default { }, }, }, + data() { + return { + jiraLogo, + }; + }, computed: { milestoneLink() { const { title } = this.issuable.milestone; return this.issuableLink({ milestone_title: title }); }, - hasLabels() { - return Boolean(this.issuable.labels && this.issuable.labels.length); + scopedLabelsAvailable() { + return this.glFeatures.scopedLabels; }, hasWeight() { return isNumber(this.issuable.weight); @@ -82,6 +94,12 @@ export default { isClosed() { return this.issuable.state === 'closed'; }, + isJiraIssue() { + return this.issuable.external_tracker === 'jira'; + }, + linkTarget() { + return this.isJiraIssue ? '_blank' : null; + }, issueCreatedToday() { return getDayDifference(new Date(this.issuable.created_at), new Date()) < 1; }, @@ -147,14 +165,14 @@ export default { value: this.issuable.upvotes, title: __('Upvotes'), class: 'js-upvotes', - faicon: 'fa-thumbs-up', + icon: 'thumb-up', }, { key: 'downvotes', value: this.issuable.downvotes, title: __('Downvotes'), class: 'js-downvotes', - faicon: 'fa-thumbs-down', + icon: 'thumb-down', }, ]; }, @@ -165,16 +183,17 @@ export default { initUserPopovers([this.$refs.openedAgoByContainer.$el]); }, methods: { - labelStyle(label) { - return { - backgroundColor: label.color, - color: label.text_color, - }; - }, issuableLink(params) { return mergeUrlParams(params, this.baseUrl); }, + isScoped({ name }) { + return isScopedLabel({ title: name }) && this.scopedLabelsAvailable; + }, labelHref({ name }) { + if (this.isJiraIssue) { + return this.issuableLink({ 'labels[]': name }); + } + return this.issuableLink({ 'label_name[]': name }); }, onSelect(ev) { @@ -214,14 +233,23 @@ export default { <div class="flex-grow-1"> <div class="title"> <span class="issue-title-text"> - <i + <gl-icon v-if="issuable.confidential" v-gl-tooltip - class="fa fa-eye-slash" + name="eye-slash" + class="gl-vertical-align-text-bottom" + :size="16" :title="$options.confidentialTooltipText" :aria-label="$options.confidentialTooltipText" - ></i> - <gl-link :href="issuable.web_url">{{ issuable.title }}</gl-link> + /> + <gl-link :href="issuable.web_url" :target="linkTarget" data-testid="issuable-title"> + {{ issuable.title }} + <gl-icon + v-if="isJiraIssue" + name="external-link" + class="gl-vertical-align-text-bottom" + /> + </gl-link> </span> <span v-if="issuable.has_tasks" class="ml-1 task-status d-none d-sm-inline-block"> {{ issuable.task_status }} @@ -229,11 +257,21 @@ export default { </div> <div class="issuable-info"> - <span class="js-ref-path">{{ referencePath }}</span> + <span class="js-ref-path"> + <span + v-if="isJiraIssue" + class="svg-container jira-logo-container" + data-testid="jira-logo" + v-html="jiraLogo" + ></span> + {{ referencePath }} + </span> <span data-testid="openedByMessage" class="d-none d-sm-inline-block mr-1"> · - <gl-sprintf :message="$options.i18n.openedAgo"> + <gl-sprintf + :message="isJiraIssue ? $options.i18n.openedAgoJira : $options.i18n.openedAgo" + > <template #timeAgoString> <span>{{ issuableCreatedAt }}</span> </template> @@ -242,6 +280,7 @@ export default { ref="openedAgoByContainer" v-bind="popoverDataAttrs" :href="issuableAuthor.web_url" + :target="linkTarget" > {{ issuableAuthor.name }} </gl-link> @@ -271,30 +310,29 @@ export default { {{ dueDateWords }} </span> - <span v-if="hasLabels" class="js-labels"> - <gl-link - v-for="label in issuable.labels" - :key="label.id" - class="label-link mr-1" - :href="labelHref(label)" - > - <span - v-gl-tooltip - class="badge color-label" - :style="labelStyle(label)" - :title="label.description" - >{{ label.name }}</span - > - </gl-link> - </span> + <gl-label + v-for="label in issuable.labels" + :key="label.id" + data-qa-selector="issuable-label" + :target="labelHref(label)" + :background-color="label.color" + :description="label.description" + :color="label.text_color" + :title="label.name" + :scoped="isScoped(label)" + size="sm" + class="mr-1" + >{{ label.name }}</gl-label + > <span v-if="hasWeight" v-gl-tooltip :title="__('Weight')" class="d-none d-sm-inline-block js-weight" + data-testid="weight" > - <icon name="weight" class="align-text-bottom" /> + <gl-icon name="weight" class="align-text-bottom" /> {{ issuable.weight }} </span> </div> @@ -303,7 +341,8 @@ export default { <!-- Issuable meta --> <div class="flex-shrink-0 d-flex flex-column align-items-end justify-content-center"> <div class="controls d-flex"> - <span v-if="isClosed" class="issuable-status">{{ __('CLOSED') }}</span> + <span v-if="isJiraIssue" data-testid="issuable-status">{{ issuable.status }}</span> + <span v-else-if="isClosed" class="issuable-status">{{ __('CLOSED') }}</span> <issue-assignees :assignees="issuable.assignees" @@ -318,23 +357,23 @@ export default { v-if="meta.value" :key="meta.key" v-gl-tooltip - :class="['d-none d-sm-inline-block ml-2', meta.class]" + :class="['d-none d-sm-inline-block ml-2 vertical-align-middle', meta.class]" :title="meta.title" > - <icon v-if="meta.icon" :name="meta.icon" /> - <i v-else :class="['fa', meta.faicon]"></i> + <gl-icon v-if="meta.icon" :name="meta.icon" /> {{ meta.value }} </span> </template> <gl-link + v-if="!isJiraIssue" v-gl-tooltip class="ml-2 js-notes" :href="`${issuable.web_url}#notes`" :title="__('Comments')" :class="{ 'no-comments': hasNoComments }" > - <i class="fa fa-comments"></i> + <gl-icon name="comments" class="gl-vertical-align-text-bottom" /> {{ userNotesCount }} </gl-link> </div> diff --git a/app/assets/javascripts/issuables_list/components/issuable_list_root_app.vue b/app/assets/javascripts/issuables_list/components/issuable_list_root_app.vue index 49a89d15c35..cc90d23eda7 100644 --- a/app/assets/javascripts/issuables_list/components/issuable_list_root_app.vue +++ b/app/assets/javascripts/issuables_list/components/issuable_list_root_app.vue @@ -1,10 +1,13 @@ <script> 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, - isFinished, isInProgress, + setFinishedAlertHideMap, + shouldShowFinishedAlert, } from '~/jira_import/utils/jira_import_utils'; export default { @@ -33,8 +36,6 @@ export default { }, data() { return { - isFinishedAlertShowing: true, - isInProgressAlertShowing: true, jiraImport: {}, }; }, @@ -46,36 +47,42 @@ export default { fullPath: this.projectPath, }; }, - update: ({ project }) => ({ - isInProgress: isInProgress(project.jiraImportStatus), - isFinished: isFinished(project.jiraImportStatus), - label: calculateJiraImportLabel( + update: ({ project }) => { + const label = calculateJiraImportLabel( project.jiraImports.nodes, project.issues.nodes.flatMap(({ labels }) => labels.nodes), - ), - }), + ); + return { + importedIssuesCount: last(project.jiraImports.nodes)?.importedIssuesCount, + label, + shouldShowFinishedAlert: shouldShowFinishedAlert(label.title, project.jiraImportStatus), + shouldShowInProgressAlert: isInProgress(project.jiraImportStatus), + }; + }, skip() { return !this.isJiraConfigured || !this.canEdit; }, }, }, computed: { + finishedMessage() { + return n__( + '%d issue successfully imported with the label', + '%d issues successfully imported with the label', + this.jiraImport.importedIssuesCount, + ); + }, labelTarget() { return `${this.issuesPath}?label_name[]=${encodeURIComponent(this.jiraImport.label.title)}`; }, - shouldShowFinishedAlert() { - return this.isFinishedAlertShowing && this.jiraImport.isFinished; - }, - shouldShowInProgressAlert() { - return this.isInProgressAlertShowing && this.jiraImport.isInProgress; - }, }, methods: { hideFinishedAlert() { - this.isFinishedAlertShowing = false; + setFinishedAlertHideMap(this.jiraImport.label.title); + this.jiraImport.shouldShowFinishedAlert = false; }, hideInProgressAlert() { - this.isInProgressAlertShowing = false; + this.jiraImport.shouldShowInProgressAlert = false; }, }, }; @@ -83,11 +90,16 @@ export default { <template> <div class="issuable-list-root"> - <gl-alert v-if="shouldShowInProgressAlert" @dismiss="hideInProgressAlert"> + <gl-alert v-if="jiraImport.shouldShowInProgressAlert" @dismiss="hideInProgressAlert"> {{ __('Import in progress. Refresh page to see newly added issues.') }} </gl-alert> - <gl-alert v-if="shouldShowFinishedAlert" variant="success" @dismiss="hideFinishedAlert"> - {{ __('Issues successfully imported with the label') }} + + <gl-alert + v-if="jiraImport.shouldShowFinishedAlert" + variant="success" + @dismiss="hideFinishedAlert" + > + {{ finishedMessage }} <gl-label :background-color="jiraImport.label.color" scoped diff --git a/app/assets/javascripts/issuables_list/components/issuables_list_app.vue b/app/assets/javascripts/issuables_list/components/issuables_list_app.vue index 1c395fd9795..21aeb2ca143 100644 --- a/app/assets/javascripts/issuables_list/components/issuables_list_app.vue +++ b/app/assets/javascripts/issuables_list/components/issuables_list_app.vue @@ -12,8 +12,10 @@ import { 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 { sortOrderMap, + availableSortOptionsJira, RELATIVE_POSITION, PAGE_SIZE, PAGE_SIZE_MANUAL, @@ -29,6 +31,7 @@ export default { GlPagination, GlSkeletonLoading, Issuable, + FilteredSearchBar, }, props: { canBulkEdit: { @@ -50,14 +53,25 @@ export default { type: String, required: true, }, + projectPath: { + type: String, + required: false, + default: '', + }, sortKey: { type: String, required: false, default: '', }, + type: { + type: String, + required: false, + default: '', + }, }, data() { return { + availableSortOptionsJira, filters: {}, isBulkEditing: false, issuables: [], @@ -118,6 +132,45 @@ export default { baseUrl() { return window.location.href.replace(/(\?.*)?(#.*)?$/, ''); }, + paginationNext() { + return this.page + 1; + }, + paginationPrev() { + return this.page - 1; + }, + paginationProps() { + const paginationProps = { value: this.page }; + + if (this.totalItems) { + return { + ...paginationProps, + perPage: this.itemsPerPage, + totalItems: this.totalItems, + }; + } + + return { + ...paginationProps, + prevPage: this.paginationPrev, + nextPage: this.paginationNext, + }; + }, + isJira() { + return this.type === 'jira'; + }, + initialFilterValue() { + const value = []; + const { search } = this.getQueryObject(); + + if (search) { + value.push(search); + } + return value; + }, + initialSortBy() { + const { sort } = this.getQueryObject(); + return sort || 'created_desc'; + }, }, watch: { selection() { @@ -222,9 +275,13 @@ export default { const { label_name: labels, milestone_title: milestoneTitle, + 'not[label_name]': excludedLabels, + 'not[milestone_title]': excludedMilestone, ...filters } = this.getQueryObject(); + // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/227880 + if (milestoneTitle) { filters.milestone = milestoneTitle; } @@ -235,58 +292,104 @@ export default { filters.state = 'opened'; } + if (excludedLabels) { + filters['not[labels]'] = excludedLabels; + } + + if (excludedMilestone) { + filters['not[milestone]'] = excludedMilestone; + } + Object.assign(filters, sortOrderMap[this.sortKey]); this.filters = filters; }, + refetchIssuables() { + const ignored = ['utf8']; + const params = omit(this.filters, ignored); + + historyPushState(setUrlParams(params, window.location.href, true, true)); + this.fetchIssuables(); + }, + handleFilter(filters) { + let search = null; + + filters.forEach(filter => { + if (typeof filter === 'string') { + search = filter; + } + }); + + this.filters.search = search; + this.page = 1; + + this.refetchIssuables(); + }, + handleSort(sort) { + this.filters.sort = sort; + this.page = 1; + + this.refetchIssuables(); + }, }, }; </script> <template> - <ul v-if="loading" class="content-list"> - <li v-for="n in $options.LOADING_LIST_ITEMS_LENGTH" :key="n" class="issue gl-px-5! gl-py-5!"> - <gl-skeleton-loading /> - </li> - </ul> - <div v-else-if="issuables.length"> - <div v-if="isBulkEditing" class="issue px-3 py-3 border-bottom border-light"> - <input type="checkbox" :checked="allIssuablesSelected" class="mr-2" @click="onSelectAll" /> - <strong>{{ __('Select all') }}</strong> - </div> - <ul - class="content-list issuable-list issues-list" - :class="{ 'manual-ordering': isManualOrdering }" - > - <issuable - v-for="issuable in issuables" - :key="issuable.id" - class="pr-3" - :class="{ 'user-can-drag': isManualOrdering }" - :issuable="issuable" - :is-bulk-editing="isBulkEditing" - :selected="isSelected(issuable.id)" - :base-url="baseUrl" - @select="onSelectIssuable" - /> + <div> + <filtered-search-bar + v-if="isJira" + :namespace="projectPath" + :search-input-placeholder="__('Search Jira issues')" + :tokens="[]" + :sort-options="availableSortOptionsJira" + :initial-filter-value="initialFilterValue" + :initial-sort-by="initialSortBy" + class="row-content-block" + @onFilter="handleFilter" + @onSort="handleSort" + /> + <ul v-if="loading" class="content-list"> + <li v-for="n in $options.LOADING_LIST_ITEMS_LENGTH" :key="n" class="issue gl-px-5! gl-py-5!"> + <gl-skeleton-loading /> + </li> </ul> - <div class="mt-3"> - <gl-pagination - v-if="totalItems" - :value="page" - :per-page="itemsPerPage" - :total-items="totalItems" - class="justify-content-center" - @input="onPaginate" - /> + <div v-else-if="issuables.length"> + <div v-if="isBulkEditing" class="issue px-3 py-3 border-bottom border-light"> + <input type="checkbox" :checked="allIssuablesSelected" class="mr-2" @click="onSelectAll" /> + <strong>{{ __('Select all') }}</strong> + </div> + <ul + class="content-list issuable-list issues-list" + :class="{ 'manual-ordering': isManualOrdering }" + > + <issuable + v-for="issuable in issuables" + :key="issuable.id" + class="pr-3" + :class="{ 'user-can-drag': isManualOrdering }" + :issuable="issuable" + :is-bulk-editing="isBulkEditing" + :selected="isSelected(issuable.id)" + :base-url="baseUrl" + @select="onSelectIssuable" + /> + </ul> + <div class="mt-3"> + <gl-pagination + v-bind="paginationProps" + class="gl-justify-content-center" + @input="onPaginate" + /> + </div> </div> + <gl-empty-state + v-else + :title="emptyState.title" + :description="emptyState.description" + :svg-path="emptySvgPath" + :primary-button-link="emptyState.primaryLink" + :primary-button-text="emptyState.primaryText" + /> </div> - <gl-empty-state - v-else - :title="emptyState.title" - :description="emptyState.description" - :svg-path="emptySvgPath" - :primary-button-link="emptyState.primaryLink" - :primary-button-text="emptyState.primaryText" - /> </template> diff --git a/app/assets/javascripts/issuables_list/constants.js b/app/assets/javascripts/issuables_list/constants.js index 71b9c52c703..f008ba1bf4a 100644 --- a/app/assets/javascripts/issuables_list/constants.js +++ b/app/assets/javascripts/issuables_list/constants.js @@ -1,3 +1,5 @@ +import { __ } from '~/locale'; + // Maps sort order as it appears in the URL query to API `order_by` and `sort` params. const PRIORITY = 'priority'; const ASC = 'asc'; @@ -31,3 +33,24 @@ export const sortOrderMap = { weight_desc: { order_by: WEIGHT, sort: DESC }, weight: { order_by: WEIGHT, sort: ASC }, }; + +export const availableSortOptionsJira = [ + { + id: 1, + title: __('Created date'), + sortDirection: { + descending: 'created_desc', + ascending: 'created_asc', + }, + }, + { + id: 2, + title: __('Last updated'), + sortDirection: { + descending: 'updated_desc', + ascending: 'updated_asc', + }, + }, +]; + +export const JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY = 'jira-import-success-alert-hide-map'; diff --git a/app/assets/javascripts/issuables_list/index.js b/app/assets/javascripts/issuables_list/index.js index 6bfb885a8af..40252c10d5f 100644 --- a/app/assets/javascripts/issuables_list/index.js +++ b/app/assets/javascripts/issuables_list/index.js @@ -36,7 +36,7 @@ function mountIssuableListRootApp() { } function mountIssuablesListApp() { - if (!gon.features?.vueIssuablesList) { + if (!gon.features?.vueIssuablesList && !gon.features?.jiraIssuesIntegration) { return; } diff --git a/app/assets/javascripts/issuables_list/queries/get_issues_list_details.query.graphql b/app/assets/javascripts/issuables_list/queries/get_issues_list_details.query.graphql index b62b9b2af60..8f9b888d19b 100644 --- a/app/assets/javascripts/issuables_list/queries/get_issues_list_details.query.graphql +++ b/app/assets/javascripts/issuables_list/queries/get_issues_list_details.query.graphql @@ -1,5 +1,3 @@ -#import "~/jira_import/queries/jira_import.fragment.graphql" - query($fullPath: ID!) { project(fullPath: $fullPath) { issues { @@ -15,7 +13,8 @@ query($fullPath: ID!) { jiraImportStatus jiraImports { nodes { - ...JiraImport + importedIssuesCount + jiraProjectKey } } } diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index 252e8e92f5e..a01faeb1c8d 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -11,7 +11,7 @@ import { __ } from './locale'; export default class Issue { constructor() { - if ($('a.btn-close').length) this.initIssueBtnEventListeners(); + if ($('.btn-close, .btn-reopen').length) this.initIssueBtnEventListeners(); if ($('.js-close-blocked-issue-warning').length) this.initIssueWarningBtnEventListener(); @@ -32,8 +32,8 @@ export default class Issue { Issue.initRelatedBranches(); } - this.closeButtons = $('a.btn-close'); - this.reopenButtons = $('a.btn-reopen'); + this.closeButtons = $('.btn-close'); + this.reopenButtons = $('.btn-reopen'); this.initCloseReopenReport(); @@ -103,7 +103,7 @@ export default class Issue { // NOTE: data attribute seems unnecessary but is actually necessary return $('.js-issuable-buttons[data-action="close-reopen"]').on( 'click', - 'a.btn-close, a.btn-reopen, a.btn-close-anyway', + '.btn-close, .btn-reopen, .btn-close-anyway', e => { e.preventDefault(); e.stopImmediatePropagation(); @@ -120,7 +120,7 @@ export default class Issue { } else { this.disableCloseReopenButton($button); - const url = $button.attr('href'); + const url = $button.data('endpoint'); return axios .put(url) diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index 09acfd1cfae..bcf5dc2aaaf 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -420,7 +420,7 @@ export default { <transition name="issuable-header-slide"> <div v-if="shouldShowStickyHeader" - class="issue-sticky-header gl-fixed gl-z-index-2 gl-bg-white gl-border-1 gl-border-b-solid gl-border-b-gray-200 gl-py-3" + class="issue-sticky-header gl-fixed gl-z-index-3 gl-bg-white gl-border-1 gl-border-b-solid gl-border-b-gray-100 gl-py-3" data-testid="issue-sticky-header" > <div diff --git a/app/assets/javascripts/issue_show/components/edit_actions.vue b/app/assets/javascripts/issue_show/components/edit_actions.vue index 588ae655de4..4ee44e50d2f 100644 --- a/app/assets/javascripts/issue_show/components/edit_actions.vue +++ b/app/assets/javascripts/issue_show/components/edit_actions.vue @@ -63,7 +63,7 @@ export default { </script> <template> - <div class="prepend-top-default append-bottom-default clearfix"> + <div class="gl-mt-3 gl-mb-3 clearfix"> <button :class="{ disabled: formState.updateLoading || !isSubmitEnabled }" :disabled="formState.updateLoading || !isSubmitEnabled" @@ -81,7 +81,7 @@ export default { v-if="shouldShowDeleteButton" :class="{ disabled: deleteLoading }" :disabled="deleteLoading" - class="btn btn-danger float-right append-right-default qa-delete-button" + class="btn btn-danger float-right gl-mr-3 qa-delete-button" type="button" @click="deleteIssuable" > diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue index 35165c9b481..0de0060615b 100644 --- a/app/assets/javascripts/issue_show/components/fields/description.vue +++ b/app/assets/javascripts/issue_show/components/fields/description.vue @@ -55,7 +55,7 @@ export default { class="note-textarea js-gfm-input js-autosize markdown-area qa-description-textarea" dir="auto" - data-supports-quick-actions="false" + data-supports-quick-actions="true" :aria-label="__('Description')" :placeholder="__('Write a comment or drag your files here…')" @keydown.meta.enter="updateIssuable" diff --git a/app/assets/javascripts/issue_show/components/issuable_header_warnings.vue b/app/assets/javascripts/issue_show/components/issuable_header_warnings.vue new file mode 100644 index 00000000000..b6816be9eb8 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/issuable_header_warnings.vue @@ -0,0 +1,28 @@ +<script> +import { mapState } from 'vuex'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { + Icon, + }, + computed: { + ...mapState({ + confidential: ({ noteableData }) => noteableData.confidential, + dicussionLocked: ({ noteableData }) => noteableData.discussion_locked, + }), + }, +}; +</script> + +<template> + <div class="gl-display-inline-block"> + <div v-if="confidential" class="issuable-warning-icon inline"> + <icon class="icon" name="eye-slash" data-testid="confidential" /> + </div> + + <div v-if="dicussionLocked" class="issuable-warning-icon inline"> + <icon class="icon" name="lock" data-testid="locked" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/issue_show/components/pinned_links.vue b/app/assets/javascripts/issue_show/components/pinned_links.vue index 4b50acceb62..a877aa2ac96 100644 --- a/app/assets/javascripts/issue_show/components/pinned_links.vue +++ b/app/assets/javascripts/issue_show/components/pinned_links.vue @@ -1,11 +1,10 @@ <script> -import { GlLink } from '@gitlab/ui'; -import Icon from '~/vue_shared/components/icon.vue'; +import { GlButton } from '@gitlab/ui'; +import { STATUS_PAGE_PUBLISHED, JOIN_ZOOM_MEETING } from '../constants'; export default { components: { - Icon, - GlLink, + GlButton, }, props: { zoomMeetingUrl: { @@ -19,32 +18,46 @@ export default { default: '', }, }, + computed: { + pinnedLinks() { + return [ + { + id: 'publishedIncidentUrl', + url: this.publishedIncidentUrl, + text: STATUS_PAGE_PUBLISHED, + icon: 'tanuki', + }, + { + id: 'zoomMeetingUrl', + url: this.zoomMeetingUrl, + text: JOIN_ZOOM_MEETING, + icon: 'brand-zoom', + }, + ]; + }, + }, + methods: { + needsPaddingClass(i) { + return i < this.pinnedLinks.length - 1; + }, + }, }; </script> <template> <div class="border-bottom gl-mb-6 gl-display-flex gl-justify-content-start"> - <div v-if="publishedIncidentUrl" class="gl-pr-3"> - <gl-link - :href="publishedIncidentUrl" - target="_blank" - class="btn btn-inverted btn-secondary btn-sm text-dark mb-3" - data-testid="publishedIncidentUrl" - > - <icon name="tanuki" :size="14" /> - <strong class="vertical-align-top">{{ __('Published on status page') }}</strong> - </gl-link> - </div> - <div v-if="zoomMeetingUrl"> - <gl-link - :href="zoomMeetingUrl" - target="_blank" - class="btn btn-inverted btn-secondary btn-sm text-dark mb-3" - data-testid="zoomMeetingUrl" - > - <icon name="brand-zoom" :size="14" /> - <strong class="vertical-align-top">{{ __('Join Zoom meeting') }}</strong> - </gl-link> - </div> + <template v-for="(link, i) in pinnedLinks"> + <div v-if="link.url" :key="link.id" :class="{ 'gl-pr-3': needsPaddingClass(i) }"> + <gl-button + :href="link.url" + target="_blank" + :icon="link.icon" + size="small" + class="gl-font-weight-bold gl-mb-5" + :data-testid="link.id" + >{{ link.text }}</gl-button + > + </div> + </template> </div> </template> diff --git a/app/assets/javascripts/issue_show/constants.js b/app/assets/javascripts/issue_show/constants.js index d73cc8cf007..6bc6ed2b372 100644 --- a/app/assets/javascripts/issue_show/constants.js +++ b/app/assets/javascripts/issue_show/constants.js @@ -15,3 +15,6 @@ export const IssuableType = { Epic: 'epic', MergeRequest: 'merge_request', }; + +export const STATUS_PAGE_PUBLISHED = __('Published on status page'); +export const JOIN_ZOOM_MEETING = __('Join Zoom meeting'); diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js index e170d338408..fe4ff133145 100644 --- a/app/assets/javascripts/issue_show/index.js +++ b/app/assets/javascripts/issue_show/index.js @@ -1,6 +1,8 @@ import Vue from 'vue'; import issuableApp from './components/app.vue'; +import IssuableHeaderWarnings from './components/issuable_header_warnings.vue'; import { parseIssuableData } from './utils/parse_data'; +import { store } from '~/notes/stores'; export default function initIssueableApp() { return new Vue({ @@ -15,3 +17,13 @@ export default function initIssueableApp() { }, }); } + +export function issuableHeaderWarnings() { + return new Vue({ + el: document.getElementById('js-issuable-header-warnings'), + store, + render(createElement) { + return createElement(IssuableHeaderWarnings); + }, + }); +} diff --git a/app/assets/javascripts/jira_import/components/jira_import_app.vue b/app/assets/javascripts/jira_import/components/jira_import_app.vue index ef0fc4716dd..6222bd28c9d 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_app.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_app.vue @@ -3,6 +3,7 @@ import { GlAlert, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; import { last } from 'lodash'; import { __ } from '~/locale'; import getJiraImportDetailsQuery from '../queries/get_jira_import_details.query.graphql'; +import getJiraUserMappingMutation from '../queries/get_jira_user_mapping.mutation.graphql'; import initiateJiraImportMutation from '../queries/initiate_jira_import.mutation.graphql'; import { addInProgressImportToStore } from '../utils/cache_update'; import { isInProgress, extractJiraProjectsOptions } from '../utils/jira_import_utils'; @@ -37,6 +38,10 @@ export default { type: String, required: true, }, + projectId: { + type: String, + required: true, + }, projectPath: { type: String, required: true, @@ -48,10 +53,12 @@ export default { }, data() { return { + isSubmitting: false, jiraImportDetails: {}, + selectedProject: undefined, + userMappings: [], errorMessage: '', showAlert: false, - selectedProject: undefined, }; }, apollo: { @@ -89,15 +96,43 @@ export default { : 'jira-import::KEY-1'; }, }, + mounted() { + if (this.isJiraConfigured) { + this.$apollo + .mutate({ + mutation: getJiraUserMappingMutation, + variables: { + input: { + projectPath: this.projectPath, + startAt: 1, + }, + }, + }) + .then(({ data }) => { + if (data.jiraImportUsers.errors.length) { + this.setAlertMessage(data.jiraImportUsers.errors.join('. ')); + } else { + this.userMappings = data.jiraImportUsers.jiraUsers; + } + }) + .catch(() => this.setAlertMessage(__('There was an error retrieving the Jira users.'))); + } + }, methods: { initiateJiraImport(project) { + this.isSubmitting = true; + this.$apollo .mutate({ mutation: initiateJiraImportMutation, variables: { input: { - projectPath: this.projectPath, jiraProjectKey: project, + projectPath: this.projectPath, + usersMapping: this.userMappings.map(({ gitlabId, jiraAccountId }) => ({ + gitlabId, + jiraAccountId, + })), }, }, update: (store, { data }) => @@ -110,7 +145,21 @@ export default { this.selectedProject = undefined; } }) - .catch(() => this.setAlertMessage(__('There was an error importing the Jira project.'))); + .catch(() => this.setAlertMessage(__('There was an error importing the Jira project.'))) + .finally(() => { + this.isSubmitting = false; + }); + }, + updateMapping(jiraAccountId, gitlabId, gitlabUsername) { + this.userMappings = this.userMappings.map(userMapping => + userMapping.jiraAccountId === jiraAccountId + ? { + ...userMapping, + gitlabId, + gitlabUsername, + } + : userMapping, + ); }, setAlertMessage(message) { this.errorMessage = message; @@ -155,9 +204,13 @@ export default { v-else v-model="selectedProject" :import-label="importLabel" + :is-submitting="isSubmitting" :issues-path="issuesPath" :jira-projects="jiraImportDetails.projects" + :project-id="projectId" + :user-mappings="userMappings" @initiateJiraImport="initiateJiraImport" + @updateMapping="updateMapping" /> </div> </template> 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 c2fe7b29c28..24bfb49a7d1 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_form.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_form.vue @@ -1,22 +1,61 @@ <script> -import { GlAvatar, GlButton, GlFormGroup, GlFormSelect, GlLabel } from '@gitlab/ui'; +import { + GlButton, + GlNewDropdown, + GlNewDropdownItem, + GlNewDropdownText, + GlFormGroup, + GlFormSelect, + GlIcon, + GlLabel, + GlLoadingIcon, + GlSearchBoxByType, + GlTable, +} from '@gitlab/ui'; +import { debounce } from 'lodash'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; export default { name: 'JiraImportForm', components: { - GlAvatar, GlButton, + GlNewDropdown, + GlNewDropdownItem, + GlNewDropdownText, GlFormGroup, GlFormSelect, + GlIcon, GlLabel, + GlLoadingIcon, + GlSearchBoxByType, + GlTable, }, - currentUserAvatarUrl: gon.current_user_avatar_url, currentUsername: gon.current_username, + dropdownLabel: __('The GitLab user to which the Jira user %{jiraDisplayName} will be mapped'), + tableConfig: [ + { + key: 'jiraDisplayName', + label: __('Jira display name'), + }, + { + key: 'arrow', + label: '', + }, + { + key: 'gitlabUsername', + label: __('GitLab username'), + }, + ], props: { importLabel: { type: String, required: true, }, + isSubmitting: { + type: Boolean, + required: true, + }, issuesPath: { type: String, required: true, @@ -25,6 +64,14 @@ export default { type: Array, required: true, }, + projectId: { + type: String, + required: true, + }, + userMappings: { + type: Array, + required: true, + }, value: { type: String, required: false, @@ -33,10 +80,53 @@ export default { }, data() { return { + isFetching: false, + searchTerm: '', selectState: null, + users: [], }; }, + computed: { + shouldShowNoMatchesFoundText() { + return !this.isFetching && this.users.length === 0; + }, + }, + watch: { + searchTerm: debounce(function debouncedUserSearch() { + this.searchUsers(); + }, 500), + }, + mounted() { + this.searchUsers() + .then(data => { + this.initialUsers = data; + }) + .catch(() => {}); + }, methods: { + searchUsers() { + const params = { + active: true, + project_id: this.projectId, + search: this.searchTerm, + }; + + this.isFetching = true; + + return axios + .get('/-/autocomplete/users.json', { params }) + .then(({ data }) => { + this.users = data; + return data; + }) + .finally(() => { + this.isFetching = false; + }); + }, + resetDropdown() { + this.searchTerm = ''; + this.users = this.initialUsers; + }, initiateJiraImport(event) { event.preventDefault(); if (this.value) { @@ -70,6 +160,7 @@ export default { > <gl-form-select id="jira-project-select" + data-qa-selector="jira_project_dropdown" class="mb-2" :options="jiraProjects" :state="selectState" @@ -79,7 +170,7 @@ export default { </gl-form-group> <gl-form-group - class="row align-items-center" + class="row gl-align-items-center gl-mb-6" :label="__('Issue label')" label-cols-sm="2" label-for="jira-project-label" @@ -93,50 +184,65 @@ export default { /> </gl-form-group> - <hr /> + <h4 class="gl-mb-4">{{ __('Jira-GitLab user mapping template') }}</h4> - <p class="offset-md-1"> + <p> {{ __( - "For each Jira issue successfully imported, we'll create a new GitLab issue with the following data:", + `Jira users have been matched with similar GitLab users. + This can be overwritten by selecting a GitLab user from the dropdown in the "GitLab + username" column. + If it wasn't possible to match a Jira user with a GitLab user, the dropdown defaults to + the user conducting the import.`, ) }} </p> - <gl-form-group - class="row align-items-center mb-1" - :label="__('Title')" - label-cols-sm="2" - label-for="jira-project-title" - > - <p id="jira-project-title" class="mb-2">{{ __('jira.issue.summary') }}</p> - </gl-form-group> - <gl-form-group - class="row align-items-center mb-1" - :label="__('Reporter')" - label-cols-sm="2" - label-for="jira-project-reporter" - > - <gl-avatar - id="jira-project-reporter" - class="mb-2" - :src="$options.currentUserAvatarUrl" - :size="24" - :aria-label="$options.currentUsername" - /> - </gl-form-group> - <gl-form-group - class="row align-items-center mb-1" - :label="__('Description')" - label-cols-sm="2" - label-for="jira-project-description" - > - <p id="jira-project-description" class="mb-2">{{ __('jira.issue.description.content') }}</p> - </gl-form-group> + <gl-table :fields="$options.tableConfig" :items="userMappings" fixed> + <template #cell(arrow)> + <gl-icon name="arrow-right" :aria-label="__('Will be mapped to')" /> + </template> + <template #cell(gitlabUsername)="data"> + <gl-new-dropdown + :text="data.value || $options.currentUsername" + class="w-100" + :aria-label=" + sprintf($options.dropdownLabel, { jiraDisplayName: data.item.jiraDisplayName }) + " + @hide="resetDropdown" + > + <gl-search-box-by-type v-model.trim="searchTerm" class="m-2" /> + + <div v-if="isFetching" class="gl-text-center"> + <gl-loading-icon /> + </div> + + <gl-new-dropdown-item + v-for="user in users" + v-else + :key="user.id" + @click="$emit('updateMapping', data.item.jiraAccountId, user.id, user.username)" + > + {{ user.username }} ({{ user.name }}) + </gl-new-dropdown-item> + + <gl-new-dropdown-text v-show="shouldShowNoMatchesFoundText" class="text-secondary"> + {{ __('No matches found') }} + </gl-new-dropdown-text> + </gl-new-dropdown> + </template> + </gl-table> <div class="footer-block row-content-block d-flex justify-content-between"> - <gl-button type="submit" category="primary" variant="success" class="js-no-auto-disable"> - {{ __('Next') }} + <gl-button + type="submit" + category="primary" + variant="success" + class="js-no-auto-disable" + :loading="isSubmitting" + data-qa-selector="jira_issues_import_button" + > + {{ __('Continue') }} </gl-button> <gl-button :href="issuesPath">{{ __('Cancel') }}</gl-button> </div> diff --git a/app/assets/javascripts/jira_import/index.js b/app/assets/javascripts/jira_import/index.js index 924cc7e6864..695a237bf50 100644 --- a/app/assets/javascripts/jira_import/index.js +++ b/app/assets/javascripts/jira_import/index.js @@ -28,6 +28,7 @@ export default function mountJiraImportApp() { isJiraConfigured: parseBoolean(el.dataset.isJiraConfigured), issuesPath: el.dataset.issuesPath, jiraIntegrationPath: el.dataset.jiraIntegrationPath, + projectId: el.dataset.projectId, projectPath: el.dataset.projectPath, setupIllustration: el.dataset.setupIllustration, }, diff --git a/app/assets/javascripts/jira_import/queries/get_jira_user_mapping.mutation.graphql b/app/assets/javascripts/jira_import/queries/get_jira_user_mapping.mutation.graphql new file mode 100644 index 00000000000..1f7c52eec58 --- /dev/null +++ b/app/assets/javascripts/jira_import/queries/get_jira_user_mapping.mutation.graphql @@ -0,0 +1,11 @@ +mutation($input: JiraImportUsersInput!) { + jiraImportUsers(input: $input) { + jiraUsers { + jiraAccountId + jiraDisplayName + jiraEmail + gitlabId + } + errors + } +} diff --git a/app/assets/javascripts/jira_import/utils/jira_import_utils.js b/app/assets/javascripts/jira_import/utils/jira_import_utils.js index e82a3f44a29..a1186b087e1 100644 --- a/app/assets/javascripts/jira_import/utils/jira_import_utils.js +++ b/app/assets/javascripts/jira_import/utils/jira_import_utils.js @@ -1,4 +1,5 @@ import { last } from 'lodash'; +import { JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY } from '~/issuables_list/constants'; export const IMPORT_STATE = { FAILED: 'failed', @@ -68,3 +69,36 @@ export const calculateJiraImportLabel = (jiraImports, labels) => { title, }; }; + +/** + * Calculates whether the Jira import success alert should be shown. + * + * @param {string} labelTitle - Jira import label, for checking localStorage + * @param {string} importStatus - Jira import status + * @returns {boolean} - A boolean indicating whether to show the success alert + */ +export const shouldShowFinishedAlert = (labelTitle, importStatus) => { + const finishedAlertHideMap = + JSON.parse(localStorage.getItem(JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY)) || {}; + + const shouldHide = finishedAlertHideMap[labelTitle]; + + return !shouldHide && isFinished(importStatus); +}; + +/** + * Updates the localStorage map to permanently hide the Jira import success alert + * + * @param {string} labelTitle - Jira import label, for checking localStorage + */ +export const setFinishedAlertHideMap = labelTitle => { + const finishedAlertHideMap = + JSON.parse(localStorage.getItem(JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY)) || {}; + + finishedAlertHideMap[labelTitle] = true; + + localStorage.setItem( + JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY, + JSON.stringify(finishedAlertHideMap), + ); +}; diff --git a/app/assets/javascripts/jobs/components/commit_block.vue b/app/assets/javascripts/jobs/components/commit_block.vue index 72a5ff01672..c4f180f200c 100644 --- a/app/assets/javascripts/jobs/components/commit_block.vue +++ b/app/assets/javascripts/jobs/components/commit_block.vue @@ -32,7 +32,7 @@ export default { block: !isLastBlock, }" > - <p class="append-bottom-5"> + <p class="gl-mb-2"> <span class="font-weight-bold">{{ __('Commit') }}</span> <gl-link :href="commit.commit_path" class="js-commit-sha commit-sha link-commit"> diff --git a/app/assets/javascripts/jobs/components/environments_block.vue b/app/assets/javascripts/jobs/components/environments_block.vue index c34a3488dbd..c78738221f1 100644 --- a/app/assets/javascripts/jobs/components/environments_block.vue +++ b/app/assets/javascripts/jobs/components/environments_block.vue @@ -274,7 +274,7 @@ export default { }; </script> <template> - <div class="prepend-top-default append-bottom-default js-environment-container"> + <div class="gl-mt-3 gl-mb-3 js-environment-container"> <div class="environment-information"> <ci-icon :status="iconStatus" /> <p class="inline gl-mb-0" v-html="environment"></p> diff --git a/app/assets/javascripts/jobs/components/erased_block.vue b/app/assets/javascripts/jobs/components/erased_block.vue index fc5e022f44a..a6d1b41c275 100644 --- a/app/assets/javascripts/jobs/components/erased_block.vue +++ b/app/assets/javascripts/jobs/components/erased_block.vue @@ -27,7 +27,7 @@ export default { }; </script> <template> - <div class="prepend-top-default js-build-erased"> + <div class="gl-mt-3 js-build-erased"> <div class="erased alert alert-warning"> <template v-if="isErasedByUser"> {{ s__('Job|Job has been erased by') }} diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue index 0783d1157be..f43a058b5f8 100644 --- a/app/assets/javascripts/jobs/components/job_app.vue +++ b/app/assets/javascripts/jobs/components/job_app.vue @@ -17,7 +17,7 @@ import UnmetPrerequisitesBlock from './unmet_prerequisites_block.vue'; import Sidebar from './sidebar.vue'; import { sprintf } from '~/locale'; import delayedJobMixin from '../mixins/delayed_job_mixin'; -import { isNewJobLogActive } from '../store/utils'; +import Log from './log/log.vue'; export default { name: 'JobPageApp', @@ -28,7 +28,7 @@ export default { EnvironmentsBlock, ErasedBlock, Icon, - Log: () => (isNewJobLogActive() ? import('./log/log.vue') : import('./job_log.vue')), + Log, LogTopBar, StuckBlock, UnmetPrerequisitesBlock, @@ -270,7 +270,7 @@ export default { <div v-if="job.archived" ref="sticky" - class="js-archived-job prepend-top-default archived-job" + class="js-archived-job gl-mt-3 archived-job" :class="{ 'sticky-top border-bottom-0': hasTrace }" > <icon name="lock" class="align-text-bottom" /> @@ -280,7 +280,7 @@ export default { <div v-if="hasTrace" class="build-trace-container position-relative" - :class="{ 'prepend-top-default': !job.archived }" + :class="{ 'gl-mt-3': !job.archived }" > <log-top-bar :class="{ diff --git a/app/assets/javascripts/jobs/components/job_log.vue b/app/assets/javascripts/jobs/components/job_log.vue deleted file mode 100644 index 20888c0af42..00000000000 --- a/app/assets/javascripts/jobs/components/job_log.vue +++ /dev/null @@ -1,59 +0,0 @@ -<script> -import { mapState, mapActions } from 'vuex'; - -export default { - name: 'JobLog', - props: { - trace: { - type: String, - required: true, - }, - isComplete: { - type: Boolean, - required: true, - }, - }, - computed: { - ...mapState(['isScrolledToBottomBeforeReceivingTrace']), - }, - updated() { - this.$nextTick(() => { - this.handleScrollDown(); - }); - }, - mounted() { - this.$nextTick(() => { - this.handleScrollDown(); - }); - }, - methods: { - ...mapActions(['scrollBottom']), - /** - * The job log is sent in HTML, which means we need to use `v-html` to render it - * Using the updated hook with $nextTick is not enough to wait for the DOM to be updated - * in this case because it runs before `v-html` has finished running, since there's no - * Vue binding. - * In order to scroll the page down after `v-html` has finished, we need to use setTimeout - */ - handleScrollDown() { - if (this.isScrolledToBottomBeforeReceivingTrace) { - setTimeout(() => { - this.scrollBottom(); - }, 0); - } - }, - }, -}; -</script> -<template> - <pre class="js-build-trace build-trace qa-build-trace"> - <code class="bash" v-html="trace"> - </code> - - <div v-if="!isComplete" class="js-log-animation build-loader-animation"> - <div class="dot"></div> - <div class="dot"></div> - <div class="dot"></div> - </div> - </pre> -</template> diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue index bcec83a7aee..a68174d8e1d 100644 --- a/app/assets/javascripts/jobs/components/job_log_controllers.vue +++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue @@ -77,7 +77,7 @@ export default { <gl-link v-if="rawPath" :href="rawPath" - class="js-raw-link text-plain text-underline prepend-left-5" + class="js-raw-link text-plain text-underline gl-ml-2" >{{ s__('Job|Complete Raw') }}</gl-link > </template> diff --git a/app/assets/javascripts/jobs/components/log/collapsible_section.vue b/app/assets/javascripts/jobs/components/log/collapsible_section.vue index 0c7b78a3da7..55cdfb691f4 100644 --- a/app/assets/javascripts/jobs/components/log/collapsible_section.vue +++ b/app/assets/javascripts/jobs/components/log/collapsible_section.vue @@ -3,7 +3,7 @@ import LogLine from './line.vue'; import LogLineHeader from './line_header.vue'; export default { - name: 'CollpasibleLogSection', + name: 'CollapsibleLogSection', components: { LogLine, LogLineHeader, diff --git a/app/assets/javascripts/jobs/components/log/line.vue b/app/assets/javascripts/jobs/components/log/line.vue index 33ee84bd4ee..48f669ae8ed 100644 --- a/app/assets/javascripts/jobs/components/log/line.vue +++ b/app/assets/javascripts/jobs/components/log/line.vue @@ -2,9 +2,7 @@ import LineNumber from './line_number.vue'; export default { - components: { - LineNumber, - }, + functional: true, props: { line: { type: Object, @@ -15,18 +13,28 @@ export default { required: true, }, }, + render(h, { props }) { + const { line, path } = props; + + const chars = line.content.map(content => { + return h( + 'span', + { + class: ['ws-pre-wrap', content.style], + }, + content.text, + ); + }); + + return h('div', { class: 'js-line log-line' }, [ + h(LineNumber, { + props: { + lineNumber: line.lineNumber, + path, + }, + }), + ...chars, + ]); + }, }; </script> - -<template> - <div class="js-line log-line"> - <line-number :line-number="line.lineNumber" :path="path" /> - <span - v-for="(content, i) in line.content" - :key="i" - :class="content.style" - class="ws-pre-wrap" - >{{ content.text }}</span - > - </div> -</template> diff --git a/app/assets/javascripts/jobs/components/log/line_number.vue b/app/assets/javascripts/jobs/components/log/line_number.vue index ae96c32874b..7ca9154d2fe 100644 --- a/app/assets/javascripts/jobs/components/log/line_number.vue +++ b/app/assets/javascripts/jobs/components/log/line_number.vue @@ -1,10 +1,6 @@ <script> -import { GlLink } from '@gitlab/ui'; - export default { - components: { - GlLink, - }, + functional: true, props: { lineNumber: { type: Number, @@ -15,41 +11,24 @@ export default { required: true, }, }, - computed: { - /** - * Builds the url for each line number - * - * @returns {String} - */ - buildLineNumber() { - return `${this.path}#${this.lineNumberId}`; - }, - /** - * Array indexes start with 0, so we add 1 - * to create the line number - * - * @returns {Number} the line number - */ - parsedLineNumber() { - return this.lineNumber + 1; - }, + render(h, { props }) { + const { lineNumber, path } = props; - /** - * Creates the anchor for each link - * - * @returns {String} - */ - lineNumberId() { - return `L${this.parsedLineNumber}`; - }, + const parsedLineNumber = lineNumber + 1; + const lineId = `L${parsedLineNumber}`; + const lineHref = `${path}#${lineId}`; + + return h( + 'a', + { + class: 'gl-link d-inline-block text-right line-number flex-shrink-0', + attrs: { + id: lineId, + href: lineHref, + }, + }, + parsedLineNumber, + ); }, }; </script> -<template> - <gl-link - :id="lineNumberId" - class="d-inline-block text-right line-number flex-shrink-0" - :href="buildLineNumber" - >{{ parsedLineNumber }}</gl-link - > -</template> diff --git a/app/assets/javascripts/jobs/components/log/log.vue b/app/assets/javascripts/jobs/components/log/log.vue index f0bdbde0602..0134e5dafe8 100644 --- a/app/assets/javascripts/jobs/components/log/log.vue +++ b/app/assets/javascripts/jobs/components/log/log.vue @@ -1,11 +1,11 @@ <script> import { mapState, mapActions } from 'vuex'; -import CollpasibleLogSection from './collapsible_section.vue'; +import CollapsibleLogSection from './collapsible_section.vue'; import LogLine from './line.vue'; export default { components: { - CollpasibleLogSection, + CollapsibleLogSection, LogLine, }, computed: { @@ -51,7 +51,7 @@ export default { <template> <code class="job-log d-block" data-qa-selector="job_log_content"> <template v-for="(section, index) in trace"> - <collpasible-log-section + <collapsible-log-section v-if="section.isHeader" :key="`collapsible-${index}`" :section="section" diff --git a/app/assets/javascripts/jobs/components/manual_variables_form.vue b/app/assets/javascripts/jobs/components/manual_variables_form.vue index d4aab5c7faf..d83c598dd48 100644 --- a/app/assets/javascripts/jobs/components/manual_variables_form.vue +++ b/app/assets/javascripts/jobs/components/manual_variables_form.vue @@ -112,7 +112,7 @@ export default { <div v-for="variable in variables" :key="variable.id" class="gl-responsive-table-row"> <div class="table-section section-50"> <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Key') }}</div> - <div class="table-mobile-content append-right-10"> + <div class="table-mobile-content gl-mr-3"> <input :ref="`${$options.inputTypes.key}-${variable.id}`" v-model="variable.key" @@ -124,7 +124,7 @@ export default { <div class="table-section section-50"> <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Value') }}</div> - <div class="table-mobile-content append-right-10"> + <div class="table-mobile-content gl-mr-3"> <input :ref="`${$options.inputTypes.value}-${variable.id}`" v-model="variable.secret_value" @@ -149,7 +149,7 @@ export default { <div class="gl-responsive-table-row"> <div class="table-section section-50"> <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Key') }}</div> - <div class="table-mobile-content append-right-10"> + <div class="table-mobile-content gl-mr-3"> <input ref="inputKey" v-model="key" @@ -161,7 +161,7 @@ export default { <div class="table-section section-50"> <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Value') }}</div> - <div class="table-mobile-content append-right-10"> + <div class="table-mobile-content gl-mr-3"> <input ref="inputSecretValue" v-model="secretValue" @@ -172,7 +172,7 @@ export default { </div> </div> </div> - <div class="d-flex prepend-top-default justify-content-center"> + <div class="d-flex gl-mt-3 justify-content-center"> <p class="text-muted" v-html="helpText"></p> </div> <div class="d-flex justify-content-center"> diff --git a/app/assets/javascripts/jobs/components/stuck_block.vue b/app/assets/javascripts/jobs/components/stuck_block.vue index da01269a50c..b69e6f9686f 100644 --- a/app/assets/javascripts/jobs/components/stuck_block.vue +++ b/app/assets/javascripts/jobs/components/stuck_block.vue @@ -31,7 +31,7 @@ export default { s__(`This job is stuck because you don't have any active runners online or available with any of these tags assigned to them:`) }} - <span v-for="(tag, index) in tags" :key="index" class="badge badge-primary append-right-4"> + <span v-for="(tag, index) in tags" :key="index" class="badge badge-primary gl-mr-2"> {{ tag }} </span> </p> diff --git a/app/assets/javascripts/jobs/components/trigger_block.vue b/app/assets/javascripts/jobs/components/trigger_block.vue index 1a076249fe7..f55429ecdae 100644 --- a/app/assets/javascripts/jobs/components/trigger_block.vue +++ b/app/assets/javascripts/jobs/components/trigger_block.vue @@ -46,7 +46,7 @@ export default { <p v-if="trigger.short_token" class="js-short-token" - :class="{ 'append-bottom-5': hasVariables, 'gl-mb-0': !hasVariables }" + :class="{ 'gl-mb-2': hasVariables, 'gl-mb-0': !hasVariables }" > <span class="font-weight-bold">{{ __('Trigger token:') }}</span> {{ trigger.short_token }} </p> diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/jobs/store/actions.js index 0ce8dfe4442..4bd8d6f58a6 100644 --- a/app/assets/javascripts/jobs/store/actions.js +++ b/app/assets/javascripts/jobs/store/actions.js @@ -195,7 +195,7 @@ export const receiveTraceError = ({ dispatch }) => { flash(__('An error occurred while fetching the job log.')); }; /** - * When the user clicks a collpasible line in the job + * When the user clicks a collapsible line in the job * log, we commit a mutation to update the state * * @param {Object} section diff --git a/app/assets/javascripts/jobs/store/mutations.js b/app/assets/javascripts/jobs/store/mutations.js index 6193d8d34ab..924b811d0d6 100644 --- a/app/assets/javascripts/jobs/store/mutations.js +++ b/app/assets/javascripts/jobs/store/mutations.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import * as types from './mutation_types'; -import { logLinesParser, updateIncrementalTrace, isNewJobLogActive } from './utils'; +import { logLinesParser, updateIncrementalTrace } from './utils'; export default { [types.SET_JOB_ENDPOINT](state, endpoint) { @@ -25,22 +25,16 @@ export default { } if (log.append) { - if (isNewJobLogActive()) { - state.trace = log.lines ? updateIncrementalTrace(log.lines, state.trace) : state.trace; - } else { - state.trace += log.html; - } + state.trace = log.lines ? updateIncrementalTrace(log.lines, state.trace) : state.trace; + state.traceSize += log.size; } else { // When the job still does not have a trace // the trace response will not have a defined // html or size. We keep the old value otherwise these // will be set to `null` - if (isNewJobLogActive()) { - state.trace = log.lines ? logLinesParser(log.lines) : state.trace; - } else { - state.trace = log.html || state.trace; - } + state.trace = log.lines ? logLinesParser(log.lines) : state.trace; + state.traceSize = log.size || state.traceSize; } diff --git a/app/assets/javascripts/jobs/store/state.js b/app/assets/javascripts/jobs/store/state.js index d76828ad19b..2fe945b2985 100644 --- a/app/assets/javascripts/jobs/store/state.js +++ b/app/assets/javascripts/jobs/store/state.js @@ -1,5 +1,3 @@ -import { isNewJobLogActive } from './utils'; - export default () => ({ jobEndpoint: null, traceEndpoint: null, @@ -18,7 +16,7 @@ export default () => ({ // Used to check if we should keep the automatic scroll isScrolledToBottomBeforeReceivingTrace: true, - trace: isNewJobLogActive() ? [] : '', + trace: [], isTraceComplete: false, traceSize: 0, isTraceSizeVisible: false, diff --git a/app/assets/javascripts/jobs/store/utils.js b/app/assets/javascripts/jobs/store/utils.js index 0b28c52a78f..8d6e5aac566 100644 --- a/app/assets/javascripts/jobs/store/utils.js +++ b/app/assets/javascripts/jobs/store/utils.js @@ -11,7 +11,7 @@ export const parseLine = (line = {}, lineNumber) => ({ /** * When a line has `section_header` set to true, we create a new * structure to allow to nest the lines that belong to the - * collpasible section + * collapsible section * * @param Object line * @param Number lineNumber @@ -91,7 +91,7 @@ export const getIncrementalLineNumber = acc => { * Parses the job log content into a structure usable by the template * * For collaspible lines (section_header = true): - * - creates a new array to hold the lines that are collpasible, + * - creates a new array to hold the lines that are collapsible, * - adds a isClosed property to handle toggle * - adds a isHeader property to handle template logic * - adds the section_duration @@ -177,5 +177,3 @@ export const updateIncrementalTrace = (newLog = [], oldParsed = []) => { return logLinesParser(newLog, parsedLog); }; - -export const isNewJobLogActive = () => gon && gon.features && gon.features.jobLogJson; diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 65d8866fcc3..63c4ad3c410 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -3,7 +3,7 @@ /* global ListLabel */ import $ from 'jquery'; -import { isEqual, escape, sortBy, template } from 'lodash'; +import { difference, isEqual, escape, sortBy, template } from 'lodash'; import { sprintf, s__, __ } from './locale'; import axios from './lib/utils/axios_utils'; import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; @@ -497,7 +497,7 @@ export default class LabelsSelect { const scopedLabelTemplate = template( [ - '<span class="gl-label gl-label-scoped" style="color: <%= escapeStr(label.color) %>;">', + '<span class="gl-label gl-label-scoped" style="color: <%= escapeStr(label.color) %>; --label-inset-border: inset 0 0 0 2px <%= escapeStr(label.color) %>;">', linkOpenTag, spanOpenTag, '<%- label.title.slice(0, label.title.lastIndexOf("::")) %>', @@ -526,9 +526,7 @@ export default class LabelsSelect { [ '<% labels.forEach(function(label){ %>', '<% if (isScopedLabel(label) && enableScopedLabels) { %>', - '<span class="d-inline-block position-relative scoped-label-wrapper">', '<%= scopedLabelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, rightLabelTextColor, tooltipTitleTemplate, escapeStr, linkAttrs: \'data-html="true"\' }) %>', - '</span>', '<% } else { %>', '<%= labelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, tooltipTitleTemplate, escapeStr, linkAttrs: "" }) %>', '<% } %>', @@ -562,45 +560,20 @@ export default class LabelsSelect { IssuableBulkUpdateActions.willUpdateLabels = true; } // eslint-disable-next-line class-methods-use-this - setDropdownData($dropdown, isMarking, value) { - const markedIds = $dropdown.data('marked') || []; - const unmarkedIds = $dropdown.data('unmarked') || []; - const indeterminateIds = $dropdown.data('indeterminate') || []; - - if (isMarking) { - markedIds.push(value); + setDropdownData($dropdown, isChecking, labelId) { + let userCheckedIds = $dropdown.data('user-checked') || []; + let userUncheckedIds = $dropdown.data('user-unchecked') || []; - let i = indeterminateIds.indexOf(value); - if (i > -1) { - indeterminateIds.splice(i, 1); - } - - i = unmarkedIds.indexOf(value); - if (i > -1) { - unmarkedIds.splice(i, 1); - } + if (isChecking) { + userCheckedIds = userCheckedIds.concat(labelId); + userUncheckedIds = difference(userUncheckedIds, [labelId]); } else { - // If marked item (not common) is unmarked - const i = markedIds.indexOf(value); - if (i > -1) { - markedIds.splice(i, 1); - } - - // If an indeterminate item is being unmarked - if (IssuableBulkUpdateActions.getOriginalIndeterminateIds().indexOf(value) > -1) { - unmarkedIds.push(value); - } - - // If a marked item is being unmarked - // (a marked item could also be a label that is present in all selection) - if (IssuableBulkUpdateActions.getOriginalCommonIds().indexOf(value) > -1) { - unmarkedIds.push(value); - } + userUncheckedIds = userUncheckedIds.concat(labelId); + userCheckedIds = difference(userCheckedIds, [labelId]); } - $dropdown.data('marked', markedIds); - $dropdown.data('unmarked', unmarkedIds); - $dropdown.data('indeterminate', indeterminateIds); + $dropdown.data('user-checked', userCheckedIds); + $dropdown.data('user-unchecked', userUncheckedIds); } // eslint-disable-next-line class-methods-use-this setOriginalDropdownData($container, $dropdown) { diff --git a/app/assets/javascripts/lazy_loader.js b/app/assets/javascripts/lazy_loader.js index 75542267f37..aa7fe087678 100644 --- a/app/assets/javascripts/lazy_loader.js +++ b/app/assets/javascripts/lazy_loader.js @@ -14,6 +14,10 @@ export default class LazyLoader { scrollContainer.addEventListener('load', () => this.register()); } + static supportsNativeLazyLoading() { + return 'loading' in HTMLImageElement.prototype; + } + static supportsIntersectionObserver() { return Boolean(window.IntersectionObserver); } @@ -23,7 +27,9 @@ export default class LazyLoader { () => { const lazyImages = [].slice.call(document.querySelectorAll('.lazy')); - if (LazyLoader.supportsIntersectionObserver()) { + if (LazyLoader.supportsNativeLazyLoading()) { + lazyImages.forEach(img => LazyLoader.loadImage(img)); + } else if (LazyLoader.supportsIntersectionObserver()) { if (this.intersectionObserver) { lazyImages.forEach(img => this.intersectionObserver.observe(img)); } @@ -72,11 +78,14 @@ export default class LazyLoader { } register() { - if (LazyLoader.supportsIntersectionObserver()) { - this.startIntersectionObserver(); - } else { - this.startLegacyObserver(); + if (!LazyLoader.supportsNativeLazyLoading()) { + if (LazyLoader.supportsIntersectionObserver()) { + this.startIntersectionObserver(); + } else { + this.startLegacyObserver(); + } } + this.startContentObserver(); this.searchLazyImages(); } @@ -148,16 +157,12 @@ export default class LazyLoader { static loadImage(img) { if (img.getAttribute('data-src')) { + img.setAttribute('loading', 'lazy'); let imgUrl = img.getAttribute('data-src'); // Only adding width + height for avatars for now if (imgUrl.indexOf('/avatar/') > -1 && imgUrl.indexOf('?') === -1) { - let targetWidth = null; - if (img.getAttribute('width')) { - targetWidth = img.getAttribute('width'); - } else { - targetWidth = img.width; - } - if (targetWidth) imgUrl += `?width=${targetWidth}`; + const targetWidth = img.getAttribute('width') || img.width; + imgUrl += `?width=${targetWidth}`; } img.setAttribute('src', imgUrl); img.removeAttribute('data-src'); diff --git a/app/assets/javascripts/lib/utils/axios_startup_calls.js b/app/assets/javascripts/lib/utils/axios_startup_calls.js new file mode 100644 index 00000000000..cb2e8a76c08 --- /dev/null +++ b/app/assets/javascripts/lib/utils/axios_startup_calls.js @@ -0,0 +1,52 @@ +import { isEmpty } from 'lodash'; +import { mergeUrlParams } from './url_utility'; + +// We should probably not couple this utility to `gon.gitlab_url` +// Also, this would replace occurrences that aren't at the beginning of the string +const removeGitLabUrl = url => url.replace(gon.gitlab_url, ''); + +const getFullUrl = req => { + const url = removeGitLabUrl(req.url); + return mergeUrlParams(req.params || {}, url); +}; + +const setupAxiosStartupCalls = axios => { + const { startup_calls: startupCalls } = window.gl || {}; + + if (!startupCalls || isEmpty(startupCalls)) { + return; + } + + // TODO: To save performance of future axios calls, we can + // remove this interceptor once the "startupCalls" have been loaded + axios.interceptors.request.use(req => { + const fullUrl = getFullUrl(req); + + const existing = startupCalls[fullUrl]; + + if (existing) { + // eslint-disable-next-line no-param-reassign + req.adapter = () => + existing.fetchCall.then(res => { + const fetchHeaders = {}; + res.headers.forEach((val, key) => { + fetchHeaders[key] = val; + }); + + // eslint-disable-next-line promise/no-nesting + return res.json().then(data => ({ + data, + status: res.status, + statusText: res.statusText, + headers: fetchHeaders, + config: req, + request: req, + })); + }); + } + + return req; + }); +}; + +export default setupAxiosStartupCalls; diff --git a/app/assets/javascripts/lib/utils/axios_utils.js b/app/assets/javascripts/lib/utils/axios_utils.js index 4eec5bffc66..9d517f45caa 100644 --- a/app/assets/javascripts/lib/utils/axios_utils.js +++ b/app/assets/javascripts/lib/utils/axios_utils.js @@ -1,6 +1,7 @@ import axios from 'axios'; import csrf from './csrf'; import suppressAjaxErrorsDuringNavigation from './suppress_ajax_errors_during_navigation'; +import setupAxiosStartupCalls from './axios_startup_calls'; axios.defaults.headers.common[csrf.headerKey] = csrf.token; // Used by Rails to check if it is a valid XHR request @@ -14,6 +15,8 @@ axios.interceptors.request.use(config => { return config; }); +setupAxiosStartupCalls(axios); + // Remove the global counter axios.interceptors.response.use( response => { diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index a60748215ab..8bf9a281151 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -53,16 +53,6 @@ export const getCspNonceValue = () => { return metaTag && metaTag.content; }; -export const ajaxGet = url => - axios - .get(url, { - params: { format: 'js' }, - responseType: 'text', - }) - .then(({ data }) => { - $.globalEval(data, { nonce: getCspNonceValue() }); - }); - export const rstrip = val => { if (val) { return val.replace(/\s+$/, ''); @@ -105,6 +95,7 @@ export const handleLocationHash = () => { const topPadding = 8; const diffFileHeader = document.querySelector('.js-file-title'); const versionMenusContainer = document.querySelector('.mr-version-menus-container'); + const fixedIssuableTitle = document.querySelector('.issue-sticky-header'); let adjustment = 0; if (fixedNav) adjustment -= fixedNav.offsetHeight; @@ -133,6 +124,10 @@ export const handleLocationHash = () => { adjustment -= versionMenusContainer.offsetHeight; } + if (isInIssuePage()) { + adjustment -= fixedIssuableTitle.offsetHeight; + } + if (isInMRPage()) { adjustment -= topPadding; } @@ -370,34 +365,6 @@ export const insertText = (target, text) => { target.dispatchEvent(event); }; -export const nodeMatchesSelector = (node, selector) => { - const matches = - Element.prototype.matches || - Element.prototype.matchesSelector || - Element.prototype.mozMatchesSelector || - Element.prototype.msMatchesSelector || - Element.prototype.oMatchesSelector || - Element.prototype.webkitMatchesSelector; - - if (matches) { - return matches.call(node, selector); - } - - // IE11 doesn't support `node.matches(selector)` - - let { parentNode } = node; - - if (!parentNode) { - parentNode = document.createElement('div'); - // eslint-disable-next-line no-param-reassign - node = node.cloneNode(true); - parentNode.appendChild(node); - } - - const matchingNodes = parentNode.querySelectorAll(selector); - return Array.prototype.indexOf.call(matchingNodes, node) !== -1; -}; - /** this will take in the headers from an API response and normalize them this way we don't run into production issues when nginx gives us lowercased header keys @@ -413,24 +380,6 @@ export const normalizeHeaders = headers => { }; /** - this will take in the getAllResponseHeaders result and normalize them - this way we don't run into production issues when nginx gives us lowercased header keys -*/ -export const normalizeCRLFHeaders = headers => { - const headersObject = {}; - const headersArray = headers.split('\n'); - - headersArray.forEach(header => { - const keyValue = header.split(': '); - - // eslint-disable-next-line prefer-destructuring - headersObject[keyValue[0]] = keyValue[1]; - }); - - return normalizeHeaders(headersObject); -}; - -/** * Parses pagination object string values into numbers. * * @param {Object} paginationInformation @@ -638,13 +587,6 @@ export const setFaviconOverlay = overlayPath => { ); }; -export const setFavicon = faviconPath => { - const faviconEl = document.getElementById('favicon'); - if (faviconEl && faviconPath) { - faviconEl.setAttribute('href', faviconPath); - } -}; - export const resetFavicon = () => { const faviconEl = document.getElementById('favicon'); @@ -883,35 +825,6 @@ export const searchBy = (query = '', searchSpace = {}) => { */ export const isScopedLabel = ({ title = '' }) => title.indexOf('::') !== -1; -window.gl = window.gl || {}; -window.gl.utils = { - ...(window.gl.utils || {}), - getPagePath, - isInGroupsPage, - isInProjectPage, - getProjectSlug, - getGroupSlug, - isInIssuePage, - ajaxGet, - rstrip, - updateTooltipTitle, - disableButtonIfEmptyField, - handleLocationHash, - isInViewport, - parseUrl, - parseUrlPathname, - getUrlParamsArray, - isMetaKey, - isMetaClick, - scrollToElement, - getParameterByName, - getSelectedFragment, - insertText, - nodeMatchesSelector, - spriteIcon, - imagePath, -}; - // Methods to set and get Cookie export const setCookie = (name, value) => Cookies.set(name, value, { expires: 365 }); diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js index eb6c9bf7eb6..993d51370ec 100644 --- a/app/assets/javascripts/lib/utils/constants.js +++ b/app/assets/javascripts/lib/utils/constants.js @@ -1,5 +1,7 @@ export const BYTES_IN_KIB = 1024; export const HIDDEN_CLASS = 'hidden'; +export const TRUNCATE_WIDTH_DEFAULT_WIDTH = 80; +export const TRUNCATE_WIDTH_DEFAULT_FONT_SIZE = 12; export const DATETIME_RANGE_TYPES = { fixed: 'fixed', diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 6b69d2febe0..6e02fc1eb91 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -89,13 +89,15 @@ export const getDayName = date => * @example * dateFormat('2017-12-05','mmm d, yyyy h:MMtt Z' ) -> "Dec 5, 2017 12:00am GMT+0000" * @param {date} datetime + * @param {String} format + * @param {Boolean} UTC convert local time to UTC * @returns {String} */ -export const formatDate = (datetime, format = 'mmm d, yyyy h:MMtt Z') => { +export const formatDate = (datetime, format = 'mmm d, yyyy h:MMtt Z', utc = false) => { if (isString(datetime) && datetime.match(/\d+-\d+\d+ /)) { throw new Error(__('Invalid date')); } - return dateFormat(datetime, format); + return dateFormat(datetime, format, utc); }; /** @@ -425,7 +427,6 @@ export const dayInQuarter = (date, quarter) => { window.gl = window.gl || {}; window.gl.utils = { ...(window.gl.utils || {}), - getTimeago, localTimeAgo, }; diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js index 8fa235f8afb..d9b0e8c4476 100644 --- a/app/assets/javascripts/lib/utils/dom_utils.js +++ b/app/assets/javascripts/lib/utils/dom_utils.js @@ -1,3 +1,4 @@ +import { has } from 'lodash'; import { isInIssuePage, isInMRPage, isInEpicPage } from './common_utils'; export const addClassIfElementExists = (element, className) => { @@ -25,3 +26,24 @@ export const toggleContainerClasses = (containerEl, classList) => { }); } }; + +/** + * Return a object mapping element dataset names to booleans. + * + * This is useful for data- attributes whose presense represent + * a truthiness, no matter the value of the attribute. The absense of the + * attribute represents falsiness. + * + * This can be useful when Rails-provided boolean-like values are passed + * directly to the HAML template, rather than cast to a string. + * + * @param {HTMLElement} element - The DOM element to inspect + * @param {string[]} names - The dataset (i.e., camelCase) names to inspect + * @returns {Object.<string, boolean>} + */ +export const parseBooleanDataAttributes = ({ dataset }, names) => + names.reduce((acc, name) => { + acc[name] = has(dataset, name); + + return acc; + }, {}); diff --git a/app/assets/javascripts/lib/utils/grammar.js b/app/assets/javascripts/lib/utils/grammar.js index 18f9e2ed846..b1f38429369 100644 --- a/app/assets/javascripts/lib/utils/grammar.js +++ b/app/assets/javascripts/lib/utils/grammar.js @@ -20,18 +20,22 @@ export const toNounSeriesText = items => { if (items.length === 0) { return ''; } else if (items.length === 1) { - return items[0]; + return sprintf(s__(`nounSeries|%{item}`), { item: items[0] }, false); } else if (items.length === 2) { - return sprintf(s__('nounSeries|%{firstItem} and %{lastItem}'), { - firstItem: items[0], - lastItem: items[1], - }); + return sprintf( + s__('nounSeries|%{firstItem} and %{lastItem}'), + { + firstItem: items[0], + lastItem: items[1], + }, + false, + ); } return items.reduce((item, nextItem, idx) => idx === items.length - 1 - ? sprintf(s__('nounSeries|%{item}, and %{lastItem}'), { item, lastItem: nextItem }) - : sprintf(s__('nounSeries|%{item}, %{nextItem}'), { item, nextItem }), + ? sprintf(s__('nounSeries|%{item}, and %{lastItem}'), { item, lastItem: nextItem }, false) + : sprintf(s__('nounSeries|%{item}, %{nextItem}'), { item, nextItem }, false), ); }; diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index 0dfc144c363..4d25ee9e4bd 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -27,9 +27,28 @@ function lineAfter(text, textarea) { .split('\n')[0]; } +function convertMonacoSelectionToAceFormat(sel) { + return { + start: { + row: sel.startLineNumber, + column: sel.startColumn, + }, + end: { + row: sel.endLineNumber, + column: sel.endColumn, + }, + }; +} + +function getEditorSelectionRange(editor) { + return window.gon.features?.monacoBlobs + ? convertMonacoSelectionToAceFormat(editor.getSelection()) + : editor.getSelectionRange(); +} + function editorBlockTagText(text, blockTag, selected, editor) { const lines = text.split('\n'); - const selectionRange = editor.getSelectionRange(); + const selectionRange = getEditorSelectionRange(editor); const shouldRemoveBlock = lines[selectionRange.start.row - 1] === blockTag && lines[selectionRange.end.row + 1] === blockTag; @@ -90,8 +109,12 @@ function moveCursor({ const endPosition = startPosition + select.length; return textArea.setSelectionRange(startPosition, endPosition); } else if (editor) { - editor.navigateLeft(tag.length - tag.indexOf(select)); - editor.getSelection().selectAWord(); + if (window.gon.features?.monacoBlobs) { + editor.selectWithinSelection(select, tag); + } else { + editor.navigateLeft(tag.length - tag.indexOf(select)); + editor.getSelection().selectAWord(); + } return; } } @@ -115,7 +138,11 @@ function moveCursor({ } } else if (editor && editorSelectionStart.row === editorSelectionEnd.row) { if (positionBetweenTags) { - editor.navigateLeft(tag.length); + if (window.gon.features?.monacoBlobs) { + editor.moveCursor(tag.length * -1); + } else { + editor.navigateLeft(tag.length); + } } } } @@ -140,7 +167,7 @@ export function insertMarkdownText({ let textToInsert; if (editor) { - const selectionRange = editor.getSelectionRange(); + const selectionRange = getEditorSelectionRange(editor); editorSelectionStart = selectionRange.start; editorSelectionEnd = selectionRange.end; @@ -237,7 +264,11 @@ export function insertMarkdownText({ } if (editor) { - editor.insert(textToInsert); + if (window.gon.features?.monacoBlobs) { + editor.replaceSelectedText(textToInsert, select); + } else { + editor.insert(textToInsert); + } } else { insertText(textArea, textToInsert); } diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index be3fe1ed620..e2953ce330c 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -1,4 +1,9 @@ -import { isString } from 'lodash'; +import { isString, memoize } from 'lodash'; + +import { + TRUNCATE_WIDTH_DEFAULT_WIDTH, + TRUNCATE_WIDTH_DEFAULT_FONT_SIZE, +} from '~/lib/utils/constants'; /** * Adds a , to a string composed by numbers, at every 3 chars. @@ -73,7 +78,79 @@ export const slugifyWithUnderscore = str => slugify(str, '_'); * @param {Number} maxLength * @returns {String} */ -export const truncate = (string, maxLength) => `${string.substr(0, maxLength - 3)}...`; +export const truncate = (string, maxLength) => { + if (string.length - 1 > maxLength) { + return `${string.substr(0, maxLength - 1)}…`; + } + + return string; +}; + +/** + * This function calculates the average char width. It does so by placing a string in the DOM and measuring the width. + * NOTE: This will cause a reflow and should be used sparsely! + * The default fontFamily is 'sans-serif' and 12px in ECharts, so that is the default basis for calculating the average with. + * https://echarts.apache.org/en/option.html#xAxis.nameTextStyle.fontFamily + * https://echarts.apache.org/en/option.html#xAxis.nameTextStyle.fontSize + * @param {Object} options + * @param {Number} options.fontSize style to size the text for measurement + * @param {String} options.fontFamily style of font family to measure the text with + * @param {String} options.chars string of chars to use as a basis for calculating average width + * @return {Number} + */ +const getAverageCharWidth = memoize(function getAverageCharWidth(options = {}) { + const { + fontSize = 12, + fontFamily = 'sans-serif', + // eslint-disable-next-line @gitlab/require-i18n-strings + chars = ' ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789', + } = options; + const div = document.createElement('div'); + + div.style.fontFamily = fontFamily; + div.style.fontSize = `${fontSize}px`; + // Place outside of view + div.style.position = 'absolute'; + div.style.left = -1000; + div.style.top = -1000; + + div.innerHTML = chars; + + document.body.appendChild(div); + const width = div.clientWidth; + document.body.removeChild(div); + + return width / chars.length / fontSize; +}); + +/** + * This function returns a truncated version of `string` if its estimated rendered width is longer than `maxWidth`, + * otherwise it will return the original `string` + * Inspired by https://bl.ocks.org/tophtucker/62f93a4658387bb61e4510c37e2e97cf + * @param {String} string text to truncate + * @param {Object} options + * @param {Number} options.maxWidth largest rendered width the text may have + * @param {Number} options.fontSize size of the font used to render the text + * @return {String} either the original string or a truncated version + */ +export const truncateWidth = (string, options = {}) => { + const { + maxWidth = TRUNCATE_WIDTH_DEFAULT_WIDTH, + fontSize = TRUNCATE_WIDTH_DEFAULT_FONT_SIZE, + } = options; + const { truncateIndex } = string.split('').reduce( + (memo, char, index) => { + let newIndex = index; + if (memo.width > maxWidth) { + newIndex = memo.truncateIndex; + } + return { width: memo.width + getAverageCharWidth() * fontSize, truncateIndex: newIndex }; + }, + { width: 0, truncateIndex: 0 }, + ); + + return truncate(string, truncateIndex); +}; /** * Truncate SHA to 8 characters diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index 0472b8cf51f..c6c34b831ee 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -344,9 +344,15 @@ export function objectToQuery(obj) { * @param {Object} params The query params to be set/updated * @param {String} url The url to be operated on * @param {Boolean} clearParams Indicates whether existing query params should be removed or not + * @param {Boolean} railsArraySyntax When enabled, changes the array syntax from `keys=` to `keys[]=` according to Rails conventions * @returns {String} A copy of the original with the updated query params */ -export const setUrlParams = (params, url = window.location.href, clearParams = false) => { +export const setUrlParams = ( + params, + url = window.location.href, + clearParams = false, + railsArraySyntax = false, +) => { const urlObj = new URL(url); const queryString = urlObj.search; const searchParams = clearParams ? new URLSearchParams('') : new URLSearchParams(queryString); @@ -355,11 +361,12 @@ export const setUrlParams = (params, url = window.location.href, clearParams = f if (params[key] === null || params[key] === undefined) { searchParams.delete(key); } else if (Array.isArray(params[key])) { + const keyName = railsArraySyntax ? `${key}[]` : key; params[key].forEach((val, idx) => { if (idx === 0) { - searchParams.set(key, val); + searchParams.set(keyName, val); } else { - searchParams.append(key, val); + searchParams.append(keyName, val); } }); } else { diff --git a/app/assets/javascripts/logs/components/environment_logs.vue b/app/assets/javascripts/logs/components/environment_logs.vue index 01a4cbd41f6..f37f48aa431 100644 --- a/app/assets/javascripts/logs/components/environment_logs.vue +++ b/app/assets/javascripts/logs/components/environment_logs.vue @@ -8,6 +8,7 @@ import { GlDropdown, GlDropdownHeader, GlDropdownItem, + GlDropdownDivider, GlInfiniteScroll, } from '@gitlab/ui'; @@ -27,6 +28,7 @@ export default { GlDropdown, GlDropdownHeader, GlDropdownItem, + GlDropdownDivider, GlInfiniteScroll, LogSimpleFilters, LogAdvancedFilters, @@ -55,6 +57,10 @@ export default { type: String, required: true, }, + clustersPath: { + type: String, + required: true, + }, }, data() { return { @@ -63,7 +69,7 @@ export default { }; }, computed: { - ...mapState('environmentLogs', ['environments', 'timeRange', 'logs', 'pods']), + ...mapState('environmentLogs', ['environments', 'timeRange', 'logs', 'pods', 'managedApps']), ...mapGetters('environmentLogs', ['trace', 'showAdvancedFilters']), showLoader() { @@ -85,12 +91,15 @@ export default { }); this.fetchEnvironments(this.environmentsPath); + this.fetchManagedApps(this.clustersPath); }, methods: { ...mapActions('environmentLogs', [ 'setInitData', 'showEnvironment', + 'showManagedApp', 'fetchEnvironments', + 'fetchManagedApps', 'refreshPodLogs', 'fetchMoreLogsPrepend', 'dismissRequestEnvironmentsError', @@ -101,6 +110,9 @@ export default { isCurrentEnvironment(envName) { return envName === this.environments.current; }, + isCurrentManagedApp(appName) { + return appName === this.managedApps.current; + }, topReached() { if (!this.logs.isLoading) { this.fetchMoreLogsPrepend(); @@ -164,12 +176,12 @@ export default { <div class="flex-grow-0"> <gl-dropdown id="environments-dropdown" - :text="environments.current" + :text="environments.current || managedApps.current" :disabled="environments.isLoading" class="mb-2 gl-h-32 pr-2 d-flex d-md-block js-environments-dropdown" > - <gl-dropdown-header class="text-center"> - {{ s__('Environments|Select environment') }} + <gl-dropdown-header class="gl-text-center"> + {{ s__('Environments|Environments') }} </gl-dropdown-header> <gl-dropdown-item v-for="env in environments.options" @@ -181,7 +193,24 @@ export default { :class="{ invisible: !isCurrentEnvironment(env.name) }" name="status_success_borderless" /> - <div class="flex-grow-1">{{ env.name }}</div> + <div class="gl-flex-grow-1">{{ env.name }}</div> + </div> + </gl-dropdown-item> + <gl-dropdown-divider /> + <gl-dropdown-header class="gl-text-center"> + {{ s__('Environments|Managed apps') }} + </gl-dropdown-header> + <gl-dropdown-item + v-for="app in managedApps.options" + :key="app.id" + @click="showManagedApp(app.name)" + > + <div class="gl-display-flex"> + <gl-icon + :class="{ invisible: !isCurrentManagedApp(app.name) }" + name="status_success_borderless" + /> + <div class="gl-flex-grow-1">{{ app.name }}</div> </div> </gl-dropdown-item> </gl-dropdown> @@ -202,7 +231,7 @@ export default { <log-control-buttons ref="scrollButtons" - class="flex-grow-0 pr-2 mb-2 controllers" + class="flex-grow-0 pr-2 mb-2 controllers gl-display-inline-flex" :scroll-down-button-disabled="scrollDownButtonDisabled" @refresh="refreshPodLogs()" @scrollDown="scrollDown" diff --git a/app/assets/javascripts/logs/components/log_control_buttons.vue b/app/assets/javascripts/logs/components/log_control_buttons.vue index 3f5de4c22e0..e44b5394fa1 100644 --- a/app/assets/javascripts/logs/components/log_control_buttons.vue +++ b/app/assets/javascripts/logs/components/log_control_buttons.vue @@ -1,11 +1,9 @@ <script> -import { GlDeprecatedButton, GlTooltipDirective } from '@gitlab/ui'; -import Icon from '~/vue_shared/components/icon.vue'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; export default { components: { - Icon, - GlDeprecatedButton, + GlButton, }, directives: { GlTooltip: GlTooltipDirective, @@ -51,14 +49,16 @@ export default { :title="__('Scroll to top')" aria-labelledby="scroll-to-top" > - <gl-deprecated-button + <gl-button id="scroll-to-top" - class="btn-blank js-scroll-to-top" + class="js-scroll-to-top gl-mr-2 btn-blank" :aria-label="__('Scroll to top')" :disabled="scrollUpButtonDisabled" + icon="scroll_up" + category="primary" + variant="default" @click="handleScrollUp()" - ><icon name="scroll_up" - /></gl-deprecated-button> + /> </div> <div v-if="scrollDownAvailable" @@ -68,25 +68,28 @@ export default { :title="__('Scroll to bottom')" aria-labelledby="scroll-to-bottom" > - <gl-deprecated-button + <gl-button id="scroll-to-bottom" - class="btn-blank js-scroll-to-bottom" + class="js-scroll-to-bottom gl-mr-2 btn-blank" :aria-label="__('Scroll to bottom')" :v-if="scrollDownAvailable" :disabled="scrollDownButtonDisabled" + icon="scroll_down" + category="primary" + variant="default" @click="handleScrollDown()" - ><icon name="scroll_down" - /></gl-deprecated-button> + /> </div> - <gl-deprecated-button + <gl-button id="refresh-log" v-gl-tooltip - class="ml-1 px-2 js-refresh-log" + class="js-refresh-log" :title="__('Refresh')" :aria-label="__('Refresh')" + icon="retry" + category="primary" + variant="default" @click="handleRefreshClick" - > - <icon name="retry" /> - </gl-deprecated-button> + /> </div> </template> diff --git a/app/assets/javascripts/logs/constants.js b/app/assets/javascripts/logs/constants.js index f83d369c6b8..0cdef53df34 100644 --- a/app/assets/javascripts/logs/constants.js +++ b/app/assets/javascripts/logs/constants.js @@ -8,4 +8,10 @@ export const tracking = { TIME_RANGE_SET: 'time_range_set', ENVIRONMENT_SELECTED: 'environment_selected', REFRESH_POD_LOGS: 'refresh_pod_logs', + MANAGED_APP_SELECTED: 'managed_app_selected', +}; + +export const logExplorerOptions = { + environments: 'environments', + managedApps: 'managedApps', }; diff --git a/app/assets/javascripts/logs/stores/actions.js b/app/assets/javascripts/logs/stores/actions.js index d828e8f8a3e..0edd825b6e9 100644 --- a/app/assets/javascripts/logs/stores/actions.js +++ b/app/assets/javascripts/logs/stores/actions.js @@ -2,7 +2,7 @@ import { backOff } from '~/lib/utils/common_utils'; import httpStatusCodes from '~/lib/utils/http_status'; import axios from '~/lib/utils/axios_utils'; import { convertToFixedRange } from '~/lib/utils/datetime_range'; -import { TOKEN_TYPE_POD_NAME, tracking } from '../constants'; +import { TOKEN_TYPE_POD_NAME, tracking, logExplorerOptions } from '../constants'; import trackLogs from '../logs_tracking_helper'; import * as types from './mutation_types'; @@ -25,9 +25,15 @@ const requestUntilData = (url, params) => const requestLogsUntilData = ({ commit, state }) => { const params = {}; - const { logs_api_path } = state.environments.options.find( - ({ name }) => name === state.environments.current, - ); + const type = state.environments.current + ? logExplorerOptions.environments + : logExplorerOptions.managedApps; + const selectedObj = state[type].options.find(({ name }) => name === state[type].current); + + const path = + type === logExplorerOptions.environments + ? selectedObj.logs_api_path + : selectedObj.gitlab_managed_apps_logs_path; if (state.pods.current) { params.pod_name = state.pods.current; @@ -48,7 +54,7 @@ const requestLogsUntilData = ({ commit, state }) => { params.cursor = state.logs.cursor; } - return requestUntilData(logs_api_path, params); + return requestUntilData(path, params); }; /** @@ -100,6 +106,11 @@ export const showEnvironment = ({ dispatch, commit }, environmentName) => { dispatch('fetchLogs', tracking.ENVIRONMENT_SELECTED); }; +export const showManagedApp = ({ dispatch, commit }, managedApp) => { + commit(types.SET_MANAGED_APP, managedApp); + dispatch('fetchLogs', tracking.MANAGED_APP_SELECTED); +}; + export const refreshPodLogs = ({ dispatch, commit }) => { commit(types.REFRESH_POD_LOGS); dispatch('fetchLogs', tracking.REFRESH_POD_LOGS); @@ -124,6 +135,23 @@ export const fetchEnvironments = ({ commit, dispatch }, environmentsPath) => { }); }; +/** + * Fetch managed apps data + * @param {Object} store + * @param {String} clustersPath + */ + +export const fetchManagedApps = ({ commit }, clustersPath) => { + return axios + .get(clustersPath) + .then(({ data }) => { + commit(types.RECEIVE_MANAGED_APPS_DATA_SUCCESS, data.clusters); + }) + .catch(() => { + commit(types.RECEIVE_MANAGED_APPS_DATA_ERROR); + }); +}; + export const fetchLogs = ({ commit, state }, trackingLabel) => { commit(types.REQUEST_LOGS_DATA); diff --git a/app/assets/javascripts/logs/stores/mutation_types.js b/app/assets/javascripts/logs/stores/mutation_types.js index 9010ec51817..eaa4b13f8bd 100644 --- a/app/assets/javascripts/logs/stores/mutation_types.js +++ b/app/assets/javascripts/logs/stores/mutation_types.js @@ -1,5 +1,6 @@ export const SET_PROJECT_ENVIRONMENT = 'SET_PROJECT_ENVIRONMENT'; export const SET_SEARCH = 'SET_SEARCH'; +export const SET_MANAGED_APP = 'SET_MANAGED_APP'; export const SET_TIME_RANGE = 'SET_TIME_RANGE'; export const SHOW_TIME_RANGE_INVALID_WARNING = 'SHOW_TIME_RANGE_INVALID_WARNING'; @@ -12,6 +13,9 @@ export const RECEIVE_ENVIRONMENTS_DATA_SUCCESS = 'RECEIVE_ENVIRONMENTS_DATA_SUCC export const RECEIVE_ENVIRONMENTS_DATA_ERROR = 'RECEIVE_ENVIRONMENTS_DATA_ERROR'; export const HIDE_REQUEST_ENVIRONMENTS_ERROR = 'HIDE_REQUEST_ENVIRONMENTS_ERROR'; +export const RECEIVE_MANAGED_APPS_DATA_SUCCESS = 'RECEIVE_MANAGED_APPS_DATA_SUCCESS'; +export const RECEIVE_MANAGED_APPS_DATA_ERROR = 'RECEIVE_MANAGED_APPS_DATA_ERROR'; + export const REQUEST_LOGS_DATA = 'REQUEST_LOGS_DATA'; export const RECEIVE_LOGS_DATA_SUCCESS = 'RECEIVE_LOGS_DATA_SUCCESS'; export const RECEIVE_LOGS_DATA_ERROR = 'RECEIVE_LOGS_DATA_ERROR'; diff --git a/app/assets/javascripts/logs/stores/mutations.js b/app/assets/javascripts/logs/stores/mutations.js index 5e1c794c3a9..be22204d88d 100644 --- a/app/assets/javascripts/logs/stores/mutations.js +++ b/app/assets/javascripts/logs/stores/mutations.js @@ -32,6 +32,9 @@ export default { // Clear current pod options state.pods.current = null; state.pods.options = []; + + // Clear current managedApps options + state.managedApps.current = null; }, [types.REQUEST_ENVIRONMENTS_DATA](state) { state.environments.options = []; @@ -107,4 +110,24 @@ export default { [types.RECEIVE_PODS_DATA_ERROR](state) { state.pods.options = []; }, + // Managed apps data + [types.RECEIVE_MANAGED_APPS_DATA_SUCCESS](state, apps) { + state.managedApps.options = apps; + state.managedApps.isLoading = false; + }, + [types.RECEIVE_MANAGED_APPS_DATA_ERROR](state) { + state.managedApps.options = []; + state.managedApps.isLoading = false; + state.managedApps.fetchError = true; + }, + [types.SET_MANAGED_APP](state, managedApp) { + state.managedApps.current = managedApp; + + // Clear current pod options + state.pods.current = null; + state.pods.options = []; + + // Clear current environment options + state.environments.current = null; + }, }; diff --git a/app/assets/javascripts/logs/stores/state.js b/app/assets/javascripts/logs/stores/state.js index 11185c9ccf1..fbe6589dd84 100644 --- a/app/assets/javascripts/logs/stores/state.js +++ b/app/assets/javascripts/logs/stores/state.js @@ -31,6 +31,16 @@ export default () => ({ }, /** + * Managed apps list information + */ + managedApps: { + options: [], + isLoading: false, + current: null, + fetchError: false, + }, + + /** * Logs including trace */ logs: { diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 5f5fd790f67..3f85295a5ed 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -19,6 +19,7 @@ import { getLocationHash, visitUrl } from './lib/utils/url_utility'; // everything else import loadAwardsHandler from './awards_handler'; +import applyGitLabUIConfig from '@gitlab/ui/dist/config'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import Flash, { removeFlashClickListener } from './flash'; import './gl_dropdown'; @@ -32,7 +33,7 @@ import initFrequentItemDropdowns from './frequent_items'; import initBreadcrumbs from './breadcrumb'; import initUsagePingConsent from './usage_ping_consent'; import initPerformanceBar from './performance_bar'; -import initGlobalSearchInput from './global_search_input'; +import initSearchAutocomplete from './search_autocomplete'; import GlFieldErrors from './gl_field_errors'; import initUserPopovers from './user_popovers'; import initBroadcastNotifications from './broadcast_notification'; @@ -42,6 +43,8 @@ import { __ } from './locale'; import 'ee_else_ce/main_ee'; +applyGitLabUIConfig(); + // expose jQuery as global (TODO: remove these) window.jQuery = jQuery; window.$ = jQuery; @@ -62,12 +65,12 @@ function disableJQueryAnimations() { } // Disable jQuery animations -if (gon && gon.disable_animations) { +if (gon?.disable_animations) { disableJQueryAnimations(); } // inject test utilities if necessary -if (process.env.NODE_ENV !== 'production' && gon && gon.test_env) { +if (process.env.NODE_ENV !== 'production' && gon?.test_env) { disableJQueryAnimations(); import(/* webpackMode: "eager" */ './test_utils/'); // eslint-disable-line no-unused-expressions } @@ -110,7 +113,7 @@ function deferredInitialisation() { initFrequentItemDropdowns(); initPersistentUserCallouts(); - if (document.querySelector('.search')) initGlobalSearchInput(); + if (document.querySelector('.search')) initSearchAutocomplete(); addSelectOnFocusBehaviour('.js-select-on-focus'); @@ -132,27 +135,6 @@ function deferredInitialisation() { .fadeOut(); }); - // Initialize select2 selects - if ($('select.select2').length) { - import(/* webpackChunkName: 'select2' */ 'select2/select2') - .then(() => { - $('select.select2').select2({ - width: 'resolve', - minimumResultsForSearch: 10, - dropdownAutoWidth: true, - }); - - // Close select2 on escape - $('.js-select2').on('select2-close', () => { - setTimeout(() => { - $('.select2-container-active').removeClass('select2-container-active'); - $(':focus').blur(); - }, 1); - }); - }) - .catch(() => {}); - } - const glTooltipDelay = localStorage.getItem('gl-tooltip-delay'); const delay = glTooltipDelay ? JSON.parse(glTooltipDelay) : 0; @@ -179,9 +161,7 @@ function deferredInitialisation() { document.addEventListener('DOMContentLoaded', () => { const $body = $('body'); const $document = $(document); - const $window = $(window); - const $sidebarGutterToggle = $('.js-sidebar-toggle'); - let bootstrapBreakpoint = bp.getBreakpointSize(); + const bootstrapBreakpoint = bp.getBreakpointSize(); if (document.querySelector('#js-peek')) initPerformanceBar({ container: '#js-peek' }); @@ -199,6 +179,15 @@ document.addEventListener('DOMContentLoaded', () => { } }); + /** + * TODO: Apparently we are collapsing the right sidebar on certain screensizes per default + * except on issue board pages. Why can't we do it with CSS? + * + * Proposal: Expose a global sidebar API, which we could import wherever we are manipulating + * the visibility of the sidebar. + * + * Quick fix: Get rid of jQuery for this implementation + */ const isBoardsPage = /(projects|groups):boards:show/.test(document.body.dataset.page); if (!isBoardsPage && (bootstrapBreakpoint === 'sm' || bootstrapBreakpoint === 'xs')) { const $rightSidebar = $('aside.right-sidebar'); @@ -225,14 +214,12 @@ document.addEventListener('DOMContentLoaded', () => { localTimeAgo($('abbr.timeago, .js-timeago'), true); - // Form submitter - $('.trigger-submit').on('change', function triggerSubmitCallback() { - $(this) - .parents('form') - .submit(); - }); - - // Disable form buttons while a form is submitting + /** + * This disables form buttons while a form is submitting + * We do not difinitively know all of the places where this is used + * + * TODO: Defer execution, migrate to behaviors, and add sentry logging + */ $body.on('ajax:complete, ajax:beforeSend, submit', 'form', function ajaxCompleteCallback(e) { const $buttons = $('[type="submit"], .js-disable-on-submit', this).not('.js-no-auto-disable'); switch (e.type) { @@ -259,7 +246,11 @@ document.addEventListener('DOMContentLoaded', () => { $('.header-content').toggleClass('menu-expanded'); }); - // Commit show suppressed diff + /** + * Show suppressed commit diff + * + * TODO: Move to commit diff pages + */ $document.on('click', '.diff-content .js-show-suppressed-diff', function showDiffCallback() { const $container = $(this).parent(); $container.next('table').show(); @@ -290,39 +281,6 @@ document.addEventListener('DOMContentLoaded', () => { $(document).trigger('toggle.comments'); }); - $document.on('breakpoint:change', (e, breakpoint) => { - const breakpointSizes = ['md', 'sm', 'xs']; - if (breakpointSizes.includes(breakpoint)) { - const $gutterIcon = $sidebarGutterToggle.find('i'); - if ($gutterIcon.hasClass('fa-angle-double-right')) { - $sidebarGutterToggle.trigger('click'); - } - - const sidebarGutterVueToggleEl = document.querySelector('.js-sidebar-vue-toggle'); - - // Sidebar has an icon which corresponds to collapsing the sidebar - // only then trigger the click. - if (sidebarGutterVueToggleEl) { - const collapseIcon = sidebarGutterVueToggleEl.querySelector('i.fa-angle-double-right'); - - if (collapseIcon) { - collapseIcon.click(); - } - } - } - }); - - function fitSidebarForSize() { - const oldBootstrapBreakpoint = bootstrapBreakpoint; - bootstrapBreakpoint = bp.getBreakpointSize(); - - if (bootstrapBreakpoint !== oldBootstrapBreakpoint) { - $document.trigger('breakpoint:change', [bootstrapBreakpoint]); - } - } - - $window.on('resize.app', fitSidebarForSize); - $('form.filter-form').on('submit', function filterFormSubmitCallback(event) { const link = document.createElement('a'); link.href = this.action; diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js index d719fd8748d..f220e9e0192 100644 --- a/app/assets/javascripts/members.js +++ b/app/assets/javascripts/members.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { disableButtonIfEmptyField } from '~/lib/utils/common_utils'; export default class Members { constructor() { @@ -13,7 +14,7 @@ export default class Members { $('.js-edit-member-form') .off('ajax:success') .on('ajax:success', this.formSuccess.bind(this)); - gl.utils.disableButtonIfEmptyField('#user_ids', 'input[name=commit]', 'change'); + disableButtonIfEmptyField('#user_ids', 'input[name=commit]', 'change'); } dropdownClicked(options) { diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 6c794c1d324..a90e4e32d34 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -1,6 +1,7 @@ /* eslint-disable func-names, no-underscore-dangle, consistent-return */ import $ from 'jquery'; +import axios from './lib/utils/axios_utils'; import { __ } from '~/locale'; import createFlash from '~/flash'; import TaskList from './task_list'; @@ -65,9 +66,17 @@ MergeRequest.prototype.showAllCommits = function() { MergeRequest.prototype.initMRBtnListeners = function() { const _this = this; - return $('a.btn-close, a.btn-reopen').on('click', function(e) { + return $('.btn-close, .btn-reopen').on('click', function(e) { const $this = $(this); const shouldSubmit = $this.hasClass('btn-comment'); + if ($this.hasClass('js-btn-issue-action')) { + const url = $this.data('endpoint'); + return axios + .put(url) + .then(() => window.location.reload()) + .catch(() => createFlash(__('Something went wrong.'))); + } + if (shouldSubmit && $this.data('submitted')) { return; } diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 6c63ab7cf95..e9b7b56a160 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -226,6 +226,8 @@ export default class MergeRequestTabs { this.resetViewContainer(); this.destroyPipelinesView(); } + + $('.detail-page-description').renderGFM(); } else if (action === this.currentAction) { // ContentTop is used to handle anything at the top of the page before the main content const mainContentContainer = document.querySelector('.content-wrapper'); diff --git a/app/assets/javascripts/monitoring/components/charts/anomaly.vue b/app/assets/javascripts/monitoring/components/charts/anomaly.vue index 34da5885c97..ac401c6e381 100644 --- a/app/assets/javascripts/monitoring/components/charts/anomaly.vue +++ b/app/assets/javascripts/monitoring/components/charts/anomaly.vue @@ -218,7 +218,7 @@ export default { <gl-chart-series-label :color="content.color"> {{ content.name }} </gl-chart-series-label> - <div class="prepend-left-32"> + <div class="gl-ml-7"> {{ yValueFormatted(seriesIndex, content.dataIndex) }} </div> </div> diff --git a/app/assets/javascripts/monitoring/components/charts/heatmap.vue b/app/assets/javascripts/monitoring/components/charts/heatmap.vue index f6f266dacf3..ddb44f7b1be 100644 --- a/app/assets/javascripts/monitoring/components/charts/heatmap.vue +++ b/app/assets/javascripts/monitoring/components/charts/heatmap.vue @@ -48,7 +48,10 @@ export default { return this.result.values.map(val => { const [yLabel] = val; - return formatDate(new Date(yLabel), { format: formats.shortTime, timezone: this.timezone }); + return formatDate(new Date(yLabel), { + format: formats.shortTime, + timezone: this.timezone, + }); }); }, result() { diff --git a/app/assets/javascripts/monitoring/components/charts/options.js b/app/assets/javascripts/monitoring/components/charts/options.js index f7822e69b1d..42252dd5897 100644 --- a/app/assets/javascripts/monitoring/components/charts/options.js +++ b/app/assets/javascripts/monitoring/components/charts/options.js @@ -17,7 +17,9 @@ const defaultTooltipFormat = defaultFormat; const defaultTooltipPrecision = 3; // Give enough space for y-axis with units and name. -const chartGridLeft = 75; +const chartGridLeft = 63; // larger gap than gitlab-ui's default to fit formatted numbers +const chartGridRight = 10; // half of the scroll-handle icon for data zoom +const yAxisNameGap = chartGridLeft - 12; // offset the axis label line-height // Axis options @@ -62,7 +64,7 @@ export const getYAxisOptions = ({ precision = defaultYAxisPrecision, } = {}) => { return { - nameGap: 63, // larger gap than gitlab-ui's default to fit with formatted numbers + nameGap: yAxisNameGap, scale: true, boundaryGap: yAxisBoundaryGap, @@ -74,11 +76,14 @@ export const getYAxisOptions = ({ }; }; -export const getTimeAxisOptions = ({ timezone = timezones.LOCAL } = {}) => ({ +export const getTimeAxisOptions = ({ + timezone = timezones.LOCAL, + format = formats.shortDateTime, +} = {}) => ({ name: __('Time'), type: axisTypes.time, axisLabel: { - formatter: date => formatDate(date, { format: formats.shortTime, timezone }), + formatter: date => formatDate(date, { format, timezone }), }, axisPointer: { snap: false, @@ -90,7 +95,10 @@ export const getTimeAxisOptions = ({ timezone = timezones.LOCAL } = {}) => ({ /** * Grid with enough room to display chart. */ -export const getChartGrid = ({ left = chartGridLeft } = {}) => ({ left }); +export const getChartGrid = ({ left = chartGridLeft, right = chartGridRight } = {}) => ({ + left, + right, +}); // Tooltip options diff --git a/app/assets/javascripts/monitoring/components/charts/single_stat.vue b/app/assets/javascripts/monitoring/components/charts/single_stat.vue index eee5eaa5eca..106c76a97dc 100644 --- a/app/assets/javascripts/monitoring/components/charts/single_stat.vue +++ b/app/assets/javascripts/monitoring/components/charts/single_stat.vue @@ -1,9 +1,11 @@ <script> import { GlSingleStat } from '@gitlab/ui/dist/charts'; +import { __ } from '~/locale'; import { SUPPORTED_FORMATS, getFormatter } from '~/lib/utils/unit_format'; import { graphDataValidatorForValues } from '../../utils'; const defaultPrecision = 2; +const emptyStateMsg = __('No data to display'); export default { components: { @@ -21,6 +23,9 @@ export default { queryInfo() { return this.graphData.metrics[0]; }, + queryMetric() { + return this.queryInfo.result[0]?.metric; + }, queryResult() { return this.queryInfo.result[0]?.value[1]; }, @@ -33,6 +38,12 @@ export default { statValue() { let formatter; + // if field is present the metric value is not displayed. Hence + // the early exit without formatting. + if (this.graphData?.field) { + return this.queryMetric?.[this.graphData.field] ?? emptyStateMsg; + } + if (this.graphData?.maxValue) { formatter = getFormatter(SUPPORTED_FORMATS.percent); return formatter(this.queryResult / Number(this.graphData.maxValue), defaultPrecision); diff --git a/app/assets/javascripts/monitoring/components/charts/stacked_column.vue b/app/assets/javascripts/monitoring/components/charts/stacked_column.vue index ac31d107e63..9bcd4419a14 100644 --- a/app/assets/javascripts/monitoring/components/charts/stacked_column.vue +++ b/app/assets/javascripts/monitoring/components/charts/stacked_column.vue @@ -6,7 +6,7 @@ import { chartHeight, legendLayoutTypes } from '../../constants'; import { s__ } from '~/locale'; import { graphDataValidatorForValues } from '../../utils'; import { getTimeAxisOptions, axisTypes } from './options'; -import { timezones } from '../../format_date'; +import { formats, timezones } from '../../format_date'; export default { components: { @@ -97,7 +97,7 @@ export default { chartOptions() { return { xAxis: { - ...getTimeAxisOptions({ timezone: this.timezone }), + ...getTimeAxisOptions({ timezone: this.timezone, format: formats.shortTime }), type: this.xAxisType, }, dataZoom: [this.dataZoomConfig], diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue index 28af2d8ba77..f2add429a80 100644 --- a/app/assets/javascripts/monitoring/components/charts/time_series.vue +++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue @@ -415,7 +415,7 @@ export default { <gl-chart-series-label :color="isMultiSeries ? content.color : ''"> {{ content.name }} </gl-chart-series-label> - <div class="prepend-left-32"> + <div class="gl-ml-7"> {{ content.value }} </div> </div> diff --git a/app/assets/javascripts/monitoring/components/create_dashboard_modal.vue b/app/assets/javascripts/monitoring/components/create_dashboard_modal.vue new file mode 100644 index 00000000000..74799002b17 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/create_dashboard_modal.vue @@ -0,0 +1,66 @@ +<script> +import { GlButton, GlModal, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { isSafeURL } from '~/lib/utils/url_utility'; + +export default { + components: { GlButton, GlModal, GlSprintf }, + props: { + modalId: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + validator: isSafeURL, + }, + addDashboardDocumentationPath: { + type: String, + required: true, + }, + }, + methods: { + cancelHandler() { + this.$refs.modal.hide(); + }, + }, + i18n: { + titleText: s__('Metrics|Create your dashboard configuration file'), + mainText: s__( + 'Metrics|To create a new dashboard, add a new YAML file to %{codeStart}.gitlab/dashboards%{codeEnd} at the root of this project.', + ), + }, +}; +</script> + +<template> + <gl-modal ref="modal" :modal-id="modalId" :title="$options.i18n.titleText"> + <p> + <gl-sprintf :message="$options.i18n.mainText"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + <template #modal-footer> + <gl-button category="secondary" @click="cancelHandler">{{ s__('Metrics|Cancel') }}</gl-button> + <gl-button + category="secondary" + variant="info" + target="_blank" + :href="addDashboardDocumentationPath" + data-testid="create-dashboard-modal-docs-button" + > + {{ s__('Metrics|View documentation') }} + </gl-button> + <gl-button + variant="success" + data-testid="create-dashboard-modal-repo-button" + :href="projectPath" + > + {{ s__('Metrics|Open repository') }} + </gl-button> + </template> + </gl-modal> +</template> diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index f54319d283e..bde62275797 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -1,6 +1,7 @@ <script> import { mapActions, mapState, mapGetters } from 'vuex'; import VueDraggable from 'vuedraggable'; +import Mousetrap from 'mousetrap'; import { GlIcon, GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; import DashboardHeader from './dashboard_header.vue'; import DashboardPanel from './dashboard_panel.vue'; @@ -24,7 +25,7 @@ import { expandedPanelPayloadFromUrl, convertVariablesForURL, } from '../utils'; -import { metricStates } from '../constants'; +import { metricStates, keyboardShortcutKeys } from '../constants'; import { defaultTimeRange } from '~/vue_shared/constants'; export default { @@ -71,6 +72,10 @@ export default { type: String, required: true, }, + addDashboardDocumentationPath: { + type: String, + required: true, + }, settingsPath: { type: String, required: true, @@ -149,21 +154,25 @@ export default { selectedTimeRange: timeRangeFromUrl() || defaultTimeRange, isRearrangingPanels: false, originalDocumentTitle: document.title, + hoveredPanel: '', }; }, computed: { ...mapState('monitoringDashboard', [ 'dashboard', 'emptyState', - 'showEmptyState', 'expandedPanel', 'variables', 'links', 'currentDashboard', + 'hasDashboardValidationWarnings', ]), ...mapGetters('monitoringDashboard', ['selectedDashboard', 'getMetricStates']), + shouldShowEmptyState() { + return Boolean(this.emptyState); + }, shouldShowVariablesSection() { - return Object.keys(this.variables).length > 0; + return Boolean(this.variables.length); }, shouldShowLinksSection() { return Object.keys(this.links).length > 0; @@ -197,12 +206,29 @@ export default { selectedDashboard(dashboard) { this.prependToDocumentTitle(dashboard?.display_name); }, + hasDashboardValidationWarnings(hasWarnings) { + /** + * This watcher is set for future SPA behaviour of the dashboard + */ + if (hasWarnings) { + createFlash( + s__( + 'Metrics|Your dashboard schema is invalid. Edit the dashboard to correct the YAML schema.', + ), + 'warning', + ); + } + }, }, created() { window.addEventListener('keyup', this.onKeyup); + + Mousetrap.bind(Object.values(keyboardShortcutKeys), this.runShortcut); }, destroyed() { window.removeEventListener('keyup', this.onKeyup); + + Mousetrap.unbind(Object.values(keyboardShortcutKeys)); }, mounted() { if (!this.hasMetrics) { @@ -254,6 +280,14 @@ export default { return null; }, /** + * Return true if the entire group is loading. + * @param {String} groupKey - Identifier for group + * @returns {boolean} + */ + isGroupLoading(groupKey) { + return this.groupSingleEmptyState(groupKey) === metricStates.LOADING; + }, + /** * A group should be not collapsed if any metric is loaded (OK) * * @param {String} groupKey - Identifier for group @@ -302,6 +336,66 @@ export default { // As a fallback, switch to default time range instead this.selectedTimeRange = defaultTimeRange; }, + isPanelHalfWidth(panelIndex, totalPanels) { + /** + * A single panel on a row should take the full width of its parent. + * All others should have half the width their parent. + */ + const isNumberOfPanelsEven = totalPanels % 2 === 0; + const isLastPanel = panelIndex === totalPanels - 1; + + return isNumberOfPanelsEven || !isLastPanel; + }, + /** + * TODO: Investigate this to utilize the eventBus from Vue + * The intentation behind this cleanup is to allow for better tests + * as well as use the correct eventBus facilities that are compatible + * with Vue 3 + * https://gitlab.com/gitlab-org/gitlab/-/issues/225583 + */ + // + runShortcut(e) { + const panel = this.$refs[this.hoveredPanel]; + + if (!panel) return; + + const [panelInstance] = panel; + let actionToRun = ''; + + switch (e.key) { + case keyboardShortcutKeys.EXPAND: + actionToRun = 'onExpandFromKeyboardShortcut'; + break; + + case keyboardShortcutKeys.VISIT_LOGS: + actionToRun = 'visitLogsPageFromKeyboardShortcut'; + break; + + case keyboardShortcutKeys.SHOW_ALERT: + actionToRun = 'showAlertModalFromKeyboardShortcut'; + break; + + case keyboardShortcutKeys.DOWNLOAD_CSV: + actionToRun = 'downloadCsvFromKeyboardShortcut'; + break; + + case keyboardShortcutKeys.CHART_COPY: + actionToRun = 'copyChartLinkFromKeyboardShotcut'; + break; + + default: + actionToRun = 'onExpandFromKeyboardShortcut'; + break; + } + + panelInstance[actionToRun](); + }, + setHoveredPanel(groupKey, graphIndex) { + this.hoveredPanel = `dashboard-panel-${groupKey}-${graphIndex}`; + }, + clearHoveredPanel() { + this.hoveredPanel = ''; + }, }, i18n: { goBackLabel: s__('Metrics|Go back (Esc)'), @@ -315,6 +409,7 @@ export default { v-if="showHeader" ref="prometheusGraphsHeader" class="prometheus-graphs-header d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 border-bottom bg-gray-light" + :add-dashboard-documentation-path="addDashboardDocumentationPath" :default-branch="defaultBranch" :rearrange-panels-available="rearrangePanelsAvailable" :custom-metrics-available="customMetricsAvailable" @@ -327,9 +422,9 @@ export default { @dateTimePickerInvalid="onDateTimePickerInvalid" @setRearrangingPanels="onSetRearrangingPanels" /> - <variables-section v-if="shouldShowVariablesSection && !showEmptyState" /> - <links-section v-if="shouldShowLinksSection && !showEmptyState" /> - <div v-if="!showEmptyState"> + <template v-if="!shouldShowEmptyState"> + <variables-section v-if="shouldShowVariablesSection" /> + <links-section v-if="shouldShowLinksSection" /> <dashboard-panel v-show="expandedPanel.panel" ref="expandedPanel" @@ -364,6 +459,7 @@ export default { :key="`${groupData.group}.${groupData.priority}`" :name="groupData.group" :show-panels="showPanels" + :is-loading="isGroupLoading(groupData.key)" :collapse-group="collapseGroup(groupData.key)" > <vue-draggable @@ -377,8 +473,14 @@ export default { <div v-for="(graphData, graphIndex) in groupData.panels" :key="`dashboard-panel-${graphIndex}`" - class="col-12 col-lg-6 px-2 mb-2 draggable" - :class="{ 'draggable-enabled': isRearrangingPanels }" + data-testid="dashboard-panel-layout-wrapper" + class="col-12 px-2 mb-2 draggable" + :class="{ + 'draggable-enabled': isRearrangingPanels, + 'col-lg-6': isPanelHalfWidth(graphIndex, groupData.panels.length), + }" + @mouseover="setHoveredPanel(groupData.key, graphIndex)" + @mouseout="clearHoveredPanel" > <div class="position-relative draggable-panel js-draggable-panel"> <div @@ -392,6 +494,7 @@ export default { </div> <dashboard-panel + :ref="`dashboard-panel-${groupData.key}-${graphIndex}`" :settings-path="settingsPath" :clipboard-text="generatePanelUrl(groupData.group, graphData)" :graph-data="graphData" @@ -414,7 +517,7 @@ export default { </div> </graph-group> </div> - </div> + </template> <empty-state v-else :selected-state="emptyState" diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue index 16a21ae0d3c..fe6ca3a2a07 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_header.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_header.vue @@ -2,12 +2,16 @@ import { debounce } from 'lodash'; import { mapActions, mapState, mapGetters } from 'vuex'; import { + GlButton, GlIcon, GlDeprecatedButton, GlDropdown, GlDropdownItem, GlDropdownHeader, GlDropdownDivider, + GlNewDropdown, + GlNewDropdownDivider, + GlNewDropdownItem, GlModal, GlLoadingIcon, GlSearchBoxByType, @@ -22,6 +26,9 @@ import Icon from '~/vue_shared/components/icon.vue'; import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue'; import DashboardsDropdown from './dashboards_dropdown.vue'; +import RefreshButton from './refresh_button.vue'; +import CreateDashboardModal from './create_dashboard_modal.vue'; +import DuplicateDashboardModal from './duplicate_dashboard_modal.vue'; import TrackEventDirective from '~/vue_shared/directives/track_event'; import { getAddMetricTrackingOptions, timeRangeToUrl } from '../utils'; @@ -31,6 +38,7 @@ import { timezones } from '../format_date'; export default { components: { Icon, + GlButton, GlIcon, GlDeprecatedButton, GlDropdown, @@ -38,12 +46,18 @@ export default { GlDropdownItem, GlDropdownHeader, GlDropdownDivider, + GlNewDropdown, + GlNewDropdownDivider, + GlNewDropdownItem, GlSearchBoxByType, GlModal, CustomMetricsFormFields, DateTimePicker, DashboardsDropdown, + RefreshButton, + DuplicateDashboardModal, + CreateDashboardModal, }, directives: { GlModal: GlModalDirective, @@ -93,6 +107,10 @@ export default { type: Object, required: true, }, + addDashboardDocumentationPath: { + type: String, + required: true, + }, }, data() { return { @@ -101,20 +119,30 @@ export default { }, computed: { ...mapState('monitoringDashboard', [ + 'emptyState', 'environmentsLoading', 'currentEnvironmentName', 'isUpdatingStarredValue', - 'showEmptyState', 'dashboardTimezone', + 'projectPath', + 'canAccessOperationsSettings', + 'operationsSettingsPath', + 'currentDashboard', ]), ...mapGetters('monitoringDashboard', ['selectedDashboard', 'filteredEnvironments']), + isOutOfTheBoxDashboard() { + return this.selectedDashboard?.out_of_the_box_dashboard; + }, + shouldShowEmptyState() { + return Boolean(this.emptyState); + }, shouldShowEnvironmentsDropdownNoMatchedMsg() { return !this.environmentsLoading && this.filteredEnvironments.length === 0; }, addingMetricsAvailable() { return ( this.customMetricsAvailable && - !this.showEmptyState && + !this.shouldShowEmptyState && // Custom metrics only avaialble on system dashboards because // they are stored in the database. This can be improved. See: // https://gitlab.com/gitlab-org/gitlab/-/issues/28241 @@ -122,23 +150,29 @@ export default { ); }, showRearrangePanelsBtn() { - return !this.showEmptyState && this.rearrangePanelsAvailable; + return !this.shouldShowEmptyState && this.rearrangePanelsAvailable; }, displayUtc() { return this.dashboardTimezone === timezones.UTC; }, + shouldShowActionsMenu() { + return Boolean(this.projectPath); + }, + shouldShowSettingsButton() { + return this.canAccessOperationsSettings && this.operationsSettingsPath; + }, }, methods: { - ...mapActions('monitoringDashboard', [ - 'filterEnvironments', - 'fetchDashboardData', - 'toggleStarredValue', - ]), + ...mapActions('monitoringDashboard', ['filterEnvironments', 'toggleStarredValue']), selectDashboard(dashboard) { - const params = { - dashboard: dashboard.path, - }; - redirectTo(mergeUrlParams(params, window.location.href)); + // Once the sidebar See metrics link is updated to the new URL, + // this sort of hardcoding will not be necessary. + // https://gitlab.com/gitlab-org/gitlab/-/issues/229277 + const baseURL = `${this.projectPath}/-/metrics`; + const dashboardPath = encodeURIComponent( + dashboard.out_of_the_box_dashboard ? dashboard.path : dashboard.display_name, + ); + redirectTo(`${baseURL}/${dashboardPath}`); }, debouncedEnvironmentsSearch: debounce(function environmentsSearchOnInput(searchTerm) { this.filterEnvironments(searchTerm); @@ -149,9 +183,6 @@ export default { onDateTimePickerInvalid() { this.$emit('dateTimePickerInvalid'); }, - refreshDashboard() { - this.fetchDashboardData(); - }, toggleRearrangingPanels() { this.$emit('setRearrangingPanels', !this.isRearrangingPanels); @@ -166,14 +197,27 @@ export default { submitCustomMetricsForm() { this.$refs.customMetricsForm.submit(); }, + getEnvironmentPath(environment) { + // Once the sidebar See metrics link is updated to the new URL, + // this sort of hardcoding will not be necessary. + // https://gitlab.com/gitlab-org/gitlab/-/issues/229277 + const baseURL = `${this.projectPath}/-/metrics`; + const dashboardPath = encodeURIComponent(this.currentDashboard || ''); + // The environment_metrics_spec.rb requires the URL to not have + // slashes. Hence, this additional check. + const url = dashboardPath ? `${baseURL}/${dashboardPath}` : baseURL; + return mergeUrlParams({ environment }, url); + }, }, - addMetric: { - title: s__('Metrics|Add metric'), - modalId: 'add-metric', + modalIds: { + addMetric: 'addMetric', + createDashboard: 'createDashboard', + duplicateDashboard: 'duplicateDashboard', }, i18n: { starDashboard: s__('Metrics|Star dashboard'), unstarDashboard: s__('Metrics|Unstar dashboard'), + addMetric: s__('Metrics|Add metric'), }, timeRanges, }; @@ -181,17 +225,20 @@ export default { <template> <div ref="prometheusGraphsHeader"> - <div class="mb-2 pr-2 d-flex d-sm-block"> + <div class="mb-2 mr-2 d-flex d-sm-block"> <dashboards-dropdown id="monitor-dashboards-dropdown" data-qa-selector="dashboards_filter_dropdown" class="flex-grow-1" toggle-class="dropdown-menu-toggle" :default-branch="defaultBranch" + :modal-id="$options.modalIds.duplicateDashboard" @selectDashboard="selectDashboard" /> </div> + <span aria-hidden="true" class="gl-pl-3 border-left gl-mb-3 d-none d-sm-block"></span> + <div class="mb-2 pr-2 d-flex d-sm-block"> <gl-dropdown id="monitor-environments-dropdown" @@ -223,7 +270,7 @@ export default { :key="environment.id" :active="environment.name === currentEnvironmentName" active-class="is-active" - :href="environment.metrics_path" + :href="getEnvironmentPath(environment.id)" >{{ environment.name }}</gl-dropdown-item > </div> @@ -252,16 +299,7 @@ export default { </div> <div class="mb-2 pr-2 d-flex d-sm-block"> - <gl-deprecated-button - ref="refreshDashboardBtn" - v-gl-tooltip - class="flex-grow-1" - variant="default" - :title="s__('Metrics|Refresh dashboard')" - @click="refreshDashboard" - > - <icon name="retry" /> - </gl-deprecated-button> + <refresh-button /> </div> <div class="flex-grow-1"></div> @@ -304,17 +342,17 @@ export default { <div v-if="addingMetricsAvailable" class="mb-2 mr-2 d-flex d-sm-block"> <gl-deprecated-button ref="addMetricBtn" - v-gl-modal="$options.addMetric.modalId" + v-gl-modal="$options.modalIds.addMetric" variant="outline-success" data-qa-selector="add_metric_button" class="flex-grow-1" > - {{ $options.addMetric.title }} + {{ $options.i18n.addMetric }} </gl-deprecated-button> <gl-modal ref="addMetricModal" - :modal-id="$options.addMetric.modalId" - :title="$options.addMetric.title" + :modal-id="$options.modalIds.addMetric" + :title="$options.i18n.addMetric" > <form ref="customMetricsForm" :action="customMetricsPath" method="post"> <custom-metrics-form-fields @@ -353,7 +391,10 @@ export default { </gl-deprecated-button> </div> - <div v-if="externalDashboardUrl.length" class="mb-2 mr-2 d-flex d-sm-block"> + <div + v-if="externalDashboardUrl && externalDashboardUrl.length" + class="mb-2 mr-2 d-flex d-sm-block" + > <gl-deprecated-button class="flex-grow-1 js-external-dashboard-link" variant="primary" @@ -364,6 +405,63 @@ export default { {{ __('View full dashboard') }} <icon name="external-link" /> </gl-deprecated-button> </div> + + <!-- This separator should be displayed only if at least one of the action menu or settings button are displayed --> + <span + v-if="shouldShowActionsMenu || shouldShowSettingsButton" + aria-hidden="true" + class="gl-pl-3 border-left gl-mb-3 d-none d-sm-block" + ></span> + + <div v-if="shouldShowActionsMenu" class="gl-mb-3 gl-mr-3 d-flex d-sm-block"> + <gl-new-dropdown + v-gl-tooltip + right + class="gl-flex-grow-1" + data-testid="actions-menu" + :title="s__('Metrics|Create dashboard')" + :icon="'plus-square'" + > + <gl-new-dropdown-item + v-gl-modal="$options.modalIds.createDashboard" + data-testid="action-create-dashboard" + >{{ s__('Metrics|Create new dashboard') }}</gl-new-dropdown-item + > + + <create-dashboard-modal + data-testid="create-dashboard-modal" + :add-dashboard-documentation-path="addDashboardDocumentationPath" + :modal-id="$options.modalIds.createDashboard" + :project-path="projectPath" + /> + + <template v-if="isOutOfTheBoxDashboard"> + <gl-new-dropdown-divider /> + <gl-new-dropdown-item + ref="duplicateDashboardItem" + v-gl-modal="$options.modalIds.duplicateDashboard" + data-testid="action-duplicate-dashboard" + > + {{ s__('Metrics|Duplicate current dashboard') }} + </gl-new-dropdown-item> + </template> + </gl-new-dropdown> + </div> + + <div v-if="shouldShowSettingsButton" class="mb-2 mr-2 d-flex d-sm-block"> + <gl-button + v-gl-tooltip + data-testid="metrics-settings-button" + icon="settings" + :href="operationsSettingsPath" + :title="s__('Metrics|Metrics Settings')" + /> + </div> </div> + <duplicate-dashboard-modal + :default-branch="defaultBranch" + :modal-id="$options.modalIds.duplicateDashboard" + @dashboardDuplicated="selectDashboard" + /> </div> </template> diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel.vue b/app/assets/javascripts/monitoring/components/dashboard_panel.vue index 9545a211bbd..3e3c8408de3 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_panel.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_panel.vue @@ -2,6 +2,7 @@ import { mapState } from 'vuex'; import { pickBy } from 'lodash'; import invalidUrl from '~/lib/utils/invalid_url'; +import { relativePathToAbsolute, getBaseURL, visitUrl, isSafeURL } from '~/lib/utils/url_utility'; import { GlResizeObserverDirective, GlIcon, @@ -29,7 +30,6 @@ 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 { isSafeURL } from '~/lib/utils/url_utility'; const events = { timeRangeZoom: 'timerangezoom', @@ -132,7 +132,8 @@ export default { return this.graphData?.title || ''; }, graphDataHasResult() { - return this.graphData?.metrics?.[0]?.result?.length > 0; + const metrics = this.graphData?.metrics || []; + return metrics.some(({ result }) => result?.length > 0); }, graphDataIsLoading() { const metrics = this.graphData?.metrics || []; @@ -207,7 +208,17 @@ export default { return MonitorTimeSeriesChart; }, isContextualMenuShown() { - return Boolean(this.graphDataHasResult && !this.basicChartComponent); + if (!this.graphDataHasResult) { + return false; + } + // Only a few charts have a contextual menu, support + // for more chart types planned at: + // https://gitlab.com/groups/gitlab-org/-/epics/3573 + return ( + this.isPanelType(panelTypes.AREA_CHART) || + this.isPanelType(panelTypes.LINE_CHART) || + this.isPanelType(panelTypes.SINGLE_STAT) + ); }, editCustomMetricLink() { if (this.graphData.metrics.length > 1) { @@ -223,13 +234,19 @@ export default { return metrics.some(({ metricId }) => this.metricsSavedToDb.includes(metricId)); }, alertWidgetAvailable() { + const supportsAlerts = + this.isPanelType(panelTypes.AREA_CHART) || this.isPanelType(panelTypes.LINE_CHART); return ( + supportsAlerts && this.prometheusAlertsAvailable && this.alertsEndpoint && this.graphData && this.hasMetricsInDb ); }, + alertModalId() { + return `alert-modal-${this.graphData.id}`; + }, }, mounted() { this.refreshTitleTooltip(); @@ -268,6 +285,11 @@ export default { onExpand() { this.$emit(events.expand); }, + onExpandFromKeyboardShortcut() { + if (this.isContextualMenuShown) { + this.onExpand(); + } + }, setAlerts(alertPath, alertAttributes) { if (alertAttributes) { this.$set(this.allAlerts, alertPath, alertAttributes); @@ -278,18 +300,45 @@ export default { safeUrl(url) { return isSafeURL(url) ? url : '#'; }, + showAlertModal() { + this.$root.$emit('bv::show::modal', this.alertModalId); + }, + showAlertModalFromKeyboardShortcut() { + if (this.isContextualMenuShown) { + this.showAlertModal(); + } + }, + visitLogsPage() { + if (this.logsPathWithTimeRange) { + visitUrl(relativePathToAbsolute(this.logsPathWithTimeRange, getBaseURL())); + } + }, + visitLogsPageFromKeyboardShortcut() { + if (this.isContextualMenuShown) { + this.visitLogsPage(); + } + }, + downloadCsvFromKeyboardShortcut() { + if (this.csvText && this.isContextualMenuShown) { + this.$refs.downloadCsvLink.$el.firstChild.click(); + } + }, + copyChartLinkFromKeyboardShotcut() { + if (this.clipboardText && this.isContextualMenuShown) { + this.$refs.copyChartLink.$el.firstChild.click(); + } + }, }, panelTypes, }; </script> <template> <div v-gl-resize-observer="onResize" class="prometheus-graph"> - <div class="d-flex align-items-center mr-3"> + <div class="d-flex align-items-center"> <slot name="topLeft"></slot> <h5 ref="graphTitle" class="prometheus-graph-title gl-font-lg font-weight-bold text-truncate gl-mr-3" - tabindex="0" > {{ title }} </h5> @@ -299,7 +348,7 @@ export default { <alert-widget v-if="isContextualMenuShown && alertWidgetAvailable" class="mx-1" - :modal-id="`alert-modal-${graphData.id}`" + :modal-id="alertModalId" :alerts-endpoint="alertsEndpoint" :relevant-queries="graphData.metrics" :alerts-to-manage="getGraphAlerts(graphData.metrics)" @@ -314,7 +363,7 @@ export default { ref="contextualMenu" data-qa-selector="prometheus_graph_widgets" > - <div class="d-flex align-items-center"> + <div data-testid="dropdown-wrapper" class="d-flex align-items-center"> <gl-dropdown v-gl-tooltip toggle-class="shadow-none border-0" @@ -369,13 +418,13 @@ export default { </gl-dropdown-item> <gl-dropdown-item v-if="alertWidgetAvailable" - v-gl-modal="`alert-modal-${graphData.id}`" + v-gl-modal="alertModalId" data-qa-selector="alert_widget_menu_item" > {{ __('Alerts') }} </gl-dropdown-item> - <template v-if="graphData.links.length"> + <template v-if="graphData.links && graphData.links.length"> <gl-dropdown-divider /> <gl-dropdown-item v-for="(link, index) in graphData.links" diff --git a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue index 8b86890715f..574f48a72fe 100644 --- a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue +++ b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue @@ -1,19 +1,14 @@ <script> import { mapState, mapActions, mapGetters } from 'vuex'; import { - GlAlert, GlIcon, GlDropdown, GlDropdownItem, GlDropdownHeader, GlDropdownDivider, GlSearchBoxByType, - GlModal, - GlLoadingIcon, GlModalDirective, } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import DuplicateDashboardForm from './duplicate_dashboard_form.vue'; const events = { selectDashboard: 'selectDashboard', @@ -21,16 +16,12 @@ const events = { export default { components: { - GlAlert, GlIcon, GlDropdown, GlDropdownItem, GlDropdownHeader, GlDropdownDivider, GlSearchBoxByType, - GlModal, - GlLoadingIcon, - DuplicateDashboardForm, }, directives: { GlModal: GlModalDirective, @@ -40,20 +31,21 @@ export default { type: String, required: true, }, + modalId: { + type: String, + required: true, + }, }, data() { return { - alert: null, - loading: false, - form: {}, searchTerm: '', }; }, computed: { ...mapState('monitoringDashboard', ['allDashboards']), ...mapGetters('monitoringDashboard', ['selectedDashboard']), - isSystemDashboard() { - return this.selectedDashboard?.system_dashboard; + isOutOfTheBoxDashboard() { + return this.selectedDashboard?.out_of_the_box_dashboard; }, selectedDashboardText() { return this.selectedDashboard?.display_name; @@ -76,10 +68,6 @@ export default { nonStarredDashboards() { return this.filteredDashboards.filter(({ starred }) => !starred); }, - - okButtonText() { - return this.loading ? s__('Metrics|Duplicating...') : s__('Metrics|Duplicate'); - }, }, methods: { ...mapActions('monitoringDashboard', ['duplicateSystemDashboard']), @@ -89,37 +77,6 @@ export default { selectDashboard(dashboard) { this.$emit(events.selectDashboard, dashboard); }, - ok(bvModalEvt) { - // Prevent modal from hiding in case submit fails - bvModalEvt.preventDefault(); - - this.loading = true; - this.alert = null; - this.duplicateSystemDashboard(this.form) - .then(createdDashboard => { - this.loading = false; - this.alert = null; - - // Trigger hide modal as submit is successful - this.$refs.duplicateDashboardModal.hide(); - - // Dashboards in the default branch become available immediately. - // Not so in other branches, so we refresh the current dashboard - const dashboard = - this.form.branch === this.defaultBranch ? createdDashboard : this.selectedDashboard; - this.$emit(events.selectDashboard, dashboard); - }) - .catch(error => { - this.loading = false; - this.alert = error; - }); - }, - hide() { - this.alert = null; - }, - formChange(form) { - this.form = form; - }, }, }; </script> @@ -178,32 +135,14 @@ export default { {{ __('No matching results') }} </div> - <template v-if="isSystemDashboard"> + <!-- + This Duplicate Dashboard item will be removed from the dashboards dropdown + in https://gitlab.com/gitlab-org/gitlab/-/issues/223223 + --> + <template v-if="isOutOfTheBoxDashboard"> <gl-dropdown-divider /> - <gl-modal - ref="duplicateDashboardModal" - modal-id="duplicateDashboardModal" - :title="s__('Metrics|Duplicate dashboard')" - ok-variant="success" - @ok="ok" - @hide="hide" - > - <gl-alert v-if="alert" class="mb-3" variant="danger" @dismiss="alert = null"> - {{ alert }} - </gl-alert> - <duplicate-dashboard-form - :dashboard="selectedDashboard" - :default-branch="defaultBranch" - @change="formChange" - /> - <template #modal-ok> - <gl-loading-icon v-if="loading" inline color="light" /> - {{ okButtonText }} - </template> - </gl-modal> - - <gl-dropdown-item ref="duplicateDashboardItem" v-gl-modal="'duplicateDashboardModal'"> + <gl-dropdown-item v-gl-modal="modalId" data-testid="duplicateDashboardItem"> {{ s__('Metrics|Duplicate dashboard') }} </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue b/app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue new file mode 100644 index 00000000000..e64afc01fd9 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue @@ -0,0 +1,95 @@ +<script> +import { mapActions, mapGetters } from 'vuex'; +import { GlAlert, GlLoadingIcon, GlModal } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import DuplicateDashboardForm from './duplicate_dashboard_form.vue'; + +const events = { + dashboardDuplicated: 'dashboardDuplicated', +}; + +export default { + components: { GlAlert, GlLoadingIcon, GlModal, DuplicateDashboardForm }, + props: { + defaultBranch: { + type: String, + required: true, + }, + modalId: { + type: String, + required: true, + }, + }, + data() { + return { + alert: null, + loading: false, + form: {}, + }; + }, + computed: { + ...mapGetters('monitoringDashboard', ['selectedDashboard']), + okButtonText() { + return this.loading ? s__('Metrics|Duplicating...') : s__('Metrics|Duplicate'); + }, + }, + methods: { + ...mapActions('monitoringDashboard', ['duplicateSystemDashboard']), + ok(bvModalEvt) { + // Prevent modal from hiding in case submit fails + bvModalEvt.preventDefault(); + + this.loading = true; + this.alert = null; + this.duplicateSystemDashboard(this.form) + .then(createdDashboard => { + this.loading = false; + this.alert = null; + + // Trigger hide modal as submit is successful + this.$refs.duplicateDashboardModal.hide(); + + // Dashboards in the default branch become available immediately. + // Not so in other branches, so we refresh the current dashboard + const dashboard = + this.form.branch === this.defaultBranch ? createdDashboard : this.selectedDashboard; + this.$emit(events.dashboardDuplicated, dashboard); + }) + .catch(error => { + this.loading = false; + this.alert = error; + }); + }, + hide() { + this.alert = null; + }, + formChange(form) { + this.form = form; + }, + }, +}; +</script> + +<template> + <gl-modal + ref="duplicateDashboardModal" + :modal-id="modalId" + :title="s__('Metrics|Duplicate dashboard')" + ok-variant="success" + @ok="ok" + @hide="hide" + > + <gl-alert v-if="alert" class="mb-3" variant="danger" @dismiss="alert = null"> + {{ alert }} + </gl-alert> + <duplicate-dashboard-form + :dashboard="selectedDashboard" + :default-branch="defaultBranch" + @change="formChange" + /> + <template #modal-ok> + <gl-loading-icon v-if="loading" inline color="light" /> + {{ okButtonText }} + </template> + </gl-modal> +</template> diff --git a/app/assets/javascripts/monitoring/components/empty_state.vue b/app/assets/javascripts/monitoring/components/empty_state.vue index d3157b731b2..5e7c9b5d906 100644 --- a/app/assets/javascripts/monitoring/components/empty_state.vue +++ b/app/assets/javascripts/monitoring/components/empty_state.vue @@ -1,12 +1,19 @@ <script> -import { GlEmptyState } from '@gitlab/ui'; +import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui'; import { __ } from '~/locale'; +import { dashboardEmptyStates } from '../constants'; export default { components: { + GlLoadingIcon, GlEmptyState, }, props: { + selectedState: { + type: String, + required: true, + validator: state => Object.values(dashboardEmptyStates).includes(state), + }, documentationPath: { type: String, required: true, @@ -21,10 +28,6 @@ export default { required: false, default: '', }, - selectedState: { - type: String, - required: true, - }, emptyGettingStartedSvgPath: { type: String, required: true, @@ -53,52 +56,49 @@ export default { }, data() { return { + /** + * Possible empty states. + * Keys in each state must match GlEmptyState props + */ states: { - gettingStarted: { - svgUrl: this.emptyGettingStartedSvgPath, + [dashboardEmptyStates.GETTING_STARTED]: { + svgPath: this.emptyGettingStartedSvgPath, title: __('Get started with performance monitoring'), description: __(`Stay updated about the performance and health of your environment by configuring Prometheus to monitor your deployments.`), - buttonText: __('Install on clusters'), - buttonPath: this.clustersPath, + primaryButtonText: __('Install on clusters'), + primaryButtonLink: this.clustersPath, secondaryButtonText: __('Configure existing installation'), - secondaryButtonPath: this.settingsPath, + secondaryButtonLink: this.settingsPath, }, - loading: { - svgUrl: this.emptyLoadingSvgPath, - title: __('Waiting for performance data'), - description: __(`Creating graphs uses the data from the Prometheus server. - If this takes a long time, ensure that data is available.`), - buttonText: __('View documentation'), - buttonPath: this.documentationPath, - secondaryButtonText: '', - secondaryButtonPath: '', - }, - noData: { - svgUrl: this.emptyNoDataSvgPath, + [dashboardEmptyStates.NO_DATA]: { + svgPath: this.emptyNoDataSvgPath, title: __('No data found'), description: __(`You are connected to the Prometheus server, but there is currently no data to display.`), - buttonText: __('Configure Prometheus'), - buttonPath: this.settingsPath, + primaryButtonText: __('Configure Prometheus'), + primaryButtonLink: this.settingsPath, secondaryButtonText: '', - secondaryButtonPath: '', + secondaryButtonLink: '', }, - unableToConnect: { - svgUrl: this.emptyUnableToConnectSvgPath, + [dashboardEmptyStates.UNABLE_TO_CONNECT]: { + svgPath: this.emptyUnableToConnectSvgPath, title: __('Unable to connect to Prometheus server'), description: __( 'Ensure connectivity is available from the GitLab server to the Prometheus server', ), - buttonText: __('View documentation'), - buttonPath: this.documentationPath, + primaryButtonText: __('View documentation'), + primaryButtonLink: this.documentationPath, secondaryButtonText: __('Configure Prometheus'), - secondaryButtonPath: this.settingsPath, + secondaryButtonLink: this.settingsPath, }, }, }; }, computed: { + isLoading() { + return this.selectedState === dashboardEmptyStates.LOADING; + }, currentState() { return this.states[this.selectedState]; }, @@ -107,14 +107,8 @@ export default { </script> <template> - <gl-empty-state - :title="currentState.title" - :description="currentState.description" - :primary-button-text="currentState.buttonText" - :primary-button-link="currentState.buttonPath" - :secondary-button-text="currentState.secondaryButtonText" - :secondary-button-link="currentState.secondaryButtonPath" - :svg-path="currentState.svgUrl" - :compact="compact" - /> + <div> + <gl-loading-icon v-if="isLoading" size="xl" class="gl-my-9" /> + <gl-empty-state v-if="currentState" v-bind="currentState" :compact="compact" /> + </div> </template> diff --git a/app/assets/javascripts/monitoring/components/graph_group.vue b/app/assets/javascripts/monitoring/components/graph_group.vue index 08fcfa3bc56..ecb8ef4a0d0 100644 --- a/app/assets/javascripts/monitoring/components/graph_group.vue +++ b/app/assets/javascripts/monitoring/components/graph_group.vue @@ -1,9 +1,10 @@ <script> -import Icon from '~/vue_shared/components/icon.vue'; +import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; export default { components: { - Icon, + GlLoadingIcon, + GlIcon, }, props: { name: { @@ -15,6 +16,11 @@ export default { required: false, default: true, }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, /** * Initial value of collapse on mount. */ @@ -52,18 +58,21 @@ export default { </script> <template> - <div v-if="showPanels" ref="graph-group" class="card prometheus-panel" tabindex="0"> + <div v-if="showPanels" ref="graph-group" class="card prometheus-panel"> <div class="card-header d-flex align-items-center"> <h4 class="flex-grow-1">{{ name }}</h4> + <gl-loading-icon v-if="isLoading" name="loading" /> <a data-testid="group-toggle-button" + :aria-label="__('Toggle collapse')" + :icon="caretIcon" role="button" - class="js-graph-group-toggle gl-text-gray-900" + class="js-graph-group-toggle gl-display-flex gl-ml-2 gl-text-gray-900" tabindex="0" @click="collapse" @keyup.enter="collapse" > - <icon :size="16" :aria-label="__('Toggle collapse')" :name="caretIcon" /> + <gl-icon :name="caretIcon" /> </a> </div> <div diff --git a/app/assets/javascripts/monitoring/components/refresh_button.vue b/app/assets/javascripts/monitoring/components/refresh_button.vue new file mode 100644 index 00000000000..5481806c3e0 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/refresh_button.vue @@ -0,0 +1,163 @@ +<script> +import { n__, __ } from '~/locale'; +import { mapActions } from 'vuex'; + +import { + GlButtonGroup, + GlButton, + GlNewDropdown, + GlNewDropdownItem, + GlNewDropdownDivider, + GlTooltipDirective, +} from '@gitlab/ui'; + +const makeInterval = (length = 0, unit = 's') => { + const shortLabel = `${length}${unit}`; + switch (unit) { + case 'd': + return { + interval: length * 24 * 60 * 60 * 1000, + shortLabel, + label: n__('%d day', '%d days', length), + }; + case 'h': + return { + interval: length * 60 * 60 * 1000, + shortLabel, + label: n__('%d hour', '%d hours', length), + }; + case 'm': + return { + interval: length * 60 * 1000, + shortLabel, + label: n__('%d minute', '%d minutes', length), + }; + case 's': + default: + return { + interval: length * 1000, + shortLabel, + label: n__('%d second', '%d seconds', length), + }; + } +}; + +export default { + components: { + GlButtonGroup, + GlButton, + GlNewDropdown, + GlNewDropdownItem, + GlNewDropdownDivider, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + data() { + return { + refreshInterval: null, + timeoutId: null, + }; + }, + computed: { + dropdownText() { + return this.refreshInterval?.shortLabel ?? __('Off'); + }, + }, + watch: { + refreshInterval() { + if (this.refreshInterval !== null) { + this.startAutoRefresh(); + } else { + this.stopAutoRefresh(); + } + }, + }, + destroyed() { + this.stopAutoRefresh(); + }, + methods: { + ...mapActions('monitoringDashboard', ['fetchDashboardData']), + + refresh() { + this.fetchDashboardData(); + }, + startAutoRefresh() { + const schedule = () => { + if (this.refreshInterval) { + this.timeoutId = setTimeout(this.startAutoRefresh, this.refreshInterval.interval); + } + }; + + this.stopAutoRefresh(); + if (document.hidden) { + // Inactive tab? Skip fetch and schedule again + schedule(); + } else { + // Active tab! Fetch data and then schedule when settled + // eslint-disable-next-line promise/catch-or-return + this.fetchDashboardData().finally(schedule); + } + }, + stopAutoRefresh() { + clearTimeout(this.timeoutId); + this.timeoutId = null; + }, + + setRefreshInterval(option) { + this.refreshInterval = option; + }, + removeRefreshInterval() { + this.refreshInterval = null; + }, + isChecked(option) { + if (this.refreshInterval) { + return option.interval === this.refreshInterval.interval; + } + return false; + }, + }, + + refreshIntervals: [ + makeInterval(5), + makeInterval(10), + makeInterval(30), + makeInterval(5, 'm'), + makeInterval(30, 'm'), + makeInterval(1, 'h'), + makeInterval(2, 'h'), + makeInterval(12, 'h'), + makeInterval(1, 'd'), + ], +}; +</script> + +<template> + <gl-button-group> + <gl-button + v-gl-tooltip + class="gl-flex-grow-1" + variant="default" + :title="s__('Metrics|Refresh dashboard')" + icon="retry" + @click="refresh" + /> + <gl-new-dropdown v-gl-tooltip :title="s__('Metrics|Set refresh rate')" :text="dropdownText"> + <gl-new-dropdown-item + :is-check-item="true" + :is-checked="refreshInterval === null" + @click="removeRefreshInterval()" + >{{ __('Off') }}</gl-new-dropdown-item + > + <gl-new-dropdown-divider /> + <gl-new-dropdown-item + v-for="(option, i) in $options.refreshIntervals" + :key="i" + :is-check-item="true" + :is-checked="isChecked(option)" + @click="setRefreshInterval(option)" + >{{ option.label }}</gl-new-dropdown-item + > + </gl-new-dropdown> + </gl-button-group> +</template> diff --git a/app/assets/javascripts/monitoring/components/variables/custom_variable.vue b/app/assets/javascripts/monitoring/components/variables/dropdown_field.vue index 0ac7c0b80df..4e48292c48d 100644 --- a/app/assets/javascripts/monitoring/components/variables/custom_variable.vue +++ b/app/assets/javascripts/monitoring/components/variables/dropdown_field.vue @@ -22,29 +22,32 @@ export default { default: '', }, options: { - type: Array, + type: Object, required: true, }, }, computed: { - defaultText() { - const selectedOpt = this.options.find(opt => opt.value === this.value); + text() { + const selectedOpt = this.options.values?.find(opt => opt.value === this.value); return selectedOpt?.text || this.value; }, }, methods: { onUpdate(value) { - this.$emit('onUpdate', this.name, value); + this.$emit('input', value); }, }, }; </script> <template> <gl-form-group :label="label"> - <gl-dropdown toggle-class="dropdown-menu-toggle" :text="defaultText"> - <gl-dropdown-item v-for="(opt, key) in options" :key="key" @click="onUpdate(opt.value)">{{ - opt.text - }}</gl-dropdown-item> + <gl-dropdown toggle-class="dropdown-menu-toggle" :text="text || s__('Metrics|Select a value')"> + <gl-dropdown-item + v-for="val in options.values" + :key="val.value" + @click="onUpdate(val.value)" + >{{ val.text }}</gl-dropdown-item + > </gl-dropdown> </gl-form-group> </template> diff --git a/app/assets/javascripts/monitoring/components/variables/text_variable.vue b/app/assets/javascripts/monitoring/components/variables/text_field.vue index ce0d19760e2..a0418806e5f 100644 --- a/app/assets/javascripts/monitoring/components/variables/text_variable.vue +++ b/app/assets/javascripts/monitoring/components/variables/text_field.vue @@ -22,7 +22,7 @@ export default { }, methods: { onUpdate(event) { - this.$emit('onUpdate', this.name, event.target.value); + this.$emit('input', event.target.value); }, }, }; diff --git a/app/assets/javascripts/monitoring/components/variables_section.vue b/app/assets/javascripts/monitoring/components/variables_section.vue index 3d1d111d5b3..25d900b07ad 100644 --- a/app/assets/javascripts/monitoring/components/variables_section.vue +++ b/app/assets/javascripts/monitoring/components/variables_section.vue @@ -1,13 +1,14 @@ <script> import { mapState, mapActions } from 'vuex'; -import CustomVariable from './variables/custom_variable.vue'; -import TextVariable from './variables/text_variable.vue'; +import DropdownField from './variables/dropdown_field.vue'; +import TextField from './variables/text_field.vue'; import { setCustomVariablesFromUrl } from '../utils'; +import { VARIABLE_TYPES } from '../constants'; export default { components: { - CustomVariable, - TextVariable, + DropdownField, + TextField, }, computed: { ...mapState('monitoringDashboard', ['variables']), @@ -15,10 +16,9 @@ export default { methods: { ...mapActions('monitoringDashboard', ['updateVariablesAndFetchData']), refreshDashboard(variable, value) { - if (this.variables[variable].value !== value) { - const changedVariable = { key: variable, value }; + if (variable.value !== value) { + this.updateVariablesAndFetchData({ name: variable.name, value }); // update the Vuex store - this.updateVariablesAndFetchData(changedVariable); // the below calls can ideally be moved out of the // component and into the actions and let the // mutation respond directly. @@ -27,27 +27,26 @@ export default { setCustomVariablesFromUrl(this.variables); } }, - variableComponent(type) { - const types = { - text: TextVariable, - custom: CustomVariable, - }; - return types[type] || TextVariable; + variableField(type) { + if (type === VARIABLE_TYPES.custom || type === VARIABLE_TYPES.metric_label_values) { + return DropdownField; + } + return TextField; }, }, }; </script> <template> <div ref="variablesSection" class="d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 variables-section"> - <div v-for="(variable, key) in variables" :key="key" class="mb-1 pr-2 d-flex d-sm-block"> + <div v-for="variable in variables" :key="variable.name" class="mb-1 pr-2 d-flex d-sm-block"> <component - :is="variableComponent(variable.type)" + :is="variableField(variable.type)" class="mb-0 flex-grow-1" :label="variable.label" :value="variable.value" - :name="key" + :name="variable.name" :options="variable.options" - @onUpdate="refreshDashboard" + @input="refreshDashboard(variable, $event)" /> </div> </div> diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js index 50330046c99..afeb3318eb9 100644 --- a/app/assets/javascripts/monitoring/constants.js +++ b/app/assets/javascripts/monitoring/constants.js @@ -1,5 +1,12 @@ export const PROMETHEUS_TIMEOUT = 120000; // TWO_MINUTES +export const dashboardEmptyStates = { + GETTING_STARTED: 'gettingStarted', + LOADING: 'loading', + NO_DATA: 'noData', + UNABLE_TO_CONNECT: 'unableToConnect', +}; + /** * States and error states in Prometheus Queries (PromQL) for metrics */ @@ -208,6 +215,14 @@ export const annotationsSymbolIcon = 'path://m5 229 5 8h-10z'; */ export const DEFAULT_DASHBOARD_PATH = 'config/prometheus/common_metrics.yml'; +/** + * GitLab provide metrics dashboards that are available to a user once + * the Prometheus managed app has been installed, without any extra setup + * required. These "out of the box" dashboards are defined under the + * `config/prometheus` path. + */ +export const OUT_OF_THE_BOX_DASHBOARDS_PATH_PREFIX = 'config/prometheus/'; + export const OPERATORS = { greaterThan: '>', equalTo: '==', @@ -230,6 +245,7 @@ export const OPERATORS = { export const VARIABLE_TYPES = { custom: 'custom', text: 'text', + metric_label_values: 'metric_label_values', }; /** @@ -242,3 +258,17 @@ export const VARIABLE_TYPES = { * before passing the data to the backend. */ export const VARIABLE_PREFIX = 'var-'; + +/** + * All of the actions inside each panel dropdown can be accessed + * via keyboard shortcuts than can be activated via mouse hovers + * and or focus via tabs. + */ + +export const keyboardShortcutKeys = { + EXPAND: 'e', + VISIT_LOGS: 'l', + SHOW_ALERT: 'a', + DOWNLOAD_CSV: 'd', + CHART_COPY: 'c', +}; diff --git a/app/assets/javascripts/monitoring/format_date.js b/app/assets/javascripts/monitoring/format_date.js index a50d441a09e..c7bc626eb11 100644 --- a/app/assets/javascripts/monitoring/format_date.js +++ b/app/assets/javascripts/monitoring/format_date.js @@ -14,6 +14,7 @@ export const timezones = { export const formats = { shortTime: 'h:MM TT', + shortDateTime: 'm/d h:MM TT', default: 'dd mmm yyyy, h:MMTT (Z)', }; diff --git a/app/assets/javascripts/monitoring/monitoring_app.js b/app/assets/javascripts/monitoring/monitoring_app.js index 08543fa6eb3..307154c9a84 100644 --- a/app/assets/javascripts/monitoring/monitoring_app.js +++ b/app/assets/javascripts/monitoring/monitoring_app.js @@ -1,9 +1,8 @@ import Vue from 'vue'; import { GlToast } from '@gitlab/ui'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import { getParameterValues } from '~/lib/utils/url_utility'; import { createStore } from './stores'; import createRouter from './router'; +import { stateAndPropsFromDataset } from './utils'; Vue.use(GlToast); @@ -11,36 +10,10 @@ export default (props = {}) => { const el = document.getElementById('prometheus-graphs'); if (el && el.dataset) { - const [currentDashboard] = getParameterValues('dashboard'); - - const { - deploymentsEndpoint, - dashboardEndpoint, - dashboardsEndpoint, - projectPath, - logsPath, - currentEnvironmentName, - dashboardTimezone, - metricsDashboardBasePath, - ...dataProps - } = el.dataset; - - const store = createStore({ - currentDashboard, - deploymentsEndpoint, - dashboardEndpoint, - dashboardsEndpoint, - dashboardTimezone, - projectPath, - logsPath, - currentEnvironmentName, - }); - - // HTML attributes are always strings, parse other types. - dataProps.hasMetrics = parseBoolean(dataProps.hasMetrics); - dataProps.customMetricsAvailable = parseBoolean(dataProps.customMetricsAvailable); - dataProps.prometheusAlertsAvailable = parseBoolean(dataProps.prometheusAlertsAvailable); + const { metricsDashboardBasePath, ...dataset } = el.dataset; + const { initState, dataProps } = stateAndPropsFromDataset(dataset); + const store = createStore(initState); const router = createRouter(metricsDashboardBasePath); // eslint-disable-next-line no-new diff --git a/app/assets/javascripts/monitoring/pages/dashboard_page.vue b/app/assets/javascripts/monitoring/pages/dashboard_page.vue index 519a20d7be3..df0e2d7f8f6 100644 --- a/app/assets/javascripts/monitoring/pages/dashboard_page.vue +++ b/app/assets/javascripts/monitoring/pages/dashboard_page.vue @@ -1,4 +1,5 @@ <script> +import { mapActions } from 'vuex'; import Dashboard from '../components/dashboard.vue'; export default { @@ -11,6 +12,16 @@ export default { required: true, }, }, + created() { + // This is to support the older URL <project>/-/environments/:env_id/metrics?dashboard=:path + // and the new format <project>/-/metrics/:dashboardPath + const encodedDashboard = this.$route.query.dashboard || this.$route.params.dashboard; + const currentDashboard = encodedDashboard ? decodeURIComponent(encodedDashboard) : null; + this.setCurrentDashboard({ currentDashboard }); + }, + methods: { + ...mapActions('monitoringDashboard', ['setCurrentDashboard']), + }, }; </script> <template> diff --git a/app/assets/javascripts/monitoring/queries/getDashboardValidationWarnings.query.graphql b/app/assets/javascripts/monitoring/queries/getDashboardValidationWarnings.query.graphql new file mode 100644 index 00000000000..302383512d3 --- /dev/null +++ b/app/assets/javascripts/monitoring/queries/getDashboardValidationWarnings.query.graphql @@ -0,0 +1,18 @@ +query getDashboardValidationWarnings( + $projectPath: ID! + $environmentName: String + $dashboardPath: String! +) { + project(fullPath: $projectPath) { + id + environments(name: $environmentName) { + nodes { + name + metricsDashboard(path: $dashboardPath) { + path + schemaValidationWarnings + } + } + } + } +} diff --git a/app/assets/javascripts/monitoring/router/constants.js b/app/assets/javascripts/monitoring/router/constants.js index acfcd03f928..fedfebe33e9 100644 --- a/app/assets/javascripts/monitoring/router/constants.js +++ b/app/assets/javascripts/monitoring/router/constants.js @@ -1,3 +1,4 @@ export const BASE_DASHBOARD_PAGE = 'dashboard'; +export const CUSTOM_DASHBOARD_PAGE = 'custom_dashboard'; export default {}; diff --git a/app/assets/javascripts/monitoring/router/routes.js b/app/assets/javascripts/monitoring/router/routes.js index 1e0cc1715a7..4b82791178a 100644 --- a/app/assets/javascripts/monitoring/router/routes.js +++ b/app/assets/javascripts/monitoring/router/routes.js @@ -1,6 +1,6 @@ import DashboardPage from '../pages/dashboard_page.vue'; -import { BASE_DASHBOARD_PAGE } from './constants'; +import { BASE_DASHBOARD_PAGE, CUSTOM_DASHBOARD_PAGE } from './constants'; /** * Because the cluster health page uses the dashboard @@ -12,7 +12,12 @@ import { BASE_DASHBOARD_PAGE } from './constants'; export default [ { name: BASE_DASHBOARD_PAGE, - path: '*', + path: '/', + component: DashboardPage, + }, + { + name: CUSTOM_DASHBOARD_PAGE, + path: '/:dashboard(.*)', component: DashboardPage, }, ]; diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 3a9cccec438..a441882a47d 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -12,6 +12,7 @@ import { import trackDashboardLoad from '../monitoring_tracking_helper'; import getEnvironments from '../queries/getEnvironments.query.graphql'; import getAnnotations from '../queries/getAnnotations.query.graphql'; +import getDashboardValidationWarnings from '../queries/getDashboardValidationWarnings.query.graphql'; import statusCodes from '../../lib/utils/http_status'; import { backOff, convertObjectPropsToCamelCase } from '../../lib/utils/common_utils'; import { s__, sprintf } from '../../locale'; @@ -20,6 +21,7 @@ import { PROMETHEUS_TIMEOUT, ENVIRONMENT_AVAILABLE_STATE, DEFAULT_DASHBOARD_PATH, + VARIABLE_TYPES, } from '../constants'; function prometheusMetricQueryParams(timeRange) { @@ -50,15 +52,14 @@ function backOffRequest(makeRequestCallback) { }, PROMETHEUS_TIMEOUT); } -function getPrometheusMetricResult(prometheusEndpoint, params) { +function getPrometheusQueryData(prometheusEndpoint, params) { return backOffRequest(() => axios.get(prometheusEndpoint, { params })) .then(res => res.data) .then(response => { if (response.status === 'error') { throw new Error(response.error); } - - return response.data.result; + return response.data; }); } @@ -76,10 +77,6 @@ export const setTimeRange = ({ commit }, timeRange) => { commit(types.SET_TIME_RANGE, timeRange); }; -export const setVariables = ({ commit }, variables) => { - commit(types.SET_VARIABLES, variables); -}; - export const filterEnvironments = ({ commit, dispatch }, searchTerm) => { commit(types.SET_ENVIRONMENTS_FILTER, searchTerm); dispatch('fetchEnvironmentsData'); @@ -100,6 +97,10 @@ export const clearExpandedPanel = ({ commit }) => { }); }; +export const setCurrentDashboard = ({ commit }, { currentDashboard }) => { + commit(types.SET_CURRENT_DASHBOARD, currentDashboard); +}; + // All Data /** @@ -117,17 +118,27 @@ export const fetchData = ({ dispatch }) => { // Metrics dashboard -export const fetchDashboard = ({ state, commit, dispatch }) => { +export const fetchDashboard = ({ state, commit, dispatch, getters }) => { dispatch('requestMetricsDashboard'); const params = {}; - if (state.currentDashboard) { - params.dashboard = state.currentDashboard; + if (getters.fullDashboardPath) { + params.dashboard = getters.fullDashboardPath; } return backOffRequest(() => axios.get(state.dashboardEndpoint, { params })) .then(resp => resp.data) - .then(response => dispatch('receiveMetricsDashboardSuccess', { response })) + .then(response => { + dispatch('receiveMetricsDashboardSuccess', { response }); + /** + * After the dashboard is fetched, there can be non-blocking invalid syntax + * in the dashboard file. This call will fetch such syntax warnings + * and surface a warning on the UI. If the invalid syntax is blocking, + * the `fetchDashboard` returns a 404 with error messages that are displayed + * on the UI. + */ + dispatch('fetchDashboardValidationWarnings'); + }) .catch(error => { Sentry.captureException(error); @@ -181,8 +192,12 @@ export const fetchDashboardData = ({ state, dispatch, getters }) => { return Promise.reject(); } + // Time range params must be pre-calculated once for all metrics and options + // A subsequent call, may calculate a different time range const defaultQueryParams = prometheusMetricQueryParams(state.timeRange); + dispatch('fetchVariableMetricLabelValues', { defaultQueryParams }); + const promises = []; state.dashboard.panelGroups.forEach(group => { group.panels.forEach(panel => { @@ -194,7 +209,7 @@ export const fetchDashboardData = ({ state, dispatch, getters }) => { return Promise.all(promises) .then(() => { - const dashboardType = state.currentDashboard === '' ? 'default' : 'custom'; + const dashboardType = getters.fullDashboardPath === '' ? 'default' : 'custom'; trackDashboardLoad({ label: `${dashboardType}_metrics_dashboard`, value: getters.metricsWithData().length, @@ -220,7 +235,7 @@ export const fetchPrometheusMetric = ( queryParams.step = metric.step; } - if (Object.keys(state.variables).length > 0) { + if (state.variables.length > 0) { queryParams = { ...queryParams, ...getters.getCustomVariablesParams, @@ -229,9 +244,9 @@ export const fetchPrometheusMetric = ( commit(types.REQUEST_METRIC_RESULT, { metricId: metric.metricId }); - return getPrometheusMetricResult(metric.prometheusEndpointPath, queryParams) - .then(result => { - commit(types.RECEIVE_METRIC_RESULT_SUCCESS, { metricId: metric.metricId, result }); + return getPrometheusQueryData(metric.prometheusEndpointPath, queryParams) + .then(data => { + commit(types.RECEIVE_METRIC_RESULT_SUCCESS, { metricId: metric.metricId, data }); }) .catch(error => { Sentry.captureException(error); @@ -312,9 +327,9 @@ export const receiveEnvironmentsDataFailure = ({ commit }) => { commit(types.RECEIVE_ENVIRONMENTS_DATA_FAILURE); }; -export const fetchAnnotations = ({ state, dispatch }) => { +export const fetchAnnotations = ({ state, dispatch, getters }) => { const { start } = convertToFixedRange(state.timeRange); - const dashboardPath = state.currentDashboard || DEFAULT_DASHBOARD_PATH; + const dashboardPath = getters.fullDashboardPath || DEFAULT_DASHBOARD_PATH; return gqClient .mutate({ mutation: getAnnotations, @@ -345,6 +360,46 @@ export const receiveAnnotationsSuccess = ({ commit }, data) => commit(types.RECEIVE_ANNOTATIONS_SUCCESS, data); export const receiveAnnotationsFailure = ({ commit }) => commit(types.RECEIVE_ANNOTATIONS_FAILURE); +export const fetchDashboardValidationWarnings = ({ state, dispatch, getters }) => { + /** + * Normally, the default dashboard won't throw any validation warnings. + * + * However, if a bug sneaks into the default dashboard making it invalid, + * this might come handy for our clients + */ + const dashboardPath = getters.fullDashboardPath || DEFAULT_DASHBOARD_PATH; + return gqClient + .mutate({ + mutation: getDashboardValidationWarnings, + variables: { + projectPath: removeLeadingSlash(state.projectPath), + environmentName: state.currentEnvironmentName, + dashboardPath, + }, + }) + .then(resp => resp.data?.project?.environments?.nodes?.[0]?.metricsDashboard) + .then(({ schemaValidationWarnings } = {}) => { + const hasWarnings = schemaValidationWarnings && schemaValidationWarnings.length !== 0; + /** + * The payload of the dispatch is a boolean, because at the moment a standard + * warning message is shown instead of the warnings the BE returns + */ + dispatch('receiveDashboardValidationWarningsSuccess', hasWarnings || false); + }) + .catch(err => { + Sentry.captureException(err); + dispatch('receiveDashboardValidationWarningsFailure'); + createFlash( + s__('Metrics|There was an error getting dashboard validation warnings information.'), + ); + }); +}; + +export const receiveDashboardValidationWarningsSuccess = ({ commit }, hasWarnings) => + commit(types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS, hasWarnings); +export const receiveDashboardValidationWarningsFailure = ({ commit }) => + commit(types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_FAILURE); + // Dashboard manipulation export const toggleStarredValue = ({ commit, state, getters }) => { @@ -416,10 +471,41 @@ export const duplicateSystemDashboard = ({ state }, payload) => { // Variables manipulation export const updateVariablesAndFetchData = ({ commit, dispatch }, updatedVariable) => { - commit(types.UPDATE_VARIABLES, updatedVariable); + commit(types.UPDATE_VARIABLE_VALUE, updatedVariable); return dispatch('fetchDashboardData'); }; +export const fetchVariableMetricLabelValues = ({ state, commit }, { defaultQueryParams }) => { + const { start_time, end_time } = defaultQueryParams; + const optionsRequests = []; + + state.variables.forEach(variable => { + if (variable.type === VARIABLE_TYPES.metric_label_values) { + const { prometheusEndpointPath, label } = variable.options; + + const optionsRequest = backOffRequest(() => + axios.get(prometheusEndpointPath, { + params: { start_time, end_time }, + }), + ) + .then(({ data }) => data.data) + .then(data => { + commit(types.UPDATE_VARIABLE_METRIC_LABEL_VALUES, { variable, label, data }); + }) + .catch(() => { + createFlash( + sprintf(s__('Metrics|There was an error getting options for variable "%{name}".'), { + name: variable.name, + }), + ); + }); + optionsRequests.push(optionsRequest); + } + }); + + return Promise.all(optionsRequests); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/monitoring/stores/getters.js b/app/assets/javascripts/monitoring/stores/getters.js index b7681012472..3aa711a0509 100644 --- a/app/assets/javascripts/monitoring/stores/getters.js +++ b/app/assets/javascripts/monitoring/stores/getters.js @@ -1,5 +1,9 @@ import { NOT_IN_DB_PREFIX } from '../constants'; -import { addPrefixToCustomVariableParams, addDashboardMetaDataToLink } from './utils'; +import { + addPrefixToCustomVariableParams, + addDashboardMetaDataToLink, + normalizeCustomDashboardPath, +} from './utils'; const metricsIdsInPanel = panel => panel.metrics.filter(metric => metric.metricId && metric.result).map(metric => metric.metricId); @@ -10,10 +14,10 @@ const metricsIdsInPanel = panel => * * @param {Object} state */ -export const selectedDashboard = state => { +export const selectedDashboard = (state, getters) => { const { allDashboards } = state; return ( - allDashboards.find(d => d.path === state.currentDashboard) || + allDashboards.find(d => d.path === getters.fullDashboardPath) || allDashboards.find(d => d.default) || null ); @@ -129,8 +133,8 @@ export const linksWithMetadata = state => { }; /** - * Maps an variables object to an array along with stripping - * the variable prefix. + * Maps a variables array to an object for replacement in + * prometheus queries. * * This method outputs an object in the below format * @@ -143,16 +147,29 @@ export const linksWithMetadata = state => { * user-defined variables coming through the URL and differentiate * from other variables used for Prometheus API endpoint. * - * @param {Object} variables - Custom variables provided by the user - * @returns {Array} The custom variables array to be send to the API + * @param {Object} state - State containing variables provided by the user + * @returns {Array} The custom variables object to be send to the API * in the format of {variables[key1]=value1, variables[key2]=value2} */ export const getCustomVariablesParams = state => - Object.keys(state.variables).reduce((acc, variable) => { - acc[addPrefixToCustomVariableParams(variable)] = state.variables[variable]?.value; + state.variables.reduce((acc, variable) => { + const { name, value } = variable; + if (value !== null) { + acc[addPrefixToCustomVariableParams(name)] = value; + } return acc; }, {}); +/** + * For a given custom dashboard file name, this method + * returns the full file path. + * + * @param {Object} state + * @returns {String} full dashboard path + */ +export const fullDashboardPath = state => + normalizeCustomDashboardPath(state.currentDashboard, state.customDashboardBasePath); + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js index 4593461776b..d408628fc4d 100644 --- a/app/assets/javascripts/monitoring/stores/mutation_types.js +++ b/app/assets/javascripts/monitoring/stores/mutation_types.js @@ -2,17 +2,25 @@ export const REQUEST_METRICS_DASHBOARD = 'REQUEST_METRICS_DASHBOARD'; export const RECEIVE_METRICS_DASHBOARD_SUCCESS = 'RECEIVE_METRICS_DASHBOARD_SUCCESS'; export const RECEIVE_METRICS_DASHBOARD_FAILURE = 'RECEIVE_METRICS_DASHBOARD_FAILURE'; -export const SET_VARIABLES = 'SET_VARIABLES'; -export const UPDATE_VARIABLES = 'UPDATE_VARIABLES'; +export const UPDATE_VARIABLE_VALUE = 'UPDATE_VARIABLE_VALUE'; +export const UPDATE_VARIABLE_METRIC_LABEL_VALUES = 'UPDATE_VARIABLE_METRIC_LABEL_VALUES'; export const REQUEST_DASHBOARD_STARRING = 'REQUEST_DASHBOARD_STARRING'; export const RECEIVE_DASHBOARD_STARRING_SUCCESS = 'RECEIVE_DASHBOARD_STARRING_SUCCESS'; export const RECEIVE_DASHBOARD_STARRING_FAILURE = 'RECEIVE_DASHBOARD_STARRING_FAILURE'; +export const SET_CURRENT_DASHBOARD = 'SET_CURRENT_DASHBOARD'; + // Annotations export const RECEIVE_ANNOTATIONS_SUCCESS = 'RECEIVE_ANNOTATIONS_SUCCESS'; export const RECEIVE_ANNOTATIONS_FAILURE = 'RECEIVE_ANNOTATIONS_FAILURE'; +// Dashboard validation warnings +export const RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS = + 'RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS'; +export const RECEIVE_DASHBOARD_VALIDATION_WARNINGS_FAILURE = + 'RECEIVE_DASHBOARD_VALIDATION_WARNINGS_FAILURE'; + // Git project deployments export const REQUEST_DEPLOYMENTS_DATA = 'REQUEST_DEPLOYMENTS_DATA'; export const RECEIVE_DEPLOYMENTS_DATA_SUCCESS = 'RECEIVE_DEPLOYMENTS_DATA_SUCCESS'; @@ -34,7 +42,6 @@ export const SET_ALL_DASHBOARDS = 'SET_ALL_DASHBOARDS'; export const SET_ENDPOINTS = 'SET_ENDPOINTS'; export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE'; -export const SET_NO_DATA_EMPTY_STATE = 'SET_NO_DATA_EMPTY_STATE'; export const SET_SHOW_ERROR_BANNER = 'SET_SHOW_ERROR_BANNER'; export const SET_PANEL_GROUP_METRICS = 'SET_PANEL_GROUP_METRICS'; export const SET_ENVIRONMENTS_FILTER = 'SET_ENVIRONMENTS_FILTER'; diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index 2d63fdd6e34..744441c8935 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -1,10 +1,11 @@ import Vue from 'vue'; import { pick } from 'lodash'; import * as types from './mutation_types'; -import { mapToDashboardViewModel, normalizeQueryResult } from './utils'; -import { BACKOFF_TIMEOUT } from '../../lib/utils/common_utils'; -import { endpointKeys, initialStateKeys, metricStates } from '../constants'; +import { mapToDashboardViewModel, 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 { optionsFromSeriesData } from './variable_mapping'; /** * Locate and return a metric in the dashboard by its id @@ -57,8 +58,7 @@ export default { * Dashboard panels structure and global state */ [types.REQUEST_METRICS_DASHBOARD](state) { - state.emptyState = 'loading'; - state.showEmptyState = true; + state.emptyState = dashboardEmptyStates.LOADING; }, [types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, dashboardYML) { const { dashboard, panelGroups, variables, links } = mapToDashboardViewModel(dashboardYML); @@ -70,12 +70,15 @@ export default { state.links = links; if (!state.dashboard.panelGroups.length) { - state.emptyState = 'noData'; + state.emptyState = dashboardEmptyStates.NO_DATA; + } else { + state.emptyState = null; } }, [types.RECEIVE_METRICS_DASHBOARD_FAILURE](state, error) { - state.emptyState = error ? 'unableToConnect' : 'noData'; - state.showEmptyState = true; + state.emptyState = error + ? dashboardEmptyStates.UNABLE_TO_CONNECT + : dashboardEmptyStates.NO_DATA; }, [types.REQUEST_DASHBOARD_STARRING](state) { @@ -94,6 +97,10 @@ export default { state.isUpdatingStarredValue = false; }, + [types.SET_CURRENT_DASHBOARD](state, currentDashboard) { + state.currentDashboard = currentDashboard; + }, + /** * Deployments and environments */ @@ -126,6 +133,16 @@ export default { }, /** + * Dashboard Validation Warnings + */ + [types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS](state, hasDashboardValidationWarnings) { + state.hasDashboardValidationWarnings = hasDashboardValidationWarnings; + }, + [types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_FAILURE](state) { + state.hasDashboardValidationWarnings = false; + }, + + /** * Individual panel/metric results */ [types.REQUEST_METRIC_RESULT](state, { metricId }) { @@ -135,19 +152,18 @@ export default { metric.state = metricStates.LOADING; } }, - [types.RECEIVE_METRIC_RESULT_SUCCESS](state, { metricId, result }) { + [types.RECEIVE_METRIC_RESULT_SUCCESS](state, { metricId, data }) { const metric = findMetricInDashboard(metricId, state.dashboard); metric.loading = false; - state.showEmptyState = false; - if (!result || result.length === 0) { + if (!data.result || data.result.length === 0) { metric.state = metricStates.NO_DATA; metric.result = null; } else { - const normalizedResults = result.map(normalizeQueryResult); + const result = normalizeQueryResponseData(data); metric.state = metricStates.OK; - metric.result = Object.freeze(normalizedResults); + metric.result = Object.freeze(result); } }, [types.RECEIVE_METRIC_RESULT_FAILURE](state, { metricId, error }) { @@ -169,11 +185,7 @@ export default { state.timeRange = timeRange; }, [types.SET_GETTING_STARTED_EMPTY_STATE](state) { - state.emptyState = 'gettingStarted'; - }, - [types.SET_NO_DATA_EMPTY_STATE](state) { - state.showEmptyState = true; - state.emptyState = 'noData'; + state.emptyState = dashboardEmptyStates.GETTING_STARTED; }, [types.SET_ALL_DASHBOARDS](state, dashboards) { state.allDashboards = dashboards || []; @@ -192,13 +204,18 @@ export default { state.expandedPanel.group = group; state.expandedPanel.panel = panel; }, - [types.SET_VARIABLES](state, variables) { - state.variables = variables; + [types.UPDATE_VARIABLE_VALUE](state, { name, value }) { + const variable = state.variables.find(v => v.name === name); + if (variable) { + Object.assign(variable, { + value, + }); + } }, - [types.UPDATE_VARIABLES](state, updatedVariable) { - Object.assign(state.variables[updatedVariable.key], { - ...state.variables[updatedVariable.key], - value: updatedVariable.value, - }); + [types.UPDATE_VARIABLE_METRIC_LABEL_VALUES](state, { variable, label, data = [] }) { + const values = optionsFromSeriesData({ label, data }); + + // Add new options with assign to ensure Vue reactivity + Object.assign(variable.options, { values }); }, }; diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js index 8000f27c0d5..89738756ffe 100644 --- a/app/assets/javascripts/monitoring/stores/state.js +++ b/app/assets/javascripts/monitoring/stores/state.js @@ -1,5 +1,6 @@ import invalidUrl from '~/lib/utils/invalid_url'; import { timezones } from '../format_date'; +import { dashboardEmptyStates } from '../constants'; export default () => ({ // API endpoints @@ -9,11 +10,24 @@ export default () => ({ // Dashboard request parameters timeRange: null, + /** + * Currently selected dashboard. For custom dashboards, + * this could be the filename or the file path. + * + * If this is the filename and full path is required, + * getters.fullDashboardPath should be used. + */ currentDashboard: null, // Dashboard data - emptyState: 'gettingStarted', - showEmptyState: true, + hasDashboardValidationWarnings: false, + + /** + * {?String} If set, dashboard should display a global + * empty state, there is no way to interact (yet) + * with the dashboard. + */ + emptyState: dashboardEmptyStates.GETTING_STARTED, showErrorBanner: true, isUpdatingStarredValue: false, dashboard: { @@ -39,7 +53,7 @@ export default () => ({ * User-defined custom variables are passed * via the dashboard yml file. */ - variables: {}, + variables: [], /** * User-defined custom links are passed * via the dashboard yml file. @@ -56,5 +70,16 @@ export default () => ({ // GitLab paths to other pages projectPath: null, + operationsSettingsPath: '', logsPath: invalidUrl, + + // static paths + customDashboardBasePath: '', + + // current user data + /** + * Flag that denotes if the currently logged user can access + * the project Settings -> Operations + */ + canAccessOperationsSettings: false, }); diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js index 5795e756282..51562593ee8 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 { NOT_IN_DB_PREFIX, linkTypes } from '../constants'; 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'; export const gqClient = createGqClient( {}, @@ -165,7 +165,7 @@ const mapLinksToViewModel = ({ url = null, title = '', type } = {}) => { * @param {Object} panel - Metrics panel * @returns {Object} */ -const mapPanelToViewModel = ({ +export const mapPanelToViewModel = ({ id = null, title = '', type, @@ -173,6 +173,7 @@ const mapPanelToViewModel = ({ x_label, y_label, y_axis = {}, + field, metrics = [], links = [], max_value, @@ -193,6 +194,7 @@ const mapPanelToViewModel = ({ y_label: yAxis.name, // Changing y_label to yLabel is pending https://gitlab.com/gitlab-org/gitlab/issues/207198 yAxis, xAxis, + field, maxValue: max_value, links: links.map(mapLinksToViewModel), metrics: mapToMetricsViewModel(metrics), @@ -289,49 +291,157 @@ export const mapToDashboardViewModel = ({ }) => { return { dashboard, - variables: mergeURLVariables(parseTemplatingVariables(templating)), + variables: mergeURLVariables(parseTemplatingVariables(templating.variables)), links: links.map(mapLinksToViewModel), panelGroups: panel_groups.map(mapToPanelGroupViewModel), }; }; +// Prometheus Results Parsing + +const dateTimeFromUnixTime = unixTime => new Date(unixTime * 1000).toISOString(); + +const mapScalarValue = ([unixTime, value]) => [dateTimeFromUnixTime(unixTime), Number(value)]; + +// Note: `string` value type is unused as of prometheus 2.19. +const mapStringValue = ([unixTime, value]) => [dateTimeFromUnixTime(unixTime), value]; + +/** + * Processes a scalar result. + * + * The corresponding result property has the following format: + * + * [ <unix_time>, "<scalar_value>" ] + * + * @param {array} result + * @returns {array} + */ +const normalizeScalarResult = result => [ + { + metric: {}, + value: mapScalarValue(result), + values: [mapScalarValue(result)], + }, +]; + +/** + * Processes a string result. + * + * The corresponding result property has the following format: + * + * [ <unix_time>, "<string_value>" ] + * + * Note: This value type is unused as of prometheus 2.19. + * + * @param {array} result + * @returns {array} + */ +const normalizeStringResult = result => [ + { + metric: {}, + value: mapStringValue(result), + values: [mapStringValue(result)], + }, +]; + +/** + * Proccesses an instant vector. + * + * Instant vectors are returned as result type `vector`. + * + * The corresponding result property has the following format: + * + * [ + * { + * "metric": { "<label_name>": "<label_value>", ... }, + * "value": [ <unix_time>, "<sample_value>" ], + * "values": [ [ <unix_time>, "<sample_value>" ] ] + * }, + * ... + * ] + * + * `metric` - Key-value pairs object representing metric measured + * `value` - The vector result + * `values` - An array with a single value representing the result + * + * This method also adds the matrix version of the vector + * by introducing a `values` array with a single element. This + * allows charts to default to `values` if needed. + * + * @param {array} result + * @returns {array} + */ +const normalizeVectorResult = result => + result.map(({ metric, value }) => { + const scalar = mapScalarValue(value); + // Add a single element to `values`, to support matrix + // style charts. + return { metric, value: scalar, values: [scalar] }; + }); + /** - * Processes a single Range vector, part of the result - * of type `matrix` in the form: + * Range vectors are returned as result type matrix. + * + * The corresponding result property has the following format: * * { * "metric": { "<label_name>": "<label_value>", ... }, + * "value": [ <unix_time>, "<sample_value>" ], * "values": [ [ <unix_time>, "<sample_value>" ], ... ] * }, * + * `metric` - Key-value pairs object representing metric measured + * `value` - The last (more recent) result + * `values` - A range of results for the metric + * * See https://prometheus.io/docs/prometheus/latest/querying/api/#range-vectors * - * @param {*} timeSeries + * @param {array} result + * @returns {object} Normalized result. */ -export const normalizeQueryResult = timeSeries => { - let normalizedResult = {}; - - if (timeSeries.values) { - normalizedResult = { - ...timeSeries, - values: timeSeries.values.map(([timestamp, value]) => [ - new Date(timestamp * 1000).toISOString(), - Number(value), - ]), - }; - // Check result for empty data - normalizedResult.values = normalizedResult.values.filter(series => { - const hasValue = d => !Number.isNaN(d[1]) && (d[1] !== null || d[1] !== undefined); - return series.find(hasValue); - }); - } else if (timeSeries.value) { - normalizedResult = { - ...timeSeries, - value: [new Date(timeSeries.value[0] * 1000).toISOString(), Number(timeSeries.value[1])], +const normalizeResultMatrix = result => + result.map(({ metric, values }) => { + const mappedValues = values.map(mapScalarValue); + return { + metric, + value: mappedValues[mappedValues.length - 1], + values: mappedValues, }; - } + }); - return normalizedResult; +/** + * Parse response data from a Prometheus Query that comes + * in the format: + * + * { + * "resultType": "matrix" | "vector" | "scalar" | "string", + * "result": <value> + * } + * + * @see https://prometheus.io/docs/prometheus/latest/querying/api/#expression-query-result-formats + * + * @param {object} data - Data containing results and result type. + * @returns {object} - A result array of metric results: + * [ + * { + * metric: { ... }, + * value: ['2015-07-01T20:10:51.781Z', '1'], + * values: [['2015-07-01T20:10:51.781Z', '1'] , ... ], + * }, + * ... + * ] + * + */ +export const normalizeQueryResponseData = data => { + const { resultType, result } = data; + if (resultType === 'vector') { + return normalizeVectorResult(result); + } else if (resultType === 'scalar') { + return normalizeScalarResult(result); + } else if (resultType === 'string') { + return normalizeStringResult(result); + } + return normalizeResultMatrix(result); }; /** @@ -345,7 +455,35 @@ export const normalizeQueryResult = timeSeries => { * * This is currently only used by getters/getCustomVariablesParams * - * @param {String} key Variable key that needs to be prefixed + * @param {String} name Variable key that needs to be prefixed * @returns {String} */ -export const addPrefixToCustomVariableParams = key => `variables[${key}]`; +export const addPrefixToCustomVariableParams = name => `variables[${name}]`; + +/** + * Normalize custom dashboard paths. This method helps support + * metrics dashboard to work with custom dashboard file names instead + * of the entire path. + * + * If dashboard is empty, it is the default dashboard. + * If dashboard is set, it usually is a custom dashboard unless + * explicitly it is set to default dashboard path. + * + * @param {String} dashboard dashboard path + * @param {String} dashboardPrefix custom dashboard directory prefix + * @returns {String} normalized dashboard path + */ +export const normalizeCustomDashboardPath = (dashboard, dashboardPrefix = '') => { + const currDashboard = dashboard || ''; + let dashboardPath = `${dashboardPrefix}/${currDashboard}`; + + if (!currDashboard) { + dashboardPath = ''; + } else if ( + currDashboard.startsWith(dashboardPrefix) || + currDashboard.startsWith(OUT_OF_THE_BOX_DASHBOARDS_PATH_PREFIX) + ) { + dashboardPath = currDashboard; + } + return dashboardPath; +}; diff --git a/app/assets/javascripts/monitoring/stores/variable_mapping.js b/app/assets/javascripts/monitoring/stores/variable_mapping.js index c0a8150063b..9245ffdb3b9 100644 --- a/app/assets/javascripts/monitoring/stores/variable_mapping.js +++ b/app/assets/javascripts/monitoring/stores/variable_mapping.js @@ -46,7 +46,7 @@ const textAdvancedVariableParser = advTextVar => ({ * @param {Object} custom variable option * @returns {Object} normalized custom variable options */ -const normalizeCustomVariableOptions = ({ default: defaultOpt = false, text, value }) => ({ +const normalizeVariableValues = ({ default: defaultOpt = false, text, value = null }) => ({ default: defaultOpt, text: text || value, value, @@ -59,17 +59,19 @@ const normalizeCustomVariableOptions = ({ default: defaultOpt = false, text, val * The default value is the option with default set to true or the first option * if none of the options have default prop true. * - * @param {Object} advVariable advance custom variable + * @param {Object} advVariable advanced custom variable * @returns {Object} */ const customAdvancedVariableParser = advVariable => { - const options = (advVariable?.options?.values ?? []).map(normalizeCustomVariableOptions); - const defaultOpt = options.find(opt => opt.default === true) || options[0]; + const values = (advVariable?.options?.values ?? []).map(normalizeVariableValues); + const defaultValue = values.find(opt => opt.default === true) || values[0]; return { type: VARIABLE_TYPES.custom, label: advVariable.label, - value: defaultOpt?.value, - options, + options: { + values, + }, + value: defaultValue?.value || null, }; }; @@ -80,7 +82,7 @@ const customAdvancedVariableParser = advVariable => { * @param {String} opt option from simple custom variable * @returns {Object} */ -const parseSimpleCustomOptions = opt => ({ text: opt, value: opt }); +export const parseSimpleCustomValues = opt => ({ text: opt, value: opt }); /** * Custom simple variables are rendered as dropdown elements in the dashboard @@ -95,15 +97,28 @@ const parseSimpleCustomOptions = opt => ({ text: opt, value: opt }); * @returns {Object} */ const customSimpleVariableParser = simpleVar => { - const options = (simpleVar || []).map(parseSimpleCustomOptions); + const values = (simpleVar || []).map(parseSimpleCustomValues); return { type: VARIABLE_TYPES.custom, - value: options[0].value, label: null, - options: options.map(normalizeCustomVariableOptions), + value: values[0].value || null, + options: { + values: values.map(normalizeVariableValues), + }, }; }; +const metricLabelValuesVariableParser = ({ label, options = {} }) => ({ + type: VARIABLE_TYPES.metric_label_values, + label, + value: null, + options: { + prometheusEndpointPath: options.prometheus_endpoint_path || '', + label: options.label || null, + values: [], // values are initially empty + }, +}); + /** * Utility method to determine if a custom variable is * simple or not. If its not simple, it is advanced. @@ -123,14 +138,16 @@ const isSimpleCustomVariable = customVar => Array.isArray(customVar); * @return {Function} parser method */ const getVariableParser = variable => { - if (isSimpleCustomVariable(variable)) { + if (isString(variable)) { + return textSimpleVariableParser; + } else if (isSimpleCustomVariable(variable)) { return customSimpleVariableParser; - } else if (variable.type === VARIABLE_TYPES.custom) { - return customAdvancedVariableParser; } else if (variable.type === VARIABLE_TYPES.text) { return textAdvancedVariableParser; - } else if (isString(variable)) { - return textSimpleVariableParser; + } else if (variable.type === VARIABLE_TYPES.custom) { + return customAdvancedVariableParser; + } else if (variable.type === VARIABLE_TYPES.metric_label_values) { + return metricLabelValuesVariableParser; } return () => null; }; @@ -141,29 +158,26 @@ const getVariableParser = variable => { * for the user to edit. The values from input elements are relayed to * backend and eventually Prometheus API. * - * This method currently is not used anywhere. Once the issue - * https://gitlab.com/gitlab-org/gitlab/-/issues/214536 is completed, - * this method will have been used by the monitoring dashboard. - * - * @param {Object} templating templating variables from the dashboard yml file - * @returns {Object} a map of processed templating variables + * @param {Object} templating variables from the dashboard yml file + * @returns {array} An array of variables to display as inputs */ -export const parseTemplatingVariables = ({ variables = {} } = {}) => - Object.entries(variables).reduce((acc, [key, variable]) => { +export const parseTemplatingVariables = (ymlVariables = {}) => + Object.entries(ymlVariables).reduce((acc, [name, ymlVariable]) => { // get the parser - const parser = getVariableParser(variable); + const parser = getVariableParser(ymlVariable); // parse the variable - const parsedVar = parser(variable); + const variable = parser(ymlVariable); // for simple custom variable label is null and it should be // replace with key instead - if (parsedVar) { - acc[key] = { - ...parsedVar, - label: parsedVar.label || key, - }; + if (variable) { + acc.push({ + ...variable, + name, + label: variable.label || name, + }); } return acc; - }, {}); + }, []); /** * Custom variables are defined in the dashboard yml file @@ -181,23 +195,81 @@ export const parseTemplatingVariables = ({ variables = {} } = {}) => * This method can be improved further. See the below issue * https://gitlab.com/gitlab-org/gitlab/-/issues/217713 * - * @param {Object} varsFromYML template variables from yml file + * @param {array} parsedYmlVariables - template variables from yml file * @returns {Object} */ -export const mergeURLVariables = (varsFromYML = {}) => { +export const mergeURLVariables = (parsedYmlVariables = []) => { const varsFromURL = templatingVariablesFromUrl(); - const variables = {}; - Object.keys(varsFromYML).forEach(key => { - if (Object.prototype.hasOwnProperty.call(varsFromURL, key)) { - variables[key] = { - ...varsFromYML[key], - value: varsFromURL[key], - }; - } else { - variables[key] = varsFromYML[key]; + parsedYmlVariables.forEach(variable => { + const { name } = variable; + if (Object.prototype.hasOwnProperty.call(varsFromURL, name)) { + Object.assign(variable, { value: varsFromURL[name] }); } }); - return variables; + return parsedYmlVariables; +}; + +/** + * Converts series data to options that can be added to a + * variable. Series data is returned from the Prometheus API + * `/api/v1/series`. + * + * Finds a `label` in the series data, so it can be used as + * a filter. + * + * For example, for the arguments: + * + * { + * "label": "job" + * "data" : [ + * { + * "__name__" : "up", + * "job" : "prometheus", + * "instance" : "localhost:9090" + * }, + * { + * "__name__" : "up", + * "job" : "node", + * "instance" : "localhost:9091" + * }, + * { + * "__name__" : "process_start_time_seconds", + * "job" : "prometheus", + * "instance" : "localhost:9090" + * } + * ] + * } + * + * It returns all the different "job" values: + * + * [ + * { + * "label": "node", + * "value": "node" + * }, + * { + * "label": "prometheus", + * "value": "prometheus" + * } + * ] + * + * @param {options} options object + * @param {options.seriesLabel} name of the searched series label + * @param {options.data} series data from the series API + * @return {array} Options objects with the shape `{ label, value }` + * + * @see https://prometheus.io/docs/prometheus/latest/querying/api/#finding-series-by-label-matchers + */ +export const optionsFromSeriesData = ({ label, data = [] }) => { + const optionsSet = data.reduce((set, seriesObject) => { + // Use `new Set` to deduplicate options + if (seriesObject[label]) { + set.add(seriesObject[label]); + } + return set; + }, new Set()); + + return [...optionsSet].map(parseSimpleCustomValues); }; export default {}; diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js index 4d2927a066e..0c6fcad9dd0 100644 --- a/app/assets/javascripts/monitoring/utils.js +++ b/app/assets/javascripts/monitoring/utils.js @@ -5,6 +5,7 @@ import { removeParams, updateHistory, } from '~/lib/utils/url_utility'; +import { parseBoolean } from '~/lib/utils/common_utils'; import { timeRangeParamNames, timeRangeFromParams, @@ -13,6 +14,50 @@ import { import { VARIABLE_PREFIX } from './constants'; /** + * Extracts the initial state and props from HTML dataset + * and places them in separate objects to setup bundle. + * @param {*} dataset + */ +export const stateAndPropsFromDataset = (dataset = {}) => { + const { + currentDashboard, + deploymentsEndpoint, + dashboardEndpoint, + dashboardsEndpoint, + dashboardTimezone, + canAccessOperationsSettings, + operationsSettingsPath, + projectPath, + logsPath, + currentEnvironmentName, + customDashboardBasePath, + ...dataProps + } = dataset; + + // HTML attributes are always strings, parse other types. + dataProps.hasMetrics = parseBoolean(dataProps.hasMetrics); + dataProps.customMetricsAvailable = parseBoolean(dataProps.customMetricsAvailable); + dataProps.prometheusAlertsAvailable = parseBoolean(dataProps.prometheusAlertsAvailable); + + return { + initState: { + currentDashboard, + deploymentsEndpoint, + dashboardEndpoint, + dashboardsEndpoint, + dashboardTimezone, + canAccessOperationsSettings, + operationsSettingsPath, + projectPath, + logsPath, + currentEnvironmentName, + customDashboardBasePath, + }, + dataProps, + }; +}; + +/** * List of non time range url parameters * This will be removed once we add support for free text variables * via the dashboard yaml files in https://gitlab.com/gitlab-org/gitlab/-/issues/215689 @@ -160,8 +205,10 @@ export const removePrefixFromLabel = label => * @returns {Object} */ export const convertVariablesForURL = variables => - Object.keys(variables || {}).reduce((acc, key) => { - acc[addPrefixToLabel(key)] = variables[key]?.value; + variables.reduce((acc, { name, value }) => { + if (value !== null) { + acc[addPrefixToLabel(name)] = value; + } return acc; }, {}); diff --git a/app/assets/javascripts/namespace_storage_limit_alert.js b/app/assets/javascripts/namespace_storage_limit_alert.js deleted file mode 100644 index 34ad93c127d..00000000000 --- a/app/assets/javascripts/namespace_storage_limit_alert.js +++ /dev/null @@ -1,20 +0,0 @@ -import Cookies from 'js-cookie'; - -const handleOnDismiss = ({ currentTarget }) => { - const { - dataset: { id, level }, - } = currentTarget; - - Cookies.set(`hide_storage_limit_alert_${id}_${level}`, true, { expires: 365 }); - - const notification = document.querySelector('.js-namespace-storage-alert'); - notification.parentNode.removeChild(notification); -}; - -export default () => { - const alert = document.querySelector('.js-namespace-storage-alert-dismiss'); - - if (alert) { - alert.addEventListener('click', handleOnDismiss); - } -}; diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 6e695de447d..f4982507adb 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -1617,7 +1617,7 @@ export default class Notes { } tempFormContent = formContent; - if (this.hasQuickActions(formContent)) { + if (this.glForm.supportsQuickActions && this.hasQuickActions(formContent)) { tempFormContent = this.stripQuickActions(formContent); hasQuickActions = true; } diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 16dcde46262..ac93d3df654 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -17,7 +17,7 @@ import { import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import * as constants from '../constants'; import eventHub from '../event_hub'; -import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; +import NoteableWarning from '../../vue_shared/components/notes/noteable_warning.vue'; import markdownField from '../../vue_shared/components/markdown/field.vue'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import loadingButton from '../../vue_shared/components/loading_button.vue'; @@ -28,8 +28,7 @@ import issuableStateMixin from '../mixins/issuable_state'; export default { name: 'CommentForm', components: { - issueWarning, - epicWarning: () => import('ee_component/vue_shared/components/epic/epic_warning.vue'), + NoteableWarning, noteSignedOutWidget, discussionLockedWidget, markdownField, @@ -126,9 +125,13 @@ export default { canToggleIssueState() { return ( this.getNoteableData.current_user.can_update && - this.getNoteableData.state !== constants.MERGED + this.getNoteableData.state !== constants.MERGED && + !this.closedAndLocked ); }, + closedAndLocked() { + return !this.isOpen && this.isLocked(this.getNoteableData); + }, endpoint() { return this.getNoteableData.create_note_path; }, @@ -350,14 +353,15 @@ export default { <form ref="commentForm" class="new-note common-note-form gfm-form js-main-target-form"> <div class="error-alert"></div> - <issue-warning - v-if="hasWarning(getNoteableData) && isIssueType" + <noteable-warning + v-if="hasWarning(getNoteableData)" :is-locked="isLocked(getNoteableData)" :is-confidential="isConfidential(getNoteableData)" - :locked-issue-docs-path="lockedIssueDocsPath" - :confidential-issue-docs-path="confidentialIssueDocsPath" + :noteable-type="noteableType" + :locked-noteable-docs-path="lockedIssueDocsPath" + :confidential-noteable-docs-path="confidentialIssueDocsPath" /> - <epic-warning :is-confidential="isConfidential(getNoteableData)" /> + <markdown-field ref="markdownField" :is-submitting="isSubmitting" @@ -374,20 +378,18 @@ export default { dir="auto" :disabled="isSubmitting" name="note[note]" - class="note-textarea js-vue-comment-form js-note-text -js-gfm-input js-autosize markdown-area js-vue-textarea qa-comment-input" + class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area js-vue-textarea qa-comment-input" data-supports-quick-actions="true" :aria-label="__('Description')" :placeholder="__('Write a comment or drag your files here…')" @keydown.up="editCurrentUserLastNote()" @keydown.meta.enter="handleSave()" @keydown.ctrl.enter="handleSave()" - > - </textarea> + ></textarea> </markdown-field> <gl-alert v-if="isToggleBlockedIssueWarning" - class="prepend-top-16" + class="gl-mt-5" :title="__('Are you sure you want to close this blocked issue?')" :primary-button-text="__('Yes, close issue')" :secondary-button-text="__('Cancel')" @@ -417,13 +419,11 @@ js-gfm-input js-autosize markdown-area js-vue-textarea qa-comment-input" </gl-alert> <div class="note-form-actions"> <div - class="btn-group -append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" + class="btn-group gl-mr-3 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" > <button :disabled="isSubmitButtonDisabled" - class="btn btn-success js-comment-button js-comment-submit-button - qa-comment-button" + class="btn btn-success js-comment-button js-comment-submit-button qa-comment-button" type="submit" :data-track-label="trackingLabel" data-track-event="click_button" @@ -440,7 +440,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" data-toggle="dropdown" :aria-label="__('Open comment type dropdown')" > - <i aria-hidden="true" class="fa fa-caret-down toggle-icon"> </i> + <i aria-hidden="true" class="fa fa-caret-down toggle-icon"></i> </button> <ul class="note-type-dropdown dropdown-open-top dropdown-menu"> @@ -450,7 +450,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" class="btn btn-transparent" @click.prevent="setNoteType('comment')" > - <i aria-hidden="true" class="fa fa-check icon"> </i> + <i aria-hidden="true" class="fa fa-check icon"></i> <div class="description"> <strong>{{ __('Comment') }}</strong> <p> @@ -470,7 +470,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" class="btn btn-transparent qa-discussion-option" @click.prevent="setNoteType('discussion')" > - <i aria-hidden="true" class="fa fa-check icon"> </i> + <i aria-hidden="true" class="fa fa-check icon"></i> <div class="description"> <strong>{{ __('Start thread') }}</strong> <p>{{ startDiscussionDescription }}</p> diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue index 0b136549c14..458da5cf67f 100644 --- a/app/assets/javascripts/notes/components/discussion_notes.vue +++ b/app/assets/javascripts/notes/components/discussion_notes.vue @@ -74,7 +74,7 @@ export default { }, }, methods: { - ...mapActions(['toggleDiscussion']), + ...mapActions(['toggleDiscussion', 'setSelectedCommentPositionHover']), componentName(note) { if (note.isPlaceholderNote) { if (note.placeholderType === SYSTEM_NOTE) { @@ -99,7 +99,11 @@ export default { <template> <div class="discussion-notes"> - <ul class="notes"> + <ul + class="notes" + @mouseenter="setSelectedCommentPositionHover(discussion.position.line_range)" + @mouseleave="setSelectedCommentPositionHover()" + > <template v-if="shouldGroupReplies"> <component :is="componentName(firstNote)" @@ -108,6 +112,7 @@ export default { :commit="commit" :help-page-path="helpPagePath" :show-reply-button="userCanReply" + :discussion-root="true" @handleDeleteNote="$emit('deleteNote')" @startReplying="$emit('startReplying')" > @@ -151,6 +156,7 @@ export default { :note="componentData(note)" :help-page-path="helpPagePath" :line="diffLine" + :discussion-root="index === 0" @handleDeleteNote="$emit('deleteNote')" > <slot v-if="index === 0" slot="avatar-badge" name="avatar-badge"></slot> diff --git a/app/assets/javascripts/notes/components/multiline_comment_form.vue b/app/assets/javascripts/notes/components/multiline_comment_form.vue index 5fba011a153..bb13eb87af7 100644 --- a/app/assets/javascripts/notes/components/multiline_comment_form.vue +++ b/app/assets/javascripts/notes/components/multiline_comment_form.vue @@ -1,4 +1,5 @@ <script> +import { mapActions } from 'vuex'; import { GlFormSelect, GlSprintf } from '@gitlab/ui'; import { getSymbol, getLineClasses } from './multiline_comment_utils'; @@ -21,19 +22,51 @@ export default { }, data() { return { - commentLineStart: { - lineCode: this.lineRange ? this.lineRange.start_line_code : this.line.line_code, - type: this.lineRange ? this.lineRange.start_line_type : this.line.type, - }, + commentLineStart: {}, + commentLineEndType: this.lineRange?.end?.line_type || this.line.type, }; }, + computed: { + lineNumber() { + return this.commentLineOptions[this.commentLineOptions.length - 1].text; + }, + }, + created() { + const line = this.lineRange?.start || this.line; + + this.commentLineStart = { + line_code: line.line_code, + type: line.type, + old_line: line.old_line, + new_line: line.new_line, + }; + this.highlightSelection(); + }, + destroyed() { + this.setSelectedCommentPosition(); + }, methods: { + ...mapActions(['setSelectedCommentPosition']), getSymbol({ type }) { return getSymbol(type); }, getLineClasses(line) { return getLineClasses(line); }, + updateCommentLineStart(value) { + this.commentLineStart = value; + this.$emit('input', value); + this.highlightSelection(); + }, + highlightSelection() { + const { line_code, new_line, old_line, type } = this.line; + const updatedLineRange = { + start: { ...this.commentLineStart }, + end: { line_code, new_line, old_line, type }, + }; + + this.setSelectedCommentPosition(updatedLineRange); + }, }, }; </script> @@ -55,12 +88,12 @@ export default { :options="commentLineOptions" size="sm" class="gl-w-auto gl-vertical-align-baseline" - @change="$emit('input', $event)" + @change="updateCommentLineStart" /> </template> <template #end> <span :class="getLineClasses(line)"> - {{ getSymbol(line) + (line.new_line || line.old_line) }} + {{ lineNumber }} </span> </template> </gl-sprintf> diff --git a/app/assets/javascripts/notes/components/multiline_comment_utils.js b/app/assets/javascripts/notes/components/multiline_comment_utils.js index dc9c55e9b30..dbae10c8f6c 100644 --- a/app/assets/javascripts/notes/components/multiline_comment_utils.js +++ b/app/assets/javascripts/notes/components/multiline_comment_utils.js @@ -7,11 +7,19 @@ export function getSymbol(type) { } function getLineNumber(lineRange, key) { - if (!lineRange || !key) return ''; - const lineCode = lineRange[`${key}_line_code`] || ''; - const lineType = lineRange[`${key}_line_type`] || ''; - const lines = lineCode.split('_') || []; - const lineNumber = lineType === 'old' ? lines[1] : lines[2]; + if (!lineRange || !key || !lineRange[key]) return ''; + const { new_line: newLine, old_line: oldLine, type } = lineRange[key]; + const otherKey = key === 'start' ? 'end' : 'start'; + + // By default we want to see the "old" or "left side" line number + // The exception is if the "end" line is on the "right" side + // `otherLineType` is only used if `type` is null to make sure the line + // number relfects the "right" side number, if that is the side + // the comment form is located on + const otherLineType = !type ? lineRange[otherKey]?.type : null; + const lineType = type || ''; + let lineNumber = oldLine; + if (lineType === 'new' || otherLineType === 'new') lineNumber = newLine; return (lineNumber && getSymbol(lineType) + lineNumber) || ''; } @@ -37,21 +45,67 @@ export function getLineClasses(line) { ]; } -export function commentLineOptions(diffLines, lineCode) { - const selectedIndex = diffLines.findIndex(line => line.line_code === lineCode); +export function commentLineOptions(diffLines, startingLine, lineCode, side = 'left') { + const preferredSide = side === 'left' ? 'old_line' : 'new_line'; + const fallbackSide = preferredSide === 'new_line' ? 'old_line' : 'new_line'; const notMatchType = l => l.type !== 'match'; + const linesCopy = [...diffLines]; // don't mutate the argument + const startingLineCode = startingLine.line_code; + + const currentIndex = linesCopy.findIndex(line => line.line_code === lineCode); // We're limiting adding comments to only lines above the current line // to make rendering simpler. Future interations will use a more // intuitive dragging interface that will make this unnecessary - const upToSelected = diffLines.slice(0, selectedIndex + 1); + const upToSelected = linesCopy.slice(0, currentIndex + 1); // Only include the lines up to the first "Show unchanged lines" block // i.e. not a "match" type const lines = takeRightWhile(upToSelected, notMatchType); - return lines.map(l => ({ - value: { lineCode: l.line_code, type: l.type }, - text: `${getSymbol(l.type)}${l.new_line || l.old_line}`, - })); + // If the selected line is "hidden" in an unchanged line block + // or "above" the current group of lines add it to the array so + // that the drop down is not defaulted to empty + const selectedIndex = lines.findIndex(line => line.line_code === startingLineCode); + if (selectedIndex < 0) lines.unshift(startingLine); + + return lines.map(l => { + const { line_code, type, old_line, new_line } = l; + return { + value: { line_code, type, old_line, new_line }, + text: `${getSymbol(type)}${l[preferredSide] || l[fallbackSide]}`, + }; + }); +} + +export function formatLineRange(start, end) { + const extractProps = ({ line_code, type, old_line, new_line }) => ({ + line_code, + type, + old_line, + new_line, + }); + return { + start: extractProps(start), + end: extractProps(end), + }; +} + +export function getCommentedLines(selectedCommentPosition, diffLines) { + if (!selectedCommentPosition) { + // This structure simplifies the logic that consumes this result + // by keeping the returned shape the same and adjusting the bounds + // to something unreachable. This way our component logic stays: + // "if index between start and end" + return { + startLine: diffLines.length + 1, + endLine: diffLines.length + 1, + }; + } + + const { start, end } = selectedCommentPosition; + const startLine = diffLines.findIndex(l => l.line_code === start.line_code); + const endLine = diffLines.findIndex(l => l.line_code === end.line_code); + + return { startLine, endLine }; } diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index f1af8be590a..7615b0518b7 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -128,6 +128,9 @@ export default { isIssue() { return this.targetType === 'issue'; }, + canAssign() { + return this.getNoteableData.current_user?.can_update && this.isIssue; + }, }, methods: { onEdit() { @@ -257,23 +260,23 @@ export default { {{ __('Copy link') }} </button> </li> - <li v-if="canEdit"> + <li v-if="canAssign"> <button - class="btn btn-transparent js-note-delete js-note-delete" + class="btn-default btn-transparent" + data-testid="assign-user" type="button" - @click.prevent="onDelete" + @click="assignUser" > - <span class="text-danger">{{ __('Delete comment') }}</span> + {{ displayAssignUserText }} </button> </li> - <li v-if="isIssue"> + <li v-if="canEdit"> <button - class="btn-default btn-transparent" - data-testid="assign-user" + class="btn btn-transparent js-note-delete js-note-delete" type="button" - @click="assignUser" + @click.prevent="onDelete" > - {{ displayAssignUserText }} + <span class="text-danger">{{ __('Delete comment') }}</span> </button> </li> </ul> diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 795ee10ca0f..24227d55ebf 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -2,7 +2,7 @@ import { mapGetters, mapActions, mapState } from 'vuex'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import eventHub from '../event_hub'; -import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; +import NoteableWarning from '../../vue_shared/components/notes/noteable_warning.vue'; import markdownField from '../../vue_shared/components/markdown/field.vue'; import issuableStateMixin from '../mixins/issuable_state'; import resolvable from '../mixins/resolvable'; @@ -12,7 +12,7 @@ import { getDraft, updateDraft } from '~/lib/utils/autosave'; export default { name: 'NoteForm', components: { - issueWarning, + NoteableWarning, markdownField, }, mixins: [issuableStateMixin, resolvable], @@ -101,6 +101,7 @@ export default { isResolving: this.resolveDiscussion, isUnresolving: !this.resolveDiscussion, resolveAsThread: true, + isSubmittingWithKeydown: false, }; }, computed: { @@ -241,6 +242,10 @@ export default { this.$emit('cancelForm', shouldConfirm, this.noteBody !== this.updatedNoteBody); }, onInput() { + if (this.isSubmittingWithKeydown) { + return; + } + if (this.autosaveKey) { const { autosaveKey, updatedNoteBody: text } = this; updateDraft(autosaveKey, text); @@ -250,6 +255,7 @@ export default { if (this.showBatchCommentsActions) { this.handleAddToReview(); } else { + this.isSubmittingWithKeydown = true; this.handleUpdate(); } }, @@ -303,12 +309,12 @@ export default { ></div> <div class="flash-container timeline-content"></div> <form :data-line-code="lineCode" class="edit-note common-note-form js-quick-submit gfm-form"> - <issue-warning + <noteable-warning v-if="hasWarning(getNoteableData)" :is-locked="isLocked(getNoteableData)" :is-confidential="isConfidential(getNoteableData)" - :locked-issue-docs-path="lockedIssueDocsPath" - :confidential-issue-docs-path="confidentialIssueDocsPath" + :locked-noteable-docs-path="lockedIssueDocsPath" + :confidential-noteable-docs-path="confidentialIssueDocsPath" /> <markdown-field @@ -404,7 +410,7 @@ export default { </button> <button v-if="discussion.resolvable" - class="btn btn-nr btn-default append-right-10 js-comment-resolve-button" + class="btn btn-nr btn-default gl-mr-3 js-comment-resolve-button" @click.prevent="handleUpdate(true)" > {{ resolveButtonTitle }} diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 0e4dd1b9c84..9bf8cffe940 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -21,6 +21,7 @@ import { getEndLineNumber, getLineClasses, commentLineOptions, + formatLineRange, } from './multiline_comment_utils'; import MultilineCommentForm from './multiline_comment_form.vue'; @@ -62,10 +63,15 @@ export default { default: false, }, diffLines: { - type: Object, + type: Array, required: false, default: null, }, + discussionRoot: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -73,10 +79,7 @@ export default { isDeleting: false, isRequesting: false, isResolving: false, - commentLineStart: { - line_code: this.line?.line_code, - type: this.line?.type, - }, + commentLineStart: {}, }; }, computed: { @@ -144,28 +147,46 @@ export default { return getEndLineNumber(this.lineRange); }, showMultiLineComment() { - return ( - this.glFeatures.multilineComments && - this.startLineNumber && - this.endLineNumber && - (this.startLineNumber !== this.endLineNumber || this.isEditing) - ); + if (!this.glFeatures.multilineComments || !this.discussionRoot) return false; + if (this.isEditing) return true; + + return this.line && this.startLineNumber !== this.endLineNumber; }, commentLineOptions() { - if (this.diffLines) { - return commentLineOptions(this.diffLines, this.line.line_code); + if (!this.diffFile || !this.line) return []; + + const sideA = this.line.type === 'new' ? 'right' : 'left'; + const sideB = sideA === 'left' ? 'right' : 'left'; + const lines = this.diffFile.highlighted_diff_lines.length + ? this.diffFile.highlighted_diff_lines + : this.diffFile.parallel_diff_lines.map(l => l[sideA] || l[sideB]); + return commentLineOptions(lines, this.commentLineStart, this.line.line_code, sideA); + }, + diffFile() { + if (this.commentLineStart.line_code) { + const lineCode = this.commentLineStart.line_code.split('_')[0]; + return this.getDiffFileByHash(lineCode); } - const diffFile = this.diffFile || this.getDiffFileByHash(this.targetNoteHash); - if (!diffFile) return null; - return commentLineOptions(diffFile.highlighted_diff_lines, this.line.line_code); + return null; }, }, - created() { + const line = this.note.position?.line_range?.start || this.line; + + this.commentLineStart = line + ? { + line_code: line.line_code, + type: line.type, + old_line: line.old_line, + new_line: line.new_line, + } + : {}; + eventHub.$on('enterEditMode', ({ noteId }) => { if (noteId === this.note.id) { this.isEditing = true; + this.setSelectedCommentPositionHover(); this.scrollToNoteIfNeeded($(this.$el)); } }); @@ -185,9 +206,11 @@ export default { 'toggleResolveNote', 'scrollToNoteIfNeeded', 'updateAssignees', + 'setSelectedCommentPositionHover', ]), editHandler() { this.isEditing = true; + this.setSelectedCommentPositionHover(); this.$emit('handleEdit'); }, deleteHandler() { @@ -224,13 +247,11 @@ export default { formUpdateHandler(noteText, parentElement, callback, resolveDiscussion) { const position = { ...this.note.position, - line_range: { - start_line_code: this.commentLineStart?.lineCode, - start_line_type: this.commentLineStart?.type, - end_line_code: this.line?.line_code, - end_line_type: this.line?.type, - }, }; + + if (this.commentLineStart && this.line) + position.line_range = formatLineRange(this.commentLineStart, this.line); + this.$emit('handleUpdateNote', { note: this.note, noteText, @@ -246,7 +267,7 @@ export default { note: { target_type: this.getNoteableData.targetType, target_id: this.note.noteable_id, - note: { note: noteText }, + note: { note: noteText, position: JSON.stringify(position) }, }, }; this.isRequesting = true; @@ -266,6 +287,7 @@ export default { } else { this.isRequesting = false; this.isEditing = true; + this.setSelectedCommentPositionHover(); this.$nextTick(() => { const msg = __('Something went wrong while editing your comment. Please try again.'); Flash(msg, 'alert', this.$el); @@ -317,14 +339,17 @@ export default { > <div v-if="showMultiLineComment" data-testid="multiline-comment"> <multiline-comment-form - v-if="isEditing && commentLineOptions && line" + v-if="isEditing && note.position" v-model="commentLineStart" :line="line" :comment-line-options="commentLineOptions" :line-range="note.position.line_range" - class="gl-mb-3 gl-text-gray-700 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-pb-3" + class="gl-mb-3 gl-text-gray-700 gl-pb-3" /> - <div v-else class="gl-mb-3 gl-text-gray-700"> + <div + v-else + class="gl-mb-3 gl-text-gray-700 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-pb-3" + > <gl-sprintf :message="__('Comment on lines %{startLine} to %{endLine}')"> <template #startLine> <span :class="getLineClasses(startLineNumber)">{{ startLineNumber }}</span> diff --git a/app/assets/javascripts/notes/components/sort_discussion.vue b/app/assets/javascripts/notes/components/sort_discussion.vue index 4a7543819eb..60b531d7597 100644 --- a/app/assets/javascripts/notes/components/sort_discussion.vue +++ b/app/assets/javascripts/notes/components/sort_discussion.vue @@ -49,7 +49,10 @@ export default { </script> <template> - <div class="mr-2 d-inline-block align-bottom full-width-mobile"> + <div + data-testid="sort-discussion-filter" + class="gl-mr-2 gl-display-inline-block gl-vertical-align-bottom full-width-mobile" + > <local-storage-sync :value="sortDirection" :storage-key="storageKey" diff --git a/app/assets/javascripts/notes/mixins/diff_line_note_form.js b/app/assets/javascripts/notes/mixins/diff_line_note_form.js index 5930b5f3321..9a2e86aeed2 100644 --- a/app/assets/javascripts/notes/mixins/diff_line_note_form.js +++ b/app/assets/javascripts/notes/mixins/diff_line_note_form.js @@ -4,6 +4,7 @@ import { TEXT_DIFF_POSITION_TYPE, IMAGE_DIFF_POSITION_TYPE } from '~/diffs/const import createFlash from '~/flash'; import { s__ } from '~/locale'; import { clearDraft } from '~/lib/utils/autosave'; +import { formatLineRange } from '~/notes/components/multiline_comment_utils'; export default { computed: { @@ -45,6 +46,9 @@ export default { }); }, addToReview(note) { + const lineRange = + (this.line && this.commentLineStart && formatLineRange(this.commentLineStart, this.line)) || + {}; const positionType = this.diffFileCommentForm ? IMAGE_DIFF_POSITION_TYPE : TEXT_DIFF_POSITION_TYPE; @@ -60,6 +64,7 @@ export default { linePosition: this.position, positionType, ...this.diffFileCommentForm, + lineRange, }); const diffFileHeadSha = this.commit && this?.diffFile?.diff_refs?.head_sha; diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js index 9281149d9d3..889883a23d0 100644 --- a/app/assets/javascripts/notes/mixins/discussion_navigation.js +++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js @@ -78,8 +78,16 @@ function handleDiscussionJump(self, fn, discussionId = self.currentDiscussionId) const isDiffView = window.mrTabs.currentAction === 'diffs'; const targetId = fn(discussionId, isDiffView); const discussion = self.getDiscussion(targetId); - jumpToDiscussion(self, discussion); - self.setCurrentDiscussionId(targetId); + const discussionFilePath = discussion.diff_file?.file_path; + + if (discussionFilePath) { + self.scrollToFile(discussionFilePath); + } + + self.$nextTick(() => { + jumpToDiscussion(self, discussion); + self.setCurrentDiscussionId(targetId); + }); } export default { @@ -95,6 +103,7 @@ export default { }, methods: { ...mapActions(['expandDiscussion', 'setCurrentDiscussionId']), + ...mapActions('diffs', ['scrollToFile']), jumpToNextDiscussion() { handleDiscussionJump(this, this.nextUnresolvedDiscussionId); diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index a5b006fc301..5b2ab255557 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -13,11 +13,35 @@ import sidebarTimeTrackingEventHub from '../../sidebar/event_hub'; import { isInViewport, scrollToElement, isInMRPage } from '../../lib/utils/common_utils'; import { mergeUrlParams } from '../../lib/utils/url_utility'; import mrWidgetEventHub from '../../vue_merge_request_widget/event_hub'; +import updateIssueConfidentialMutation from '~/sidebar/components/confidential/queries/update_issue_confidential.mutation.graphql'; import { __, sprintf } from '~/locale'; import Api from '~/api'; let eTagPoll; +export const updateConfidentialityOnIssue = ({ commit, getters }, { confidential, fullPath }) => { + const { iid } = getters.getNoteableData; + + return utils.gqClient + .mutate({ + mutation: updateIssueConfidentialMutation, + variables: { + input: { + projectPath: fullPath, + iid: String(iid), + confidential, + }, + }, + }) + .then(({ data }) => { + const { + issueSetConfidential: { issue }, + } = data; + + commit(types.SET_ISSUE_CONFIDENTIAL, issue.confidential); + }); +}; + export const expandDiscussion = ({ commit, dispatch }, data) => { if (data.discussionId) { dispatch('diffs/renderFileForDiscussionId', data.discussionId, { root: true }); @@ -32,6 +56,8 @@ export const setNotesData = ({ commit }, data) => commit(types.SET_NOTES_DATA, d export const setNoteableData = ({ commit }, data) => commit(types.SET_NOTEABLE_DATA, data); +export const setConfidentiality = ({ commit }, data) => commit(types.SET_ISSUE_CONFIDENTIAL, data); + export const setUserData = ({ commit }, data) => commit(types.SET_USER_DATA, data); export const setLastFetchedAt = ({ commit }, data) => commit(types.SET_LAST_FETCHED_AT, data); @@ -73,6 +99,14 @@ export const setDiscussionSortDirection = ({ commit }, direction) => { commit(types.SET_DISCUSSIONS_SORT, direction); }; +export const setSelectedCommentPosition = ({ commit }, position) => { + commit(types.SET_SELECTED_COMMENT_POSITION, position); +}; + +export const setSelectedCommentPositionHover = ({ commit }, position) => { + commit(types.SET_SELECTED_COMMENT_POSITION_HOVER, position); +}; + export const removeNote = ({ commit, dispatch, state }, note) => { const discussion = state.discussions.find(({ id }) => id === note.discussion_id); @@ -205,7 +239,6 @@ export const closeIssue = ({ commit, dispatch, state }) => { commit(types.CLOSE_ISSUE); dispatch('emitStateChangedEvent', data); dispatch('toggleStateButtonLoading', false); - dispatch('toggleBlockedIssueWarning', false); }); }; @@ -377,9 +410,8 @@ export const saveNote = ({ commit, dispatch }, noteData) => { }; const pollSuccessCallBack = (resp, commit, state, getters, dispatch) => { - if (resp.notes && resp.notes.length) { - updateOrCreateNotes({ commit, state, getters, dispatch }, resp.notes); - + if (resp.notes?.length) { + dispatch('updateOrCreateNotes', resp.notes); dispatch('startTaskList'); } @@ -399,12 +431,12 @@ const getFetchDataParams = state => { return { endpoint, options }; }; -export const fetchData = ({ commit, state, getters }) => { +export const fetchData = ({ commit, state, getters, dispatch }) => { const { endpoint, options } = getFetchDataParams(state); axios .get(endpoint, options) - .then(({ data }) => pollSuccessCallBack(data, commit, state, getters)) + .then(({ data }) => pollSuccessCallBack(data, commit, state, getters, dispatch)) .catch(() => Flash(__('Something went wrong while fetching latest comments.'))); }; @@ -424,7 +456,7 @@ export const poll = ({ commit, state, getters, dispatch }) => { if (!Visibility.hidden()) { eTagPoll.makeRequest(); } else { - fetchData({ commit, state, getters }); + dispatch('fetchData'); } Visibility.change(() => { diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js index 329bf5e147e..1649e63c61f 100644 --- a/app/assets/javascripts/notes/stores/modules/index.js +++ b/app/assets/javascripts/notes/stores/modules/index.js @@ -12,6 +12,15 @@ export default () => ({ lastFetchedAt: null, currentDiscussionId: null, batchSuggestionsInfo: [], + /** + * selectedCommentPosition & selectedCommentPosition structures are the same as `position.line_range`: + * { + * start: { line_code: string, new_line: number, old_line:number, type: string }, + * end: { line_code: string, new_line: number, old_line:number, type: string }, + * } + */ + selectedCommentPosition: null, + selectedCommentPositionHover: null, // View layer isToggleStateButtonLoading: false, @@ -26,6 +35,7 @@ export default () => ({ }, userData: {}, noteableData: { + discussion_locked: false, confidential: false, // TODO: Move data like this to Issue Store, should not be apart of notes. current_user: {}, preview_note_path: 'path/to/preview', diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index 538774ee467..f2236b18beb 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -33,12 +33,15 @@ export const SET_EXPAND_DISCUSSIONS = 'SET_EXPAND_DISCUSSIONS'; export const UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS = 'UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS'; export const SET_CURRENT_DISCUSSION_ID = 'SET_CURRENT_DISCUSSION_ID'; export const SET_DISCUSSIONS_SORT = 'SET_DISCUSSIONS_SORT'; +export const SET_SELECTED_COMMENT_POSITION = 'SET_SELECTED_COMMENT_POSITION'; +export const SET_SELECTED_COMMENT_POSITION_HOVER = 'SET_SELECTED_COMMENT_POSITION_HOVER'; // Issue export const CLOSE_ISSUE = 'CLOSE_ISSUE'; export const REOPEN_ISSUE = 'REOPEN_ISSUE'; export const TOGGLE_STATE_BUTTON_LOADING = 'TOGGLE_STATE_BUTTON_LOADING'; export const TOGGLE_BLOCKED_ISSUE_WARNING = 'TOGGLE_BLOCKED_ISSUE_WARNING'; +export const SET_ISSUE_CONFIDENTIAL = 'SET_ISSUE_CONFIDENTIAL'; // Description version export const REQUEST_DESCRIPTION_VERSION = 'REQUEST_DESCRIPTION_VERSION'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index 2aeadcb2da1..e5f1c11fb35 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -95,6 +95,10 @@ export default { Object.assign(state, { noteableData: data }); }, + [types.SET_ISSUE_CONFIDENTIAL](state, data) { + state.noteableData.confidential = data; + }, + [types.SET_USER_DATA](state, data) { Object.assign(state, { userData: data }); }, @@ -304,6 +308,14 @@ export default { state.discussionSortOrder = sort; }, + [types.SET_SELECTED_COMMENT_POSITION](state, position) { + state.selectedCommentPosition = position; + }, + + [types.SET_SELECTED_COMMENT_POSITION_HOVER](state, position) { + state.selectedCommentPositionHover = position; + }, + [types.DISABLE_COMMENTS](state, value) { state.commentsDisabled = value; }, diff --git a/app/assets/javascripts/notes/stores/utils.js b/app/assets/javascripts/notes/stores/utils.js index 97dcd54fe88..10faac0c32b 100644 --- a/app/assets/javascripts/notes/stores/utils.js +++ b/app/assets/javascripts/notes/stores/utils.js @@ -1,6 +1,7 @@ import AjaxCache from '~/lib/utils/ajax_cache'; import { trimFirstCharOfLineContent } from '~/diffs/store/utils'; import { sprintf, __ } from '~/locale'; +import createGqClient, { fetchPolicies } from '~/lib/graphql'; // factory function because global flag makes RegExp stateful const createQuickActionsRegex = () => /^\/\w+.*$/gm; @@ -34,3 +35,10 @@ export const stripQuickActions = note => note.replace(createQuickActionsRegex(), export const prepareDiffLines = diffLines => diffLines.map(line => ({ ...trimFirstCharOfLineContent(line) })); + +export const gqClient = createGqClient( + {}, + { + fetchPolicy: fetchPolicies.NO_CACHE, + }, +); 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 674b807edbe..da7f81759ea 100644 --- a/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js +++ b/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js @@ -32,7 +32,7 @@ export default class AbuseReports { $messageCellElement.text(originalMessage); } else { $messageCellElement.data('messageTruncated', 'true'); - $messageCellElement.text(`${originalMessage.substr(0, MAX_MESSAGE_LENGTH - 3)}...`); + $messageCellElement.text(truncate(originalMessage, MAX_MESSAGE_LENGTH)); } } } diff --git a/app/assets/javascripts/pages/admin/clusters/show/index.js b/app/assets/javascripts/pages/admin/clusters/show/index.js index 8001d2dd1da..ccf631b2c53 100644 --- a/app/assets/javascripts/pages/admin/clusters/show/index.js +++ b/app/assets/javascripts/pages/admin/clusters/show/index.js @@ -1,5 +1,7 @@ import ClustersBundle from '~/clusters/clusters_bundle'; +import initClusterHealth from '~/pages/projects/clusters/show/cluster_health'; document.addEventListener('DOMContentLoaded', () => { new ClustersBundle(); // eslint-disable-line no-new + initClusterHealth(); }); diff --git a/app/assets/javascripts/pages/admin/groups/show/index.js b/app/assets/javascripts/pages/admin/groups/show/index.js index b0cdad627a6..69d219d29f7 100644 --- a/app/assets/javascripts/pages/admin/groups/show/index.js +++ b/app/assets/javascripts/pages/admin/groups/show/index.js @@ -1,3 +1,23 @@ -import UsersSelect from '../../../../users_select'; +import Vue from 'vue'; +import UsersSelect from '~/users_select'; +import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue'; -document.addEventListener('DOMContentLoaded', () => new UsersSelect()); +function mountRemoveMemberModal() { + const el = document.querySelector('.js-remove-member-modal'); + if (!el) { + return false; + } + + return new Vue({ + el, + render(createComponent) { + return createComponent(RemoveMemberModal); + }, + }); +} + +document.addEventListener('DOMContentLoaded', () => { + mountRemoveMemberModal(); + + new UsersSelect(); // eslint-disable-line no-new +}); diff --git a/app/assets/javascripts/pages/admin/projects/index.js b/app/assets/javascripts/pages/admin/projects/index.js index d6b1e747aec..d86c5e2ddb8 100644 --- a/app/assets/javascripts/pages/admin/projects/index.js +++ b/app/assets/javascripts/pages/admin/projects/index.js @@ -1,7 +1,25 @@ -import ProjectsList from '../../../projects_list'; -import NamespaceSelect from '../../../namespace_select'; +import Vue from 'vue'; +import ProjectsList from '~/projects_list'; +import NamespaceSelect from '~/namespace_select'; +import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue'; + +function mountRemoveMemberModal() { + const el = document.querySelector('.js-remove-member-modal'); + if (!el) { + return false; + } + + return new Vue({ + el, + render(createComponent) { + return createComponent(RemoveMemberModal); + }, + }); +} document.addEventListener('DOMContentLoaded', () => { + mountRemoveMemberModal(); + new ProjectsList(); // eslint-disable-line no-new document diff --git a/app/assets/javascripts/pages/constants.js b/app/assets/javascripts/pages/constants.js index 5e119454ce1..35c67190b62 100644 --- a/app/assets/javascripts/pages/constants.js +++ b/app/assets/javascripts/pages/constants.js @@ -4,4 +4,5 @@ export const FILTERED_SEARCH = { MERGE_REQUESTS: 'merge_requests', ISSUES: 'issues', ADMIN_RUNNERS: 'admin/runners', + GROUP_RUNNERS_ANCHOR: 'runners-settings', }; diff --git a/app/assets/javascripts/pages/groups/clusters/show/index.js b/app/assets/javascripts/pages/groups/clusters/show/index.js index 8001d2dd1da..ccf631b2c53 100644 --- a/app/assets/javascripts/pages/groups/clusters/show/index.js +++ b/app/assets/javascripts/pages/groups/clusters/show/index.js @@ -1,5 +1,7 @@ import ClustersBundle from '~/clusters/clusters_bundle'; +import initClusterHealth from '~/pages/projects/clusters/show/cluster_health'; document.addEventListener('DOMContentLoaded', () => { new ClustersBundle(); // eslint-disable-line no-new + initClusterHealth(); }); diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js new file mode 100644 index 00000000000..e146592e134 --- /dev/null +++ b/app/assets/javascripts/pages/groups/group_members/index.js @@ -0,0 +1,30 @@ +import Vue from 'vue'; +import Members from 'ee_else_ce/members'; +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'; + +function mountRemoveMemberModal() { + const el = document.querySelector('.js-remove-member-modal'); + if (!el) { + return false; + } + + return new Vue({ + el, + render(createComponent) { + return createComponent(RemoveMemberModal); + }, + }); +} + +document.addEventListener('DOMContentLoaded', () => { + groupsSelect(); + memberExpirationDate(); + memberExpirationDate('.js-access-expiration-date-groups'); + mountRemoveMemberModal(); + + new Members(); // eslint-disable-line no-new + new UsersSelect(); // eslint-disable-line no-new +}); diff --git a/app/assets/javascripts/pages/groups/group_members/index/index.js b/app/assets/javascripts/pages/groups/group_members/index/index.js deleted file mode 100644 index 0c732922e81..00000000000 --- a/app/assets/javascripts/pages/groups/group_members/index/index.js +++ /dev/null @@ -1,14 +0,0 @@ -/* eslint-disable no-new */ - -import Members from 'ee_else_ce/members'; -import memberExpirationDate from '~/member_expiration_date'; -import UsersSelect from '~/users_select'; -import groupsSelect from '~/groups_select'; - -document.addEventListener('DOMContentLoaded', () => { - memberExpirationDate(); - memberExpirationDate('.js-access-expiration-date-groups'); - new Members(); - groupsSelect(); - new UsersSelect(); -}); 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 eeaa6527431..d2684b6af59 100644 --- a/app/assets/javascripts/pages/groups/new/group_path_validator.js +++ b/app/assets/javascripts/pages/groups/new/group_path_validator.js @@ -12,6 +12,7 @@ const successMessageSelector = '.validation-success'; const pendingMessageSelector = '.validation-pending'; const unavailableMessageSelector = '.validation-error'; const suggestionsMessageSelector = '.gl-path-suggestions'; +const inputGroupSelector = '.input-group'; export default class GroupPathValidator extends InputValidator { constructor(opts = {}) { @@ -39,7 +40,7 @@ export default class GroupPathValidator extends InputValidator { static validateGroupPathInput(inputDomElement) { const groupPath = inputDomElement.value; - if (inputDomElement.checkValidity() && groupPath.length > 0) { + if (inputDomElement.checkValidity() && groupPath.length > 1) { GroupPathValidator.setMessageVisibility(inputDomElement, pendingMessageSelector); fetchGroupPathAvailability(groupPath) @@ -69,9 +70,10 @@ export default class GroupPathValidator extends InputValidator { } static setMessageVisibility(inputDomElement, messageSelector, isVisible = true) { - const messageElement = inputDomElement.parentElement.parentElement.querySelector( - messageSelector, - ); + const messageElement = inputDomElement + .closest(inputGroupSelector) + .parentElement.querySelector(messageSelector); + messageElement.classList.toggle('hide', !isVisible); } 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 479c82265f2..23283f46a5d 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 @@ -1,11 +1,20 @@ import initSettingsPanels from '~/settings_panels'; import AjaxVariableList from '~/ci_variable_list/ajax_variable_list'; import initVariableList from '~/ci_variable_list'; +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'; document.addEventListener('DOMContentLoaded', () => { // Initialize expandable settings panels initSettingsPanels(); + initFilteredSearch({ + page: FILTERED_SEARCH.ADMIN_RUNNERS, + filteredSearchTokenKeys: AdminRunnersFilteredSearchTokenKeys, + anchor: FILTERED_SEARCH.GROUP_RUNNERS_ANCHOR, + }); + if (gon.features.newVariablesUi) { initVariableList(); } else { diff --git a/app/assets/javascripts/pages/groups/shared/group_details.js b/app/assets/javascripts/pages/groups/shared/group_details.js index 85daff3f60f..37b253d7c48 100644 --- a/app/assets/javascripts/pages/groups/shared/group_details.js +++ b/app/assets/javascripts/pages/groups/shared/group_details.js @@ -8,7 +8,6 @@ import NotificationsForm from '~/notifications_form'; import ProjectsList from '~/projects_list'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import GroupTabs from './group_tabs'; -import initNamespaceStorageLimitAlert from '~/namespace_storage_limit_alert'; export default function initGroupDetails(actionName = 'show') { const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup'); @@ -28,6 +27,4 @@ export default function initGroupDetails(actionName = 'show') { if (newGroupChildWrapper) { new NewGroupChild(newGroupChildWrapper); } - - initNamespaceStorageLimitAlert(); } diff --git a/app/assets/javascripts/pages/profiles/show/index.js b/app/assets/javascripts/pages/profiles/show/index.js index ad003181728..74ab1bc13a9 100644 --- a/app/assets/javascripts/pages/profiles/show/index.js +++ b/app/assets/javascripts/pages/profiles/show/index.js @@ -4,6 +4,7 @@ import emojiRegex from 'emoji-regex'; import createFlash from '~/flash'; import EmojiMenu from './emoji_menu'; import { __ } from '~/locale'; +import * as Emoji from '~/emoji'; const defaultStatusEmoji = 'speech_balloon'; @@ -55,8 +56,8 @@ document.addEventListener('DOMContentLoaded', () => { } }); - import(/* webpackChunkName: 'emoji' */ '~/emoji') - .then(Emoji => { + Emoji.initEmojiMap() + .then(() => { const emojiMenu = new EmojiMenu( Emoji, toggleEmojiMenuButtonSelector, diff --git a/app/assets/javascripts/pages/projects/clusters/show/cluster_health.js b/app/assets/javascripts/pages/projects/clusters/show/cluster_health.js new file mode 100644 index 00000000000..382d39645a9 --- /dev/null +++ b/app/assets/javascripts/pages/projects/clusters/show/cluster_health.js @@ -0,0 +1,18 @@ +import monitoringApp from '~/monitoring/monitoring_app'; + +export default () => { + const el = document.getElementById('prometheus-graphs'); + + if (el && el.dataset) { + monitoringApp({ + ...el.dataset, + showLegend: false, + showHeader: false, + showPanels: false, + forceSmallGraph: true, + smallEmptyState: true, + currentEnvironmentName: '', + hasMetrics: true, + }); + } +}; diff --git a/app/assets/javascripts/pages/projects/clusters/show/index.js b/app/assets/javascripts/pages/projects/clusters/show/index.js index 397f9faf6fe..d20e2c19583 100644 --- a/app/assets/javascripts/pages/projects/clusters/show/index.js +++ b/app/assets/javascripts/pages/projects/clusters/show/index.js @@ -1,7 +1,9 @@ import ClustersBundle from '~/clusters/clusters_bundle'; import initGkeNamespace from '~/create_cluster/gke_cluster_namespace'; +import initClusterHealth from './cluster_health'; document.addEventListener('DOMContentLoaded', () => { new ClustersBundle(); // eslint-disable-line no-new initGkeNamespace(); + initClusterHealth(); }); diff --git a/app/assets/javascripts/pages/projects/commit/pipelines/index.js b/app/assets/javascripts/pages/projects/commit/pipelines/index.js index 9f08260c3d6..1415a6f60c8 100644 --- a/app/assets/javascripts/pages/projects/commit/pipelines/index.js +++ b/app/assets/javascripts/pages/projects/commit/pipelines/index.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown'; import initPipelines from '~/commit/pipelines/pipelines_bundle'; +import { fetchCommitMergeRequests } from '~/commit_merge_requests'; document.addEventListener('DOMContentLoaded', () => { new MiniPipelineGraph({ @@ -8,5 +9,6 @@ document.addEventListener('DOMContentLoaded', () => { }).bindEvents(); // eslint-disable-next-line no-jquery/no-load $('.commit-info.branches').load(document.querySelector('.js-commit-box').dataset.commitPath); + fetchCommitMergeRequests(); initPipelines(); }); diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js index 9fb07917f9b..e65c18c07a9 100644 --- a/app/assets/javascripts/pages/projects/edit/index.js +++ b/app/assets/javascripts/pages/projects/edit/index.js @@ -7,13 +7,20 @@ 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 initProjectRemoveModal from '~/projects/project_remove_modal'; +import UserCallout from '~/user_callout'; +import initServiceDesk from '~/projects/settings_service_desk'; document.addEventListener('DOMContentLoaded', () => { initFilePickers(); initConfirmDangerModal(); initSettingsPanels(); + initProjectRemoveModal(); mountBadgeSettings(PROJECT_BADGE); + new UserCallout({ className: 'js-service-desk-callout' }); // eslint-disable-line no-new + initServiceDesk(); + initProjectLoadingSpinner(); initProjectPermissionsSettings(); setupTransferEdit('.js-project-transfer-form', 'select.select2'); diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue new file mode 100644 index 00000000000..77753521342 --- /dev/null +++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue @@ -0,0 +1,91 @@ +<script> +import { GlTabs, GlTab, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import createFlash from '~/flash'; +import ForkGroupsListItem from './fork_groups_list_item.vue'; + +export default { + components: { + GlTabs, + GlTab, + GlLoadingIcon, + GlSearchBoxByType, + ForkGroupsListItem, + }, + props: { + hasReachedProjectLimit: { + type: Boolean, + required: true, + }, + endpoint: { + type: String, + required: true, + }, + }, + data() { + return { + namespaces: null, + filter: '', + }; + }, + computed: { + filteredNamespaces() { + return this.namespaces.filter(n => n.name.toLowerCase().includes(this.filter.toLowerCase())); + }, + }, + + mounted() { + this.loadGroups(); + }, + + methods: { + loadGroups() { + axios + .get(this.endpoint) + .then(response => { + this.namespaces = response.data.namespaces; + }) + .catch(() => createFlash(__('There was a problem fetching groups.'))); + }, + }, + + i18n: { + searchPlaceholder: __('Search by name'), + }, +}; +</script> +<template> + <gl-tabs class="fork-groups"> + <gl-tab :title="__('Groups and subgroups')"> + <gl-loading-icon v-if="!namespaces" size="md" class="gl-mt-3" /> + <template v-else-if="namespaces.length === 0"> + <div class="gl-text-center"> + <div class="h5">{{ __('No available groups to fork the project.') }}</div> + <p class="gl-mt-5"> + {{ __('You must have permission to create a project in a group before forking.') }} + </p> + </div> + </template> + <div v-else-if="filteredNamespaces.length === 0" class="gl-text-center gl-mt-3"> + {{ s__('GroupsTree|No groups matched your search') }} + </div> + <ul v-else class="groups-list group-list-tree"> + <fork-groups-list-item + v-for="(namespace, index) in filteredNamespaces" + :key="index" + :group="namespace" + :has-reached-project-limit="hasReachedProjectLimit" + /> + </ul> + </gl-tab> + <template #tabs-end> + <gl-search-box-by-type + v-if="namespaces && namespaces.length" + v-model="filter" + :placeholder="$options.i18n.searchPlaceholder" + class="gl-align-self-center gl-ml-auto fork-filtered-search" + /> + </template> + </gl-tabs> +</template> 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 new file mode 100644 index 00000000000..792c2f3db34 --- /dev/null +++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue @@ -0,0 +1,147 @@ +<script> +import { + GlLink, + GlButton, + GlIcon, + GlAvatar, + GlTooltipDirective, + GlTooltip, + GlBadge, +} from '@gitlab/ui'; +import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE } from '~/groups/constants'; +import { __ } from '~/locale'; +import csrf from '~/lib/utils/csrf'; + +export default { + components: { + GlIcon, + GlAvatar, + GlBadge, + GlButton, + GlTooltip, + GlLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + group: { + type: Object, + required: true, + }, + hasReachedProjectLimit: { + type: Boolean, + required: true, + }, + }, + data() { + return { namespaces: null }; + }, + + computed: { + rowClass() { + return { + 'has-description': this.group.description, + 'being-removed': this.isGroupPendingRemoval, + }; + }, + isGroupPendingRemoval() { + return this.group.marked_for_deletion; + }, + hasForkedProject() { + return Boolean(this.group.forked_project_path); + }, + visibilityIcon() { + return VISIBILITY_TYPE_ICON[this.group.visibility]; + }, + visibilityTooltip() { + return GROUP_VISIBILITY_TYPE[this.group.visibility]; + }, + isSelectButtonDisabled() { + return this.hasReachedProjectLimit || !this.group.can_create_project; + }, + selectButtonDisabledTooltip() { + return this.hasReachedProjectLimit + ? this.$options.i18n.hasReachedProjectLimitMessage + : this.$options.i18n.insufficientPermissionsMessage; + }, + }, + + i18n: { + hasReachedProjectLimitMessage: __('You have reached your project limit'), + insufficientPermissionsMessage: __( + 'You must have permission to create a project in a namespace before forking.', + ), + }, + + csrf, +}; +</script> +<template> + <li :class="rowClass" class="group-row"> + <div class="group-row-contents gl-display-flex gl-align-items-center gl-py-3 gl-pr-5"> + <div class="folder-toggle-wrap gl-mr-2 gl-display-flex gl-align-items-center"> + <gl-icon name="folder-o" /> + </div> + <gl-link + :href="group.relative_path" + class="gl-display-none gl-flex-shrink-0 gl-display-sm-flex gl-mr-3" + > + <gl-avatar :size="32" shape="rect" :entity-name="group.name" :src="group.avatarUrl" /> + </gl-link> + <div class="gl-min-w-0 gl-display-flex gl-flex-grow-1 gl-flex-shrink-1 gl-align-items-center"> + <div class="gl-min-w-0 gl-flex-grow-1 flex-shrink-1"> + <div class="title gl-display-flex gl-align-items-center gl-flex-wrap gl-mr-3"> + <gl-link :href="group.relative_path" class="gl-mt-3 gl-mr-3 gl-text-gray-900!">{{ + group.full_name + }}</gl-link> + <gl-icon + v-gl-tooltip.hover.bottom + class="gl-mr-0 gl-inline-flex gl-mt-3 text-secondary" + :name="visibilityIcon" + :title="visibilityTooltip" + /> + <gl-badge + v-if="isGroupPendingRemoval" + variant="warning" + class="gl-display-none gl-display-sm-flex gl-mt-3 gl-mr-1" + >{{ __('pending removal') }}</gl-badge + > + <span v-if="group.permission" class="user-access-role gl-mt-3"> + {{ group.permission }} + </span> + </div> + <div v-if="group.description" class="description"> + <span v-html="group.markdown_description"> </span> + </div> + </div> + <div class="gl-display-flex gl-flex-shrink-0"> + <gl-button + v-if="hasForkedProject" + class="gl-h-7 gl-text-decoration-none!" + :href="group.forked_project_path" + >{{ __('Go to fork') }}</gl-button + > + <template v-else> + <div ref="selectButtonWrapper"> + <form method="POST" :action="group.fork_path"> + <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> + <gl-button + type="submit" + class="gl-h-7 gl-text-decoration-none!" + :data-qa-name="group.full_name" + variant="success" + :disabled="isSelectButtonDisabled" + >{{ __('Select') }}</gl-button + > + </form> + </div> + <gl-tooltip v-if="isSelectButtonDisabled" :target="() => $refs.selectButtonWrapper"> + {{ selectButtonDisabledTooltip }} + </gl-tooltip> + </template> + </div> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue index af8fb032c22..39d6df33a85 100644 --- a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue +++ b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue @@ -63,17 +63,19 @@ export default { selectedDailyCoverageName() { return this.selectedDailyCoverage?.group_name; }, - formattedData() { - if (this.selectedDailyCoverage?.data) { - return this.selectedDailyCoverage.data.map(value => [ - dateFormat(value.date, 'mmm dd'), - value.coverage, - ]); - } - + sortedData() { // If the fetching failed, we return an empty array which // allow the graph to render while empty - return []; + if (!this.selectedDailyCoverage?.data) { + return []; + } + + return [...this.selectedDailyCoverage.data].sort( + (a, b) => new Date(a.date) - new Date(b.date), + ); + }, + formattedData() { + return this.sortedData.map(value => [dateFormat(value.date, 'mmm dd'), value.coverage]); }, chartData() { return [ diff --git a/app/assets/javascripts/pages/projects/integrations/jira/issues/index/index.js b/app/assets/javascripts/pages/projects/integrations/jira/issues/index/index.js new file mode 100644 index 00000000000..260ba69b4bc --- /dev/null +++ b/app/assets/javascripts/pages/projects/integrations/jira/issues/index/index.js @@ -0,0 +1,5 @@ +import initIssuablesList from '~/issuables_list'; + +document.addEventListener('DOMContentLoaded', () => { + initIssuablesList(); +}); diff --git a/app/assets/javascripts/pages/projects/issues/service_desk/filtered_search.js b/app/assets/javascripts/pages/projects/issues/service_desk/filtered_search.js new file mode 100644 index 00000000000..72003b61c8a --- /dev/null +++ b/app/assets/javascripts/pages/projects/issues/service_desk/filtered_search.js @@ -0,0 +1,30 @@ +/* eslint-disable class-methods-use-this */ +import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; +import FilteredSearchManager from 'ee_else_ce/filtered_search/filtered_search_manager'; + +const AUTHOR_PARAM_KEY = 'author_username'; + +export default class FilteredSearchServiceDesk extends FilteredSearchManager { + constructor(supportBotData) { + super({ + page: 'service_desk', + filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, + }); + + this.supportBotData = supportBotData; + } + + canEdit(tokenName) { + return tokenName !== 'author'; + } + + modifyUrlParams(paramsArray) { + const supportBotParamPair = `${AUTHOR_PARAM_KEY}=${this.supportBotData.username}`; + const onlyValidParams = paramsArray.filter(param => param.indexOf(AUTHOR_PARAM_KEY) === -1); + + // unshift ensures author param is always first token element + onlyValidParams.unshift(supportBotParamPair); + + return onlyValidParams; + } +} diff --git a/app/assets/javascripts/pages/projects/issues/service_desk/index.js b/app/assets/javascripts/pages/projects/issues/service_desk/index.js new file mode 100644 index 00000000000..56054f5fc80 --- /dev/null +++ b/app/assets/javascripts/pages/projects/issues/service_desk/index.js @@ -0,0 +1,11 @@ +import FilteredSearchServiceDesk from './filtered_search'; + +document.addEventListener('DOMContentLoaded', () => { + const supportBotData = JSON.parse( + document.querySelector('.js-service-desk-issues').dataset.supportBot, + ); + + const filteredSearchManager = new FilteredSearchServiceDesk(supportBotData); + + filteredSearchManager.setup(); +}); diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js index 46c9b2fe0af..32f77465347 100644 --- a/app/assets/javascripts/pages/projects/issues/show.js +++ b/app/assets/javascripts/pages/projects/issues/show.js @@ -3,7 +3,7 @@ import Issue from '~/issue'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import ZenMode from '~/zen_mode'; import '~/notes/index'; -import initIssueableApp from '~/issue_show'; +import initIssueableApp, { issuableHeaderWarnings } from '~/issue_show'; import initSentryErrorStackTraceApp from '~/sentry_error_stack_trace'; import initRelatedMergeRequestsApp from '~/related_merge_requests'; import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle'; @@ -12,15 +12,17 @@ export default function() { initIssueableApp(); initSentryErrorStackTraceApp(); initRelatedMergeRequestsApp(); + issuableHeaderWarnings(); - // .js-design-management is currently EE-only. - // This will be moved to CE as part of https://gitlab.com/gitlab-org/gitlab/-/issues/212566#frontend - // at which point this conditional can be removed. - if (document.querySelector('.js-design-management')) { - import(/* webpackChunkName: 'design_management' */ '~/design_management') - .then(module => module.default()) - .catch(() => {}); - } + import(/* webpackChunkName: 'design_management' */ '~/design_management') + .then(module => module.default()) + .catch(() => {}); + + // This will be removed when we remove the `design_management_moved` feature flag + // See https://gitlab.com/gitlab-org/gitlab/-/issues/223197 + import(/* webpackChunkName: 'design_management' */ '~/design_management_new') + .then(module => module.default()) + .catch(() => {}); new Issue(); // eslint-disable-line no-new new ShortcutsIssuable(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/metrics_dashboard/index.js b/app/assets/javascripts/pages/projects/metrics_dashboard/index.js new file mode 100644 index 00000000000..d3028aec313 --- /dev/null +++ b/app/assets/javascripts/pages/projects/metrics_dashboard/index.js @@ -0,0 +1,3 @@ +import monitoringApp from '~/monitoring/monitoring_app'; + +document.addEventListener('DOMContentLoaded', monitoringApp); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue index 4efabcb7df3..5ef1f959b2c 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue @@ -1,12 +1,19 @@ <script> -import { GlSprintf, GlLink } from '@gitlab/ui'; +import { GlFormRadio, GlFormRadioGroup, GlLink, GlSprintf } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; import { getWeekdayNames } from '~/lib/utils/datetime_utility'; +const KEY_EVERY_DAY = 'everyDay'; +const KEY_EVERY_WEEK = 'everyWeek'; +const KEY_EVERY_MONTH = 'everyMonth'; +const KEY_CUSTOM = 'custom'; + export default { components: { - GlSprintf, + GlFormRadio, + GlFormRadioGroup, GlLink, + GlSprintf, }, props: { initialCronInterval: { @@ -22,6 +29,7 @@ export default { randomWeekDayIndex: this.generateRandomWeekDayIndex(), randomDay: this.generateRandomDay(), inputNameAttribute: 'schedule[cron]', + radioValue: this.initialCronInterval ? KEY_CUSTOM : KEY_EVERY_DAY, cronInterval: this.initialCronInterval, cronSyntaxUrl: 'https://en.wikipedia.org/wiki/Cron', }; @@ -29,14 +37,11 @@ export default { computed: { cronIntervalPresets() { return { - everyDay: `0 ${this.randomHour} * * *`, - everyWeek: `0 ${this.randomHour} * * ${this.randomWeekDayIndex}`, - everyMonth: `0 ${this.randomHour} ${this.randomDay} * *`, + [KEY_EVERY_DAY]: `0 ${this.randomHour} * * *`, + [KEY_EVERY_WEEK]: `0 ${this.randomHour} * * ${this.randomWeekDayIndex}`, + [KEY_EVERY_MONTH]: `0 ${this.randomHour} ${this.randomDay} * *`, }; }, - intervalIsPreset() { - return Object.values(this.cronIntervalPresets).includes(this.cronInterval); - }, formattedTime() { if (this.randomHour > 12) { return `${this.randomHour - 12}:00pm`; @@ -45,24 +50,36 @@ export default { } return `${this.randomHour}:00am`; }, + radioOptions() { + return [ + { + value: KEY_EVERY_DAY, + text: sprintf(s__(`Every day (at %{time})`), { time: this.formattedTime }), + }, + { + value: KEY_EVERY_WEEK, + text: sprintf(s__('Every week (%{weekday} at %{time})'), { + weekday: this.weekday, + time: this.formattedTime, + }), + }, + { + value: KEY_EVERY_MONTH, + text: sprintf(s__('Every month (Day %{day} at %{time})'), { + day: this.randomDay, + time: this.formattedTime, + }), + }, + { + value: KEY_CUSTOM, + text: s__('PipelineScheduleIntervalPattern|Custom (%{linkStart}Cron syntax%{linkEnd})'), + link: this.cronSyntaxUrl, + }, + ]; + }, weekday() { return getWeekdayNames()[this.randomWeekDayIndex]; }, - everyDayText() { - return sprintf(s__(`Every day (at %{time})`), { time: this.formattedTime }); - }, - everyWeekText() { - return sprintf(s__('Every week (%{weekday} at %{time})'), { - weekday: this.weekday, - time: this.formattedTime, - }); - }, - everyMonthText() { - return sprintf(s__('Every month (Day %{day} at %{time})'), { - day: this.randomDay, - time: this.formattedTime, - }); - }, }, watch: { cronInterval() { @@ -72,38 +89,18 @@ export default { gl.pipelineScheduleFieldErrors.updateFormValidityState(); }); }, - }, - // If at the mounting stage the default is still an empty string, we - // know we are not editing an existing field so we update it so - // that the default is the first radio option - mounted() { - if (this.cronInterval === '') { - this.cronInterval = this.cronIntervalPresets.everyDay; - } + radioValue: { + immediate: true, + handler(val) { + if (val !== KEY_CUSTOM) { + this.cronInterval = this.cronIntervalPresets[val]; + } + }, + }, }, methods: { - setCustomInput(e) { - if (!this.isEditingCustom) { - this.isEditingCustom = true; - this.$refs.customInput.click(); - // Because we need to manually trigger the click on the radio btn, - // it will add a space to update the v-model. If the user is typing - // and the space is added, it will feel very unituitive so we reset - // the value to the original - this.cronInterval = e.target.value; - } - if (this.intervalIsPreset) { - this.isEditingCustom = false; - } - }, - toggleCustomInput(shouldEnable) { - this.isEditingCustom = shouldEnable; - - if (shouldEnable) { - // We need to change the value so other radios don't remain selected - // because the model (cronInterval) hasn't changed. The server trims it. - this.cronInterval = `${this.cronInterval} `; - } + onCustomInput() { + this.radioValue = KEY_CUSTOM; }, generateRandomHour() { return Math.floor(Math.random() * 23); @@ -119,89 +116,33 @@ export default { </script> <template> - <div class="interval-pattern-form-group"> - <div class="cron-preset-radio-input"> - <input - id="every-day" - v-model="cronInterval" - :name="inputNameAttribute" - :value="cronIntervalPresets.everyDay" - class="label-bold" - type="radio" - @click="toggleCustomInput(false)" - /> - - <label class="label-bold" for="every-day"> - {{ everyDayText }} - </label> - </div> - - <div class="cron-preset-radio-input"> - <input - id="every-week" - v-model="cronInterval" - :name="inputNameAttribute" - :value="cronIntervalPresets.everyWeek" - class="label-bold" - type="radio" - @click="toggleCustomInput(false)" - /> - - <label class="label-bold" for="every-week"> - {{ everyWeekText }} - </label> - </div> - - <div class="cron-preset-radio-input"> - <input - id="every-month" - v-model="cronInterval" - :name="inputNameAttribute" - :value="cronIntervalPresets.everyMonth" - class="label-bold" - type="radio" - @click="toggleCustomInput(false)" - /> - - <label class="label-bold" for="every-month"> - {{ everyMonthText }} - </label> - </div> - - <div class="cron-preset-radio-input"> - <input - id="custom" - ref="customInput" - v-model="cronInterval" - :name="inputNameAttribute" - :value="cronInterval" - class="label-bold" - type="radio" - @click="toggleCustomInput(true)" - /> - - <label for="custom"> {{ s__('PipelineSheduleIntervalPattern|Custom') }} </label> - - <gl-sprintf :message="__('(%{linkStart}Cron syntax%{linkEnd})')"> - <template #link="{content}"> - <gl-link :href="cronSyntaxUrl" target="_blank" class="gl-font-sm"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </div> - - <div class="cron-interval-input-wrapper"> - <input - id="schedule_cron" - v-model="cronInterval" - :placeholder="__('Define a custom pattern with cron syntax')" - :name="inputNameAttribute" - class="form-control inline cron-interval-input" - type="text" - required="true" - @input="setCustomInput" - /> - </div> + <div> + <gl-form-radio-group v-model="radioValue" :name="inputNameAttribute"> + <gl-form-radio + v-for="option in radioOptions" + :key="option.value" + :value="option.value" + :data-testid="option.value" + > + <gl-sprintf v-if="option.link" :message="option.text"> + <template #link="{content}"> + <gl-link :href="option.link" target="_blank" class="gl-font-sm"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + <template v-else>{{ option.text }}</template> + </gl-form-radio> + </gl-form-radio-group> + <input + id="schedule_cron" + v-model="cronInterval" + :placeholder="__('Define a custom pattern with cron syntax')" + :name="inputNameAttribute" + class="form-control inline cron-interval-input" + type="text" + required="true" + @input="onCustomInput" + /> </div> </template> diff --git a/app/assets/javascripts/pages/projects/pipelines/index/index.js b/app/assets/javascripts/pages/projects/pipelines/index/index.js index 2c37d7da4a7..bed9a751d4c 100644 --- a/app/assets/javascripts/pages/projects/pipelines/index/index.js +++ b/app/assets/javascripts/pages/projects/pipelines/index/index.js @@ -8,7 +8,7 @@ import { } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import PipelinesStore from '../../../../pipelines/stores/pipelines_store'; -import pipelinesComponent from '../../../../pipelines/components/pipelines.vue'; +import pipelinesComponent from '../../../../pipelines/components/pipelines_list/pipelines.vue'; import Translate from '../../../../vue_shared/translate'; Vue.use(Translate); @@ -40,6 +40,7 @@ document.addEventListener( props: { store: this.store, endpoint: this.dataset.endpoint, + pipelineScheduleUrl: this.dataset.pipelineScheduleUrl, helpPagePath: this.dataset.helpPagePath, emptyStateSvgPath: this.dataset.emptyStateSvgPath, errorStateSvgPath: this.dataset.errorStateSvgPath, diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js index f39765818e7..e146592e134 100644 --- a/app/assets/javascripts/pages/projects/project_members/index.js +++ b/app/assets/javascripts/pages/projects/project_members/index.js @@ -1,12 +1,30 @@ +import Vue from 'vue'; import Members from 'ee_else_ce/members'; -import memberExpirationDate from '../../../member_expiration_date'; -import UsersSelect from '../../../users_select'; -import groupsSelect from '../../../groups_select'; +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'; + +function mountRemoveMemberModal() { + const el = document.querySelector('.js-remove-member-modal'); + if (!el) { + return false; + } + + return new Vue({ + el, + render(createComponent) { + return createComponent(RemoveMemberModal); + }, + }); +} document.addEventListener('DOMContentLoaded', () => { - memberExpirationDate('.js-access-expiration-date-groups'); groupsSelect(); memberExpirationDate(); + memberExpirationDate('.js-access-expiration-date-groups'); + mountRemoveMemberModal(); + new Members(); // eslint-disable-line no-new new UsersSelect(); // eslint-disable-line no-new }); diff --git a/app/assets/javascripts/pages/projects/releases/new/index.js b/app/assets/javascripts/pages/projects/releases/new/index.js new file mode 100644 index 00000000000..0e314aacf8a --- /dev/null +++ b/app/assets/javascripts/pages/projects/releases/new/index.js @@ -0,0 +1,7 @@ +import ZenMode from '~/zen_mode'; +import initNewRelease from '~/releases/mount_new'; + +document.addEventListener('DOMContentLoaded', () => { + new ZenMode(); // eslint-disable-line no-new + initNewRelease(); +}); diff --git a/app/assets/javascripts/pages/projects/settings/operations/show/index.js b/app/assets/javascripts/pages/projects/settings/operations/show/index.js index 721d4a31fe4..1b9ec44ed4a 100644 --- a/app/assets/javascripts/pages/projects/settings/operations/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/operations/show/index.js @@ -1,13 +1,17 @@ import mountErrorTrackingForm from '~/error_tracking_settings'; +import mountAlertsSettings from '~/alerts_settings'; import mountOperationSettings from '~/operation_settings'; import mountGrafanaIntegration from '~/grafana_integration'; import initSettingsPanels from '~/settings_panels'; +import initIncidentsSettings from '~/incidents_settings'; document.addEventListener('DOMContentLoaded', () => { + initIncidentsSettings(); mountErrorTrackingForm(); mountOperationSettings(); mountGrafanaIntegration(); if (!IS_EE) { initSettingsPanels(); } + mountAlertsSettings(document.querySelector('.js-alerts-settings')); }); 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 7181332a1d6..a95f0af46cd 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 @@ -426,7 +426,7 @@ export default { v-if="lfsAvailable" ref="git-lfs-settings" :help-path="lfsHelpPath" - :label="s__('ProjectSettings|Git Large File Storage')" + :label="s__('ProjectSettings|Git Large File Storage (LFS)')" :help-text=" s__('ProjectSettings|Manages large files such as audio, video, and graphics files') " diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js index 3c44053e2b2..c65cc3e4c57 100644 --- a/app/assets/javascripts/pages/projects/show/index.js +++ b/app/assets/javascripts/pages/projects/show/index.js @@ -1,25 +1,18 @@ -import $ from 'jquery'; -import 'jquery.waitforimages'; - import initBlob from '~/blob_edit/blob_bundle'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import NotificationsForm from '~/notifications_form'; import UserCallout from '~/user_callout'; -import TreeView from '~/tree'; import BlobViewer from '~/blob/viewer/index'; import Activities from '~/activities'; -import { ajaxGet } from '~/lib/utils/common_utils'; -import GpgBadges from '~/gpg_badges'; import initReadMore from '~/read_more'; import leaveByUrl from '~/namespaces/leave_by_url'; import Star from '../../../star'; import notificationsDropdown from '../../../notifications_dropdown'; -import initNamespaceStorageLimitAlert from '~/namespace_storage_limit_alert'; import { showLearnGitLabProjectPopover } from '~/onboarding_issues'; +import initTree from 'ee_else_ce/repository'; document.addEventListener('DOMContentLoaded', () => { initReadMore(); - initNamespaceStorageLimitAlert(); new Star(); // eslint-disable-line no-new notificationsDropdown(); new ShortcutsNavigation(); // eslint-disable-line no-new @@ -31,10 +24,10 @@ document.addEventListener('DOMContentLoaded', () => { }); // Project show page loads different overview content based on user preferences - const treeSlider = document.querySelector('#tree-slider'); + const treeSlider = document.getElementById('js-tree-list'); if (treeSlider) { - new TreeView(); // eslint-disable-line no-new initBlob(); + initTree(); } if (document.querySelector('.blob-viewer')) { @@ -45,21 +38,7 @@ document.addEventListener('DOMContentLoaded', () => { new Activities(); // eslint-disable-line no-new } - $(treeSlider).waitForImages(() => { - ajaxGet(document.querySelector('.js-tree-content').dataset.logsPath); - }); - - GpgBadges.fetch(); leaveByUrl('project'); - if (document.getElementById('js-tree-list')) { - initBlob(); - import('ee_else_ce/repository') - .then(m => m.default()) - .catch(e => { - throw e; - }); - } - showLearnGitLabProjectPopover(); }); diff --git a/app/assets/javascripts/pages/projects/tree/show/index.js b/app/assets/javascripts/pages/projects/tree/show/index.js index 0d1d32317fe..78a4ea23f1a 100644 --- a/app/assets/javascripts/pages/projects/tree/show/index.js +++ b/app/assets/javascripts/pages/projects/tree/show/index.js @@ -1,53 +1,12 @@ import $ from 'jquery'; -import 'jquery.waitforimages'; - -import Vue from 'vue'; import initBlob from '~/blob_edit/blob_bundle'; -import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; -import GpgBadges from '~/gpg_badges'; -import TreeView from '../../../../tree'; import ShortcutsNavigation from '../../../../behaviors/shortcuts/shortcuts_navigation'; -import BlobViewer from '../../../../blob/viewer'; import NewCommitForm from '../../../../new_commit_form'; -import { ajaxGet } from '../../../../lib/utils/common_utils'; +import initTree from 'ee_else_ce/repository'; document.addEventListener('DOMContentLoaded', () => { new ShortcutsNavigation(); // eslint-disable-line no-new - new TreeView(); // eslint-disable-line no-new - new BlobViewer(); // eslint-disable-line no-new new NewCommitForm($('.js-create-dir-form')); // eslint-disable-line no-new - $('#tree-slider').waitForImages(() => - ajaxGet(document.querySelector('.js-tree-content').dataset.logsPath), - ); - initBlob(); - const commitPipelineStatusEl = document.querySelector('.js-commit-pipeline-status'); - const statusLink = document.querySelector('.commit-actions .ci-status-link'); - if (statusLink != null) { - statusLink.remove(); - // eslint-disable-next-line no-new - new Vue({ - el: commitPipelineStatusEl, - components: { - commitPipelineStatus, - }, - render(createElement) { - return createElement('commit-pipeline-status', { - props: { - endpoint: commitPipelineStatusEl.dataset.endpoint, - }, - }); - }, - }); - } - - GpgBadges.fetch(); - - if (document.getElementById('js-tree-list')) { - import('ee_else_ce/repository') - .then(m => m.default()) - .catch(e => { - throw e; - }); - } + initTree(); }); diff --git a/app/assets/javascripts/pages/search/init_filtered_search.js b/app/assets/javascripts/pages/search/init_filtered_search.js index e54e32199f0..b331a2bee6a 100644 --- a/app/assets/javascripts/pages/search/init_filtered_search.js +++ b/app/assets/javascripts/pages/search/init_filtered_search.js @@ -7,6 +7,7 @@ export default ({ isGroupAncestor, isGroupDecendent, stateFiltersSelector, + anchor, }) => { const filteredSearchEnabled = FilteredSearchManager && document.querySelector('.filtered-search'); if (filteredSearchEnabled) { @@ -17,6 +18,7 @@ export default ({ isGroupDecendent, filteredSearchTokenKeys, stateFiltersSelector, + anchor, }); filteredSearchManager.setup(); } diff --git a/app/assets/javascripts/pages/sessions/new/length_validator.js b/app/assets/javascripts/pages/sessions/new/length_validator.js index 3d687ca08cc..92482c81f3c 100644 --- a/app/assets/javascripts/pages/sessions/new/length_validator.js +++ b/app/assets/javascripts/pages/sessions/new/length_validator.js @@ -21,11 +21,24 @@ export default class LengthValidator extends InputValidator { ); const { value } = this.inputDomElement; - const { maxLengthMessage, maxLength } = this.inputDomElement.dataset; - - this.errorMessage = maxLengthMessage; - - this.invalidInput = value.length > parseInt(maxLength, 10); + const { + minLength, + minLengthMessage, + maxLengthMessage, + maxLength, + } = this.inputDomElement.dataset; + + this.invalidInput = false; + + if (value.length > parseInt(maxLength, 10)) { + this.invalidInput = true; + this.errorMessage = maxLengthMessage; + } + + if (value.length < parseInt(minLength, 10)) { + this.invalidInput = true; + this.errorMessage = minLengthMessage; + } this.setValidationStateAndMessage(); } diff --git a/app/assets/javascripts/pages/sessions/new/username_validator.js b/app/assets/javascripts/pages/sessions/new/username_validator.js index 1048e3b4548..ecb5e677290 100644 --- a/app/assets/javascripts/pages/sessions/new/username_validator.js +++ b/app/assets/javascripts/pages/sessions/new/username_validator.js @@ -39,7 +39,7 @@ export default class UsernameValidator extends InputValidator { static validateUsernameInput(inputDomElement) { const username = inputDomElement.value; - if (inputDomElement.checkValidity() && username.length > 0) { + if (inputDomElement.checkValidity() && username.length > 1) { UsernameValidator.setMessageVisibility(inputDomElement, pendingMessageSelector); UsernameValidator.fetchUsernameAvailability(username) .then(usernameTaken => { diff --git a/app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue b/app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue index 580cca49b5e..a7b7d597fb7 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue @@ -55,13 +55,22 @@ export default { <template> <div class="d-inline-block"> - <button v-gl-modal="modalId" type="button" class="btn btn-danger">{{ __('Delete') }}</button> + <button + v-gl-modal="modalId" + type="button" + class="btn btn-danger" + data-qa-selector="delete_button" + > + {{ __('Delete') }} + </button> <gl-modal :title="title" - :ok-title="s__('WikiPageConfirmDelete|Delete page')" + :action-primary="{ + text: s__('WikiPageConfirmDelete|Delete page'), + attributes: { variant: 'danger', 'data-qa-selector': 'confirm_deletion_button' }, + }" :modal-id="modalId" title-tag="h4" - ok-variant="danger" @ok="onSubmit" > {{ message }} diff --git a/app/assets/javascripts/pages/shared/wikis/wikis.js b/app/assets/javascripts/pages/shared/wikis/wikis.js index ed67219383b..41d43812b5d 100644 --- a/app/assets/javascripts/pages/shared/wikis/wikis.js +++ b/app/assets/javascripts/pages/shared/wikis/wikis.js @@ -1,5 +1,6 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { s__, sprintf } from '~/locale'; +import Tracking from '~/tracking'; const MARKDOWN_LINK_TEXT = { markdown: '[Link Title](page-slug)', @@ -8,6 +9,9 @@ const MARKDOWN_LINK_TEXT = { org: '[[page-slug]]', }; +const TRACKING_EVENT_NAME = 'view_wiki_page'; +const TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/wiki_page_context/jsonschema/1-0-0'; + export default class Wikis { constructor() { this.sidebarEl = document.querySelector('.js-wiki-sidebar'); @@ -57,6 +61,8 @@ export default class Wikis { window.onbeforeunload = null; }); } + + Wikis.trackPageView(); } handleWikiTitleChange(e) { @@ -97,4 +103,17 @@ export default class Wikis { classList.remove('right-sidebar-expanded'); } } + + static trackPageView() { + const wikiPageContent = document.querySelector('.js-wiki-page-content[data-tracking-context]'); + if (!wikiPageContent) return; + + Tracking.event(document.body.dataset.page, TRACKING_EVENT_NAME, { + label: TRACKING_EVENT_NAME, + context: { + schema: TRACKING_CONTEXT_SCHEMA, + data: JSON.parse(wikiPageContent.dataset.trackingContext), + }, + }); + } } diff --git a/app/assets/javascripts/performance_bar/components/request_selector.vue b/app/assets/javascripts/performance_bar/components/request_selector.vue index 115b2ff08ac..c22a648d17f 100644 --- a/app/assets/javascripts/performance_bar/components/request_selector.vue +++ b/app/assets/javascripts/performance_bar/components/request_selector.vue @@ -45,7 +45,7 @@ export default { }; </script> <template> - <div id="peek-request-selector" data-qa-selector="request_dropdown"> + <div id="peek-request-selector" data-qa-selector="request_dropdown" class="view"> <select v-model="currentRequestId"> <option v-for="request in requests" diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js index b3068c46bcb..b8a1397d8f6 100644 --- a/app/assets/javascripts/persistent_user_callout.js +++ b/app/assets/javascripts/persistent_user_callout.js @@ -18,17 +18,21 @@ export default class PersistentUserCallout { init() { const closeButton = this.container.querySelector('.js-close'); + const followLink = this.container.querySelector('.js-follow-link'); - if (!closeButton) { - return; + if (closeButton) { + this.handleCloseButtonCallout(closeButton); + } else if (followLink) { + this.handleFollowLinkCallout(followLink); } + } + handleCloseButtonCallout(closeButton) { closeButton.addEventListener('click', event => this.dismiss(event)); if (this.deferLinks) { this.container.addEventListener('click', event => { const isDeferredLink = event.target.classList.contains(DEFERRED_LINK_CLASS); - if (isDeferredLink) { const { href, target } = event.target; @@ -38,6 +42,10 @@ export default class PersistentUserCallout { } } + handleFollowLinkCallout(followLink) { + followLink.addEventListener('click', event => this.registerCalloutWithLink(event)); + } + dismiss(event, deferredLinkOptions = null) { event.preventDefault(); @@ -58,6 +66,27 @@ export default class PersistentUserCallout { }); } + registerCalloutWithLink(event) { + event.preventDefault(); + + const { href } = event.currentTarget; + + axios + .post(this.dismissEndpoint, { + feature_name: this.featureId, + }) + .then(() => { + window.location.assign(href); + }) + .catch(() => { + Flash( + __( + 'An error occurred while acknowledging the notification. Refresh the page and try again.', + ), + ); + }); + } + static factory(container, options) { if (!container) { return undefined; diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js index 6e292299778..f4fe605f0a2 100644 --- a/app/assets/javascripts/persistent_user_callouts.js +++ b/app/assets/javascripts/persistent_user_callouts.js @@ -4,6 +4,9 @@ const PERSISTENT_USER_CALLOUTS = [ '.js-recovery-settings-callout', '.js-users-over-license-callout', '.js-admin-licensed-user-count-threshold', + '.js-buy-pipeline-minutes-notification-callout', + '.js-alerts-moved-alert', + '.js-token-expiry-callout', ]; const initCallouts = () => { diff --git a/app/assets/javascripts/pipelines/components/dag/constants.js b/app/assets/javascripts/pipelines/components/dag/constants.js index 51b1fb4f4cc..b6a98fdc488 100644 --- a/app/assets/javascripts/pipelines/components/dag/constants.js +++ b/app/assets/javascripts/pipelines/components/dag/constants.js @@ -8,3 +8,8 @@ export const DEFAULT = 'default'; export const IS_HIGHLIGHTED = 'dag-highlighted'; export const LINK_SELECTOR = 'dag-link'; export const NODE_SELECTOR = 'dag-node'; + +/* Annotation types */ +export const ADD_NOTE = 'add'; +export const REMOVE_NOTE = 'remove'; +export const REPLACE_NOTES = 'replace'; diff --git a/app/assets/javascripts/pipelines/components/dag/dag.vue b/app/assets/javascripts/pipelines/components/dag/dag.vue index 6e0d23ef87f..85163a666e2 100644 --- a/app/assets/javascripts/pipelines/components/dag/dag.vue +++ b/app/assets/javascripts/pipelines/components/dag/dag.vue @@ -1,19 +1,32 @@ <script> -import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; +import { GlAlert, GlButton, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; +import { isEmpty } from 'lodash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import DagGraph from './dag_graph.vue'; -import { DEFAULT, PARSE_FAILURE, LOAD_FAILURE, UNSUPPORTED_DATA } from './constants'; +import DagAnnotations from './dag_annotations.vue'; +import { + DEFAULT, + PARSE_FAILURE, + LOAD_FAILURE, + UNSUPPORTED_DATA, + ADD_NOTE, + REMOVE_NOTE, + REPLACE_NOTES, +} from './constants'; import { parseData } from './parsing_utils'; export default { // eslint-disable-next-line @gitlab/require-i18n-strings name: 'Dag', components: { + DagAnnotations, DagGraph, GlAlert, GlLink, GlSprintf, + GlEmptyState, + GlButton, }, props: { graphUrl: { @@ -21,21 +34,43 @@ export default { required: false, default: '', }, + emptySvgPath: { + type: String, + required: true, + default: '', + }, + dagDocPath: { + type: String, + required: true, + default: '', + }, }, data() { return { - showFailureAlert: false, - showBetaInfo: true, + annotationsMap: {}, failureType: null, graphData: null, + showFailureAlert: false, + showBetaInfo: true, + hasNoDependentJobs: false, }; }, errorTexts: { [LOAD_FAILURE]: __('We are currently unable to fetch data for this graph.'), [PARSE_FAILURE]: __('There was an error parsing the data for this graph.'), - [UNSUPPORTED_DATA]: __('A DAG must have two dependent jobs to be visualized on this tab.'), + [UNSUPPORTED_DATA]: __('DAG visualization requires at least 3 dependent jobs.'), [DEFAULT]: __('An unknown error occurred while loading this graph.'), }, + emptyStateTexts: { + title: __('Start using Directed Acyclic Graphs (DAG)'), + firstDescription: __( + "This pipeline does not use the %{codeStart}needs%{codeEnd} keyword and can't be represented as a directed acyclic graph.", + ), + secondDescription: __( + 'Using %{codeStart}needs%{codeEnd} allows jobs to run before their stage is reached, as soon as their individual dependencies are met, which speeds up your pipelines.', + ), + button: __('Learn more about job dependencies'), + }, computed: { betaMessage() { return __( @@ -66,6 +101,9 @@ export default { }; } }, + shouldDisplayAnnotations() { + return !isEmpty(this.annotationsMap); + }, shouldDisplayGraph() { return Boolean(!this.showFailureAlert && this.graphData); }, @@ -86,6 +124,9 @@ export default { .catch(() => reportFailure(LOAD_FAILURE)); }, methods: { + addAnnotationToMap({ uid, source, target }) { + this.$set(this.annotationsMap, uid, { source, target }); + }, processGraphData(data) { let parsed; @@ -96,11 +137,18 @@ export default { return; } - if (parsed.links.length < 2) { + if (parsed.links.length === 1) { this.reportFailure(UNSUPPORTED_DATA); return; } + // If there are no links, we don't report failure + // as it simply means the user does not use job dependencies + if (parsed.links.length === 0) { + this.hasNoDependentJobs = true; + return; + } + this.graphData = parsed; }, hideAlert() { @@ -109,10 +157,28 @@ export default { hideBetaInfo() { this.showBetaInfo = false; }, + removeAnnotationFromMap({ uid }) { + this.$delete(this.annotationsMap, uid); + }, reportFailure(type) { this.showFailureAlert = true; this.failureType = type; }, + updateAnnotation({ type, data }) { + switch (type) { + case ADD_NOTE: + this.addAnnotationToMap(data); + break; + case REMOVE_NOTE: + this.removeAnnotationFromMap(data); + break; + case REPLACE_NOTES: + this.annotationsMap = data; + break; + default: + break; + } + }, }, }; </script> @@ -131,6 +197,43 @@ export default { </template> </gl-sprintf> </gl-alert> - <dag-graph v-if="shouldDisplayGraph" :graph-data="graphData" @onFailure="reportFailure" /> + <div class="gl-relative"> + <dag-annotations v-if="shouldDisplayAnnotations" :annotations="annotationsMap" /> + <dag-graph + v-if="shouldDisplayGraph" + :graph-data="graphData" + @onFailure="reportFailure" + @update-annotation="updateAnnotation" + /> + <gl-empty-state + v-else-if="hasNoDependentJobs" + :svg-path="emptySvgPath" + :title="$options.emptyStateTexts.title" + > + <template #description> + <div class="gl-text-left"> + <p> + <gl-sprintf :message="$options.emptyStateTexts.firstDescription"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + <p> + <gl-sprintf :message="$options.emptyStateTexts.secondDescription"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + </div> + </template> + <template #actions> + <gl-button :href="dagDocPath" target="__blank" variant="success"> + {{ $options.emptyStateTexts.button }} + </gl-button> + </template> + </gl-empty-state> + </div> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/dag/dag_annotations.vue b/app/assets/javascripts/pipelines/components/dag/dag_annotations.vue new file mode 100644 index 00000000000..a1500166cdc --- /dev/null +++ b/app/assets/javascripts/pipelines/components/dag/dag_annotations.vue @@ -0,0 +1,73 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + name: 'DagAnnotations', + components: { + GlButton, + }, + props: { + annotations: { + type: Object, + required: true, + }, + }, + data() { + return { + showList: true, + }; + }, + computed: { + linkText() { + return this.showList ? __('Hide list') : __('Show list'); + }, + shouldShowLink() { + return Object.keys(this.annotations).length > 1; + }, + wrapperClasses() { + return [ + 'gl-display-flex', + 'gl-flex-direction-column', + 'gl-fixed', + 'gl-right-1', + 'gl-top-66vh', + 'gl-w-max-content', + 'gl-px-5', + 'gl-py-4', + 'gl-rounded-base', + 'gl-bg-white', + ].join(' '); + }, + }, + methods: { + toggleList() { + this.showList = !this.showList; + }, + }, +}; +</script> +<template> + <div :class="wrapperClasses"> + <div v-if="showList"> + <div + v-for="note in annotations" + :key="note.uid" + class="gl-display-flex gl-align-items-center" + > + <div + data-testid="dag-color-block" + class="gl-w-6 gl-h-5" + :style="{ + background: `linear-gradient(0.25turn, ${note.source.color} 40%, ${note.target.color} 60%)`, + }" + ></div> + <div data-testid="dag-note-text" class="gl-px-2 gl-font-base gl-align-items-center"> + {{ note.source.name }} → {{ note.target.name }} + </div> + </div> + </div> + + <gl-button v-if="shouldShowLink" variant="link" @click="toggleList">{{ linkText }}</gl-button> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/dag/dag_graph.vue b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue index 063ec091e4d..d12baa9617e 100644 --- a/app/assets/javascripts/pipelines/components/dag/dag_graph.vue +++ b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue @@ -1,8 +1,17 @@ <script> import * as d3 from 'd3'; import { uniqueId } from 'lodash'; -import { LINK_SELECTOR, NODE_SELECTOR, PARSE_FAILURE } from './constants'; import { + LINK_SELECTOR, + NODE_SELECTOR, + PARSE_FAILURE, + ADD_NOTE, + REMOVE_NOTE, + REPLACE_NOTES, +} from './constants'; +import { + currentIsLive, + getLiveLinksAsDict, highlightLinks, restoreLinks, toggleLinkHighlight, @@ -25,6 +34,11 @@ export default { containerClasses: ['dag-graph-container', 'gl-display-flex', 'gl-flex-direction-column'].join( ' ', ), + hoverFadeClasses: [ + 'gl-cursor-pointer', + 'gl-transition-duration-slow', + 'gl-transition-timing-function-ease', + ].join(' '), }, gitLabColorRotation: [ '#e17223', @@ -50,8 +64,8 @@ export default { data() { return { color: () => {}, - width: 0, height: 0, + width: 0, }; }, mounted() { @@ -60,7 +74,7 @@ export default { try { countedAndTransformed = this.transformData(this.graphData); } catch { - this.$emit('onFailure', PARSE_FAILURE); + this.$emit('on-failure', PARSE_FAILURE); return; } @@ -90,17 +104,33 @@ export default { }, appendLinkInteractions(link) { + const { baseOpacity } = this.$options.viewOptions; return link - .on('mouseover', highlightLinks) - .on('mouseout', restoreLinks.bind(null, this.$options.viewOptions.baseOpacity)) - .on('click', toggleLinkHighlight.bind(null, this.$options.viewOptions.baseOpacity)); + .on('mouseover', (d, idx, collection) => { + if (currentIsLive(idx, collection)) { + return; + } + this.$emit('update-annotation', { type: ADD_NOTE, data: d }); + highlightLinks(d, idx, collection); + }) + .on('mouseout', (d, idx, collection) => { + if (currentIsLive(idx, collection)) { + return; + } + this.$emit('update-annotation', { type: REMOVE_NOTE, data: d }); + restoreLinks(baseOpacity); + }) + .on('click', (d, idx, collection) => { + toggleLinkHighlight(baseOpacity, d, idx, collection); + this.$emit('update-annotation', { type: REPLACE_NOTES, data: getLiveLinksAsDict() }); + }); }, appendNodeInteractions(node) { - return node.on( - 'click', - togglePathHighlights.bind(null, this.$options.viewOptions.baseOpacity), - ); + return node.on('click', (d, idx, collection) => { + togglePathHighlights(this.$options.viewOptions.baseOpacity, d, idx, collection); + this.$emit('update-annotation', { type: REPLACE_NOTES, data: getLiveLinksAsDict() }); + }); }, appendLabelAsForeignObject(d, i, n) { @@ -230,7 +260,10 @@ export default { .attr('id', d => { return this.createAndAssignId(d, 'uid', LINK_SELECTOR); }) - .classed(`${LINK_SELECTOR} gl-cursor-pointer`, true); + .classed( + `${LINK_SELECTOR} gl-transition-property-stroke-opacity ${this.$options.viewOptions.hoverFadeClasses}`, + true, + ); }, generateNodes(svg, nodeData) { @@ -242,7 +275,10 @@ export default { .data(nodeData) .enter() .append('line') - .classed(`${NODE_SELECTOR} gl-cursor-pointer`, true) + .classed( + `${NODE_SELECTOR} gl-transition-property-stroke ${this.$options.viewOptions.hoverFadeClasses}`, + true, + ) .attr('id', d => { return this.createAndAssignId(d, 'uid', NODE_SELECTOR); }) @@ -260,6 +296,11 @@ export default { .attr('y2', d => d.y1 - 4); }, + initColors() { + const colorFn = d3.scaleOrdinal(this.$options.gitLabColorRotation); + return ({ name }) => colorFn(name); + }, + labelNodes(svg, nodeData) { return svg .append('g') @@ -271,11 +312,6 @@ export default { .each(this.appendLabelAsForeignObject); }, - initColors() { - const colorFn = d3.scaleOrdinal(this.$options.gitLabColorRotation); - return ({ name }) => colorFn(name); - }, - transformData(parsed) { const baseLayout = createSankey()(parsed); const cleanedNodes = removeOrphanNodes(baseLayout.nodes); diff --git a/app/assets/javascripts/pipelines/components/dag/interactions.js b/app/assets/javascripts/pipelines/components/dag/interactions.js index c9008730c90..e9f3e9f0e2c 100644 --- a/app/assets/javascripts/pipelines/components/dag/interactions.js +++ b/app/assets/javascripts/pipelines/components/dag/interactions.js @@ -5,10 +5,20 @@ export const highlightIn = 1; export const highlightOut = 0.2; const getCurrent = (idx, collection) => d3.select(collection[idx]); -const currentIsLive = (idx, collection) => getCurrent(idx, collection).classed(IS_HIGHLIGHTED); +const getLiveLinks = () => d3.selectAll(`.${LINK_SELECTOR}.${IS_HIGHLIGHTED}`); const getOtherLinks = () => d3.selectAll(`.${LINK_SELECTOR}:not(.${IS_HIGHLIGHTED})`); const getNodesNotLive = () => d3.selectAll(`.${NODE_SELECTOR}:not(.${IS_HIGHLIGHTED})`); +export const getLiveLinksAsDict = () => { + return Object.fromEntries( + getLiveLinks() + .data() + .map(d => [d.uid, d]), + ); +}; +export const currentIsLive = (idx, collection) => + getCurrent(idx, collection).classed(IS_HIGHLIGHTED); + const backgroundLinks = selection => selection.style('stroke-opacity', highlightOut); const backgroundNodes = selection => selection.attr('stroke', '#f2f2f2'); const foregroundLinks = selection => selection.style('stroke-opacity', highlightIn); @@ -16,10 +26,10 @@ const foregroundNodes = selection => selection.attr('stroke', d => d.color); const renewLinks = (selection, baseOpacity) => selection.style('stroke-opacity', baseOpacity); const renewNodes = selection => selection.attr('stroke', d => d.color); -const getAllLinkAncestors = node => { +export const getAllLinkAncestors = node => { if (node.targetLinks) { return node.targetLinks.flatMap(n => { - return [n.uid, ...getAllLinkAncestors(n.source)]; + return [n, ...getAllLinkAncestors(n.source)]; }); } @@ -59,8 +69,8 @@ const highlightPath = (parentLinks, parentNodes) => { backgroundNodes(getNodesNotLive()); /* highlight correct links */ - parentLinks.forEach(id => { - foregroundLinks(d3.select(`#${id}`)).classed(IS_HIGHLIGHTED, true); + parentLinks.forEach(({ uid }) => { + foregroundLinks(d3.select(`#${uid}`)).classed(IS_HIGHLIGHTED, true); }); /* highlight correct nodes */ @@ -69,9 +79,22 @@ const highlightPath = (parentLinks, parentNodes) => { }); }; +const restoreNodes = () => { + /* + When paths are unclicked, they can take down nodes that + are still in use for other paths. This checks the live paths and + rehighlights their nodes. + */ + + getLiveLinks().each(d => { + foregroundNodes(d3.select(`#${d.source.uid}`)).classed(IS_HIGHLIGHTED, true); + foregroundNodes(d3.select(`#${d.target.uid}`)).classed(IS_HIGHLIGHTED, true); + }); +}; + const restorePath = (parentLinks, parentNodes, baseOpacity) => { - parentLinks.forEach(id => { - renewLinks(d3.select(`#${id}`), baseOpacity).classed(IS_HIGHLIGHTED, false); + parentLinks.forEach(({ uid }) => { + renewLinks(d3.select(`#${uid}`), baseOpacity).classed(IS_HIGHLIGHTED, false); }); parentNodes.forEach(id => { @@ -86,14 +109,10 @@ const restorePath = (parentLinks, parentNodes, baseOpacity) => { backgroundLinks(getOtherLinks()); backgroundNodes(getNodesNotLive()); + restoreNodes(); }; -export const restoreLinks = (baseOpacity, d, idx, collection) => { - /* in this case, it has just been clicked */ - if (currentIsLive(idx, collection)) { - return; - } - +export const restoreLinks = baseOpacity => { /* if there exist live links, reset to highlight out / pale otherwise, reset to base @@ -111,11 +130,12 @@ export const restoreLinks = (baseOpacity, d, idx, collection) => { export const toggleLinkHighlight = (baseOpacity, d, idx, collection) => { if (currentIsLive(idx, collection)) { - restorePath([d.uid], [d.source.uid, d.target.uid], baseOpacity); + restorePath([d], [d.source.uid, d.target.uid], baseOpacity); + restoreNodes(); return; } - highlightPath([d.uid], [d.source.uid, d.target.uid]); + highlightPath([d], [d.source.uid, d.target.uid]); }; export const togglePathHighlights = (baseOpacity, d, idx, collection) => { diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 1ff5b662d18..6b890688a48 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -43,6 +43,7 @@ export default { data() { return { downstreamMarginTop: null, + jobName: null, }; }, computed: { @@ -91,13 +92,9 @@ export default { /** * Calculates the margin top of the clicked downstream pipeline by * subtracting the clicked downstream pipelines offsetTop by it's parent's - * offsetTop and then subtracting either 15 (if child) or 30 (if not a child) - * due to the height of node and stage name margin bottom. + * offsetTop and then subtracting 15 */ - this.downstreamMarginTop = this.calculateMarginTop( - downstreamNode, - downstreamNode.classList.contains('child-pipeline') ? 15 : 30, - ); + this.downstreamMarginTop = this.calculateMarginTop(downstreamNode, 15); /** * If the expanded trigger is defined and the id is different than the @@ -120,6 +117,9 @@ export default { hasUpstream(index) { return index === 0 && this.hasTriggeredBy; }, + setJob(jobName) { + this.jobName = jobName; + }, }, }; </script> @@ -172,7 +172,7 @@ export default { :class="{ 'has-upstream prepend-left-64': hasUpstream(index), 'has-only-one-job': hasOnlyOneJob(stage), - 'append-right-46': shouldAddRightMargin(index), + 'gl-mr-26': shouldAddRightMargin(index), }" :title="capitalizeStageName(stage.name)" :groups="stage.groups" @@ -180,6 +180,7 @@ export default { :is-first-column="isFirstColumn(index)" :has-triggered-by="hasTriggeredBy" :action="stage.status.action" + :job-hovered="jobName" @refreshPipelineGraph="refreshPipelineGraph" /> </ul> @@ -191,6 +192,7 @@ export default { :project-id="pipelineProjectId" graph-position="right" @linkedPipelineClick="handleClickedDownstream" + @downstreamHovered="setJob" /> <pipeline-graph diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index bfd314e0439..4d72cc55b34 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -31,6 +31,7 @@ import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; */ export default { + hoverClass: 'gl-inset-border-1-blue-500', components: { ActionComponent, JobNameComponent, @@ -55,6 +56,11 @@ export default { required: false, default: Infinity, }, + jobHovered: { + type: String, + required: false, + default: '', + }, }, computed: { boundary() { @@ -95,6 +101,11 @@ export default { hasAction() { return this.job.status && this.job.status.action && this.job.status.action.path; }, + jobClasses() { + return this.job.name === this.jobHovered + ? `${this.$options.hoverClass} ${this.cssClassJobName}` + : this.cssClassJobName; + }, }, methods: { pipelineActionRequestComplete() { @@ -120,8 +131,9 @@ export default { v-else v-gl-tooltip="{ boundary, placement: 'bottom' }" :title="tooltipText" - :class="cssClassJobName" + :class="jobClasses" class="js-job-component-tooltip non-details-job-component" + data-testid="job-without-link" > <job-name-component :name="job.name" :status="job.status" /> </div> diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue index 550b9daa521..733553e02c0 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue @@ -1,7 +1,7 @@ <script> import { GlLoadingIcon, GlTooltipDirective, GlDeprecatedButton } from '@gitlab/ui'; import CiStatus from '~/vue_shared/components/ci_icon.vue'; -import { __ } from '~/locale'; +import { __, sprintf } from '~/locale'; export default { directives: { @@ -28,7 +28,8 @@ export default { }, computed: { tooltipText() { - return `${this.projectName} - ${this.pipelineStatus.label}`; + return `${this.downstreamTitle} #${this.pipeline.id} - ${this.pipelineStatus.label} + ${this.sourceJobInfo}`; }, buttonId() { return `js-linked-pipeline-${this.pipeline.id}`; @@ -39,25 +40,32 @@ export default { projectName() { return this.pipeline.project.name; }, + downstreamTitle() { + return this.childPipeline ? __('child-pipeline') : this.pipeline.project.name; + }, parentPipeline() { // Refactor string match when BE returns Upstream/Downstream indicators return this.projectId === this.pipeline.project.id && this.columnTitle === __('Upstream'); }, childPipeline() { // Refactor string match when BE returns Upstream/Downstream indicators - return this.projectId === this.pipeline.project.id && this.columnTitle === __('Downstream'); + return this.projectId === this.pipeline.project.id && this.isDownstream; }, label() { - return this.parentPipeline ? __('Parent') : __('Child'); - }, - childTooltipText() { - return __('This pipeline was triggered by a parent pipeline'); + if (this.parentPipeline) { + return __('Parent'); + } else if (this.childPipeline) { + return __('Child'); + } + return __('Multi-project'); }, - parentTooltipText() { - return __('This pipeline triggered a child pipeline'); + isDownstream() { + return this.columnTitle === __('Downstream'); }, - labelToolTipText() { - return this.label === __('Parent') ? this.parentTooltipText : this.childTooltipText; + sourceJobInfo() { + return this.isDownstream + ? sprintf(__('Created by %{job}'), { job: this.pipeline.source_job.name }) + : ''; }, }, methods: { @@ -68,6 +76,12 @@ export default { hideTooltips() { this.$root.$emit('bv::hide::tooltip'); }, + onDownstreamHovered() { + this.$emit('downstreamHovered', this.pipeline.source_job.name); + }, + onDownstreamHoverLeave() { + this.$emit('downstreamHovered', ''); + }, }, }; </script> @@ -76,7 +90,10 @@ export default { <li ref="linkedPipeline" class="linked-pipeline build" - :class="{ 'child-pipeline': childPipeline }" + :class="{ 'downstream-pipeline': isDownstream }" + data-qa-selector="child_pipeline" + @mouseover="onDownstreamHovered" + @mouseleave="onDownstreamHoverLeave" > <gl-deprecated-button :id="buttonId" @@ -94,15 +111,9 @@ export default { css-classes="position-top-0" class="js-linked-pipeline-status" /> - <span class="str-truncated align-bottom"> {{ projectName }} • #{{ pipeline.id }} </span> - <div v-if="parentPipeline || childPipeline" class="parent-child-label-container"> - <span - v-gl-tooltip.bottom - :title="labelToolTipText" - class="badge badge-primary" - @mouseover="hideTooltips" - >{{ label }}</span - > + <span class="str-truncated"> {{ downstreamTitle }} • #{{ pipeline.id }} </span> + <div class="gl-pt-2"> + <span class="badge badge-primary" data-testid="downstream-pipeline-label">{{ label }}</span> </div> </gl-deprecated-button> </li> 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 e3429184c05..c4dfd3382a2 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue @@ -28,7 +28,7 @@ export default { columnClass() { const positionValues = { right: 'prepend-left-64', - left: 'append-right-32', + left: 'gl-mr-7', }; return `graph-position-${this.graphPosition} ${positionValues[this.graphPosition]}`; }, @@ -41,6 +41,9 @@ export default { onPipelineClick(downstreamNode, pipeline, index) { this.$emit('linkedPipelineClick', pipeline, index, downstreamNode); }, + onDownstreamHovered(jobName) { + this.$emit('downstreamHovered', jobName); + }, }, }; </script> @@ -61,6 +64,7 @@ export default { :column-title="columnTitle" :project-id="projectId" @pipelineClicked="onPipelineClick($event, pipeline, index)" + @downstreamHovered="onDownstreamHovered" /> </ul> </div> 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 bed0ed51d5f..9de6ba819c2 100644 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -36,6 +36,11 @@ export default { required: false, default: () => ({}), }, + jobHovered: { + type: String, + required: false, + default: '', + }, }, computed: { hasAction() { @@ -80,6 +85,7 @@ export default { <job-item v-if="group.size === 1" :job="group.jobs[0]" + :job-hovered="jobHovered" css-class-job-name="build-content" @pipelineActionRequestComplete="pipelineActionRequestComplete" /> diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index e7777d0d3af..dff642161db 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -108,7 +108,7 @@ export default { /> </ci-header> - <gl-loading-icon v-if="isLoading" size="lg" class="prepend-top-default append-bottom-default" /> + <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3 gl-mb-3" /> <gl-modal :modal-id="$options.DELETE_MODAL_ID" diff --git a/app/assets/javascripts/pipelines/components/blank_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/blank_state.vue index 6c3a4a27606..6c3a4a27606 100644 --- a/app/assets/javascripts/pipelines/components/blank_state.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/blank_state.vue diff --git a/app/assets/javascripts/pipelines/components/empty_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue index 74ada6a4d15..74ada6a4d15 100644 --- a/app/assets/javascripts/pipelines/components/empty_state.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue diff --git a/app/assets/javascripts/pipelines/components/nav_controls.vue b/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue index 4f6c9d2bd90..a66bbb7e5ba 100644 --- a/app/assets/javascripts/pipelines/components/nav_controls.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue @@ -1,6 +1,6 @@ <script> import { GlDeprecatedButton } from '@gitlab/ui'; -import LoadingButton from '../../vue_shared/components/loading_button.vue'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; export default { name: 'PipelineNavControls', diff --git a/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue index f604edd8859..f604edd8859 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue diff --git a/app/assets/javascripts/pipelines/components/pipeline_triggerer.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue index 740b54cd8e0..35fd9837b3e 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_triggerer.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue @@ -26,9 +26,9 @@ export default { :img-src="user.avatar_url" :img-size="26" :tooltip-text="user.name" - class="prepend-left-default js-pipeline-url-user" + class="gl-ml-3 js-pipeline-url-user" /> - <span v-else class="prepend-left-default js-pipeline-url-api api"> + <span v-else class="gl-ml-3 js-pipeline-url-api api"> {{ s__('Pipelines|API') }} </span> </div> diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue index 6c977b841af..2905b2ca26f 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue @@ -1,6 +1,7 @@ <script> import { GlLink, GlTooltipDirective } from '@gitlab/ui'; import { escape } from 'lodash'; +import { SCHEDULE_ORIGIN } from '../../constants'; import { __, sprintf } from '~/locale'; import popover from '~/vue_shared/directives/popover'; @@ -27,6 +28,10 @@ export default { type: Object, required: true, }, + pipelineScheduleUrl: { + type: String, + required: true, + }, autoDevopsHelpPath: { type: String, required: true, @@ -36,6 +41,9 @@ export default { user() { return this.pipeline.user; }, + isScheduled() { + return this.pipeline.source === SCHEDULE_ORIGIN; + }, popoverOptions() { return { html: true, @@ -61,16 +69,28 @@ export default { <gl-link :href="pipeline.path" class="js-pipeline-url-link js-onboarding-pipeline-item" + data-testid="pipeline-url-link" data-qa-selector="pipeline_url_link" > <span class="pipeline-id">#{{ pipeline.id }}</span> </gl-link> <div class="label-container"> + <gl-link v-if="isScheduled" :href="pipelineScheduleUrl" target="__blank"> + <span + v-gl-tooltip + :title="__('This pipeline was triggered by a schedule.')" + class="badge badge-info" + data-testid="pipeline-url-scheduled" + > + {{ __('Scheduled') }} + </span> + </gl-link> <span v-if="pipeline.flags.latest" v-gl-tooltip :title="__('Latest pipeline for the most recent commit on this branch')" class="js-pipeline-url-latest badge badge-success" + data-testid="pipeline-url-latest" > {{ __('latest') }} </span> @@ -79,6 +99,7 @@ export default { v-gl-tooltip :title="pipeline.yaml_errors" class="js-pipeline-url-yaml badge badge-danger" + data-testid="pipeline-url-yaml" > {{ __('yaml invalid') }} </span> @@ -87,6 +108,7 @@ export default { v-gl-tooltip :title="pipeline.failure_reason" class="js-pipeline-url-failure badge badge-danger" + data-testid="pipeline-url-failure" > {{ __('error') }} </span> @@ -95,10 +117,15 @@ export default { v-popover="popoverOptions" tabindex="0" class="js-pipeline-url-autodevops badge badge-info autodevops-badge" + data-testid="pipeline-url-autodevops" role="button" >{{ __('Auto DevOps') }}</gl-link > - <span v-if="pipeline.flags.stuck" class="js-pipeline-url-stuck badge badge-warning"> + <span + v-if="pipeline.flags.stuck" + class="js-pipeline-url-stuck badge badge-warning" + data-testid="pipeline-url-stuck" + > {{ __('stuck') }} </span> <span @@ -110,6 +137,7 @@ export default { ) " class="js-pipeline-url-detached badge badge-info" + data-testid="pipeline-url-detached" > {{ __('detached') }} </span> diff --git a/app/assets/javascripts/pipelines/components/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue index dbf29b0c29c..0c531650fd2 100644 --- a/app/assets/javascripts/pipelines/components/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue @@ -1,17 +1,18 @@ <script> import { isEqual } from 'lodash'; -import { __, sprintf, s__ } from '../../locale'; -import 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 { __, s__ } from '~/locale'; +import 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 { getParameterByName } from '~/lib/utils/common_utils'; +import CIPaginationMixin from '~/vue_shared/mixins/ci_pagination_api_mixin'; +import Icon from '~/vue_shared/components/icon.vue'; import PipelinesFilteredSearch from './pipelines_filtered_search.vue'; -import { validateParams } from '../utils'; -import { ANY_TRIGGER_AUTHOR, RAW_TEXT_WARNING, FILTER_TAG_IDENTIFIER } from '../constants'; +import { validateParams } from '../../utils'; +import { ANY_TRIGGER_AUTHOR, RAW_TEXT_WARNING, FILTER_TAG_IDENTIFIER } from '../../constants'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { @@ -20,6 +21,7 @@ export default { NavigationTabs, NavigationControls, PipelinesFilteredSearch, + Icon, }, mixins: [pipelinesMixin, CIPaginationMixin, glFeatureFlagsMixin()], props: { @@ -40,6 +42,11 @@ export default { type: String, required: true, }, + pipelineScheduleUrl: { + type: String, + required: false, + default: '', + }, helpPagePath: { type: String, required: true, @@ -115,8 +122,6 @@ export default { }, scopes: { all: 'all', - pending: 'pending', - running: 'running', finished: 'finished', branches: 'branches', tags: 'tags', @@ -169,13 +174,8 @@ export default { }, emptyTabMessage() { - const { scopes } = this.$options; - const possibleScopes = [scopes.pending, scopes.running, scopes.finished]; - - if (possibleScopes.includes(this.scope)) { - return sprintf(s__('Pipelines|There are currently no %{scope} pipelines.'), { - scope: this.scope, - }); + if (this.scope === this.$options.scopes.finished) { + return s__('Pipelines|There are currently no finished pipelines.'); } return s__('Pipelines|There are currently no pipelines.'); @@ -193,21 +193,8 @@ export default { isActive: this.scope === 'all', }, { - name: __('Pending'), - scope: scopes.pending, - count: count.pending, - isActive: this.scope === 'pending', - }, - { - name: __('Running'), - scope: scopes.running, - count: count.running, - isActive: this.scope === 'running', - }, - { name: __('Finished'), scope: scopes.finished, - count: count.finished, isActive: this.scope === 'finished', }, { @@ -298,8 +285,8 @@ export default { v-if="shouldRenderTabs || shouldRenderButtons" class="top-area scrolling-tabs-container inner-page-scroll-tabs" > - <div class="fade-left"><i class="fa fa-angle-left" aria-hidden="true"> </i></div> - <div class="fade-right"><i class="fa fa-angle-right" aria-hidden="true"> </i></div> + <div class="fade-left"><icon name="chevron-lg-left" :size="12" /></div> + <div class="fade-right"><icon name="chevron-lg-right" :size="12" /></div> <navigation-tabs v-if="shouldRenderTabs" @@ -358,6 +345,7 @@ export default { <div v-else-if="stateToRender === $options.stateMap.tableList" class="table-holder"> <pipelines-table-component :pipelines="state.pipelines" + :pipeline-schedule-url="pipelineScheduleUrl" :update-graph-dropdown="updateGraphDropdown" :auto-devops-help-path="autoDevopsPath" :view-type="viewType" diff --git a/app/assets/javascripts/pipelines/components/pipelines_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue index 7d4276e8d2e..3009ca7a775 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_actions.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue @@ -5,7 +5,7 @@ import flash from '~/flash'; import { s__, __, sprintf } from '~/locale'; import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; import Icon from '~/vue_shared/components/icon.vue'; -import eventHub from '../event_hub'; +import eventHub from '../../event_hub'; export default { directives: { diff --git a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue index 59c066b2683..59c066b2683 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue diff --git a/app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue index 0505a8668d1..0505a8668d1 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue diff --git a/app/assets/javascripts/pipelines/components/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue index d3ba0c97f6b..b8112149778 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue @@ -2,7 +2,7 @@ import { GlTooltipDirective } from '@gitlab/ui'; import PipelinesTableRowComponent from './pipelines_table_row.vue'; import PipelineStopModal from './pipeline_stop_modal.vue'; -import eventHub from '../event_hub'; +import eventHub from '../../event_hub'; /** * Pipelines Table Component. @@ -22,6 +22,11 @@ export default { type: Array, required: true, }, + pipelineScheduleUrl: { + type: String, + required: false, + default: '', + }, updateGraphDropdown: { type: Boolean, required: false, @@ -91,6 +96,7 @@ export default { v-for="model in pipelines" :key="model.id" :pipeline="model" + :pipeline-schedule-url="pipelineScheduleUrl" :update-graph-dropdown="updateGraphDropdown" :auto-devops-help-path="autoDevopsHelpPath" :view-type="viewType" diff --git a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue index 981914dd046..f25994a7506 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue @@ -1,16 +1,16 @@ <script> -import eventHub from '../event_hub'; +import eventHub from '../../event_hub'; import PipelinesActionsComponent from './pipelines_actions.vue'; import PipelinesArtifactsComponent from './pipelines_artifacts.vue'; -import CiBadge from '../../vue_shared/components/ci_badge_link.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 LoadingButton from '../../vue_shared/components/loading_button.vue'; -import Icon from '../../vue_shared/components/icon.vue'; -import { PIPELINES_TABLE } from '../constants'; +import CommitComponent from '~/vue_shared/components/commit.vue'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import { PIPELINES_TABLE } from '../../constants'; /** * Pipeline table row. @@ -35,6 +35,11 @@ export default { type: Object, required: true, }, + pipelineScheduleUrl: { + type: String, + required: false, + default: '', + }, updateGraphDropdown: { type: Boolean, required: false, @@ -274,7 +279,11 @@ export default { </div> </div> - <pipeline-url :pipeline="pipeline" :auto-devops-help-path="autoDevopsHelpPath" /> + <pipeline-url + :pipeline="pipeline" + :pipeline-schedule-url="pipelineScheduleUrl" + :auto-devops-help-path="autoDevopsHelpPath" + /> <pipeline-triggerer :pipeline="pipeline" /> <div class="table-section section-wrap section-20"> @@ -300,7 +309,8 @@ export default { <div v-for="(stage, index) in pipeline.details.stages" :key="index" - class="stage-container dropdown js-mini-pipeline-graph" + class="stage-container dropdown" + data-testid="widget-mini-pipeline-graph" > <pipeline-stage :type="$options.pipelinesTable" diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue index 569920a4f31..99492bd8357 100644 --- a/app/assets/javascripts/pipelines/components/stage.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue @@ -14,13 +14,13 @@ import $ from 'jquery'; import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '../../locale'; -import Flash from '../../flash'; -import axios from '../../lib/utils/axios_utils'; -import eventHub from '../event_hub'; -import Icon from '../../vue_shared/components/icon.vue'; -import JobItem from './graph/job_item.vue'; -import { PIPELINES_TABLE } from '../constants'; +import { __ } from '~/locale'; +import Flash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import eventHub from '../../event_hub'; +import Icon from '~/vue_shared/components/icon.vue'; +import JobItem from '../graph/job_item.vue'; +import { PIPELINES_TABLE } from '../../constants'; export default { components: { diff --git a/app/assets/javascripts/pipelines/components/time_ago.vue b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue index 2a23a0f6744..8a01e1fe3f5 100644 --- a/app/assets/javascripts/pipelines/components/time_ago.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue @@ -1,8 +1,8 @@ <script> import iconTimerSvg from 'icons/_icon_timer.svg'; -import '../../lib/utils/datetime_utility'; -import tooltip from '../../vue_shared/directives/tooltip'; -import timeagoMixin from '../../vue_shared/mixins/timeago'; +import '~/lib/utils/datetime_utility'; +import tooltip from '~/vue_shared/directives/tooltip'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; export default { directives: { diff --git a/app/assets/javascripts/pipelines/components/tokens/pipeline_branch_name_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue index da14bb2d308..b6eff2931d3 100644 --- a/app/assets/javascripts/pipelines/components/tokens/pipeline_branch_name_token.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue @@ -1,7 +1,7 @@ <script> import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; import Api from '~/api'; -import { FETCH_BRANCH_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../constants'; +import { FETCH_BRANCH_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../../constants'; import createFlash from '~/flash'; import { debounce } from 'lodash'; diff --git a/app/assets/javascripts/pipelines/components/tokens/pipeline_status_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_status_token.vue index dc43d94f4fd..dc43d94f4fd 100644 --- a/app/assets/javascripts/pipelines/components/tokens/pipeline_status_token.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_status_token.vue diff --git a/app/assets/javascripts/pipelines/components/tokens/pipeline_tag_name_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue index 7b209c5fa12..64de6d2a053 100644 --- a/app/assets/javascripts/pipelines/components/tokens/pipeline_tag_name_token.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue @@ -1,7 +1,7 @@ <script> import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; import Api from '~/api'; -import { FETCH_TAG_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../constants'; +import { FETCH_TAG_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../../constants'; import createFlash from '~/flash'; import { debounce } from 'lodash'; diff --git a/app/assets/javascripts/pipelines/components/tokens/pipeline_trigger_author_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue index 4062a3b11bb..b5aeb3fe9e0 100644 --- a/app/assets/javascripts/pipelines/components/tokens/pipeline_trigger_author_token.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue @@ -13,7 +13,7 @@ import { ANY_TRIGGER_AUTHOR, FETCH_AUTHOR_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY, -} from '../../constants'; +} from '../../../constants'; export default { anyTriggerAuthor: ANY_TRIGGER_AUTHOR, diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue index 06ab45adf80..8746784aa57 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue @@ -1,10 +1,9 @@ <script> -import { mapActions, mapState } from 'vuex'; +import { mapActions, mapGetters, mapState } from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; import TestSuiteTable from './test_suite_table.vue'; import TestSummary from './test_summary.vue'; import TestSummaryTable from './test_summary_table.vue'; -import store from '~/pipelines/stores/test_reports'; export default { name: 'TestReports', @@ -14,24 +13,37 @@ export default { TestSummary, TestSummaryTable, }, - store, computed: { - ...mapState(['isLoading', 'selectedSuite', 'testReports']), + ...mapState(['hasFullReport', 'isLoading', 'selectedSuiteIndex', 'testReports']), + ...mapGetters(['getSelectedSuite']), showSuite() { - return this.selectedSuite.total_count > 0; + return this.selectedSuiteIndex !== null; }, showTests() { const { test_suites: testSuites = [] } = this.testReports; return testSuites.length > 0; }, }, + created() { + this.fetchSummary(); + }, methods: { - ...mapActions(['setSelectedSuite', 'removeSelectedSuite']), + ...mapActions([ + 'fetchFullReport', + 'fetchSummary', + 'setSelectedSuiteIndex', + 'removeSelectedSuiteIndex', + ]), summaryBackClick() { - this.removeSelectedSuite(); + this.removeSelectedSuiteIndex(); }, - summaryTableRowClick(suite) { - this.setSelectedSuite(suite); + summaryTableRowClick(index) { + this.setSelectedSuiteIndex(index); + + // Fetch the full report when the user clicks to see more details + if (!this.hasFullReport) { + this.fetchFullReport(); + } }, beforeEnterTransition() { document.documentElement.style.overflowX = 'hidden'; @@ -45,7 +57,7 @@ export default { <template> <div v-if="isLoading"> - <gl-loading-icon size="lg" class="prepend-top-default js-loading-spinner" /> + <gl-loading-icon size="lg" class="gl-mt-3 js-loading-spinner" /> </div> <div @@ -59,7 +71,7 @@ export default { @after-leave="afterLeaveTransition" > <div v-if="showSuite" key="detail" class="w-100 position-absolute slide-enter-to-element"> - <test-summary :report="selectedSuite" show-back @on-back-click="summaryBackClick" /> + <test-summary :report="getSelectedSuite" show-back @on-back-click="summaryBackClick" /> <test-suite-table /> </div> @@ -73,7 +85,7 @@ export default { </div> <div v-else> - <div class="row prepend-top-default"> + <div class="row gl-mt-3"> <div class="col-12"> <p class="js-no-tests-to-show">{{ s__('TestReports|There are no tests to show.') }}</p> </div> 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 be7f27f210d..d57b1466177 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 @@ -1,8 +1,8 @@ <script> import { mapGetters } from 'vuex'; import Icon from '~/vue_shared/components/icon.vue'; -import store from '~/pipelines/stores/test_reports'; import { __ } from '~/locale'; +import { GlTooltipDirective } from '@gitlab/ui'; import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; export default { @@ -11,7 +11,9 @@ export default { Icon, SmartVirtualList, }, - store, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { heading: { type: String, @@ -32,16 +34,16 @@ export default { <template> <div> - <div class="row prepend-top-default"> + <div class="row gl-mt-3"> <div class="col-12"> <h4>{{ heading }}</h4> </div> </div> - <div v-if="hasSuites" class="test-reports-table append-bottom-default js-test-cases-table"> + <div v-if="hasSuites" class="test-reports-table gl-mb-3 js-test-cases-table"> <div role="row" class="gl-responsive-table-row table-row-header font-weight-bold fgray"> <div role="rowheader" class="table-section section-20"> - {{ __('Class') }} + {{ __('Suite') }} </div> <div role="rowheader" class="table-section section-20"> {{ __('Name') }} @@ -68,13 +70,25 @@ export default { class="gl-responsive-table-row rounded align-items-md-start mt-xs-3 js-case-row" > <div class="table-section section-20 section-wrap"> - <div role="rowheader" class="table-mobile-header">{{ __('Class') }}</div> - <div class="table-mobile-content pr-md-1 text-truncate">{{ testCase.classname }}</div> + <div role="rowheader" class="table-mobile-header">{{ __('Suite') }}</div> + <div + v-gl-tooltip + :title="testCase.classname" + class="table-mobile-content pr-md-1 text-truncate" + > + {{ testCase.classname }} + </div> </div> <div class="table-section section-20 section-wrap"> <div role="rowheader" class="table-mobile-header">{{ __('Name') }}</div> - <div class="table-mobile-content pr-md-1 text-truncate">{{ testCase.name }}</div> + <div + v-gl-tooltip + :title="testCase.name" + class="table-mobile-content pr-md-1 text-truncate" + > + {{ testCase.name }} + </div> </div> <div class="table-section section-10 section-wrap"> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue index 67646c537bd..712ac5eb0e5 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue @@ -72,7 +72,7 @@ export default { <gl-deprecated-button v-if="showBack" size="sm" - class="append-right-default js-back-button" + class="gl-mr-3 js-back-button" @click="onBackClick" > <icon name="angle-left" /> @@ -85,7 +85,7 @@ export default { <div class="row mt-2"> <div class="col-4 col-md"> <span class="js-total-tests">{{ - sprintf(s__('TestReports|%{count} jobs'), { count: report.total_count }) + sprintf(s__('TestReports|%{count} tests'), { count: report.total_count }) }}</span> </div> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue index 4dfb67dd8e8..6cfb795595d 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue @@ -2,7 +2,6 @@ import { mapGetters } from 'vuex'; import { s__ } from '~/locale'; import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import store from '~/pipelines/stores/test_reports'; import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; export default { @@ -14,12 +13,11 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - store, props: { heading: { type: String, required: false, - default: s__('TestReports|Test suites'), + default: s__('TestReports|Jobs'), }, }, computed: { @@ -29,8 +27,8 @@ export default { }, }, methods: { - tableRowClick(suite) { - this.$emit('row-click', suite); + tableRowClick(index) { + this.$emit('row-click', index); }, }, maxShownRows: 20, @@ -40,16 +38,16 @@ export default { <template> <div> - <div class="row prepend-top-default"> + <div class="row gl-mt-3"> <div class="col-12"> <h4>{{ heading }}</h4> </div> </div> - <div v-if="hasSuites" class="test-reports-table append-bottom-default js-test-suites-table"> + <div v-if="hasSuites" class="test-reports-table gl-mb-3 js-test-suites-table"> <div role="row" class="gl-responsive-table-row table-row-header font-weight-bold"> <div role="rowheader" class="table-section section-25 pl-3"> - {{ __('Suite') }} + {{ __('Job') }} </div> <div role="rowheader" class="table-section section-25"> {{ __('Duration') }} @@ -84,7 +82,7 @@ export default { :class="{ 'gl-responsive-table-row-clickable cursor-pointer': !testSuite.suite_error, }" - @click="tableRowClick(testSuite)" + @click="tableRowClick(index)" > <div class="table-section section-25"> <div role="rowheader" class="table-mobile-header font-weight-bold"> diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js index c709f329728..abe5e1060c8 100644 --- a/app/assets/javascripts/pipelines/constants.js +++ b/app/assets/javascripts/pipelines/constants.js @@ -7,6 +7,7 @@ export const FILTER_PIPELINES_SEARCH_DELAY = 200; export const ANY_TRIGGER_AUTHOR = 'Any'; export const SUPPORTED_FILTER_PARAMETERS = ['username', 'ref', 'status']; export const FILTER_TAG_IDENTIFIER = 'tag'; +export const SCHEDULE_ORIGIN = 'schedule'; export const TestStatus = { FAILED: 'failed', diff --git a/app/assets/javascripts/pipelines/mixins/pipelines.js b/app/assets/javascripts/pipelines/mixins/pipelines.js index 876b30299fb..7710a96e5fb 100644 --- a/app/assets/javascripts/pipelines/mixins/pipelines.js +++ b/app/assets/javascripts/pipelines/mixins/pipelines.js @@ -1,11 +1,11 @@ import Visibility from 'visibilityjs'; import { GlLoadingIcon } from '@gitlab/ui'; -import { __ } from '../../locale'; -import createFlash from '../../flash'; -import Poll from '../../lib/utils/poll'; -import EmptyState from '../components/empty_state.vue'; -import SvgBlankState from '../components/blank_state.vue'; -import PipelinesTableComponent from '../components/pipelines_table.vue'; +import { __ } from '~/locale'; +import createFlash from '~/flash'; +import Poll from '~/lib/utils/poll'; +import EmptyState from '../components/pipelines_list/empty_state.vue'; +import SvgBlankState from '../components/pipelines_list/blank_state.vue'; +import PipelinesTableComponent from '../components/pipelines_list/pipelines_table.vue'; import eventHub from '../event_hub'; import { CANCEL_REQUEST } from '../constants'; diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index 90109598542..f1102a9bddf 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -10,8 +10,7 @@ import PipelinesMediator from './pipeline_details_mediator'; import pipelineHeader from './components/header_component.vue'; import eventHub from './event_hub'; import TestReports from './components/test_reports/test_reports.vue'; -import testReportsStore from './stores/test_reports'; -import axios from '~/lib/utils/axios_utils'; +import createTestReportsStore from './stores/test_reports'; Vue.use(Translate); @@ -93,15 +92,11 @@ const createPipelineHeaderApp = mediator => { }); }; -const createPipelinesTabs = dataset => { +const createPipelinesTabs = testReportsStore => { const tabsElement = document.querySelector('.pipelines-tabs'); - const testReportsEnabled = - window.gon && window.gon.features && window.gon.features.junitPipelineView; - - if (tabsElement && testReportsEnabled) { - const fetchReportsAction = 'fetchReports'; - testReportsStore.dispatch('setEndpoint', dataset.testReportEndpoint); + if (tabsElement) { + const fetchReportsAction = 'fetchFullReport'; const isTestTabActive = Boolean( document.querySelector('.pipelines-tabs > li > a.test-tab.active'), ); @@ -121,28 +116,35 @@ const createPipelinesTabs = dataset => { } }; -const createTestDetails = detailsEndpoint => { +const createTestDetails = () => { + if (!window.gon?.features?.junitPipelineView) { + return; + } + + const el = document.querySelector('#js-pipeline-tests-detail'); + const { fullReportEndpoint, summaryEndpoint, countEndpoint } = el?.dataset || {}; + + const testReportsStore = createTestReportsStore({ + fullReportEndpoint, + summaryEndpoint: summaryEndpoint || countEndpoint, + useBuildSummaryReport: window.gon?.features?.buildReportSummary, + }); + + if (!window.gon?.features?.buildReportSummary) { + createPipelinesTabs(testReportsStore); + } + // eslint-disable-next-line no-new new Vue({ - el: '#js-pipeline-tests-detail', + el, components: { TestReports, }, + store: testReportsStore, render(createElement) { return createElement('test-reports'); }, }); - - axios - .get(detailsEndpoint) - .then(({ data }) => { - if (!data.total_count) { - return; - } - - document.querySelector('.js-test-report-badge-counter').innerHTML = data.total_count; - }) - .catch(() => {}); }; const createDagApp = () => { @@ -151,7 +153,8 @@ const createDagApp = () => { } const el = document.querySelector('#js-pipeline-dag-vue'); - const graphUrl = el?.dataset?.pipelineDataPath; + const { pipelineDataPath, emptySvgPath, dagDocPath } = el?.dataset; + // eslint-disable-next-line no-new new Vue({ el, @@ -161,7 +164,9 @@ const createDagApp = () => { render(createElement) { return createElement('dag', { props: { - graphUrl, + graphUrl: pipelineDataPath, + emptySvgPath, + dagDocPath, }, }); }, @@ -175,7 +180,6 @@ export default () => { createPipelinesDetailApp(mediator); createPipelineHeaderApp(mediator); - createPipelinesTabs(dataset); - createTestDetails(dataset.testReportsCountEndpoint); + createTestDetails(); createDagApp(); }; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/actions.js b/app/assets/javascripts/pipelines/stores/test_reports/actions.js index 71d875c1a83..ccacb9f7e97 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/actions.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/actions.js @@ -3,17 +3,42 @@ import * as types from './mutation_types'; import createFlash from '~/flash'; import { s__ } from '~/locale'; -export const setEndpoint = ({ commit }, data) => commit(types.SET_ENDPOINT, data); +export const fetchSummary = ({ state, commit, dispatch }) => { + // If we do this without the build_report_summary feature flag enabled + // it causes a race condition for toggleLoading and ruins the loading + // state in the application + if (state.useBuildSummaryReport) { + dispatch('toggleLoading'); + } -export const fetchReports = ({ state, commit, dispatch }) => { + return axios + .get(state.summaryEndpoint) + .then(({ data }) => { + commit(types.SET_SUMMARY, data); + + if (!state.useBuildSummaryReport) { + // Set the tab counter badge to total_count + // This is temporary until we can server-side render that count number + // (see https://gitlab.com/gitlab-org/gitlab/-/issues/223134) + document.querySelector('.js-test-report-badge-counter').innerHTML = data.total_count || 0; + } + }) + .catch(() => { + createFlash(s__('TestReports|There was an error fetching the summary.')); + }) + .finally(() => { + if (state.useBuildSummaryReport) { + dispatch('toggleLoading'); + } + }); +}; + +export const fetchFullReport = ({ state, commit, dispatch }) => { dispatch('toggleLoading'); return axios - .get(state.endpoint) - .then(response => { - const { data } = response; - commit(types.SET_REPORTS, data); - }) + .get(state.fullReportEndpoint) + .then(({ data }) => commit(types.SET_REPORTS, data)) .catch(() => { createFlash(s__('TestReports|There was an error fetching the test reports.')); }) @@ -22,8 +47,10 @@ export const fetchReports = ({ state, commit, dispatch }) => { }); }; -export const setSelectedSuite = ({ commit }, data) => commit(types.SET_SELECTED_SUITE, data); -export const removeSelectedSuite = ({ commit }) => commit(types.SET_SELECTED_SUITE, {}); +export const setSelectedSuiteIndex = ({ commit }, data) => + commit(types.SET_SELECTED_SUITE_INDEX, data); +export const removeSelectedSuiteIndex = ({ commit }) => + commit(types.SET_SELECTED_SUITE_INDEX, null); export const toggleLoading = ({ commit }) => commit(types.TOGGLE_LOADING); // prevent babel-plugin-rewire from generating an invalid default during karma tests diff --git a/app/assets/javascripts/pipelines/stores/test_reports/getters.js b/app/assets/javascripts/pipelines/stores/test_reports/getters.js index 788c1d32987..877762b77c9 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/getters.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/getters.js @@ -9,14 +9,12 @@ export const getTestSuites = state => { })); }; -export const getSuiteTests = state => { - const { selectedSuite } = state; - - if (selectedSuite.test_cases) { - return selectedSuite.test_cases.sort(sortTestCases).map(addIconStatus); - } +export const getSelectedSuite = state => + state.testReports?.test_suites?.[state.selectedSuiteIndex] || {}; - return []; +export const getSuiteTests = state => { + const { test_cases: testCases = [] } = getSelectedSuite(state); + return testCases.sort(sortTestCases).map(addIconStatus); }; // prevent babel-plugin-rewire from generating an invalid default during karma tests diff --git a/app/assets/javascripts/pipelines/stores/test_reports/index.js b/app/assets/javascripts/pipelines/stores/test_reports/index.js index 318dff5bcb2..88f61b09025 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/index.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/index.js @@ -7,9 +7,10 @@ import mutations from './mutations'; Vue.use(Vuex); -export default new Vuex.Store({ - actions, - getters, - mutations, - state, -}); +export default initialState => + new Vuex.Store({ + actions, + getters, + mutations, + state: state(initialState), + }); diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js b/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js index 832e45cf7a1..76405557b51 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js @@ -1,4 +1,4 @@ -export const SET_ENDPOINT = 'SET_ENDPOINT'; export const SET_REPORTS = 'SET_REPORTS'; -export const SET_SELECTED_SUITE = 'SET_SELECTED_SUITE'; +export const SET_SELECTED_SUITE_INDEX = 'SET_SELECTED_SUITE_INDEX'; +export const SET_SUMMARY = 'SET_SUMMARY'; export const TOGGLE_LOADING = 'TOGGLE_LOADING'; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutations.js b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js index 349e6ec0469..2531ab1e87c 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/mutations.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js @@ -1,16 +1,16 @@ import * as types from './mutation_types'; export default { - [types.SET_ENDPOINT](state, endpoint) { - Object.assign(state, { endpoint }); + [types.SET_REPORTS](state, testReports) { + Object.assign(state, { testReports, hasFullReport: true }); }, - [types.SET_REPORTS](state, testReports) { - Object.assign(state, { testReports }); + [types.SET_SELECTED_SUITE_INDEX](state, selectedSuiteIndex) { + Object.assign(state, { selectedSuiteIndex }); }, - [types.SET_SELECTED_SUITE](state, selectedSuite) { - Object.assign(state, { selectedSuite }); + [types.SET_SUMMARY](state, summary) { + Object.assign(state, { testReports: { ...state.testReports, ...summary } }); }, [types.TOGGLE_LOADING](state) { diff --git a/app/assets/javascripts/pipelines/stores/test_reports/state.js b/app/assets/javascripts/pipelines/stores/test_reports/state.js index 80a0c2a46a0..bcf5c147916 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/state.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/state.js @@ -1,6 +1,13 @@ -export default () => ({ - endpoint: '', +export default ({ + fullReportEndpoint = '', + summaryEndpoint = '', + useBuildSummaryReport = false, +}) => ({ + summaryEndpoint, + fullReportEndpoint, testReports: {}, - selectedSuite: {}, + selectedSuiteIndex: null, + hasFullReport: false, isLoading: false, + useBuildSummaryReport, }); diff --git a/app/assets/javascripts/projects/commits/store/actions.js b/app/assets/javascripts/projects/commits/store/actions.js index 0a52a92ae9d..f0832bd36a5 100644 --- a/app/assets/javascripts/projects/commits/store/actions.js +++ b/app/assets/javascripts/projects/commits/store/actions.js @@ -18,7 +18,7 @@ export default { fetchAuthors({ dispatch, state }, author = null) { const { projectId } = state; return axios - .get(joinPaths(gon.relative_url_root || '', '/autocomplete/users.json'), { + .get(joinPaths(gon.relative_url_root || '', '/-/autocomplete/users.json'), { params: { project_id: projectId, active: true, diff --git a/app/assets/javascripts/projects/components/remove_modal.vue b/app/assets/javascripts/projects/components/remove_modal.vue new file mode 100644 index 00000000000..37f58efcb30 --- /dev/null +++ b/app/assets/javascripts/projects/components/remove_modal.vue @@ -0,0 +1,108 @@ +<script> +import { GlModal, GlModalDirective, GlSprintf, GlFormInput, GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { rstrip } from '~/lib/utils/common_utils'; +import csrf from '~/lib/utils/csrf'; + +export default { + components: { + GlModal, + GlSprintf, + GlFormInput, + GlButton, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + confirmPhrase: { + type: String, + required: true, + }, + warningMessage: { + type: String, + required: true, + }, + formPath: { + type: String, + required: true, + }, + }, + data() { + return { + userInput: null, + }; + }, + computed: { + buttonDisabled() { + return rstrip(this.userInput) !== this.confirmPhrase; + }, + csrfToken() { + return csrf.token; + }, + }, + methods: { + submitForm() { + this.$refs.form.submit(); + }, + }, + strings: { + removeProject: __('Remove project'), + title: __('Confirmation required'), + confirm: __('Confirm'), + dataLoss: __( + 'This action can lead to data loss. To prevent accidental actions we ask you to confirm your intention.', + ), + confirmText: __('Please type %{phrase_code} to proceed or close this modal to cancel.'), + }, + modalId: 'remove-project-modal', +}; +</script> + +<template> + <form ref="form" :action="formPath" method="post"> + <input type="hidden" name="_method" value="delete" /> + <input :value="csrfToken" type="hidden" name="authenticity_token" /> + <gl-button v-gl-modal="$options.modalId" category="primary" variant="danger">{{ + $options.strings.removeProject + }}</gl-button> + <gl-modal + ref="removeModal" + :modal-id="$options.modalId" + size="sm" + ok-variant="danger" + footer-class="bg-gray-light gl-p-5" + > + <template #modal-title>{{ $options.strings.title }}</template> + <template #modal-footer> + <div class="gl-w-full gl-display-flex gl-just-content-start gl-m-0"> + <gl-button + :disabled="buttonDisabled" + category="primary" + variant="danger" + @click="submitForm" + > + {{ $options.strings.confirm }} + </gl-button> + </div> + </template> + <div> + <p class="gl-text-red-500 gl-font-weight-bold">{{ warningMessage }}</p> + <p class="gl-mb-0">{{ $options.strings.dataLoss }}</p> + <p> + <gl-sprintf :message="$options.strings.confirmText"> + <template #phrase_code> + <code>{{ confirmPhrase }}</code> + </template> + </gl-sprintf> + </p> + <gl-form-input + id="confirm_name_input" + v-model="userInput" + name="confirm_name_input" + type="text" + /> + </div> + </gl-modal> + </form> +</template> diff --git a/app/assets/javascripts/projects/pipelines/charts/components/pipelines_area_chart.vue b/app/assets/javascripts/projects/pipelines/charts/components/pipelines_area_chart.vue index d701f238a2e..d726196aadf 100644 --- a/app/assets/javascripts/projects/pipelines/charts/components/pipelines_area_chart.vue +++ b/app/assets/javascripts/projects/pipelines/charts/components/pipelines_area_chart.vue @@ -28,7 +28,7 @@ export default { }; </script> <template> - <div class="prepend-top-default"> + <div class="gl-mt-3"> <p> <slot></slot> </p> diff --git a/app/assets/javascripts/projects/project_remove_modal.js b/app/assets/javascripts/projects/project_remove_modal.js new file mode 100644 index 00000000000..dbdad1bf6f1 --- /dev/null +++ b/app/assets/javascripts/projects/project_remove_modal.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import RemoveProjectModal from './components/remove_modal.vue'; + +export default (selector = '#js-confirm-project-remove') => { + const el = document.querySelector(selector); + + if (!el) return; + + const { formPath, confirmPhrase, warningMessage } = el.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el, + render(createElement) { + return createElement(RemoveProjectModal, { + props: { + confirmPhrase, + warningMessage, + formPath, + }, + }); + }, + }); +}; 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 new file mode 100644 index 00000000000..d61569fcd6e --- /dev/null +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue @@ -0,0 +1,160 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import { __ } from '~/locale'; +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: { + initialIsEnabled: { + type: Boolean, + required: true, + }, + endpoint: { + type: String, + required: true, + }, + initialIncomingEmail: { + type: String, + required: false, + default: '', + }, + selectedTemplate: { + type: String, + required: false, + default: '', + }, + outgoingName: { + type: String, + required: false, + default: '', + }, + projectKey: { + type: String, + required: false, + default: '', + }, + templates: { + type: Array, + required: false, + default: () => [], + }, + }, + + data() { + return { + isEnabled: this.initialIsEnabled, + incomingEmail: this.initialIncomingEmail, + isTemplateSaving: false, + isAlertShowing: false, + alertVariant: 'danger', + alertMessage: '', + }; + }, + + created() { + eventHub.$on('serviceDeskEnabledCheckboxToggled', this.onEnableToggled); + eventHub.$on('serviceDeskTemplateSave', this.onSaveTemplate); + + this.service = new ServiceDeskService(this.endpoint); + + if (this.isEnabled && !this.incomingEmail) { + this.fetchIncomingEmail(); + } + }, + + beforeDestroy() { + eventHub.$off('serviceDeskEnabledCheckboxToggled', this.onEnableToggled); + eventHub.$off('serviceDeskTemplateSave', this.onSaveTemplate); + }, + + methods: { + fetchIncomingEmail() { + this.service + .fetchIncomingEmail() + .then(({ data }) => { + const email = data.service_desk_address; + if (!email) { + throw new Error(__("Response didn't include `service_desk_address`")); + } + + this.incomingEmail = email; + }) + .catch(() => + this.showAlert(__('An error occurred while fetching the Service Desk address.')), + ); + }, + + onEnableToggled(isChecked) { + this.isEnabled = isChecked; + this.incomingEmail = ''; + + this.service + .toggleServiceDesk(isChecked) + .then(({ data }) => { + const email = data.service_desk_address; + if (isChecked && !email) { + throw new Error(__("Response didn't include `service_desk_address`")); + } + + this.incomingEmail = email; + }) + .catch(() => { + const message = isChecked + ? __('An error occurred while enabling Service Desk.') + : __('An error occurred while disabling Service Desk.'); + + this.showAlert(message); + }); + }, + + onSaveTemplate({ selectedTemplate, outgoingName, projectKey }) { + this.isTemplateSaving = true; + this.service + .updateTemplate({ selectedTemplate, outgoingName, projectKey }, this.isEnabled) + .then(() => this.showAlert(__('Template was successfully saved.'), 'success')) + .catch(() => + this.showAlert( + __('An error occurred while saving the template. Please check if the template exists.'), + ), + ) + .finally(() => { + this.isTemplateSaving = false; + }); + }, + + showAlert(message, variant = 'danger') { + this.isAlertShowing = true; + this.alertMessage = message; + this.alertVariant = variant; + }, + + onDismiss() { + this.isAlertShowing = false; + }, + }, +}; +</script> + +<template> + <div> + <gl-alert v-if="isAlertShowing" class="mb-3" :variant="alertVariant" @dismiss="onDismiss"> + {{ alertMessage }} + </gl-alert> + <service-desk-setting + :is-enabled="isEnabled" + :incoming-email="incomingEmail" + :initial-selected-template="selectedTemplate" + :initial-outgoing-name="outgoingName" + :initial-project-key="projectKey" + :templates="templates" + :is-template-saving="isTemplateSaving" + /> + </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 new file mode 100644 index 00000000000..43c20fea43e --- /dev/null +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue @@ -0,0 +1,169 @@ +<script> +import { GlDeprecatedButton, GlFormSelect, GlToggle, GlLoadingIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; +import tooltip from '~/vue_shared/directives/tooltip'; +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', + directives: { + tooltip, + }, + components: { + ClipboardButton, + GlDeprecatedButton, + GlFormSelect, + GlToggle, + GlLoadingIcon, + }, + mixins: [glFeatureFlagsMixin()], + props: { + isEnabled: { + type: Boolean, + required: true, + }, + incomingEmail: { + type: String, + required: false, + default: '', + }, + initialSelectedTemplate: { + type: String, + required: false, + default: '', + }, + initialOutgoingName: { + type: String, + required: false, + default: '', + }, + initialProjectKey: { + type: String, + required: false, + default: '', + }, + templates: { + type: Array, + required: false, + default: () => [], + }, + isTemplateSaving: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + selectedTemplate: this.initialSelectedTemplate, + outgoingName: this.initialOutgoingName || __('GitLab Support Bot'), + projectKey: this.initialProjectKey, + }; + }, + computed: { + templateOptions() { + return [''].concat(this.templates); + }, + hasProjectKeySupport() { + return Boolean(this.glFeatures.serviceDeskCustomAddress); + }, + }, + methods: { + onCheckboxToggle(isChecked) { + eventHub.$emit('serviceDeskEnabledCheckboxToggled', isChecked); + }, + onSaveTemplate() { + eventHub.$emit('serviceDeskTemplateSave', { + selectedTemplate: this.selectedTemplate, + outgoingName: this.outgoingName, + projectKey: this.projectKey, + }); + }, + }, +}; +</script> + +<template> + <div> + <gl-toggle + id="service-desk-checkbox" + :value="isEnabled" + class="d-inline-block align-middle mr-1" + label="Service desk" + label-position="left" + @change="onCheckboxToggle" + /> + <label class="align-middle" for="service-desk-checkbox"> + {{ __('Activate Service Desk') }} + </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"> + {{ __('Forward external support email address to') }} + </strong> + <template v-if="incomingEmail"> + <div class="input-group"> + <input + ref="service-desk-incoming-email" + type="text" + class="form-control incoming-email h-auto" + :placeholder="__('Incoming email')" + :aria-label="__('Incoming email')" + aria-describedby="incoming-email-describer" + :value="incomingEmail" + disabled="true" + /> + <div class="input-group-append"> + <clipboard-button + :title="__('Copy')" + :text="incomingEmail" + css-class="btn qa-clipboard-button" + /> + </div> + </div> + </template> + <template v-else> + <gl-loading-icon :inline="true" /> + <span class="sr-only">{{ __('Fetching incoming email') }}</span> + </template> + + <label for="service-desk-template-select" class="mt-3"> + {{ __('Template to append to all Service Desk issues') }} + </label> + <gl-form-select + id="service-desk-template-select" + v-model="selectedTemplate" + :options="templateOptions" + /> + <label for="service-desk-email-from-name" class="mt-3"> + {{ __('Email display name') }} + </label> + <input id="service-desk-email-from-name" v-model.trim="outgoingName" class="form-control" /> + <span class="form-text text-muted"> + {{ __('Emails sent from Service Desk will have this name') }} + </span> + <template v-if="hasProjectKeySupport"> + <label for="service-desk-project-suffix" class="mt-3"> + {{ __('Project name suffix') }} + </label> + <input id="service-desk-project-suffix" v-model.trim="projectKey" class="form-control" /> + <span class="form-text text-muted mb-3"> + {{ + __( + 'Project name suffix is a user-defined string which will be appended to the project path, and will form the Service Desk email address.', + ) + }} + </span> + </template> + <gl-deprecated-button + variant="success" + :disabled="isTemplateSaving" + @click="onSaveTemplate" + >{{ __('Save template') }}</gl-deprecated-button + > + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/projects/settings_service_desk/event_hub.js b/app/assets/javascripts/projects/settings_service_desk/event_hub.js new file mode 100644 index 00000000000..e31806ad199 --- /dev/null +++ b/app/assets/javascripts/projects/settings_service_desk/event_hub.js @@ -0,0 +1,3 @@ +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 new file mode 100644 index 00000000000..15c077de72e --- /dev/null +++ b/app/assets/javascripts/projects/settings_service_desk/index.js @@ -0,0 +1,41 @@ +import Vue from 'vue'; +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, + 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, + initialIncomingEmail: this.incomingEmail, + selectedTemplate: this.selectedTemplate, + outgoingName: this.outgoingName, + projectKey: this.projectKey, + templates: this.templates, + }, + }); + }, + }); + } +}; 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 new file mode 100644 index 00000000000..d707763c64e --- /dev/null +++ b/app/assets/javascripts/projects/settings_service_desk/services/service_desk_service.js @@ -0,0 +1,27 @@ +import axios from '~/lib/utils/axios_utils'; + +class ServiceDeskService { + constructor(endpoint) { + this.endpoint = endpoint; + } + + fetchIncomingEmail() { + return axios.get(this.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_alerts/components/reset_key.vue b/app/assets/javascripts/prometheus_alerts/components/reset_key.vue index 15b6a29e5cf..941a05583ad 100644 --- a/app/assets/javascripts/prometheus_alerts/components/reset_key.vue +++ b/app/assets/javascripts/prometheus_alerts/components/reset_key.vue @@ -41,6 +41,11 @@ export default { type: String, required: true, }, + disabled: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -88,7 +93,11 @@ export default { <div class="input-group"> <gl-form-input id="notify-url" :readonly="true" :value="notifyUrl" /> <span class="input-group-append"> - <clipboard-button :text="notifyUrl" :title="$options.copyToClipboard" /> + <clipboard-button + :text="notifyUrl" + :title="$options.copyToClipboard" + :disabled="disabled" + /> </span> </div> </gl-form-group> @@ -100,7 +109,11 @@ export default { <div class="input-group"> <gl-form-input id="authorization-key" :readonly="true" :value="authorizationKey" /> <span class="input-group-append"> - <clipboard-button :text="authorizationKey" :title="$options.copyToClipboard" /> + <clipboard-button + :text="authorizationKey" + :title="$options.copyToClipboard" + :disabled="disabled" + /> </span> </div> </gl-form-group> @@ -118,13 +131,20 @@ export default { ) }} </gl-modal> - <gl-deprecated-button v-gl-modal.authKeyModal class="js-reset-auth-key">{{ - __('Reset key') - }}</gl-deprecated-button> + <gl-deprecated-button + v-gl-modal.authKeyModal + class="js-reset-auth-key" + :disabled="disabled" + >{{ __('Reset key') }}</gl-deprecated-button + > </template> - <gl-deprecated-button v-else class="js-reset-auth-key" @click="resetKey">{{ - __('Generate key') - }}</gl-deprecated-button> + <gl-deprecated-button + v-else + :disabled="disabled" + class="js-reset-auth-key" + @click="resetKey" + >{{ __('Generate key') }}</gl-deprecated-button + > </div> </div> </template> diff --git a/app/assets/javascripts/prometheus_alerts/index.js b/app/assets/javascripts/prometheus_alerts/index.js index a42f19e5245..7efe6ed186b 100644 --- a/app/assets/javascripts/prometheus_alerts/index.js +++ b/app/assets/javascripts/prometheus_alerts/index.js @@ -8,7 +8,7 @@ export default () => { return; } - const { authorizationKey, changeKeyUrl, notifyUrl, learnMoreUrl } = el.dataset; + const { authorizationKey, changeKeyUrl, notifyUrl, learnMoreUrl, disabled } = el.dataset; // eslint-disable-next-line no-new new Vue({ @@ -20,6 +20,7 @@ export default () => { changeKeyUrl, notifyUrl, learnMoreUrl, + disabled, }, }); }, diff --git a/app/assets/javascripts/ref/components/ref_results_section.vue b/app/assets/javascripts/ref/components/ref_results_section.vue new file mode 100644 index 00000000000..32e916052c4 --- /dev/null +++ b/app/assets/javascripts/ref/components/ref_results_section.vue @@ -0,0 +1,124 @@ +<script> +import { GlNewDropdownHeader, GlNewDropdownItem, GlBadge, GlIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + name: 'RefResultsSection', + components: { + GlNewDropdownHeader, + GlNewDropdownItem, + GlBadge, + GlIcon, + }, + props: { + sectionTitle: { + type: String, + required: true, + }, + + totalCount: { + type: Number, + required: true, + }, + + /** + * An array of object that have the following properties: + * + * - name (String, required): The name of the ref that will be displayed + * - value (String, optional): The value that will be selected when the ref + * is selected. If not provided, `name` will be used as the value. + * For example, commits use the short SHA for `name` + * and long SHA for `value`. + * - subtitle (String, optional): Text to render underneath the name. + * For example, used to render the commit's title underneath its SHA. + * - default (Boolean, optional): Whether or not to render a "default" + * indicator next to the item. Used to indicate + * the project's default branch. + * + */ + items: { + type: Array, + required: true, + validator: items => Array.isArray(items) && items.every(item => item.name), + }, + + /** + * The currently selected ref. + * Used to render a check mark by the selected item. + * */ + selectedRef: { + type: String, + required: false, + default: '', + }, + + /** + * An error object that indicates that an error + * occurred while fetching items for this section + */ + error: { + type: Error, + required: false, + default: null, + }, + + /** The message to display if an error occurs */ + errorMessage: { + type: String, + required: false, + default: '', + }, + }, + computed: { + totalCountText() { + return this.totalCount > 999 ? s__('TotalRefCountIndicator|1000+') : `${this.totalCount}`; + }, + }, + methods: { + showCheck(item) { + return item.name === this.selectedRef || item.value === this.selectedRef; + }, + }, +}; +</script> + +<template> + <div> + <gl-new-dropdown-header> + <div class="gl-display-flex align-items-center" data-testid="section-header"> + <span class="gl-mr-2 gl-mb-1">{{ sectionTitle }}</span> + <gl-badge variant="neutral">{{ totalCountText }}</gl-badge> + </div> + </gl-new-dropdown-header> + <template v-if="error"> + <div class="gl-display-flex align-items-start text-danger gl-ml-4 gl-mr-4 gl-mb-3"> + <gl-icon name="error" class="gl-mr-2 gl-mt-2 gl-flex-shrink-0" /> + <span>{{ errorMessage }}</span> + </div> + </template> + <template v-else> + <gl-new-dropdown-item + v-for="item in items" + :key="item.name" + @click="$emit('selected', item.value || item.name)" + > + <div class="gl-display-flex align-items-start"> + <gl-icon + name="mobile-issue-close" + class="gl-mr-2 gl-flex-shrink-0" + :class="{ 'gl-visibility-hidden': !showCheck(item) }" + /> + + <div class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column"> + <span class="gl-font-monospace">{{ item.name }}</span> + <span class="gl-text-gray-600">{{ item.subtitle }}</span> + </div> + + <gl-badge v-if="item.default" size="sm" variant="info">{{ + s__('DefaultBranchLabel|default') + }}</gl-badge> + </div> + </gl-new-dropdown-item> + </template> + </div> +</template> diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue new file mode 100644 index 00000000000..012a391a3da --- /dev/null +++ b/app/assets/javascripts/ref/components/ref_selector.vue @@ -0,0 +1,186 @@ +<script> +import { mapActions, mapGetters, mapState } from 'vuex'; +import { + GlNewDropdown, + GlNewDropdownDivider, + GlNewDropdownHeader, + GlSearchBoxByType, + GlSprintf, + GlIcon, + GlLoadingIcon, +} from '@gitlab/ui'; +import { debounce } from 'lodash'; +import createStore from '../stores'; +import { SEARCH_DEBOUNCE_MS, DEFAULT_I18N } from '../constants'; +import RefResultsSection from './ref_results_section.vue'; + +export default { + name: 'RefSelector', + store: createStore(), + components: { + GlNewDropdown, + GlNewDropdownDivider, + GlNewDropdownHeader, + GlSearchBoxByType, + GlSprintf, + GlIcon, + GlLoadingIcon, + RefResultsSection, + }, + props: { + value: { + type: String, + required: false, + default: '', + }, + projectId: { + type: String, + required: true, + }, + translations: { + type: Object, + required: false, + default: () => ({}), + }, + }, + data() { + return { + query: '', + }; + }, + computed: { + ...mapState({ + matches: state => state.matches, + lastQuery: state => state.query, + selectedRef: state => state.selectedRef, + }), + ...mapGetters(['isLoading', 'isQueryPossiblyASha']), + i18n() { + return { + ...DEFAULT_I18N, + ...this.translations, + }; + }, + showBranchesSection() { + return Boolean(this.matches.branches.totalCount > 0 || this.matches.branches.error); + }, + showTagsSection() { + return Boolean(this.matches.tags.totalCount > 0 || this.matches.tags.error); + }, + showCommitsSection() { + return Boolean(this.matches.commits.totalCount > 0 || this.matches.commits.error); + }, + showNoResults() { + return !this.showBranchesSection && !this.showTagsSection && !this.showCommitsSection; + }, + }, + created() { + this.setProjectId(this.projectId); + this.search(this.query); + }, + methods: { + ...mapActions(['setProjectId', 'setSelectedRef', 'search']), + focusSearchBox() { + this.$refs.searchBox.$el.querySelector('input').focus(); + }, + onSearchBoxInput: debounce(function search() { + this.search(this.query); + }, SEARCH_DEBOUNCE_MS), + selectRef(ref) { + this.setSelectedRef(ref); + this.$emit('input', this.selectedRef); + }, + }, +}; +</script> + +<template> + <gl-new-dropdown class="ref-selector" @shown="focusSearchBox"> + <template slot="button-content"> + <span class="gl-flex-grow-1 gl-ml-2 gl-text-gray-600" data-testid="button-content"> + <span v-if="selectedRef" class="gl-font-monospace">{{ selectedRef }}</span> + <span v-else>{{ i18n.noRefSelected }}</span> + </span> + <gl-icon name="chevron-down" /> + </template> + + <div class="gl-display-flex gl-flex-direction-column ref-selector-dropdown-content"> + <gl-new-dropdown-header> + <span class="gl-text-center gl-display-block">{{ i18n.dropdownHeader }}</span> + </gl-new-dropdown-header> + + <gl-new-dropdown-divider /> + + <gl-search-box-by-type + ref="searchBox" + v-model.trim="query" + class="gl-m-3" + :placeholder="i18n.searchPlaceholder" + @input="onSearchBoxInput" + /> + + <div class="gl-flex-grow-1 gl-overflow-y-auto"> + <gl-loading-icon v-if="isLoading" size="lg" class="gl-my-3" /> + + <div + v-else-if="showNoResults" + class="gl-text-center gl-mx-3 gl-py-3" + data-testid="no-results" + > + <gl-sprintf v-if="lastQuery" :message="i18n.noResultsWithQuery"> + <template #query> + <b class="gl-word-break-all">{{ lastQuery }}</b> + </template> + </gl-sprintf> + + <span v-else>{{ i18n.noResults }}</span> + </div> + + <template v-else> + <template v-if="showBranchesSection"> + <ref-results-section + :section-title="i18n.branches" + :total-count="matches.branches.totalCount" + :items="matches.branches.list" + :selected-ref="selectedRef" + :error="matches.branches.error" + :error-message="i18n.branchesErrorMessage" + data-testid="branches-section" + @selected="selectRef($event)" + /> + + <gl-new-dropdown-divider v-if="showTagsSection || showCommitsSection" /> + </template> + + <template v-if="showTagsSection"> + <ref-results-section + :section-title="i18n.tags" + :total-count="matches.tags.totalCount" + :items="matches.tags.list" + :selected-ref="selectedRef" + :error="matches.tags.error" + :error-message="i18n.tagsErrorMessage" + data-testid="tags-section" + @selected="selectRef($event)" + /> + + <gl-new-dropdown-divider v-if="showCommitsSection" /> + </template> + + <template v-if="showCommitsSection"> + <ref-results-section + :section-title="i18n.commits" + :total-count="matches.commits.totalCount" + :items="matches.commits.list" + :selected-ref="selectedRef" + :error="matches.commits.error" + :error-message="i18n.commitsErrorMessage" + data-testid="commits-section" + @selected="selectRef($event)" + /> + </template> + </template> + </div> + </div> + </gl-new-dropdown> +</template> diff --git a/app/assets/javascripts/ref/constants.js b/app/assets/javascripts/ref/constants.js new file mode 100644 index 00000000000..ca82b951377 --- /dev/null +++ b/app/assets/javascripts/ref/constants.js @@ -0,0 +1,19 @@ +import { __ } from '~/locale'; + +export const X_TOTAL_HEADER = 'x-total'; + +export const SEARCH_DEBOUNCE_MS = 250; + +export const DEFAULT_I18N = Object.freeze({ + dropdownHeader: __('Select Git revision'), + searchPlaceholder: __('Search by Git revision'), + noResultsWithQuery: __('No matching results for "%{query}"'), + noResults: __('No matching results'), + branchesErrorMessage: __('An error occurred while fetching branches. Retry the search.'), + tagsErrorMessage: __('An error occurred while fetching tags. Retry the search.'), + commitsErrorMessage: __('An error occurred while fetching commits. Retry the search.'), + branches: __('Branches'), + tags: __('Tags'), + commits: __('Commits'), + noRefSelected: __('No ref selected'), +}); diff --git a/app/assets/javascripts/ref/stores/actions.js b/app/assets/javascripts/ref/stores/actions.js new file mode 100644 index 00000000000..8fcc99cef38 --- /dev/null +++ b/app/assets/javascripts/ref/stores/actions.js @@ -0,0 +1,65 @@ +import Api from '~/api'; +import * as types from './mutation_types'; + +export const setProjectId = ({ commit }, projectId) => commit(types.SET_PROJECT_ID, projectId); + +export const setSelectedRef = ({ commit }, selectedRef) => + commit(types.SET_SELECTED_REF, selectedRef); + +export const search = ({ dispatch, commit }, query) => { + commit(types.SET_QUERY, query); + + dispatch('searchBranches'); + dispatch('searchTags'); + dispatch('searchCommits'); +}; + +export const searchBranches = ({ commit, state }) => { + commit(types.REQUEST_START); + + Api.branches(state.projectId, state.query) + .then(response => { + commit(types.RECEIVE_BRANCHES_SUCCESS, response); + }) + .catch(error => { + commit(types.RECEIVE_BRANCHES_ERROR, error); + }) + .finally(() => { + commit(types.REQUEST_FINISH); + }); +}; + +export const searchTags = ({ commit, state }) => { + commit(types.REQUEST_START); + + Api.tags(state.projectId, state.query) + .then(response => { + commit(types.RECEIVE_TAGS_SUCCESS, response); + }) + .catch(error => { + commit(types.RECEIVE_TAGS_ERROR, error); + }) + .finally(() => { + commit(types.REQUEST_FINISH); + }); +}; + +export const searchCommits = ({ commit, state, getters }) => { + // Only query the Commit API if the search query looks like a commit SHA + if (getters.isQueryPossiblyASha) { + commit(types.REQUEST_START); + + Api.commit(state.projectId, state.query) + .then(response => { + commit(types.RECEIVE_COMMITS_SUCCESS, response); + }) + .catch(error => { + commit(types.RECEIVE_COMMITS_ERROR, error); + }) + .finally(() => { + commit(types.REQUEST_FINISH); + }); + } else { + commit(types.RESET_COMMIT_MATCHES); + } +}; diff --git a/app/assets/javascripts/ref/stores/getters.js b/app/assets/javascripts/ref/stores/getters.js new file mode 100644 index 00000000000..02d4ae8ff91 --- /dev/null +++ b/app/assets/javascripts/ref/stores/getters.js @@ -0,0 +1,5 @@ +/** Returns `true` if the query string looks like it could be a commit SHA */ +export const isQueryPossiblyASha = ({ query }) => /^[0-9a-f]{4,40}$/i.test(query); + +/** Returns `true` if there is at least one in-progress request */ +export const isLoading = ({ requestCount }) => requestCount > 0; diff --git a/app/assets/javascripts/ref/stores/index.js b/app/assets/javascripts/ref/stores/index.js new file mode 100644 index 00000000000..2bebffc19ab --- /dev/null +++ b/app/assets/javascripts/ref/stores/index.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; +import createState from './state'; + +Vue.use(Vuex); + +export default () => + new Vuex.Store({ + actions, + getters, + mutations, + state: createState(), + }); diff --git a/app/assets/javascripts/ref/stores/mutation_types.js b/app/assets/javascripts/ref/stores/mutation_types.js new file mode 100644 index 00000000000..9f6195f5f3f --- /dev/null +++ b/app/assets/javascripts/ref/stores/mutation_types.js @@ -0,0 +1,16 @@ +export const SET_PROJECT_ID = 'SET_PROJECT_ID'; +export const SET_SELECTED_REF = 'SET_SELECTED_REF'; +export const SET_QUERY = 'SET_QUERY'; + +export const REQUEST_START = 'REQUEST_START'; +export const REQUEST_FINISH = 'REQUEST_FINISH'; + +export const RECEIVE_BRANCHES_SUCCESS = 'RECEIVE_BRANCHES_SUCCESS'; +export const RECEIVE_BRANCHES_ERROR = 'RECEIVE_BRANCHES_ERROR'; + +export const RECEIVE_TAGS_SUCCESS = 'RECEIVE_TAGS_SUCCESS'; +export const RECEIVE_TAGS_ERROR = 'RECEIVE_TAGS_ERROR'; + +export const RECEIVE_COMMITS_SUCCESS = 'RECEIVE_COMMITS_SUCCESS'; +export const RECEIVE_COMMITS_ERROR = 'RECEIVE_COMMITS_ERROR'; +export const RESET_COMMIT_MATCHES = 'RESET_COMMIT_MATCHES'; diff --git a/app/assets/javascripts/ref/stores/mutations.js b/app/assets/javascripts/ref/stores/mutations.js new file mode 100644 index 00000000000..73f9d7ee487 --- /dev/null +++ b/app/assets/javascripts/ref/stores/mutations.js @@ -0,0 +1,91 @@ +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'; + +export default { + [types.SET_PROJECT_ID](state, projectId) { + state.projectId = projectId; + }, + [types.SET_SELECTED_REF](state, selectedRef) { + state.selectedRef = selectedRef; + }, + [types.SET_QUERY](state, query) { + state.query = query; + }, + + [types.REQUEST_START](state) { + state.requestCount += 1; + }, + [types.REQUEST_FINISH](state) { + state.requestCount -= 1; + }, + + [types.RECEIVE_BRANCHES_SUCCESS](state, response) { + state.matches.branches = { + list: convertObjectPropsToCamelCase(response.data).map(b => ({ + name: b.name, + default: b.default, + })), + totalCount: parseInt(response.headers[X_TOTAL_HEADER], 10), + error: null, + }; + }, + [types.RECEIVE_BRANCHES_ERROR](state, error) { + state.matches.branches = { + list: [], + totalCount: 0, + error, + }; + }, + + [types.RECEIVE_TAGS_SUCCESS](state, response) { + state.matches.tags = { + list: convertObjectPropsToCamelCase(response.data).map(b => ({ + name: b.name, + })), + totalCount: parseInt(response.headers[X_TOTAL_HEADER], 10), + error: null, + }; + }, + [types.RECEIVE_TAGS_ERROR](state, error) { + state.matches.tags = { + list: [], + totalCount: 0, + error, + }; + }, + + [types.RECEIVE_COMMITS_SUCCESS](state, response) { + const commit = convertObjectPropsToCamelCase(response.data); + + state.matches.commits = { + list: [ + { + name: commit.shortId, + value: commit.id, + subtitle: commit.title, + }, + ], + totalCount: 1, + error: null, + }; + }, + [types.RECEIVE_COMMITS_ERROR](state, error) { + state.matches.commits = { + list: [], + totalCount: 0, + + // 404's are expected when the search query doesn't match any commits + // and shouldn't be treated as an actual error + error: error.response?.status !== httpStatusCodes.NOT_FOUND ? error : null, + }; + }, + [types.RESET_COMMIT_MATCHES](state) { + state.matches.commits = { + list: [], + totalCount: 0, + error: null, + }; + }, +}; diff --git a/app/assets/javascripts/ref/stores/state.js b/app/assets/javascripts/ref/stores/state.js new file mode 100644 index 00000000000..65b9d6449d7 --- /dev/null +++ b/app/assets/javascripts/ref/stores/state.js @@ -0,0 +1,24 @@ +export default () => ({ + projectId: null, + + query: '', + matches: { + branches: { + list: [], + totalCount: 0, + error: null, + }, + tags: { + list: [], + totalCount: 0, + error: null, + }, + commits: { + list: [], + totalCount: 0, + error: null, + }, + }, + selectedRef: null, + requestCount: 0, +}); diff --git a/app/assets/javascripts/registry/explorer/components/delete_button.vue b/app/assets/javascripts/registry/explorer/components/delete_button.vue new file mode 100644 index 00000000000..dab6a26ea16 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/delete_button.vue @@ -0,0 +1,56 @@ +<script> +import { GlTooltipDirective, GlButton } from '@gitlab/ui'; + +export default { + name: 'DeleteButton', + components: { + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + title: { + type: String, + required: true, + }, + tooltipTitle: { + type: String, + required: true, + }, + disabled: { + type: Boolean, + default: false, + required: false, + }, + tooltipDisabled: { + type: Boolean, + default: false, + required: false, + }, + }, + computed: { + tooltipConfiguration() { + return { + disabled: this.tooltipDisabled, + title: this.tooltipTitle, + }; + }, + }, +}; +</script> + +<template> + <div v-gl-tooltip="tooltipConfiguration"> + <gl-button + v-gl-tooltip + :disabled="disabled" + :title="title" + :aria-label="title" + category="secondary" + variant="danger" + icon="remove" + @click="$emit('delete')" + /> + </div> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/details_row.vue b/app/assets/javascripts/registry/explorer/components/details_page/details_row.vue new file mode 100644 index 00000000000..c4358b83e23 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/details_page/details_row.vue @@ -0,0 +1,26 @@ +<script> +import { GlIcon } from '@gitlab/ui'; + +export default { + components: { + GlIcon, + }, + props: { + icon: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div + class="gl-display-flex gl-align-items-center gl-font-monospace gl-font-sm gl-py-2 gl-word-break-all" + > + <gl-icon :name="icon" class="gl-mr-4" /> + <span> + <slot></slot> + </span> + </div> +</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 new file mode 100644 index 00000000000..8494967ab57 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue @@ -0,0 +1,77 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import TagsListRow from './tags_list_row.vue'; +import { REMOVE_TAGS_BUTTON_TITLE, TAGS_LIST_TITLE } from '../../constants/index'; + +export default { + components: { + GlButton, + TagsListRow, + }, + props: { + tags: { + type: Array, + required: false, + default: () => [], + }, + isDesktop: { + type: Boolean, + default: false, + required: false, + }, + }, + i18n: { + REMOVE_TAGS_BUTTON_TITLE, + TAGS_LIST_TITLE, + }, + data() { + return { + selectedItems: {}, + }; + }, + computed: { + hasSelectedItems() { + return this.tags.some(tag => this.selectedItems[tag.name]); + }, + showMultiDeleteButton() { + return this.tags.some(tag => tag.destroy_path) && this.isDesktop; + }, + }, + methods: { + updateSelectedItems(name) { + this.$set(this.selectedItems, name, !this.selectedItems[name]); + }, + }, +}; +</script> + +<template> + <div> + <div class="gl-display-flex gl-justify-content-space-between gl-mb-3"> + <h5 data-testid="list-title"> + {{ $options.i18n.TAGS_LIST_TITLE }} + </h5> + + <gl-button + v-if="showMultiDeleteButton" + :disabled="!hasSelectedItems" + category="secondary" + variant="danger" + @click="$emit('delete', selectedItems)" + > + {{ $options.i18n.REMOVE_TAGS_BUTTON_TITLE }} + </gl-button> + </div> + <tags-list-row + v-for="(tag, index) in tags" + :key="tag.path" + :tag="tag" + :first="index === 0" + :last="index === tags.length - 1" + :selected="selectedItems[tag.name]" + :is-desktop="isDesktop" + @select="updateSelectedItems(tag.name)" + @delete="$emit('delete', { [tag.name]: true })" + /> + </div> +</template> 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 new file mode 100644 index 00000000000..51ba2337db6 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue @@ -0,0 +1,220 @@ +<script> +import { GlFormCheckbox, GlTooltipDirective, GlSprintf, GlIcon } from '@gitlab/ui'; +import { n__ } from '~/locale'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +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 DeleteButton from '../delete_button.vue'; +import ListItem from '../list_item.vue'; +import DetailsRow from './details_row.vue'; +import { + REMOVE_TAG_BUTTON_TITLE, + DIGEST_LABEL, + CREATED_AT_LABEL, + REMOVE_TAG_BUTTON_DISABLE_TOOLTIP, + PUBLISHED_DETAILS_ROW_TEXT, + MANIFEST_DETAILS_ROW_TEST, + CONFIGURATION_DETAILS_ROW_TEST, + MISSING_MANIFEST_WARNING_TOOLTIP, + NOT_AVAILABLE_TEXT, + NOT_AVAILABLE_SIZE, +} from '../../constants/index'; + +export default { + components: { + GlSprintf, + GlFormCheckbox, + GlIcon, + DeleteButton, + ListItem, + ClipboardButton, + TimeAgoTooltip, + DetailsRow, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + tag: { + type: Object, + required: true, + }, + isDesktop: { + type: Boolean, + default: false, + required: false, + }, + selected: { + type: Boolean, + default: false, + required: false, + }, + }, + i18n: { + REMOVE_TAG_BUTTON_TITLE, + DIGEST_LABEL, + CREATED_AT_LABEL, + REMOVE_TAG_BUTTON_DISABLE_TOOLTIP, + PUBLISHED_DETAILS_ROW_TEXT, + MANIFEST_DETAILS_ROW_TEST, + CONFIGURATION_DETAILS_ROW_TEST, + MISSING_MANIFEST_WARNING_TOOLTIP, + }, + computed: { + formattedSize() { + return this.tag.total_size ? numberToHumanSize(this.tag.total_size) : NOT_AVAILABLE_SIZE; + }, + layers() { + return this.tag.layers ? n__('%d layer', '%d layers', this.tag.layers) : ''; + }, + mobileClasses() { + return this.isDesktop ? '' : 'mw-s'; + }, + shortDigest() { + // remove sha256: from the string, and show only the first 7 char + return this.tag.digest?.substring(7, 14) ?? NOT_AVAILABLE_TEXT; + }, + publishedDate() { + return formatDate(this.tag.created_at, 'isoDate'); + }, + publishedTime() { + return formatDate(this.tag.created_at, 'hh:MM Z'); + }, + formattedRevision() { + // to be removed when API response is adjusted + // see https://gitlab.com/gitlab-org/gitlab/-/issues/225324 + // eslint-disable-next-line @gitlab/require-i18n-strings + return `sha256:${this.tag.revision}`; + }, + tagLocation() { + return this.tag.path?.replace(`:${this.tag.name}`, ''); + }, + invalidTag() { + return !this.tag.digest; + }, + }, +}; +</script> + +<template> + <list-item v-bind="$attrs" :selected="selected"> + <template #left-action> + <gl-form-checkbox + v-if="Boolean(tag.destroy_path)" + :disabled="invalidTag" + class="gl-m-0" + :checked="selected" + @change="$emit('select')" + /> + </template> + <template #left-primary> + <div class="gl-display-flex gl-align-items-center"> + <div + v-gl-tooltip="{ title: tag.name }" + data-testid="name" + class="gl-text-overflow-ellipsis gl-overflow-hidden gl-white-space-nowrap" + :class="mobileClasses" + > + {{ tag.name }} + </div> + + <clipboard-button + v-if="tag.location" + :title="tag.location" + :text="tag.location" + css-class="btn-default btn-transparent btn-clipboard" + /> + + <gl-icon + v-if="invalidTag" + v-gl-tooltip="{ title: $options.i18n.MISSING_MANIFEST_WARNING_TOOLTIP }" + name="warning" + class="gl-text-orange-500 gl-mb-2 gl-ml-2" + /> + </div> + </template> + + <template #left-secondary> + <span data-testid="size"> + {{ formattedSize }} + <template v-if="formattedSize && layers" + >·</template + > + {{ layers }} + </span> + </template> + <template #right-primary> + <span data-testid="time"> + <gl-sprintf :message="$options.i18n.CREATED_AT_LABEL"> + <template #timeInfo> + <time-ago-tooltip :time="tag.created_at" /> + </template> + </gl-sprintf> + </span> + </template> + <template #right-secondary> + <span data-testid="digest"> + <gl-sprintf :message="$options.i18n.DIGEST_LABEL"> + <template #imageId>{{ shortDigest }}</template> + </gl-sprintf> + </span> + </template> + <template #right-action> + <delete-button + :disabled="!tag.destroy_path || invalidTag" + :title="$options.i18n.REMOVE_TAG_BUTTON_TITLE" + :tooltip-title="$options.i18n.REMOVE_TAG_BUTTON_DISABLE_TOOLTIP" + :tooltip-disabled="Boolean(tag.destroy_path)" + data-testid="single-delete-button" + @delete="$emit('delete')" + /> + </template> + + <template v-if="!invalidTag" #details_published> + <details-row icon="clock" data-testid="published-date-detail"> + <gl-sprintf :message="$options.i18n.PUBLISHED_DETAILS_ROW_TEXT"> + <template #repositoryPath> + <i>{{ tagLocation }}</i> + </template> + <template #time> + {{ publishedTime }} + </template> + <template #date> + {{ publishedDate }} + </template> + </gl-sprintf> + </details-row> + </template> + <template v-if="!invalidTag" #details_manifest_digest> + <details-row icon="log" data-testid="manifest-detail"> + <gl-sprintf :message="$options.i18n.MANIFEST_DETAILS_ROW_TEST"> + <template #digest> + {{ tag.digest }} + </template> + </gl-sprintf> + <clipboard-button + v-if="tag.digest" + :title="tag.digest" + :text="tag.digest" + css-class="btn-default btn-transparent btn-clipboard gl-p-0" + /> + </details-row> + </template> + <template v-if="!invalidTag" #details_configuration_digest> + <details-row icon="cloud-gear" data-testid="configuration-detail"> + <gl-sprintf :message="$options.i18n.CONFIGURATION_DETAILS_ROW_TEST"> + <template #digest> + {{ formattedRevision }} + </template> + </gl-sprintf> + <clipboard-button + v-if="formattedRevision" + :title="formattedRevision" + :text="formattedRevision" + css-class="btn-default btn-transparent btn-clipboard gl-p-0" + /> + </details-row> + </template> + </list-item> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_table.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_table.vue deleted file mode 100644 index 81be778e1e5..00000000000 --- a/app/assets/javascripts/registry/explorer/components/details_page/tags_table.vue +++ /dev/null @@ -1,210 +0,0 @@ -<script> -import { GlTable, GlFormCheckbox, GlButton, GlTooltipDirective } from '@gitlab/ui'; -import { n__ } from '~/locale'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import { numberToHumanSize } from '~/lib/utils/number_utils'; -import timeagoMixin from '~/vue_shared/mixins/timeago'; -import { - LIST_KEY_TAG, - LIST_KEY_IMAGE_ID, - LIST_KEY_SIZE, - LIST_KEY_LAST_UPDATED, - LIST_KEY_ACTIONS, - LIST_KEY_CHECKBOX, - LIST_LABEL_TAG, - LIST_LABEL_IMAGE_ID, - LIST_LABEL_SIZE, - LIST_LABEL_LAST_UPDATED, - REMOVE_TAGS_BUTTON_TITLE, - REMOVE_TAG_BUTTON_TITLE, -} from '../../constants/index'; - -export default { - components: { - GlTable, - GlFormCheckbox, - GlButton, - ClipboardButton, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - mixins: [timeagoMixin], - props: { - tags: { - type: Array, - required: false, - default: () => [], - }, - isLoading: { - type: Boolean, - required: false, - default: false, - }, - isDesktop: { - type: Boolean, - required: false, - default: false, - }, - }, - i18n: { - REMOVE_TAGS_BUTTON_TITLE, - REMOVE_TAG_BUTTON_TITLE, - }, - data() { - return { - selectedItems: [], - }; - }, - computed: { - fields() { - const tagClass = this.isDesktop ? 'w-25' : ''; - const tagInnerClass = this.isDesktop ? 'mw-m' : 'gl-justify-content-end'; - return [ - { key: LIST_KEY_CHECKBOX, label: '', class: 'gl-w-16' }, - { - key: LIST_KEY_TAG, - label: LIST_LABEL_TAG, - class: `${tagClass} js-tag-column`, - innerClass: tagInnerClass, - }, - { key: LIST_KEY_IMAGE_ID, label: LIST_LABEL_IMAGE_ID }, - { key: LIST_KEY_SIZE, label: LIST_LABEL_SIZE }, - { key: LIST_KEY_LAST_UPDATED, label: LIST_LABEL_LAST_UPDATED }, - { key: LIST_KEY_ACTIONS, label: '' }, - ].filter(f => f.key !== LIST_KEY_CHECKBOX || this.isDesktop); - }, - tagsNames() { - return this.tags.map(t => t.name); - }, - selectAllChecked() { - return this.selectedItems.length === this.tags.length && this.tags.length > 0; - }, - }, - watch: { - tagsNames: { - immediate: false, - handler(tagsNames) { - this.selectedItems = this.selectedItems.filter(t => tagsNames.includes(t)); - }, - }, - }, - methods: { - formatSize(size) { - return numberToHumanSize(size); - }, - layers(layers) { - return layers ? n__('%d layer', '%d layers', layers) : ''; - }, - onSelectAllChange() { - if (this.selectAllChecked) { - this.selectedItems = []; - } else { - this.selectedItems = this.tags.map(x => x.name); - } - }, - updateSelectedItems(name) { - const delIndex = this.selectedItems.findIndex(x => x === name); - - if (delIndex > -1) { - this.selectedItems.splice(delIndex, 1); - } else { - this.selectedItems.push(name); - } - }, - }, -}; -</script> - -<template> - <gl-table :items="tags" :fields="fields" :stacked="!isDesktop" show-empty :busy="isLoading"> - <template v-if="isDesktop" #head(checkbox)> - <gl-form-checkbox - data-testid="mainCheckbox" - :checked="selectAllChecked" - @change="onSelectAllChange" - /> - </template> - <template #head(actions)> - <span class="gl-display-flex gl-justify-content-end"> - <gl-button - v-gl-tooltip - data-testid="bulkDeleteButton" - :disabled="!selectedItems || selectedItems.length === 0" - icon="remove" - variant="danger" - :title="$options.i18n.REMOVE_TAGS_BUTTON_TITLE" - :aria-label="$options.i18n.REMOVE_TAGS_BUTTON_TITLE" - @click="$emit('delete', selectedItems)" - /> - </span> - </template> - - <template #cell(checkbox)="{item}"> - <gl-form-checkbox - data-testid="rowCheckbox" - :checked="selectedItems.includes(item.name)" - @change="updateSelectedItems(item.name)" - /> - </template> - <template #cell(name)="{item, field}"> - <div data-testid="rowName" :class="[field.innerClass, 'gl-display-flex']"> - <span - v-gl-tooltip - data-testid="rowNameText" - :title="item.name" - class="gl-text-overflow-ellipsis gl-overflow-hidden gl-white-space-nowrap" - > - {{ item.name }} - </span> - <clipboard-button - v-if="item.location" - data-testid="rowClipboardButton" - :title="item.location" - :text="item.location" - css-class="btn-default btn-transparent btn-clipboard" - /> - </div> - </template> - <template #cell(short_revision)="{value}"> - <span data-testid="rowShortRevision"> - {{ value }} - </span> - </template> - <template #cell(total_size)="{item}"> - <span data-testid="rowSize"> - {{ formatSize(item.total_size) }} - <template v-if="item.total_size && item.layers"> - · - </template> - {{ layers(item.layers) }} - </span> - </template> - <template #cell(created_at)="{value}"> - <span v-gl-tooltip data-testid="rowTime" :title="tooltipTitle(value)"> - {{ timeFormatted(value) }} - </span> - </template> - <template #cell(actions)="{item}"> - <span class="gl-display-flex gl-justify-content-end"> - <gl-button - data-testid="singleDeleteButton" - :title="$options.i18n.REMOVE_TAG_BUTTON_TITLE" - :aria-label="$options.i18n.REMOVE_TAG_BUTTON_TITLE" - :disabled="!item.destroy_path" - variant="danger" - icon="remove" - category="secondary" - @click="$emit('delete', [item.name])" - /> - </span> - </template> - - <template #empty> - <slot name="empty"></slot> - </template> - <template #table-busy> - <slot name="loader"></slot> - </template> - </gl-table> -</template> diff --git a/app/assets/javascripts/registry/explorer/components/list_item.vue b/app/assets/javascripts/registry/explorer/components/list_item.vue new file mode 100644 index 00000000000..7b5afe8fd9d --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/list_item.vue @@ -0,0 +1,128 @@ +<script> +import { GlButton } from '@gitlab/ui'; + +export default { + name: 'ListItem', + components: { GlButton }, + props: { + first: { + type: Boolean, + default: false, + required: false, + }, + last: { + type: Boolean, + default: false, + required: false, + }, + disabled: { + type: Boolean, + default: false, + required: false, + }, + selected: { + type: Boolean, + default: false, + required: false, + }, + }, + data() { + return { + isDetailsShown: false, + detailsSlots: [], + }; + }, + computed: { + optionalClasses() { + return { + 'gl-border-t-1': !this.first, + 'gl-border-t-2': this.first, + 'gl-border-b-1': !this.last, + 'gl-border-b-2': this.last, + 'disabled-content': this.disabled, + 'gl-border-gray-100': !this.selected, + 'gl-bg-blue-50 gl-border-blue-200': this.selected, + }; + }, + }, + mounted() { + this.detailsSlots = Object.keys(this.$slots).filter(k => k.startsWith('details_')); + }, + methods: { + toggleDetails() { + this.isDetailsShown = !this.isDetailsShown; + }, + }, +}; +</script> + +<template> + <div + class="gl-display-flex gl-flex-direction-column gl-border-b-solid gl-border-t-solid" + :class="optionalClasses" + > + <div class="gl-display-flex gl-align-items-center gl-py-4 gl-px-2"> + <div + v-if="$slots['left-action']" + class="gl-w-7 gl-display-none gl-display-sm-flex gl-justify-content-start gl-pl-2" + > + <slot name="left-action"></slot> + </div> + <div class="gl-display-flex gl-flex-direction-column gl-flex-fill-1"> + <div + class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-text-black-normal gl-font-weight-bold" + > + <div class="gl-display-flex gl-align-items-center"> + <slot name="left-primary"></slot> + <gl-button + v-if="detailsSlots.length > 0" + :selected="isDetailsShown" + icon="ellipsis_h" + size="small" + class="gl-ml-2 gl-display-none gl-display-sm-block" + @click="toggleDetails" + /> + </div> + <div> + <slot name="right-primary"></slot> + </div> + </div> + <div + class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-font-sm gl-text-gray-500" + > + <div> + <slot name="left-secondary"></slot> + </div> + <div> + <slot name="right-secondary"></slot> + </div> + </div> + </div> + <div + v-if="$slots['right-action']" + class="gl-w-9 gl-display-none gl-display-sm-flex gl-justify-content-end gl-pr-2" + > + <slot name="right-action"></slot> + </div> + </div> + <div class="gl-display-flex"> + <div class="gl-w-7"></div> + <div + v-if="isDetailsShown" + class="gl-display-flex gl-flex-direction-column gl-flex-fill-1 gl-bg-gray-10 gl-rounded-base gl-inset-border-1-gray-100 gl-mb-3" + > + <div + v-for="(row, detailIndex) in detailsSlots" + :key="detailIndex" + class="gl-px-5 gl-py-2" + :class="{ + 'gl-border-gray-100 gl-border-t-solid gl-border-t-1': detailIndex !== 0, + }" + > + <slot :name="row"></slot> + </div> + </div> + <div class="gl-w-9"></div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue b/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue index a29a9bd23c3..80cc392f86a 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue @@ -18,10 +18,9 @@ export default { <gl-empty-state :title="s__('ContainerRegistry|There are no container images available in this group')" :svg-path="config.noContainersImage" - class="container-message" > <template #description> - <p class="js-no-container-images-text"> + <p> <gl-sprintf :message=" s__( diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue b/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue index 9d48769cbad..65cf51fd1d1 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue @@ -37,7 +37,8 @@ export default { v-for="(listItem, index) in images" :key="index" :item="listItem" - :show-top-border="index === 0" + :first="index === 0" + :last="index === images.length - 1" @delete="$emit('delete', $event)" /> diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue index cd878c38081..2874d89d913 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue @@ -1,7 +1,9 @@ <script> -import { GlTooltipDirective, GlButton, GlIcon, GlSprintf } from '@gitlab/ui'; +import { GlTooltipDirective, GlIcon, GlSprintf } from '@gitlab/ui'; import { n__ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import ListItem from '../list_item.vue'; +import DeleteButton from '../delete_button.vue'; import { ASYNC_DELETE_IMAGE_ERROR_MESSAGE, @@ -14,9 +16,10 @@ export default { name: 'ImageListrow', components: { ClipboardButton, - GlButton, + DeleteButton, GlSprintf, GlIcon, + ListItem, }, directives: { GlTooltip: GlTooltipDirective, @@ -26,11 +29,6 @@ export default { type: Object, required: true, }, - showTopBorder: { - type: Boolean, - default: false, - required: false, - }, }, i18n: { LIST_DELETE_BUTTON_DISABLED, @@ -62,75 +60,55 @@ export default { </script> <template> - <div + <list-item v-gl-tooltip="{ placement: 'left', disabled: !item.deleting, title: $options.i18n.ROW_SCHEDULED_FOR_DELETION, }" + v-bind="$attrs" + :disabled="item.deleting" > - <div - class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-py-2 gl-px-1 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-py-4 " - :class="{ - 'gl-border-t-solid gl-border-t-1': showTopBorder, - 'disabled-content': item.deleting, - }" - > - <div class="gl-display-flex gl-flex-direction-column"> - <div class="gl-display-flex gl-align-items-center"> - <router-link - class="gl-text-black-normal gl-font-weight-bold" - data-testid="detailsLink" - :to="{ name: 'details', params: { id: encodedItem } }" - > - {{ item.path }} - </router-link> - <clipboard-button - v-if="item.location" - :disabled="item.deleting" - :text="item.location" - :title="item.location" - css-class="btn-default btn-transparent btn-clipboard gl-text-gray-500" - /> - <gl-icon - v-if="item.failedDelete" - v-gl-tooltip - :title="$options.i18n.ASYNC_DELETE_IMAGE_ERROR_MESSAGE" - name="warning" - class="text-warning" - /> - </div> - <div class="gl-font-sm gl-text-gray-500"> - <span class="gl-display-flex gl-align-items-center" data-testid="tagsCount"> - <gl-icon name="tag" class="gl-mr-2" /> - <gl-sprintf :message="tagsCountText"> - <template #count> - {{ item.tags_count }} - </template> - </gl-sprintf> - </span> - </div> - </div> - <div - v-gl-tooltip="{ - disabled: item.destroy_path, - title: $options.i18n.LIST_DELETE_BUTTON_DISABLED, - }" - class="d-none d-sm-block" - data-testid="deleteButtonWrapper" + <template #left-primary> + <router-link + class="gl-text-black-normal gl-font-weight-bold" + data-testid="detailsLink" + :to="{ name: 'details', params: { id: encodedItem } }" > - <gl-button - v-gl-tooltip - data-testid="deleteImageButton" - :disabled="disabledDelete" - :title="$options.i18n.REMOVE_REPOSITORY_LABEL" - :aria-label="$options.i18n.REMOVE_REPOSITORY_LABEL" - category="secondary" - variant="danger" - icon="remove" - @click="$emit('delete', item)" - /> - </div> - </div> - </div> + {{ item.path }} + </router-link> + <clipboard-button + v-if="item.location" + :disabled="item.deleting" + :text="item.location" + :title="item.location" + css-class="btn-default btn-transparent btn-clipboard gl-text-gray-500" + /> + <gl-icon + v-if="item.failedDelete" + v-gl-tooltip="{ title: $options.i18n.ASYNC_DELETE_IMAGE_ERROR_MESSAGE }" + name="warning" + class="gl-text-orange-500" + /> + </template> + <template #left-secondary> + <span class="gl-display-flex gl-align-items-center" data-testid="tagsCount"> + <gl-icon name="tag" class="gl-mr-2" /> + <gl-sprintf :message="tagsCountText"> + <template #count> + {{ item.tags_count }} + </template> + </gl-sprintf> + </span> + </template> + <template #right-action> + <delete-button + :title="$options.i18n.REMOVE_REPOSITORY_LABEL" + :disabled="disabledDelete" + :tooltip-disabled="Boolean(item.destroy_path)" + :tooltip-title="$options.i18n.LIST_DELETE_BUTTON_DISABLED" + @delete="$emit('delete', item)" + /> + </template> + </list-item> </template> diff --git a/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue b/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue index c27d53f4351..35eb0b11e40 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue @@ -1,5 +1,5 @@ <script> -import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui'; +import { GlEmptyState, GlSprintf, GlLink, GlFormInputGroup, GlFormInput } from '@gitlab/ui'; import { mapState, mapGetters } from 'vuex'; import { s__ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; @@ -17,6 +17,8 @@ export default { GlEmptyState, GlSprintf, GlLink, + GlFormInputGroup, + GlFormInput, }, i18n: { quickStart: QUICK_START, @@ -43,10 +45,9 @@ export default { <gl-empty-state :title="s__('ContainerRegistry|There are no container images stored for this project')" :svg-path="config.noContainersImage" - class="container-message" > <template #description> - <p class="js-no-container-images-text"> + <p> <gl-sprintf :message="$options.i18n.introText"> <template #docLink="{content}"> <gl-link :href="config.helpPagePath" target="_blank">{{ content }}</gl-link> @@ -54,7 +55,7 @@ export default { </gl-sprintf> </p> <h5>{{ $options.i18n.quickStart }}</h5> - <p class="js-not-logged-in-to-registry-text"> + <p> <gl-sprintf :message="$options.i18n.notLoggedInMessage"> <template #twofaDocLink="{content}"> <gl-link :href="config.twoFactorAuthHelpLink" target="_blank">{{ content }}</gl-link> @@ -66,42 +67,49 @@ export default { </template> </gl-sprintf> </p> - <div class="input-group append-bottom-10"> - <input :value="dockerLoginCommand" type="text" class="form-control monospace" readonly /> - <span class="input-group-append"> + <gl-form-input-group class="gl-mb-4"> + <gl-form-input + :value="dockerLoginCommand" + readonly + type="text" + class="gl-font-monospace!" + /> + <template #append> <clipboard-button :text="dockerLoginCommand" :title="$options.i18n.copyLoginTitle" - class="input-group-text" + class="gl-m-0!" /> - </span> - </div> - <p></p> - <p> + </template> + </gl-form-input-group> + <p class="gl-mb-4"> {{ $options.i18n.addImageText }} </p> - - <div class="input-group append-bottom-10"> - <input :value="dockerBuildCommand" type="text" class="form-control monospace" readonly /> - <span class="input-group-append"> + <gl-form-input-group class="gl-mb-4 "> + <gl-form-input + :value="dockerBuildCommand" + readonly + type="text" + class="gl-font-monospace!" + /> + <template #append> <clipboard-button :text="dockerBuildCommand" :title="$options.i18n.copyBuildTitle" - class="input-group-text" + class="gl-m-0!" /> - </span> - </div> - - <div class="input-group"> - <input :value="dockerPushCommand" type="text" class="form-control monospace" readonly /> - <span class="input-group-append"> + </template> + </gl-form-input-group> + <gl-form-input-group> + <gl-form-input :value="dockerPushCommand" readonly type="text" class="gl-font-monospace!" /> + <template #append> <clipboard-button :text="dockerPushCommand" :title="$options.i18n.copyPushTitle" - class="input-group-text" + class="gl-m-0!" /> - </span> - </div> + </template> + </gl-form-input-group> </template> </gl-empty-state> </template> diff --git a/app/assets/javascripts/registry/explorer/constants/details.js b/app/assets/javascripts/registry/explorer/constants/details.js index a1fa995c17f..1dc5882d415 100644 --- a/app/assets/javascripts/registry/explorer/constants/details.js +++ b/app/assets/javascripts/registry/explorer/constants/details.js @@ -1,4 +1,4 @@ -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; // Translations strings export const DETAILS_PAGE_TITLE = s__('ContainerRegistry|%{imageName} tags'); @@ -14,12 +14,20 @@ export const DELETE_TAGS_ERROR_MESSAGE = s__( export const DELETE_TAGS_SUCCESS_MESSAGE = s__( 'ContainerRegistry|Tags successfully marked for deletion.', ); -export const LIST_LABEL_TAG = s__('ContainerRegistry|Tag'); -export const LIST_LABEL_IMAGE_ID = s__('ContainerRegistry|Image ID'); -export const LIST_LABEL_SIZE = s__('ContainerRegistry|Compressed Size'); -export const LIST_LABEL_LAST_UPDATED = s__('ContainerRegistry|Last Updated'); + +export const TAGS_LIST_TITLE = s__('ContainerRegistry|Image tags'); +export const DIGEST_LABEL = s__('ContainerRegistry|Digest: %{imageId}'); +export const CREATED_AT_LABEL = s__('ContainerRegistry|Published %{timeInfo}'); +export const PUBLISHED_DETAILS_ROW_TEXT = s__( + 'ContainerRegistry|Published to the %{repositoryPath} image repository at %{time} on %{date}', +); +export const MANIFEST_DETAILS_ROW_TEST = s__('ContainerRegistry|Manifest digest: %{digest}'); +export const CONFIGURATION_DETAILS_ROW_TEST = s__( + 'ContainerRegistry|Configuration digest: %{digest}', +); + export const REMOVE_TAG_BUTTON_TITLE = s__('ContainerRegistry|Remove tag'); -export const REMOVE_TAGS_BUTTON_TITLE = s__('ContainerRegistry|Remove selected tags'); +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?`, ); @@ -36,17 +44,21 @@ export const ADMIN_GARBAGE_COLLECTION_TIP = s__( 'ContainerRegistry|Remember to run %{docLinkStart}garbage collection%{docLinkEnd} to remove the stale data from storage.', ); +export const REMOVE_TAG_BUTTON_DISABLE_TOOLTIP = s__( + 'ContainerRegistry|Deletion disabled due to missing or insufficient permissions.', +); + +export const MISSING_MANIFEST_WARNING_TOOLTIP = s__( + 'ContainerRegistry|Invalid tag: missing manifest digest', +); + +export const NOT_AVAILABLE_TEXT = __('N/A'); +export const NOT_AVAILABLE_SIZE = __('0 bytes'); // Parameters export const DEFAULT_PAGE = 1; export const DEFAULT_PAGE_SIZE = 10; export const GROUP_PAGE_TYPE = 'groups'; -export const LIST_KEY_TAG = 'name'; -export const LIST_KEY_IMAGE_ID = 'short_revision'; -export const LIST_KEY_SIZE = 'total_size'; -export const LIST_KEY_LAST_UPDATED = 'created_at'; -export const LIST_KEY_ACTIONS = 'actions'; -export const LIST_KEY_CHECKBOX = 'checkbox'; export const ALERT_SUCCESS_TAG = 'success_tag'; export const ALERT_DANGER_TAG = 'danger_tag'; export const ALERT_SUCCESS_TAGS = 'success_tags'; diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue index 598e643ce1a..cf811156704 100644 --- a/app/assets/javascripts/registry/explorer/pages/details.vue +++ b/app/assets/javascripts/registry/explorer/pages/details.vue @@ -6,7 +6,7 @@ import Tracking from '~/tracking'; import DeleteAlert from '../components/details_page/delete_alert.vue'; import DeleteModal from '../components/details_page/delete_modal.vue'; import DetailsHeader from '../components/details_page/details_header.vue'; -import TagsTable from '../components/details_page/tags_table.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'; @@ -24,7 +24,7 @@ export default { DetailsHeader, GlPagination, DeleteModal, - TagsTable, + TagsList, TagsLoader, EmptyTagsState, }, @@ -65,10 +65,8 @@ export default { }, methods: { ...mapActions(['requestTagsList', 'requestDeleteTag', 'requestDeleteTags']), - deleteTags(toBeDeletedList) { - this.itemsToBeDeleted = toBeDeletedList.map(name => ({ - ...this.tags.find(t => t.name === name), - })); + deleteTags(toBeDeleted) { + this.itemsToBeDeleted = this.tags.filter(tag => toBeDeleted[tag.name]); this.track('click_button'); this.$refs.deleteModal.show(); }, @@ -114,24 +112,21 @@ export default { </script> <template> - <div v-gl-resize-observer="handleResize" class="my-3 w-100 slide-enter-to-element"> + <div v-gl-resize-observer="handleResize" class="gl-my-3 gl-w-full slide-enter-to-element"> <delete-alert v-model="deleteAlertType" :garbage-collection-help-page-path="config.garbageCollectionHelpPagePath" :is-admin="config.isAdmin" - class="my-2" + class="gl-my-2" /> <details-header :image-name="imageName" /> - <tags-table :tags="tags" :is-loading="isLoading" :is-desktop="isDesktop" @delete="deleteTags"> - <template #empty> - <empty-tags-state :no-containers-image="config.noContainersImage" /> - </template> - <template #loader> - <tags-loader v-once /> - </template> - </tags-table> + <tags-loader v-if="isLoading" /> + <template v-else> + <empty-tags-state v-if="tags.length === 0" :no-containers-image="config.noContainersImage" /> + <tags-list v-else :tags="tags" :is-desktop="isDesktop" @delete="deleteTags" /> + </template> <gl-pagination v-if="!isLoading" @@ -140,7 +135,7 @@ export default { :per-page="tagsPagination.perPage" :total-items="tagsPagination.total" align="center" - class="w-100" + class="gl-w-full gl-mt-3" /> <delete-modal diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue index e8a26dc58f2..1d353651c38 100644 --- a/app/assets/javascripts/registry/explorer/pages/list.vue +++ b/app/assets/javascripts/registry/explorer/pages/list.vue @@ -217,7 +217,6 @@ export default { :svg-path="config.noContainersImage" data-testid="emptySearch" :title="$options.i18n.EMPTY_RESULT_TITLE" - class="container-message" > <template #description> {{ $options.i18n.EMPTY_RESULT_MESSAGE }} diff --git a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue index b4a59fd0178..2ee7bbef4c6 100644 --- a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue +++ b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue @@ -1,11 +1,16 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; -import { s__ } from '~/locale'; import { FETCH_SETTINGS_ERROR_MESSAGE } from '../../shared/constants'; import SettingsForm from './settings_form.vue'; +import { + UNAVAILABLE_FEATURE_TITLE, + UNAVAILABLE_FEATURE_INTRO_TEXT, + UNAVAILABLE_USER_FEATURE_TEXT, + UNAVAILABLE_ADMIN_FEATURE_TEXT, +} from '../constants'; export default { components: { @@ -15,17 +20,9 @@ export default { GlLink, }, i18n: { - unavailableFeatureTitle: s__( - `ContainerRegistry|Container Registry tag expiration and retention policy is disabled`, - ), - unavailableFeatureIntroText: s__( - `ContainerRegistry|The Container Registry tag expiration and retention policies for this project have not been enabled.`, - ), - unavailableUserFeatureText: s__(`ContainerRegistry|Please contact your administrator.`), - unavailableAdminFeatureText: s__( - `ContainerRegistry| Please visit the %{linkStart}administration settings%{linkEnd} to enable this feature.`, - ), - fetchSettingsErrorText: FETCH_SETTINGS_ERROR_MESSAGE, + UNAVAILABLE_FEATURE_TITLE, + UNAVAILABLE_FEATURE_INTRO_TEXT, + FETCH_SETTINGS_ERROR_MESSAGE, }, data() { return { @@ -42,9 +39,7 @@ export default { return this.isDisabled && !this.fetchSettingsError; }, unavailableFeatureMessage() { - return this.isAdmin - ? this.$options.i18n.unavailableAdminFeatureText - : this.$options.i18n.unavailableUserFeatureText; + return this.isAdmin ? UNAVAILABLE_ADMIN_FEATURE_TEXT : UNAVAILABLE_USER_FEATURE_TEXT; }, }, mounted() { @@ -60,39 +55,24 @@ export default { <template> <div> - <p> - {{ s__('ContainerRegistry|Tag expiration policy is designed to:') }} - </p> - <ul> - <li>{{ s__('ContainerRegistry|Keep and protect the images that matter most.') }}</li> - <li> - {{ - s__( - "ContainerRegistry|Automatically remove extra images that aren't designed to be kept.", - ) - }} - </li> - </ul> <settings-form v-if="showSettingForm" /> <template v-else> <gl-alert v-if="showDisabledFormMessage" :dismissible="false" - :title="$options.i18n.unavailableFeatureTitle" + :title="$options.i18n.UNAVAILABLE_FEATURE_TITLE" variant="tip" > - {{ $options.i18n.unavailableFeatureIntroText }} + {{ $options.i18n.UNAVAILABLE_FEATURE_INTRO_TEXT }} <gl-sprintf :message="unavailableFeatureMessage"> <template #link="{ content }"> - <gl-link :href="adminSettingsPath" target="_blank"> - {{ content }} - </gl-link> + <gl-link :href="adminSettingsPath" target="_blank">{{ content }}</gl-link> </template> </gl-sprintf> </gl-alert> <gl-alert v-else-if="fetchSettingsError" variant="warning" :dismissible="false"> - <gl-sprintf :message="$options.i18n.fetchSettingsErrorText" /> + <gl-sprintf :message="$options.i18n.FETCH_SETTINGS_ERROR_MESSAGE" /> </gl-alert> </template> </div> diff --git a/app/assets/javascripts/registry/settings/components/settings_form.vue b/app/assets/javascripts/registry/settings/components/settings_form.vue index afd502109bf..f129922c1d2 100644 --- a/app/assets/javascripts/registry/settings/components/settings_form.vue +++ b/app/assets/javascripts/registry/settings/components/settings_form.vue @@ -1,13 +1,15 @@ <script> +import { get } from 'lodash'; import { mapActions, mapState, mapGetters } from 'vuex'; import { GlCard, GlDeprecatedButton, GlLoadingIcon } from '@gitlab/ui'; import Tracking from '~/tracking'; +import { mapComputed } from '~/vuex_shared/bindings'; import { UPDATE_SETTINGS_ERROR_MESSAGE, UPDATE_SETTINGS_SUCCESS_MESSAGE, } from '../../shared/constants'; -import { mapComputed } from '~/vuex_shared/bindings'; import ExpirationPolicyFields from '../../shared/components/expiration_policy_fields.vue'; +import { SET_CLEANUP_POLICY_BUTTON, CLEANUP_POLICY_CARD_HEADER } from '../constants'; export default { components: { @@ -21,12 +23,17 @@ export default { cols: 3, align: 'right', }, + i18n: { + CLEANUP_POLICY_CARD_HEADER, + SET_CLEANUP_POLICY_BUTTON, + }, data() { return { tracking: { label: 'docker_container_retention_and_expiration_policies', }, - formIsValid: true, + fieldsAreValid: true, + apiErrors: null, }; }, computed: { @@ -34,7 +41,7 @@ export default { ...mapGetters({ isEdited: 'getIsEdited' }), ...mapComputed([{ key: 'settings', getter: 'getSettings' }], 'updateSettings'), isSubmitButtonDisabled() { - return !this.formIsValid || this.isLoading; + return !this.fieldsAreValid || this.isLoading; }, isCancelButtonDisabled() { return !this.isEdited || this.isLoading; @@ -44,13 +51,35 @@ export default { ...mapActions(['resetSettings', 'saveSettings']), reset() { this.track('reset_form'); + this.apiErrors = null; this.resetSettings(); }, + setApiErrors(response) { + const messages = get(response, 'data.message', []); + + this.apiErrors = Object.keys(messages).reduce((acc, curr) => { + if (curr.startsWith('container_expiration_policy.')) { + const key = curr.replace('container_expiration_policy.', ''); + acc[key] = get(messages, [curr, 0], ''); + } + return acc; + }, {}); + }, submit() { this.track('submit_form'); + this.apiErrors = null; this.saveSettings() .then(() => this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE, { type: 'success' })) - .catch(() => this.$toast.show(UPDATE_SETTINGS_ERROR_MESSAGE, { type: 'error' })); + .catch(({ response }) => { + this.setApiErrors(response); + this.$toast.show(UPDATE_SETTINGS_ERROR_MESSAGE, { type: 'error' }); + }); + }, + onModelChange(changePayload) { + this.settings = changePayload.newValue; + if (this.apiErrors) { + this.apiErrors[changePayload.modified] = undefined; + } }, }, }; @@ -60,23 +89,25 @@ export default { <form ref="form-element" @submit.prevent="submit" @reset.prevent="reset"> <gl-card> <template #header> - {{ s__('ContainerRegistry|Tag expiration policy') }} + {{ $options.i18n.CLEANUP_POLICY_CARD_HEADER }} </template> <template #default> <expiration-policy-fields - v-model="settings" + :value="settings" :form-options="formOptions" :is-loading="isLoading" - @validated="formIsValid = true" - @invalidated="formIsValid = false" + :api-errors="apiErrors" + @validated="fieldsAreValid = true" + @invalidated="fieldsAreValid = false" + @input="onModelChange" /> </template> <template #footer> - <div class="d-flex justify-content-end"> + <div class="gl-display-flex gl-justify-content-end"> <gl-deprecated-button ref="cancel-button" type="reset" - class="mr-2 d-block" + class="gl-mr-3 gl-display-block" :disabled="isCancelButtonDisabled" > {{ __('Cancel') }} @@ -86,10 +117,10 @@ export default { type="submit" :disabled="isSubmitButtonDisabled" variant="success" - class="d-flex justify-content-center align-items-center js-no-auto-disable" + class="gl-display-flex gl-justify-content-center gl-align-items-center js-no-auto-disable" > - {{ __('Save expiration policy') }} - <gl-loading-icon v-if="isLoading" class="ml-2" /> + {{ $options.i18n.SET_CLEANUP_POLICY_BUTTON }} + <gl-loading-icon v-if="isLoading" class="gl-ml-3" /> </gl-deprecated-button> </div> </template> diff --git a/app/assets/javascripts/registry/settings/constants.js b/app/assets/javascripts/registry/settings/constants.js new file mode 100644 index 00000000000..e790658f491 --- /dev/null +++ b/app/assets/javascripts/registry/settings/constants.js @@ -0,0 +1,14 @@ +import { s__, __ } from '~/locale'; + +export const SET_CLEANUP_POLICY_BUTTON = s__('ContainerRegistry|Set cleanup policy'); +export const CLEANUP_POLICY_CARD_HEADER = s__('ContainerRegistry|Tag expiration policy'); +export const UNAVAILABLE_FEATURE_TITLE = s__( + `ContainerRegistry|Cleanup policy for tags is disabled`, +); +export const UNAVAILABLE_FEATURE_INTRO_TEXT = s__( + `ContainerRegistry|This project's cleanup policy for tags is not enabled.`, +); +export const UNAVAILABLE_USER_FEATURE_TEXT = __(`Please contact your administrator.`); +export const UNAVAILABLE_ADMIN_FEATURE_TEXT = s__( + `ContainerRegistry| Please visit the %{linkStart}administration settings%{linkEnd} to enable this feature.`, +); diff --git a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue index 04a547db07e..1ff2f6f99e5 100644 --- a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue +++ b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue @@ -34,6 +34,11 @@ export default { required: false, default: () => ({}), }, + apiErrors: { + type: Object, + required: false, + default: null, + }, isLoading: { type: Boolean, required: false, @@ -56,9 +61,8 @@ export default { }, }, i18n: { - textAreaInvalidFeedback: TEXT_AREA_INVALID_FEEDBACK, - enableToggleLabel: ENABLE_TOGGLE_LABEL, - enableToggleDescription: ENABLE_TOGGLE_DESCRIPTION, + ENABLE_TOGGLE_LABEL, + ENABLE_TOGGLE_DESCRIPTION, }, selectList: [ { @@ -86,7 +90,6 @@ export default { label: NAME_REGEX_LABEL, model: 'name_regex', placeholder: NAME_REGEX_PLACEHOLDER, - stateVariable: 'nameRegexState', description: NAME_REGEX_DESCRIPTION, }, { @@ -94,7 +97,6 @@ export default { label: NAME_REGEX_KEEP_LABEL, model: 'name_regex_keep', placeholder: NAME_REGEX_KEEP_PLACEHOLDER, - stateVariable: 'nameKeepRegexState', description: NAME_REGEX_KEEP_DESCRIPTION, }, ], @@ -111,16 +113,34 @@ export default { policyEnabledText() { return this.enabled ? ENABLED_TEXT : DISABLED_TEXT; }, - textAreaState() { + textAreaValidation() { + const nameRegexErrors = + this.apiErrors?.name_regex || this.validateRegexLength(this.name_regex); + const nameKeepRegexErrors = + this.apiErrors?.name_regex_keep || this.validateRegexLength(this.name_regex_keep); + return { - nameRegexState: this.validateNameRegex(this.name_regex), - nameKeepRegexState: this.validateNameRegex(this.name_regex_keep), + /* + * The state has this form: + * null: gray border, no message + * true: green border, no message ( because none is configured) + * false: red border, error message + * So in this function we keep null if the are no message otherwise we 'invert' the error message + */ + name_regex: { + state: nameRegexErrors === null ? null : !nameRegexErrors, + message: nameRegexErrors, + }, + name_regex_keep: { + state: nameKeepRegexErrors === null ? null : !nameKeepRegexErrors, + message: nameKeepRegexErrors, + }, }; }, fieldsValidity() { return ( - this.textAreaState.nameRegexState !== false && - this.textAreaState.nameKeepRegexState !== false + this.textAreaValidation.name_regex.state !== false && + this.textAreaValidation.name_regex_keep.state !== false ); }, isFormElementDisabled() { @@ -140,8 +160,11 @@ export default { }, }, methods: { - validateNameRegex(value) { - return value ? value.length <= NAME_REGEX_LENGTH : null; + validateRegexLength(value) { + if (!value) { + return null; + } + return value.length <= NAME_REGEX_LENGTH ? '' : TEXT_AREA_INVALID_FEEDBACK; }, idGenerator(id) { return `${id}_${this.uniqueId}`; @@ -154,22 +177,22 @@ export default { </script> <template> - <div ref="form-elements" class="lh-2"> + <div ref="form-elements" class="gl-line-height-20"> <gl-form-group :id="idGenerator('expiration-policy-toggle-group')" :label-cols="labelCols" :label-align="labelAlign" :label-for="idGenerator('expiration-policy-toggle')" - :label="$options.i18n.enableToggleLabel" + :label="$options.i18n.ENABLE_TOGGLE_LABEL" > - <div class="d-flex align-items-start"> + <div class="gl-display-flex"> <gl-toggle :id="idGenerator('expiration-policy-toggle')" v-model="enabled" :disabled="isLoading" /> - <span class="mb-2 ml-1 lh-2"> - <gl-sprintf :message="$options.i18n.enableToggleDescription"> + <span class="gl-mb-3 gl-ml-3 gl-line-height-20"> + <gl-sprintf :message="$options.i18n.ENABLE_TOGGLE_DESCRIPTION"> <template #toggleStatus> <strong>{{ policyEnabledText }}</strong> </template> @@ -210,8 +233,8 @@ export default { :label-cols="labelCols" :label-align="labelAlign" :label-for="idGenerator(textarea.name)" - :state="textAreaState[textarea.stateVariable]" - :invalid-feedback="$options.i18n.textAreaInvalidFeedback" + :state="textAreaValidation[textarea.model].state" + :invalid-feedback="textAreaValidation[textarea.model].message" > <template #label> <gl-sprintf :message="textarea.label"> @@ -224,7 +247,7 @@ export default { :id="idGenerator(textarea.name)" :value="value[textarea.model]" :placeholder="textarea.placeholder" - :state="textAreaState[textarea.stateVariable]" + :state="textAreaValidation[textarea.model].state" :disabled="isFormElementDisabled" trim @input="updateModel($event, textarea.model)" diff --git a/app/assets/javascripts/registry/shared/constants.js b/app/assets/javascripts/registry/shared/constants.js index 4689d01b1c8..36d55c7610e 100644 --- a/app/assets/javascripts/registry/shared/constants.js +++ b/app/assets/javascripts/registry/shared/constants.js @@ -1,29 +1,29 @@ import { s__, __ } from '~/locale'; export const FETCH_SETTINGS_ERROR_MESSAGE = s__( - 'ContainerRegistry|Something went wrong while fetching the expiration policy.', + 'ContainerRegistry|Something went wrong while fetching the cleanup policy.', ); export const UPDATE_SETTINGS_ERROR_MESSAGE = s__( - 'ContainerRegistry|Something went wrong while updating the expiration policy.', + 'ContainerRegistry|Something went wrong while updating the cleanup policy.', ); export const UPDATE_SETTINGS_SUCCESS_MESSAGE = s__( - 'ContainerRegistry|Expiration policy successfully saved.', + 'ContainerRegistry|Cleanup policy successfully saved.', ); export const NAME_REGEX_LENGTH = 255; -export const ENABLED_TEXT = __('enabled'); -export const DISABLED_TEXT = __('disabled'); +export const ENABLED_TEXT = __('Enabled'); +export const DISABLED_TEXT = __('Disabled'); -export const ENABLE_TOGGLE_LABEL = s__('ContainerRegistry|Expiration policy:'); +export const ENABLE_TOGGLE_LABEL = s__('ContainerRegistry|Cleanup policy:'); export const ENABLE_TOGGLE_DESCRIPTION = s__( - 'ContainerRegistry|Docker tag expiration policy is %{toggleStatus}', + 'ContainerRegistry|%{toggleStatus} - Tags matching the patterns defined below will be scheduled for deletion', ); export const TEXT_AREA_INVALID_FEEDBACK = s__( - 'ContainerRegistry|The value of this input should be less than 255 characters', + 'ContainerRegistry|The value of this input should be less than 256 characters', ); export const EXPIRATION_INTERVAL_LABEL = s__('ContainerRegistry|Expiration interval:'); @@ -34,12 +34,12 @@ export const NAME_REGEX_LABEL = s__( ); export const NAME_REGEX_PLACEHOLDER = '.*'; export const NAME_REGEX_DESCRIPTION = s__( - 'ContainerRegistry|Regular expressions such as %{codeStart}.*-test%{codeEnd} or %{codeStart}dev-.*%{codeEnd} are supported. To select all tags, use %{codeStart}.*%{codeEnd}', + 'ContainerRegistry|Wildcards such as %{codeStart}.*-test%{codeEnd} or %{codeStart}dev-.*%{codeEnd} are supported. To select all tags, use %{codeStart}.*%{codeEnd}', ); export const NAME_REGEX_KEEP_LABEL = s__( 'ContainerRegistry|Tags with names matching this regex pattern will %{italicStart}be preserved:%{italicEnd}', ); export const NAME_REGEX_KEEP_PLACEHOLDER = ''; export const NAME_REGEX_KEEP_DESCRIPTION = s__( - 'ContainerRegistry|Regular expressions such as %{codeStart}.*-master%{codeEnd} or %{codeStart}release-.*%{codeEnd} are supported', + 'ContainerRegistry|Wildcards such as %{codeStart}.*-master%{codeEnd} or %{codeStart}release-.*%{codeEnd} are supported', ); diff --git a/app/assets/javascripts/registry/shared/utils.js b/app/assets/javascripts/registry/shared/utils.js index d85a3ad28c2..a7377773842 100644 --- a/app/assets/javascripts/registry/shared/utils.js +++ b/app/assets/javascripts/registry/shared/utils.js @@ -11,7 +11,7 @@ export const mapComputedToEvent = (list, root) => { return this[root][e]; }, set(value) { - this.$emit('input', { ...this[root], [e]: value }); + this.$emit('input', { newValue: { ...this[root], [e]: value }, modified: e }); }, }; }); diff --git a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue index 05803ba09ab..15e9b8559d4 100644 --- a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue +++ b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue @@ -82,7 +82,7 @@ export default { {{ __('Related merge requests') }} </span> <div v-if="totalCount" class="d-inline-flex lh-100 align-middle"> - <div class="mr-count-badge border-width-1px border-style-solid border-color-default"> + <div class="mr-count-badge gl-display-inline-flex"> <div class="mr-count-badge-count"> <svg class="s16 mr-1 text-secondary"> <icon name="merge-request" class="mr-1 text-secondary" /> diff --git a/app/assets/javascripts/releases/components/app_new.vue b/app/assets/javascripts/releases/components/app_new.vue new file mode 100644 index 00000000000..563f76b3281 --- /dev/null +++ b/app/assets/javascripts/releases/components/app_new.vue @@ -0,0 +1,9 @@ +<script> +export default { + name: 'ReleaseNewApp', + components: {}, +}; +</script> +<template> + <div></div> +</template> diff --git a/app/assets/javascripts/releases/components/app_show.vue b/app/assets/javascripts/releases/components/app_show.vue index d521edcc361..0e65d722952 100644 --- a/app/assets/javascripts/releases/components/app_show.vue +++ b/app/assets/javascripts/releases/components/app_show.vue @@ -21,7 +21,7 @@ export default { }; </script> <template> - <div class="prepend-top-default"> + <div class="gl-mt-3"> <gl-skeleton-loading v-if="isFetchingRelease" /> <release-block v-else-if="!fetchError" :release="release" /> diff --git a/app/assets/javascripts/releases/components/evidence_block.vue b/app/assets/javascripts/releases/components/evidence_block.vue index 2cc15777343..6468e2ded62 100644 --- a/app/assets/javascripts/releases/components/evidence_block.vue +++ b/app/assets/javascripts/releases/components/evidence_block.vue @@ -59,7 +59,7 @@ export default { <template> <div> - <div class="card-text prepend-top-default"> + <div class="card-text gl-mt-3"> <b>{{ __('Evidence collection') }}</b> </div> <div v-for="(evidence, index) in evidences" :key="evidenceTitle(index)" class="mb-2"> diff --git a/app/assets/javascripts/releases/components/release_block.vue b/app/assets/javascripts/releases/components/release_block.vue index adb0e69b786..e0061d88ccb 100644 --- a/app/assets/javascripts/releases/components/release_block.vue +++ b/app/assets/javascripts/releases/components/release_block.vue @@ -108,7 +108,7 @@ export default { <release-block-assets v-if="shouldRenderAssets" :assets="assets" /> <evidence-block v-if="hasEvidence && shouldShowEvidence" :release="release" /> - <div ref="gfm-content" class="card-text prepend-top-default"> + <div ref="gfm-content" class="card-text gl-mt-3"> <div class="md" v-html="release.descriptionHtml"></div> </div> </div> diff --git a/app/assets/javascripts/releases/components/release_block_assets.vue b/app/assets/javascripts/releases/components/release_block_assets.vue index e07646e9a9f..ab29ceb0ce6 100644 --- a/app/assets/javascripts/releases/components/release_block_assets.vue +++ b/app/assets/javascripts/releases/components/release_block_assets.vue @@ -4,7 +4,7 @@ import Icon from '~/vue_shared/components/icon.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { ASSET_LINK_TYPE } from '../constants'; import { __, s__, sprintf } from '~/locale'; -import { difference } from 'lodash'; +import { difference, get } from 'lodash'; export default { name: 'ReleaseBlockAssets', @@ -54,7 +54,7 @@ export default { sections() { return [ { - links: this.assets.sources.map(s => ({ + links: get(this.assets, 'sources', []).map(s => ({ url: s.url, name: sprintf(__('Source code (%{fileExtension})'), { fileExtension: s.format }), })), @@ -96,7 +96,7 @@ export default { </script> <template> - <div class="card-text prepend-top-default"> + <div class="card-text gl-mt-3"> <template v-if="glFeatures.releaseAssetLinkType"> <gl-button data-testid="accordion-button" @@ -157,7 +157,7 @@ export default { <ul v-if="assets.links.length" class="pl-0 mb-0 gl-mt-3 list-unstyled js-assets-list"> <li v-for="link in assets.links" :key="link.name" class="gl-mb-3"> <gl-link v-gl-tooltip.bottom :title="__('Download asset')" :href="link.directAssetUrl"> - <icon name="package" class="align-middle append-right-4 align-text-bottom" /> + <icon name="package" class="align-middle gl-mr-2 align-text-bottom" /> {{ link.name }} <span v-if="link.external" data-testid="external-link-indicator">{{ __('(external source)') @@ -174,7 +174,7 @@ export default { aria-haspopup="true" aria-expanded="false" > - <icon name="doc-code" class="align-top append-right-4" /> + <icon name="doc-code" class="align-top gl-mr-2" /> {{ __('Source code') }} <icon name="chevron-down" /> </button> diff --git a/app/assets/javascripts/releases/components/release_block_header.vue b/app/assets/javascripts/releases/components/release_block_header.vue index ed49841757a..310fba0fe76 100644 --- a/app/assets/javascripts/releases/components/release_block_header.vue +++ b/app/assets/javascripts/releases/components/release_block_header.vue @@ -56,7 +56,7 @@ export default { v-gl-tooltip category="primary" variant="default" - class="append-right-10 js-edit-button ml-2 pb-2" + class="gl-mr-3 js-edit-button ml-2 pb-2" :title="__('Edit this release')" :href="editLink" > diff --git a/app/assets/javascripts/releases/components/release_block_metadata.vue b/app/assets/javascripts/releases/components/release_block_metadata.vue index a3377ce044a..861c2e11798 100644 --- a/app/assets/javascripts/releases/components/release_block_metadata.vue +++ b/app/assets/javascripts/releases/components/release_block_metadata.vue @@ -75,7 +75,7 @@ export default { <release-block-milestones v-if="shouldRenderMilestones" :milestones="release.milestones" /> - <div class="append-right-4"> + <div class="gl-mr-2"> • <span v-gl-tooltip.bottom diff --git a/app/assets/javascripts/releases/components/release_block_milestone_info.vue b/app/assets/javascripts/releases/components/release_block_milestone_info.vue index 4f75e15a149..b16ae400d6b 100644 --- a/app/assets/javascripts/releases/components/release_block_milestone_info.vue +++ b/app/assets/javascripts/releases/components/release_block_milestone_info.vue @@ -126,12 +126,12 @@ export default { v-gl-tooltip :title="milestone.description" :href="milestone.webUrl" - class="append-right-4" + class="gl-mr-2" > {{ milestone.title }} </gl-link> <template v-if="shouldRenderBullet(index)"> - <span :key="'bullet-' + milestone.id" class="append-right-4">•</span> + <span :key="'bullet-' + milestone.id" class="gl-mr-2">•</span> </template> <template v-if="shouldRenderShowMoreLink(index)"> <gl-button :key="'more-button-' + milestone.id" variant="link" @click="toggleShowAll"> diff --git a/app/assets/javascripts/releases/mount_new.js b/app/assets/javascripts/releases/mount_new.js new file mode 100644 index 00000000000..eb02c194c59 --- /dev/null +++ b/app/assets/javascripts/releases/mount_new.js @@ -0,0 +1,20 @@ +import Vue from 'vue'; +import ReleaseNewApp from './components/app_new.vue'; +import createStore from './stores'; +import createDetailModule from './stores/modules/detail'; + +export default () => { + const el = document.getElementById('js-new-release-page'); + + const store = createStore({ + modules: { + detail: createDetailModule(el.dataset), + }, + }); + + return new Vue({ + el, + store, + render: h => h(ReleaseNewApp), + }); +}; diff --git a/app/assets/javascripts/releases/stores/modules/detail/state.js b/app/assets/javascripts/releases/stores/modules/detail/state.js index 6d0d102c719..966c1c00ef5 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/state.js +++ b/app/assets/javascripts/releases/stores/modules/detail/state.js @@ -1,17 +1,17 @@ export default ({ projectId, - tagName, - releasesPagePath, markdownDocsPath, markdownPreviewPath, updateReleaseApiDocsPath, releaseAssetsDocsPath, manageMilestonesPath, newMilestonePath, + + tagName = null, + releasesPagePath = null, + defaultBranch = null, }) => ({ projectId, - tagName, - releasesPagePath, markdownDocsPath, markdownPreviewPath, updateReleaseApiDocsPath, @@ -19,6 +19,10 @@ export default ({ manageMilestonesPath, newMilestonePath, + tagName, + releasesPagePath, + defaultBranch, + /** The Release object */ release: null, diff --git a/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue b/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue index 653dcced98b..ed4f3c4e0fe 100644 --- a/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue +++ b/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue @@ -36,13 +36,9 @@ export default { }; </script> <template> - <div class="report-block-list-issue-description prepend-top-5 append-bottom-5"> + <div class="report-block-list-issue-description gl-mt-2 gl-mb-2"> <div ref="accessibility-issue-description" class="report-block-list-issue-description-text"> - <div - v-if="isNew" - ref="accessibility-issue-is-new-badge" - class="badge badge-danger append-right-5" - > + <div v-if="isNew" ref="accessibility-issue-is-new-badge" class="badge badge-danger gl-mr-2"> {{ s__('AccessibilityReport|New') }} </div> <div> @@ -55,7 +51,7 @@ export default { ) }} <gl-link ref="accessibility-issue-learn-more" :href="learnMoreUrl" target="_blank">{{ - s__('AccessibilityReport|Learn More') + s__('AccessibilityReport|Learn more') }}</gl-link> </div> {{ sprintf(s__('AccessibilityReport|Message: %{message}'), { message: issue.message }) }} diff --git a/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue b/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue new file mode 100644 index 00000000000..0c758ee2b5c --- /dev/null +++ b/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue @@ -0,0 +1,42 @@ +<script> +/** + * Renders Code quality body text + * Fixed: [name] in [link]:[line] + */ +import ReportLink from '~/reports/components/report_link.vue'; +import { STATUS_SUCCESS } from '~/reports/constants'; + +export default { + name: 'CodequalityIssueBody', + + components: { + ReportLink, + }, + props: { + status: { + type: String, + required: true, + }, + issue: { + type: Object, + required: true, + }, + }, + computed: { + isStatusSuccess() { + return this.status === STATUS_SUCCESS; + }, + }, +}; +</script> +<template> + <div class="report-block-list-issue-description gl-mt-2 gl-mb-2"> + <div class="report-block-list-issue-description-text"> + <template v-if="isStatusSuccess">{{ s__('ciReport|Fixed:') }}</template> + + {{ issue.name }} + </div> + + <report-link v-if="issue.path" :issue="issue" /> + </div> +</template> 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 new file mode 100644 index 00000000000..f3d5b1a80f8 --- /dev/null +++ b/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue @@ -0,0 +1,83 @@ +<script> +import { mapState, mapActions, mapGetters } from 'vuex'; +import { componentNames } from '~/reports/components/issue_body'; +import { s__, sprintf } from '~/locale'; +import ReportSection from '~/reports/components/report_section.vue'; +import createStore from './store'; + +export default { + name: 'GroupedCodequalityReportsApp', + store: createStore(), + components: { + ReportSection, + }, + props: { + headPath: { + type: String, + required: true, + }, + headBlobPath: { + type: String, + required: true, + }, + basePath: { + type: String, + required: false, + default: null, + }, + baseBlobPath: { + type: String, + required: false, + default: null, + }, + codequalityHelpPath: { + type: String, + required: true, + }, + }, + componentNames, + computed: { + ...mapState(['newIssues', 'resolvedIssues']), + ...mapGetters([ + 'hasCodequalityIssues', + 'codequalityStatus', + 'codequalityText', + 'codequalityPopover', + ]), + }, + created() { + this.setPaths({ + basePath: this.basePath, + headPath: this.headPath, + baseBlobPath: this.baseBlobPath, + headBlobPath: this.headBlobPath, + helpPath: this.codequalityHelpPath, + }); + + this.fetchReports(); + }, + methods: { + ...mapActions(['fetchReports', 'setPaths']), + }, + loadingText: sprintf(s__('ciReport|Loading %{reportName} report'), { + reportName: 'codeclimate', + }), + errorText: sprintf(s__('ciReport|Failed to load %{reportName} report'), { + reportName: 'codeclimate', + }), +}; +</script> +<template> + <report-section + :status="codequalityStatus" + :loading-text="$options.loadingText" + :error-text="$options.errorText" + :success-text="codequalityText" + :unresolved-issues="newIssues" + :resolved-issues="resolvedIssues" + :has-issues="hasCodequalityIssues" + :component="$options.componentNames.CodequalityIssueBody" + :popover-options="codequalityPopover" + class="js-codequality-widget mr-widget-border-top mr-report" + /> +</template> diff --git a/app/assets/javascripts/reports/codequality_report/store/actions.js b/app/assets/javascripts/reports/codequality_report/store/actions.js new file mode 100644 index 00000000000..bf84d27b5ea --- /dev/null +++ b/app/assets/javascripts/reports/codequality_report/store/actions.js @@ -0,0 +1,30 @@ +import axios from '~/lib/utils/axios_utils'; +import * as types from './mutation_types'; +import { parseCodeclimateMetrics, doCodeClimateComparison } from './utils/codequality_comparison'; + +export const setPaths = ({ commit }, paths) => commit(types.SET_PATHS, paths); + +export const fetchReports = ({ state, dispatch, commit }) => { + commit(types.REQUEST_REPORTS); + + if (!state.basePath) { + return dispatch('receiveReportsError'); + } + return Promise.all([axios.get(state.headPath), axios.get(state.basePath)]) + .then(results => + doCodeClimateComparison( + parseCodeclimateMetrics(results[0].data, state.headBlobPath), + parseCodeclimateMetrics(results[1].data, state.baseBlobPath), + ), + ) + .then(data => dispatch('receiveReportsSuccess', data)) + .catch(() => dispatch('receiveReportsError')); +}; + +export const receiveReportsSuccess = ({ commit }, data) => { + commit(types.RECEIVE_REPORTS_SUCCESS, data); +}; + +export const receiveReportsError = ({ commit }) => { + commit(types.RECEIVE_REPORTS_ERROR); +}; diff --git a/app/assets/javascripts/reports/codequality_report/store/getters.js b/app/assets/javascripts/reports/codequality_report/store/getters.js new file mode 100644 index 00000000000..5df58c7f85f --- /dev/null +++ b/app/assets/javascripts/reports/codequality_report/store/getters.js @@ -0,0 +1,58 @@ +import { LOADING, ERROR, SUCCESS } from '../../constants'; +import { sprintf, __, s__, n__ } from '~/locale'; + +export const hasCodequalityIssues = state => + Boolean(state.newIssues?.length || state.resolvedIssues?.length); + +export const codequalityStatus = state => { + if (state.isLoading) { + return LOADING; + } + if (state.hasError) { + return ERROR; + } + + return SUCCESS; +}; + +export const codequalityText = state => { + const { newIssues, resolvedIssues } = state; + const text = []; + + if (!newIssues.length && !resolvedIssues.length) { + text.push(s__('ciReport|No changes to code quality')); + } else { + text.push(s__('ciReport|Code quality')); + + if (resolvedIssues.length) { + text.push(n__(' improved on %d point', ' improved on %d points', resolvedIssues.length)); + } + + if (newIssues.length && resolvedIssues.length) { + text.push(__(' and')); + } + + if (newIssues.length) { + text.push(n__(' degraded on %d point', ' degraded on %d points', newIssues.length)); + } + } + + return text.join(''); +}; + +export const codequalityPopover = state => { + if (state.headPath && !state.basePath) { + return { + title: s__('ciReport|Base pipeline codequality artifact not found'), + content: sprintf( + s__('ciReport|%{linkStartTag}Learn more about codequality reports %{linkEndTag}'), + { + linkStartTag: `<a href="${state.helpPath}" target="_blank" rel="noopener noreferrer">`, + linkEndTag: '<i class="fa fa-external-link" aria-hidden="true"></i></a>', + }, + false, + ), + }; + } + return {}; +}; diff --git a/app/assets/javascripts/reports/codequality_report/store/index.js b/app/assets/javascripts/reports/codequality_report/store/index.js new file mode 100644 index 00000000000..047964260ad --- /dev/null +++ b/app/assets/javascripts/reports/codequality_report/store/index.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; +import state from './state'; + +Vue.use(Vuex); + +export default initialState => + new Vuex.Store({ + actions, + getters, + mutations, + state: state(initialState), + }); diff --git a/app/assets/javascripts/reports/codequality_report/store/mutation_types.js b/app/assets/javascripts/reports/codequality_report/store/mutation_types.js new file mode 100644 index 00000000000..c362c973ae1 --- /dev/null +++ b/app/assets/javascripts/reports/codequality_report/store/mutation_types.js @@ -0,0 +1,5 @@ +export const SET_PATHS = 'SET_PATHS'; + +export const REQUEST_REPORTS = 'REQUEST_REPORTS'; +export const RECEIVE_REPORTS_SUCCESS = 'RECEIVE_REPORTS_SUCCESS'; +export const RECEIVE_REPORTS_ERROR = 'RECEIVE_REPORTS_ERROR'; diff --git a/app/assets/javascripts/reports/codequality_report/store/mutations.js b/app/assets/javascripts/reports/codequality_report/store/mutations.js new file mode 100644 index 00000000000..7ef4f3ce2db --- /dev/null +++ b/app/assets/javascripts/reports/codequality_report/store/mutations.js @@ -0,0 +1,24 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_PATHS](state, paths) { + state.basePath = paths.basePath; + state.headPath = paths.headPath; + state.baseBlobPath = paths.baseBlobPath; + state.headBlobPath = paths.headBlobPath; + state.helpPath = paths.helpPath; + }, + [types.REQUEST_REPORTS](state) { + state.isLoading = true; + }, + [types.RECEIVE_REPORTS_SUCCESS](state, data) { + state.hasError = false; + state.isLoading = false; + state.newIssues = data.newIssues; + state.resolvedIssues = data.resolvedIssues; + }, + [types.RECEIVE_REPORTS_ERROR](state) { + state.isLoading = false; + state.hasError = true; + }, +}; diff --git a/app/assets/javascripts/reports/codequality_report/store/state.js b/app/assets/javascripts/reports/codequality_report/store/state.js new file mode 100644 index 00000000000..38ab53b432e --- /dev/null +++ b/app/assets/javascripts/reports/codequality_report/store/state.js @@ -0,0 +1,15 @@ +export default () => ({ + basePath: null, + headPath: null, + + baseBlobPath: null, + headBlobPath: null, + + isLoading: false, + hasError: false, + + newIssues: [], + resolvedIssues: [], + + helpPath: null, +}); 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 new file mode 100644 index 00000000000..eba9e340c4e --- /dev/null +++ b/app/assets/javascripts/reports/codequality_report/store/utils/codequality_comparison.js @@ -0,0 +1,41 @@ +import CodeQualityComparisonWorker from '../../workers/codequality_comparison_worker'; + +export const parseCodeclimateMetrics = (issues = [], path = '') => { + return issues.map(issue => { + const parsedIssue = { + ...issue, + name: issue.description, + }; + + if (issue?.location?.path) { + let parseCodeQualityUrl = `${path}/${issue.location.path}`; + parsedIssue.path = issue.location.path; + + if (issue?.location?.lines?.begin) { + parsedIssue.line = issue.location.lines.begin; + parseCodeQualityUrl += `#L${issue.location.lines.begin}`; + } else if (issue?.location?.positions?.begin?.line) { + parsedIssue.line = issue.location.positions.begin.line; + parseCodeQualityUrl += `#L${issue.location.positions.begin.line}`; + } + + parsedIssue.urlPath = parseCodeQualityUrl; + } + + return parsedIssue; + }); +}; + +export const doCodeClimateComparison = (headIssues, baseIssues) => { + // Do these comparisons in worker threads to avoid blocking the main thread + return new Promise((resolve, reject) => { + const worker = new CodeQualityComparisonWorker(); + worker.addEventListener('message', ({ data }) => + data.newIssues && data.resolvedIssues ? resolve(data) : reject(data), + ); + worker.postMessage({ + headIssues, + baseIssues, + }); + }); +}; diff --git a/app/assets/javascripts/reports/codequality_report/workers/codequality_comparison_worker.js b/app/assets/javascripts/reports/codequality_report/workers/codequality_comparison_worker.js new file mode 100644 index 00000000000..fc55602f95c --- /dev/null +++ b/app/assets/javascripts/reports/codequality_report/workers/codequality_comparison_worker.js @@ -0,0 +1,28 @@ +import { differenceBy } from 'lodash'; + +const KEY_TO_FILTER_BY = 'fingerprint'; + +// eslint-disable-next-line no-restricted-globals +self.addEventListener('message', e => { + const { data } = e; + + if (data === undefined) { + return null; + } + + const { headIssues, baseIssues } = data; + + if (!headIssues || !baseIssues) { + // eslint-disable-next-line no-restricted-globals + return self.postMessage({}); + } + + // eslint-disable-next-line no-restricted-globals + self.postMessage({ + newIssues: differenceBy(headIssues, baseIssues, KEY_TO_FILTER_BY), + resolvedIssues: differenceBy(baseIssues, headIssues, KEY_TO_FILTER_BY), + }); + + // eslint-disable-next-line no-restricted-globals + return self.close(); +}); 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 a670cad5f9f..b8a8cb940e7 100644 --- a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue +++ b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue @@ -6,7 +6,9 @@ import ReportSection from './report_section.vue'; import SummaryRow from './summary_row.vue'; import IssuesList from './issues_list.vue'; import Modal from './modal.vue'; +import { GlButton } from '@gitlab/ui'; import createStore from '../store'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { summaryTextBuilder, reportTextBuilder, statusIcon } from '../store/utils'; export default { @@ -17,12 +19,19 @@ export default { SummaryRow, IssuesList, Modal, + GlButton, }, + mixins: [glFeatureFlagsMixin()], props: { endpoint: { type: String, required: true, }, + pipelinePath: { + type: String, + required: false, + default: '', + }, }, componentNames, computed: { @@ -43,6 +52,12 @@ export default { return summaryTextBuilder(s__('Reports|Test summary'), this.summary); }, + testTabURL() { + return `${this.pipelinePath}/test_report`; + }, + showViewFullReport() { + return Boolean(this.glFeatures.junitPipelineView) && this.pipelinePath.length; + }, }, created() { this.setEndpoint(this.endpoint); @@ -98,6 +113,16 @@ export default { :has-issues="reports.length > 0" class="mr-widget-section grouped-security-reports mr-report" > + <template v-if="showViewFullReport" #actionButtons> + <gl-button + :href="testTabURL" + icon="external-link" + data-testid="group-test-reports-full-link" + class="gl-mr-3" + > + {{ s__('ciReport|View full report') }} + </gl-button> + </template> <template #body> <div class="mr-widget-grouped-section report-block"> <template v-for="(report, i) in reports"> diff --git a/app/assets/javascripts/reports/components/issue_body.js b/app/assets/javascripts/reports/components/issue_body.js index e106e60951b..1e6dc4f8b78 100644 --- a/app/assets/javascripts/reports/components/issue_body.js +++ b/app/assets/javascripts/reports/components/issue_body.js @@ -1,12 +1,15 @@ 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'; export const components = { AccessibilityIssueBody, + CodequalityIssueBody, TestIssueBody, }; export const componentNames = { AccessibilityIssueBody: AccessibilityIssueBody.name, + CodequalityIssueBody: CodequalityIssueBody.name, TestIssueBody: TestIssueBody.name, }; diff --git a/app/assets/javascripts/reports/components/report_item.vue b/app/assets/javascripts/reports/components/report_item.vue index 51062cd7928..1b47d03aa01 100644 --- a/app/assets/javascripts/reports/components/report_item.vue +++ b/app/assets/javascripts/reports/components/report_item.vue @@ -52,7 +52,7 @@ export default { v-if="showReportSectionStatusIcon" :status="status" :status-icon-size="statusIconSize" - class="append-right-default" + class="gl-mr-3" /> <component :is="component" v-if="component" :issue="issue" :status="status" :is-new="isNew" /> diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue index 68956fc6d2b..63af8a5a9ac 100644 --- a/app/assets/javascripts/reports/components/report_section.vue +++ b/app/assets/javascripts/reports/components/report_section.vue @@ -91,6 +91,11 @@ export default { required: false, default: undefined, }, + shouldEmitToggleEvent: { + type: Boolean, + required: false, + default: false, + }, }, data() { @@ -157,6 +162,9 @@ export default { }, methods: { toggleCollapsed() { + if (this.shouldEmitToggleEvent) { + this.$emit('toggleEvent'); + } this.isCollapsed = !this.isCollapsed; }, }, @@ -171,7 +179,7 @@ export default { <div> {{ headerText }} <slot :name="slotName"></slot> - <popover v-if="hasPopover" :options="popoverOptions" class="prepend-left-5" /> + <popover v-if="hasPopover" :options="popoverOptions" class="gl-ml-2" /> </div> <slot name="subHeading"></slot> </div> diff --git a/app/assets/javascripts/reports/components/summary_row.vue b/app/assets/javascripts/reports/components/summary_row.vue index b9fc902cd3a..3232c0edf96 100644 --- a/app/assets/javascripts/reports/components/summary_row.vue +++ b/app/assets/javascripts/reports/components/summary_row.vue @@ -21,7 +21,8 @@ export default { props: { summary: { type: String, - required: true, + required: false, + default: '', }, statusIcon: { type: String, @@ -45,7 +46,7 @@ export default { </script> <template> <div class="report-block-list-issue report-block-list-issue-parent align-items-center"> - <div class="report-block-list-icon append-right-default"> + <div class="report-block-list-icon gl-mr-3"> <gl-loading-icon v-if="statusIcon === 'loading'" css-class="report-block-list-loading-icon" @@ -58,8 +59,8 @@ export default { class="report-block-list-issue-description-text" data-testid="test-summary-row-description" > - {{ summary - }}<span v-if="popoverOptions" class="text-nowrap" + <slot name="summary">{{ summary }}</slot + ><span v-if="popoverOptions" class="text-nowrap" > <popover v-if="popoverOptions" :options="popoverOptions" class="align-top" /> </span> </div> diff --git a/app/assets/javascripts/reports/components/test_issue_body.vue b/app/assets/javascripts/reports/components/test_issue_body.vue index c41238070b1..4e0631740d8 100644 --- a/app/assets/javascripts/reports/components/test_issue_body.vue +++ b/app/assets/javascripts/reports/components/test_issue_body.vue @@ -25,14 +25,14 @@ export default { }; </script> <template> - <div class="report-block-list-issue-description prepend-top-5 append-bottom-5"> + <div class="report-block-list-issue-description gl-mt-2 gl-mb-2"> <div class="report-block-list-issue-description-text" data-testid="test-issue-body-description"> <button type="button" class="btn-link btn-blank text-left break-link vulnerability-name-button" @click="openModal({ issue })" > - <div v-if="isNew" class="badge badge-danger append-right-5">{{ s__('New') }}</div> + <div v-if="isNew" class="badge badge-danger gl-mr-2">{{ s__('New') }}</div> {{ issue.name }} </button> </div> diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue index c8549180a25..5e0ad7acdfd 100644 --- a/app/assets/javascripts/repository/components/table/index.vue +++ b/app/assets/javascripts/repository/components/table/index.vue @@ -80,7 +80,7 @@ export default { <table-header v-once /> <tbody> <parent-row - v-show="showParentRow" + v-if="showParentRow" :commit-ref="escapedRef" :path="path" :loading-path="loadingPath" @@ -97,6 +97,7 @@ export default { :path="entry.flatPath" :type="entry.type" :url="entry.webUrl" + :mode="entry.mode" :submodule-tree-url="entry.treeUrl" :lfs-oid="entry.lfsOid" :loading-path="loadingPath" diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index d5363016335..615e329f415 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -66,6 +66,11 @@ export default { type: String, required: true, }, + mode: { + type: String, + required: false, + default: '', + }, type: { type: String, required: true, @@ -140,6 +145,7 @@ export default { > <file-icon :file-name="fullPath" + :file-mode="mode" :folder="isFolder" :submodule="isSubmodule" :loading="path === loadingPath" diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue index 7b34e9ef60d..59ba1caa8c9 100644 --- a/app/assets/javascripts/repository/components/tree_content.vue +++ b/app/assets/javascripts/repository/components/tree_content.vue @@ -5,7 +5,6 @@ import FileTable from './table/index.vue'; import getRefMixin from '../mixins/get_ref'; import getFiles from '../queries/getFiles.query.graphql'; import getProjectPath from '../queries/getProjectPath.query.graphql'; -import getVueFileListLfsBadge from '../queries/getVueFileListLfsBadge.query.graphql'; import FilePreview from './preview/index.vue'; import { readmeFile } from '../utils/readme'; @@ -21,9 +20,6 @@ export default { projectPath: { query: getProjectPath, }, - vueFileListLfsBadge: { - query: getVueFileListLfsBadge, - }, }, props: { path: { @@ -47,7 +43,6 @@ export default { blobs: [], }, isLoadingFiles: false, - vueFileListLfsBadge: false, }; }, computed: { @@ -82,7 +77,6 @@ export default { path: this.path || '/', nextPageCursor: this.nextPageCursor, pageSize: PAGE_SIZE, - vueLfsEnabled: this.vueFileListLfsBadge, }, }) .then(({ data }) => { diff --git a/app/assets/javascripts/repository/components/web_ide_link.vue b/app/assets/javascripts/repository/components/web_ide_link.vue new file mode 100644 index 00000000000..6549d5a3878 --- /dev/null +++ b/app/assets/javascripts/repository/components/web_ide_link.vue @@ -0,0 +1,47 @@ +<script> +import TreeActionLink from './tree_action_link.vue'; +import { __ } from '~/locale'; +import { webIDEUrl } from '~/lib/utils/url_utility'; + +export default { + components: { + TreeActionLink, + }, + props: { + projectPath: { + type: String, + required: true, + }, + refSha: { + type: String, + required: true, + }, + canPushCode: { + type: Boolean, + required: false, + default: true, + }, + forkPath: { + type: String, + required: false, + default: '', + }, + }, + computed: { + showLinkToFork() { + return !this.canPushCode && this.forkPath; + }, + text() { + return this.showLinkToFork ? __('Edit fork in Web IDE') : __('Web IDE'); + }, + path() { + const path = this.showLinkToFork ? this.forkPath : this.projectPath; + return webIDEUrl(`/${path}/edit/${this.refSha}/-/${this.$route.params.path || ''}`); + }, + }, +}; +</script> + +<template> + <tree-action-link :path="path" :text="text" data-qa-selector="web_ide_button" /> +</template> diff --git a/app/assets/javascripts/repository/graphql.js b/app/assets/javascripts/repository/graphql.js index 6640b636597..450a1571165 100644 --- a/app/assets/javascripts/repository/graphql.js +++ b/app/assets/javascripts/repository/graphql.js @@ -30,7 +30,7 @@ const defaultClient = createDefaultClient( }, readme(_, { url }) { return axios - .get(url, { params: { viewer: 'rich', format: 'json' } }) + .get(url, { params: { format: 'json', viewer: 'rich' } }) .then(({ data }) => ({ ...data, __typename: 'ReadmeFile' })); }, }, diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 6528e283372..4f80ab4ff5d 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -4,18 +4,26 @@ 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 WebIdeLink from './components/web_ide_link.vue'; 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 { webIDEUrl } from '../lib/utils/url_utility'; import { __ } from '../locale'; export default function setupVueRepositoryList() { const el = document.getElementById('js-tree-list'); const { dataset } = el; - const { projectPath, projectShortPath, ref, escapedRef, fullName } = dataset; + const { + canPushCode, + projectPath, + projectShortPath, + forkPath, + ref, + escapedRef, + fullName, + } = dataset; const router = createRouter(projectPath, escapedRef); apolloProvider.clients.defaultClient.cache.writeData({ @@ -24,7 +32,6 @@ export default function setupVueRepositoryList() { projectShortPath, ref, escapedRef, - vueFileListLfsBadge: gon.features?.vueFileListLfsBadge || false, commits: [], }, }); @@ -118,11 +125,12 @@ export default function setupVueRepositoryList() { el: webIdeLinkEl, router, render(h) { - return h(TreeActionLink, { + return h(WebIdeLink, { props: { - path: webIDEUrl(`/${projectPath}/edit/${ref}/-/${this.$route.params.path || ''}`), - text: __('Web IDE'), - cssClass: 'qa-web-ide-button', + projectPath, + refSha: ref, + forkPath, + canPushCode: parseBoolean(canPushCode), }, }); }, diff --git a/app/assets/javascripts/repository/queries/getFiles.query.graphql b/app/assets/javascripts/repository/queries/getFiles.query.graphql index 01ad72ef752..feb89df0492 100644 --- a/app/assets/javascripts/repository/queries/getFiles.query.graphql +++ b/app/assets/javascripts/repository/queries/getFiles.query.graphql @@ -14,7 +14,6 @@ query getFiles( $ref: String! $pageSize: Int! $nextPageCursor: String - $vueLfsEnabled: Boolean = false ) { project(fullPath: $projectPath) { repository { @@ -46,8 +45,9 @@ query getFiles( edges { node { ...TreeEntry + mode webUrl - lfsOid @include(if: $vueLfsEnabled) + lfsOid } } pageInfo { diff --git a/app/assets/javascripts/repository/queries/getVueFileListLfsBadge.query.graphql b/app/assets/javascripts/repository/queries/getVueFileListLfsBadge.query.graphql deleted file mode 100644 index eb21c1e73d8..00000000000 --- a/app/assets/javascripts/repository/queries/getVueFileListLfsBadge.query.graphql +++ /dev/null @@ -1,3 +0,0 @@ -query getVueFileListLfsBadge { - vueFileListLfsBadge @client -} diff --git a/app/assets/javascripts/global_search_input.js b/app/assets/javascripts/search_autocomplete.js index a7c121259d4..05e0b9e7089 100644 --- a/app/assets/javascripts/global_search_input.js +++ b/app/assets/javascripts/search_autocomplete.js @@ -1,8 +1,10 @@ /* eslint-disable no-return-assign, consistent-return, class-methods-use-this */ import $ from 'jquery'; -import { throttle } from 'lodash'; +import { escape, throttle } from 'lodash'; import { s__, __, sprintf } from '~/locale'; +import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar_helper'; +import axios from './lib/utils/axios_utils'; import { isInGroupsPage, isInProjectPage, @@ -65,11 +67,15 @@ function setSearchOptions() { } } -export class GlobalSearchInput { - constructor({ wrap } = {}) { +export class SearchAutocomplete { + constructor({ wrap, optsEl, autocompletePath, projectId, projectRef } = {}) { setSearchOptions(); this.bindEventContext(); this.wrap = wrap || $('.search'); + this.optsEl = optsEl || this.wrap.find('.search-autocomplete-opts'); + this.autocompletePath = autocompletePath || this.optsEl.data('autocompletePath'); + this.projectId = projectId || (this.optsEl.data('autocompleteProjectId') || ''); + this.projectRef = projectRef || (this.optsEl.data('autocompleteProjectRef') || ''); this.dropdown = this.wrap.find('.dropdown'); this.dropdownToggle = this.wrap.find('.js-dropdown-search-toggle'); this.dropdownMenu = this.dropdown.find('.dropdown-menu'); @@ -86,7 +92,7 @@ export class GlobalSearchInput { // Only when user is logged in if (gon.current_user_id) { - this.createGlobalSearchInput(); + this.createAutocomplete(); } this.bindEvents(); @@ -111,7 +117,7 @@ export class GlobalSearchInput { return (this.originalState = this.serializeState()); } - createGlobalSearchInput() { + createAutocomplete() { return this.searchInput.glDropdown({ filterInputBlur: false, filterable: true, @@ -143,17 +149,116 @@ export class GlobalSearchInput { if (glDropdownInstance) { glDropdownInstance.filter.options.callback(contents); } - this.enableDropdown(); + this.enableAutocomplete(); } return; } - const options = this.scopedSearchOptions(term); + // Prevent multiple ajax calls + if (this.loadingSuggestions) { + return; + } - callback(options); + this.loadingSuggestions = true; + + return axios + .get(this.autocompletePath, { + params: { + project_id: this.projectId, + project_ref: this.projectRef, + term, + }, + }) + .then(response => { + const options = this.scopedSearchOptions(term); + + // List results + let lastCategory = null; + for (let i = 0, len = response.data.length; i < len; i += 1) { + const suggestion = response.data[i]; + // Add group header before list each group + if (lastCategory !== suggestion.category) { + options.push({ type: 'separator' }); + options.push({ + type: 'header', + content: suggestion.category, + }); + lastCategory = suggestion.category; + } + + // Add the suggestion + options.push({ + id: `${suggestion.category.toLowerCase()}-${suggestion.id}`, + icon: this.getAvatar(suggestion), + category: suggestion.category, + text: suggestion.label, + url: suggestion.url, + }); + } - this.highlightFirstRow(); - this.setScrollFade(); + callback(options); + + this.loadingSuggestions = false; + this.highlightFirstRow(); + this.setScrollFade(); + }) + .catch(() => { + this.loadingSuggestions = false; + }); + } + + getCategoryContents() { + const userName = gon.current_username; + const { projectOptions, groupOptions, dashboardOptions } = gl; + + // Get options + let options; + if (isInProjectPage() && projectOptions) { + options = projectOptions[getProjectSlug()]; + } else if (isInGroupsPage() && groupOptions) { + options = groupOptions[getGroupSlug()]; + } else if (dashboardOptions) { + options = dashboardOptions; + } + + const { issuesPath, mrPath, name, issuesDisabled } = options; + const baseItems = []; + + if (name) { + baseItems.push({ + type: 'header', + content: `${name}`, + }); + } + + const issueItems = [ + { + text: s__('SearchAutocomplete|Issues assigned to me'), + url: `${issuesPath}/?assignee_username=${userName}`, + }, + { + text: s__("SearchAutocomplete|Issues I've created"), + url: `${issuesPath}/?author_username=${userName}`, + }, + ]; + const mergeRequestItems = [ + { + text: s__('SearchAutocomplete|Merge requests assigned to me'), + url: `${mrPath}/?assignee_username=${userName}`, + }, + { + text: s__("SearchAutocomplete|Merge requests I've created"), + url: `${mrPath}/?author_username=${userName}`, + }, + ]; + + let items; + if (issuesDisabled) { + items = baseItems.concat(mergeRequestItems); + } else { + items = baseItems.concat(...issueItems, ...mergeRequestItems); + } + return items; } // Add option to proceed with the search for each @@ -238,7 +343,7 @@ export class GlobalSearchInput { }); } - enableDropdown() { + enableAutocomplete() { this.setScrollFade(); // No need to enable anything if user is not logged in @@ -255,7 +360,7 @@ export class GlobalSearchInput { } onSearchInputChange() { - this.enableDropdown(); + this.enableAutocomplete(); } onSearchInputKeyUp(e) { @@ -264,7 +369,7 @@ export class GlobalSearchInput { this.restoreOriginalState(); break; case KEYCODE.ENTER: - this.disableDropdown(); + this.disableAutocomplete(); break; default: } @@ -317,7 +422,7 @@ export class GlobalSearchInput { return results; } - disableDropdown() { + disableAutocomplete() { if (!this.searchInput.hasClass('js-autocomplete-disabled') && this.dropdown.hasClass('show')) { this.searchInput.addClass('js-autocomplete-disabled'); this.dropdownToggle.dropdown('toggle'); @@ -333,8 +438,16 @@ export class GlobalSearchInput { onClick(item, $el, e) { if (window.location.pathname.indexOf(item.url) !== -1) { if (!e.metaKey) e.preventDefault(); + /* eslint-disable-next-line @gitlab/require-i18n-strings */ + if (item.category === 'Projects') { + this.projectInputEl.val(item.id); + } + // eslint-disable-next-line @gitlab/require-i18n-strings + if (item.category === 'Groups') { + this.groupInputEl.val(item.id); + } $el.removeClass('is-active'); - this.disableDropdown(); + this.disableAutocomplete(); return this.searchInput.val('').focus(); } } @@ -343,58 +456,20 @@ export class GlobalSearchInput { this.searchInput.data('glDropdown').highlightRowAtIndex(null, 0); } - getCategoryContents() { - const userName = gon.current_username; - const { projectOptions, groupOptions, dashboardOptions } = gl; - - // Get options - let options; - if (isInProjectPage() && projectOptions) { - options = projectOptions[getProjectSlug()]; - } else if (isInGroupsPage() && groupOptions) { - options = groupOptions[getGroupSlug()]; - } else if (dashboardOptions) { - options = dashboardOptions; + getAvatar(item) { + if (!Object.hasOwnProperty.call(item, 'avatar_url')) { + return false; } - const { issuesPath, mrPath, name, issuesDisabled } = options; - const baseItems = []; - - if (name) { - baseItems.push({ - type: 'header', - content: `${name}`, - }); - } + const { label, id } = item; + const avatarUrl = item.avatar_url; + const avatar = avatarUrl + ? `<img class="search-item-avatar" src="${avatarUrl}" />` + : `<div class="s16 avatar identicon ${getIdenticonBackgroundClass(id)}">${getIdenticonTitle( + escape(label), + )}</div>`; - const issueItems = [ - { - text: s__('SearchAutocomplete|Issues assigned to me'), - url: `${issuesPath}/?assignee_username=${userName}`, - }, - { - text: s__("SearchAutocomplete|Issues I've created"), - url: `${issuesPath}/?author_username=${userName}`, - }, - ]; - const mergeRequestItems = [ - { - text: s__('SearchAutocomplete|Merge requests assigned to me'), - url: `${mrPath}/?assignee_username=${userName}`, - }, - { - text: s__("SearchAutocomplete|Merge requests I've created"), - url: `${mrPath}/?author_username=${userName}`, - }, - ]; - - let items; - if (issuesDisabled) { - items = baseItems.concat(mergeRequestItems); - } else { - items = baseItems.concat(...issueItems, ...mergeRequestItems); - } - return items; + return avatar; } isScrolledUp() { @@ -420,6 +495,6 @@ export class GlobalSearchInput { } } -export default function initGlobalSearchInput(opts) { - return new GlobalSearchInput(opts); +export default function initSearchAutocomplete(opts) { + return new SearchAutocomplete(opts); } diff --git a/app/assets/javascripts/serverless/components/environment_row.vue b/app/assets/javascripts/serverless/components/environment_row.vue index 089e0550583..c46dfb66afe 100644 --- a/app/assets/javascripts/serverless/components/environment_row.vue +++ b/app/assets/javascripts/serverless/components/environment_row.vue @@ -54,7 +54,7 @@ export default { <div class="folder-toggle-wrap d-flex align-items-center"> <item-caret :is-group-open="isOpen" /> </div> - <div class="group-text flex-grow title namespace-title prepend-left-default"> + <div class="group-text flex-grow title namespace-title gl-ml-3"> {{ envName }} </div> </div> diff --git a/app/assets/javascripts/serverless/components/function_details.vue b/app/assets/javascripts/serverless/components/function_details.vue index 2ac57ac5bcb..53c78b93254 100644 --- a/app/assets/javascripts/serverless/components/function_details.vue +++ b/app/assets/javascripts/serverless/components/function_details.vue @@ -71,7 +71,7 @@ export default { <template> <section id="serverless-function-details"> <h3 class="serverless-function-name">{{ name }}</h3> - <div class="append-bottom-default serverless-function-description"> + <div class="gl-mb-3 serverless-function-description"> <div v-for="(line, index) in description.split('\n')" :key="index">{{ line }}</div> </div> <url :uri="funcUrl" /> diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue index 2b1291ac70f..8fa48134f1f 100644 --- a/app/assets/javascripts/serverless/components/functions.vue +++ b/app/assets/javascripts/serverless/components/functions.vue @@ -75,11 +75,7 @@ export default { <template> <section id="serverless-functions" class="flex-grow"> - <gl-loading-icon - v-if="checkingInstalled" - size="lg" - class="prepend-top-default append-bottom-default" - /> + <gl-loading-icon v-if="checkingInstalled" size="lg" class="gl-mt-3 gl-mb-3" /> <div v-else-if="isInstalled"> <div v-if="hasFunctionData"> @@ -95,11 +91,7 @@ export default { </ul> </div> </template> - <gl-loading-icon - v-if="isLoading" - size="lg" - class="prepend-top-default append-bottom-default js-functions-loader" - /> + <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"> <div class="text-content"> 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 fd1f9eae152..d5ae9b04090 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 @@ -8,6 +8,7 @@ import { __, s__ } from '~/locale'; import Api from '~/api'; import eventHub from './event_hub'; import EmojiMenuInModal from './emoji_menu_in_modal'; +import * as Emoji from '~/emoji'; const emojiMenuClass = 'js-modal-status-emoji-menu'; @@ -64,8 +65,8 @@ export default { const emojiAutocomplete = new GfmAutoComplete(); emojiAutocomplete.setup($(this.$refs.statusMessageField), { emojis: true }); - import(/* webpackChunkName: 'emoji' */ '~/emoji') - .then(Emoji => { + Emoji.initEmojiMap() + .then(() => { if (this.emoji) { this.emojiTag = Emoji.glEmojiTag(this.emoji); } diff --git a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue index 550a1be1e64..0987603cafd 100644 --- a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue @@ -1,5 +1,5 @@ <script> -import { mapState } from 'vuex'; +import { mapState, mapActions } from 'vuex'; import { __ } from '~/locale'; import Flash from '~/flash'; import tooltip from '~/vue_shared/directives/tooltip'; @@ -18,6 +18,10 @@ export default { }, mixins: [recaptchaModalImplementor], props: { + fullPath: { + required: true, + type: String, + }, isEditable: { required: true, type: Boolean, @@ -42,16 +46,24 @@ export default { }, }, created() { + eventHub.$on('updateConfidentialAttribute', this.updateConfidentialAttribute); eventHub.$on('closeConfidentialityForm', this.toggleForm); }, beforeDestroy() { + eventHub.$off('updateConfidentialAttribute', this.updateConfidentialAttribute); eventHub.$off('closeConfidentialityForm', this.toggleForm); }, methods: { + ...mapActions(['setConfidentiality']), toggleForm() { this.edit = !this.edit; }, - updateConfidentialAttribute(confidential) { + closeForm() { + this.edit = false; + }, + updateConfidentialAttribute() { + // TODO: rm when FF is defaulted to on. + const confidential = !this.confidential; this.service .update('issue', { confidential }) .then(({ data }) => this.checkForSpam(data)) @@ -97,12 +109,8 @@ export default { > </div> <div class="value sidebar-item-value hide-collapsed"> - <edit-form - v-if="edit" - :is-confidential="confidential" - :update-confidential-attribute="updateConfidentialAttribute" - /> - <div v-if="!confidential" class="no-value sidebar-item-value"> + <edit-form v-if="edit" :is-confidential="confidential" :full-path="fullPath" /> + <div v-if="!confidential" class="no-value sidebar-item-value" data-testid="not-confidential"> <icon :size="16" name="eye" aria-hidden="true" class="sidebar-item-icon inline" /> {{ __('Not confidential') }} </div> diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue index 0ecbf934c25..9dd4f04acdb 100644 --- a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue +++ b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue @@ -11,9 +11,9 @@ export default { required: true, type: Boolean, }, - updateConfidentialAttribute: { + fullPath: { required: true, - type: Function, + type: String, }, }, computed: { @@ -37,10 +37,7 @@ export default { <div> <p v-if="!isConfidential" v-html="confidentialityOnWarning"></p> <p v-else v-html="confidentialityOffWarning"></p> - <edit-form-buttons - :is-confidential="isConfidential" - :update-confidential-attribute="updateConfidentialAttribute" - /> + <edit-form-buttons :full-path="fullPath" /> </div> </div> </div> diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue index e106afea9f5..80928649a03 100644 --- a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue +++ b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue @@ -1,35 +1,60 @@ <script> import $ from 'jquery'; -import eventHub from '../../event_hub'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { mapActions, mapState } from 'vuex'; import { __ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import Flash from '~/flash'; +import eventHub from '../../event_hub'; export default { + components: { + GlLoadingIcon, + }, + mixins: [glFeatureFlagsMixin()], props: { - isConfidential: { - required: true, - type: Boolean, - }, - updateConfidentialAttribute: { + fullPath: { required: true, - type: Function, + type: String, }, }, + data() { + return { + isLoading: false, + }; + }, computed: { + ...mapState({ confidential: ({ noteableData }) => noteableData.confidential }), toggleButtonText() { - return this.isConfidential ? __('Turn Off') : __('Turn On'); - }, - updateConfidentialBool() { - return !this.isConfidential; + if (this.isLoading) { + return __('Applying'); + } + + return this.confidential ? __('Turn Off') : __('Turn On'); }, }, methods: { + ...mapActions(['updateConfidentialityOnIssue']), closeForm() { eventHub.$emit('closeConfidentialityForm'); $(this.$el).trigger('hidden.gl.dropdown'); }, submitForm() { - this.closeForm(); - this.updateConfidentialAttribute(this.updateConfidentialBool); + this.isLoading = true; + const confidential = !this.confidential; + + if (this.glFeatures.confidentialApolloSidebar) { + this.updateConfidentialityOnIssue({ confidential, fullPath: this.fullPath }) + .catch(() => { + Flash(__('Something went wrong trying to change the confidentiality of this issue')); + }) + .finally(() => { + this.closeForm(); + this.isLoading = false; + }); + } else { + eventHub.$emit('updateConfidentialAttribute'); + } }, }, }; @@ -37,15 +62,17 @@ export default { <template> <div class="sidebar-item-warning-message-actions"> - <button type="button" class="btn btn-default append-right-10" @click="closeForm"> + <button type="button" class="btn btn-default gl-mr-3" @click="closeForm"> {{ __('Cancel') }} </button> <button type="button" class="btn btn-close" data-testid="confidential-toggle" + :disabled="isLoading" @click.prevent="submitForm" > + <gl-loading-icon v-if="isLoading" inline /> {{ toggleButtonText }} </button> </div> diff --git a/app/assets/javascripts/sidebar/components/confidential/queries/update_issue_confidential.mutation.graphql b/app/assets/javascripts/sidebar/components/confidential/queries/update_issue_confidential.mutation.graphql new file mode 100644 index 00000000000..2459aa346c9 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/confidential/queries/update_issue_confidential.mutation.graphql @@ -0,0 +1,7 @@ +mutation updateIssueConfidential($input: IssueSetConfidentialInput!) { + issueSetConfidential(input: $input) { + issue { + confidential + } + } +} 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 f88bde624b4..2e85ded8ade 100644 --- a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue +++ b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue @@ -41,7 +41,7 @@ export default { <template> <div class="sidebar-item-warning-message-actions"> - <button type="button" class="btn btn-default append-right-10" @click="closeForm"> + <button type="button" class="btn btn-default gl-mr-3" @click="closeForm"> {{ __('Cancel') }} </button> diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index e371091fc53..2c108835c36 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -11,6 +11,7 @@ import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptio import Translate from '../vue_shared/translate'; import createDefaultClient from '~/lib/graphql'; import { store } from '~/notes/stores'; +import { isInIssuePage } from '~/lib/utils/common_utils'; Vue.use(Translate); Vue.use(VueApollo); @@ -43,7 +44,7 @@ function mountAssigneesComponent(mediator) { projectPath: fullPath, field: el.dataset.field, signedIn: el.hasAttribute('data-signed-in'), - issuableType: gl.utils.isInIssuePage() ? 'issue' : 'merge_request', + issuableType: isInIssuePage() ? 'issue' : 'merge_request', }, }), }); @@ -52,20 +53,30 @@ function mountAssigneesComponent(mediator) { function mountConfidentialComponent(mediator) { const el = document.getElementById('js-confidential-entry-point'); + const { fullPath, iid } = getSidebarOptions(); + if (!el) return; const dataNode = document.getElementById('js-confidential-issue-data'); const initialData = JSON.parse(dataNode.innerHTML); - const ConfidentialComp = Vue.extend(ConfidentialIssueSidebar); - - new ConfidentialComp({ + // eslint-disable-next-line no-new + new Vue({ + el, store, - propsData: { - isEditable: initialData.is_editable, - service: mediator.service, + components: { + ConfidentialIssueSidebar, }, - }).$mount(el); + render: createElement => + createElement('confidential-issue-sidebar', { + props: { + iid: String(iid), + fullPath, + isEditable: initialData.is_editable, + service: mediator.service, + }, + }), + }); } function mountLockComponent(mediator) { @@ -83,7 +94,7 @@ function mountLockComponent(mediator) { isLocked: initialData.is_locked, isEditable: initialData.is_editable, mediator, - issuableType: gl.utils.isInIssuePage() ? 'issue' : 'merge_request', + issuableType: isInIssuePage() ? 'issue' : 'merge_request', }, }).$mount(el); } diff --git a/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql b/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql index 8cc68f6ea9a..2aff7da4605 100644 --- a/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql +++ b/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql @@ -1,6 +1,6 @@ -query ($fullPath: ID!, $iid: String!) { - project (fullPath: $fullPath) { - issue (iid: $iid) { +query($fullPath: ID!, $iid: String!) { + project(fullPath: $fullPath) { + issue(iid: $iid) { iid } } diff --git a/app/assets/javascripts/sidebar/queries/sidebarDetailsForHealthStatusFeatureFlag.query.graphql b/app/assets/javascripts/sidebar/queries/sidebarDetailsForHealthStatusFeatureFlag.query.graphql index 8cc68f6ea9a..2aff7da4605 100644 --- a/app/assets/javascripts/sidebar/queries/sidebarDetailsForHealthStatusFeatureFlag.query.graphql +++ b/app/assets/javascripts/sidebar/queries/sidebarDetailsForHealthStatusFeatureFlag.query.graphql @@ -1,6 +1,6 @@ -query ($fullPath: ID!, $iid: String!) { - project (fullPath: $fullPath) { - issue (iid: $iid) { +query($fullPath: ID!, $iid: String!) { + project(fullPath: $fullPath) { + issue(iid: $iid) { iid } } diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue index a6651515e47..c01f9524ca8 100644 --- a/app/assets/javascripts/snippets/components/edit.vue +++ b/app/assets/javascripts/snippets/components/edit.vue @@ -3,9 +3,8 @@ import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import Flash from '~/flash'; import { __, sprintf } from '~/locale'; -import axios from '~/lib/utils/axios_utils'; import TitleField from '~/vue_shared/components/form/title.vue'; -import { getBaseURL, joinPaths, redirectTo } from '~/lib/utils/url_utility'; +import { redirectTo } from '~/lib/utils/url_utility'; import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue'; import UpdateSnippetMutation from '../mutations/updateSnippet.mutation.graphql'; @@ -15,6 +14,9 @@ import { SNIPPET_VISIBILITY_PRIVATE, SNIPPET_CREATE_MUTATION_ERROR, SNIPPET_UPDATE_MUTATION_ERROR, + SNIPPET_BLOB_ACTION_CREATE, + SNIPPET_BLOB_ACTION_UPDATE, + SNIPPET_BLOB_ACTION_MOVE, } from '../constants'; import SnippetBlobEdit from './snippet_blob_edit.vue'; import SnippetVisibilityEdit from './snippet_visibility_edit.vue'; @@ -53,17 +55,25 @@ export default { }, data() { return { - blob: {}, - fileName: '', - content: '', - isContentLoading: true, + blobsActions: {}, isUpdating: false, newSnippet: false, }; }, computed: { + getActionsEntries() { + return Object.values(this.blobsActions); + }, + allBlobsHaveContent() { + const entries = this.getActionsEntries; + return entries.length > 0 && !entries.find(action => !action.content); + }, + allBlobChangesRegistered() { + const entries = this.getActionsEntries; + return entries.length > 0 && !entries.find(action => action.action === ''); + }, updatePrevented() { - return this.snippet.title === '' || this.content === '' || this.isUpdating; + return this.snippet.title === '' || !this.allBlobsHaveContent || this.isUpdating; }, isProjectSnippet() { return Boolean(this.projectPath); @@ -74,8 +84,7 @@ export default { title: this.snippet.title, description: this.snippet.description, visibilityLevel: this.snippet.visibilityLevel, - fileName: this.fileName, - content: this.content, + files: this.getActionsEntries.filter(entry => entry.action !== ''), }; }, saveButtonLabel() { @@ -97,9 +106,57 @@ export default { return `${this.isProjectSnippet ? 'project' : 'personal'}_snippet_description`; }, }, + created() { + window.addEventListener('beforeunload', this.onBeforeUnload); + }, + destroyed() { + window.removeEventListener('beforeunload', this.onBeforeUnload); + }, methods: { - updateFileName(newName) { - this.fileName = newName; + onBeforeUnload(e = {}) { + const returnValue = __('Are you sure you want to lose unsaved changes?'); + + if (!this.allBlobChangesRegistered) return undefined; + + Object.assign(e, { returnValue }); + return returnValue; + }, + updateBlobActions(args = {}) { + // `_constants` is the internal prop that + // should not be sent to the mutation. Hence we filter it out from + // the argsToUpdateAction that is the data-basis for the mutation. + const { _constants: blobConstants, ...argsToUpdateAction } = args; + const { previousPath, filePath, content } = argsToUpdateAction; + let actionEntry = this.blobsActions[blobConstants.id] || {}; + let tunedActions = { + action: '', + previousPath, + }; + + if (this.newSnippet) { + // new snippet, hence new blob + tunedActions = { + action: SNIPPET_BLOB_ACTION_CREATE, + previousPath: '', + }; + } else if (previousPath && filePath) { + // renaming of a blob + renaming & content update + const renamedToOriginal = filePath === blobConstants.originalPath; + tunedActions = { + action: renamedToOriginal ? SNIPPET_BLOB_ACTION_UPDATE : SNIPPET_BLOB_ACTION_MOVE, + previousPath: !renamedToOriginal ? blobConstants.originalPath : '', + }; + } else if (content !== blobConstants.originalContent) { + // content update only + tunedActions = { + action: SNIPPET_BLOB_ACTION_UPDATE, + previousPath: '', + }; + } + + actionEntry = { ...actionEntry, ...argsToUpdateAction, ...tunedActions }; + + this.$set(this.blobsActions, blobConstants.id, actionEntry); }, flashAPIFailure(err) { const defaultErrorMsg = this.newSnippet @@ -111,24 +168,9 @@ export default { onNewSnippetFetched() { this.newSnippet = true; this.snippet = this.$options.newSnippetSchema; - this.blob = this.snippet.blob; - this.isContentLoading = false; }, onExistingSnippetFetched() { this.newSnippet = false; - const { blob } = this.snippet; - this.blob = blob; - this.fileName = blob.name; - const baseUrl = getBaseURL(); - const url = joinPaths(baseUrl, blob.rawPath); - - axios - .get(url) - .then(res => { - this.content = res.data; - this.isContentLoading = false; - }) - .catch(e => this.flashAPIFailure(e)); }, onSnippetFetch(snippetRes) { if (snippetRes.data.snippets.edges.length === 0) { @@ -172,6 +214,7 @@ export default { if (errors.length) { this.flashAPIFailure(errors[0]); } else { + this.originalContent = this.content; redirectTo(baseObj.snippet.webUrl); } }) @@ -184,7 +227,6 @@ export default { title: '', description: '', visibilityLevel: SNIPPET_VISIBILITY_PRIVATE, - blob: {}, }, }; </script> @@ -215,12 +257,16 @@ export default { :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" /> - <snippet-blob-edit - v-model="content" - :file-name="fileName" - :is-loading="isContentLoading" - @name-change="updateFileName" - /> + <template v-if="blobs.length"> + <snippet-blob-edit + v-for="blob in blobs" + :key="blob.name" + :blob="blob" + @blob-updated="updateBlobActions" + /> + </template> + <snippet-blob-edit v-else @blob-updated="updateBlobActions" /> + <snippet-visibility-edit v-model="snippet.visibilityLevel" :help-link="visibilityHelpLink" diff --git a/app/assets/javascripts/snippets/components/show.vue b/app/assets/javascripts/snippets/components/show.vue index bc0034d397e..0779e87e6b6 100644 --- a/app/assets/javascripts/snippets/components/show.vue +++ b/app/assets/javascripts/snippets/components/show.vue @@ -1,19 +1,27 @@ <script> +import BlobEmbeddable from '~/blob/components/blob_embeddable.vue'; import SnippetHeader from './snippet_header.vue'; import SnippetTitle from './snippet_title.vue'; import SnippetBlob from './snippet_blob_view.vue'; import { GlLoadingIcon } from '@gitlab/ui'; import { getSnippetMixin } from '../mixins/snippets'; +import { SNIPPET_VISIBILITY_PUBLIC } from '~/snippets/constants'; export default { components: { + BlobEmbeddable, SnippetHeader, SnippetTitle, GlLoadingIcon, SnippetBlob, }, mixins: [getSnippetMixin], + computed: { + embeddable() { + return this.snippet.visibilityLevel === SNIPPET_VISIBILITY_PUBLIC; + }, + }, }; </script> <template> @@ -27,7 +35,10 @@ export default { <template v-else> <snippet-header :snippet="snippet" /> <snippet-title :snippet="snippet" /> - <snippet-blob :snippet="snippet" /> + <blob-embeddable v-if="embeddable" class="gl-mb-5" :url="snippet.webUrl" /> + <div v-for="blob in blobs" :key="blob.path"> + <snippet-blob :snippet="snippet" :blob="blob" /> + </div> </template> </div> </template> diff --git a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue index 62c29b0c7cd..3c2dbfff6e1 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue @@ -2,6 +2,17 @@ import BlobHeaderEdit from '~/blob/components/blob_edit_header.vue'; import BlobContentEdit from '~/blob/components/blob_edit_content.vue'; import { GlLoadingIcon } from '@gitlab/ui'; +import { getBaseURL, joinPaths } from '~/lib/utils/url_utility'; +import axios from '~/lib/utils/axios_utils'; +import { SNIPPET_BLOB_CONTENT_FETCH_ERROR } from '~/snippets/constants'; +import Flash from '~/flash'; +import { sprintf } from '~/locale'; + +function localId() { + return Math.floor((1 + Math.random()) * 0x10000) + .toString(16) + .substring(1); +} export default { components: { @@ -11,20 +22,70 @@ export default { }, inheritAttrs: false, props: { - value: { - type: String, + blob: { + type: Object, required: false, - default: '', + default: null, + validator: ({ rawPath }) => Boolean(rawPath), }, - fileName: { - type: String, - required: false, - default: '', + }, + data() { + return { + id: localId(), + filePath: this.blob?.path || '', + previousPath: '', + originalPath: this.blob?.path || '', + content: this.blob?.content || '', + originalContent: '', + isContentLoading: this.blob, + }; + }, + watch: { + filePath(filePath, previousPath) { + this.previousPath = previousPath; + this.notifyAboutUpdates({ previousPath }); }, - isLoading: { - type: Boolean, - required: false, - default: true, + content() { + this.notifyAboutUpdates(); + }, + }, + mounted() { + if (this.blob) { + this.fetchBlobContent(); + } + }, + methods: { + notifyAboutUpdates(args = {}) { + const { filePath, previousPath } = args; + this.$emit('blob-updated', { + filePath: filePath || this.filePath, + previousPath: previousPath || this.previousPath, + content: this.content, + _constants: { + originalPath: this.originalPath, + originalContent: this.originalContent, + id: this.id, + }, + }); + }, + fetchBlobContent() { + const baseUrl = getBaseURL(); + const url = joinPaths(baseUrl, this.blob.rawPath); + + axios + .get(url) + .then(res => { + this.originalContent = res.data; + this.content = res.data; + }) + .catch(e => this.flashAPIFailure(e)) + .finally(() => { + this.isContentLoading = false; + }); + }, + flashAPIFailure(err) { + Flash(sprintf(SNIPPET_BLOB_CONTENT_FETCH_ERROR, { err })); + this.isContentLoading = false; }, }, }; @@ -33,23 +94,14 @@ export default { <div class="form-group file-editor"> <label>{{ s__('Snippets|File') }}</label> <div class="file-holder snippet"> - <blob-header-edit - :value="fileName" - data-qa-selector="file_name_field" - @input="$emit('name-change', $event)" - /> + <blob-header-edit v-model="filePath" data-qa-selector="file_name_field" /> <gl-loading-icon - v-if="isLoading" + v-if="isContentLoading" :label="__('Loading snippet')" size="lg" class="loading-animation prepend-top-20 append-bottom-20" /> - <blob-content-edit - v-else - :value="value" - :file-name="fileName" - @input="$emit('input', $event)" - /> + <blob-content-edit v-else v-model="content" :file-name="filePath" /> </div> </div> </template> diff --git a/app/assets/javascripts/snippets/components/snippet_blob_view.vue b/app/assets/javascripts/snippets/components/snippet_blob_view.vue index 7472aff3318..afd038eef58 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_view.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_view.vue @@ -1,6 +1,4 @@ <script> -import BlobEmbeddable from '~/blob/components/blob_embeddable.vue'; -import { SNIPPET_VISIBILITY_PUBLIC } from '../constants'; import BlobHeader from '~/blob/components/blob_header.vue'; import BlobContent from '~/blob/components/blob_content.vue'; import CloneDropdownButton from '~/vue_shared/components/clone_dropdown.vue'; @@ -16,7 +14,6 @@ import { export default { components: { - BlobEmbeddable, BlobHeader, BlobContent, CloneDropdownButton, @@ -49,21 +46,19 @@ export default { type: Object, required: true, }, + blob: { + type: Object, + required: true, + }, }, data() { return { - blob: this.snippet.blob, blobContent: '', activeViewerType: - this.snippet.blob?.richViewer && !window.location.hash - ? RICH_BLOB_VIEWER - : SIMPLE_BLOB_VIEWER, + this.blob?.richViewer && !window.location.hash ? RICH_BLOB_VIEWER : SIMPLE_BLOB_VIEWER, }; }, computed: { - embeddable() { - return this.snippet.visibilityLevel === SNIPPET_VISIBILITY_PUBLIC; - }, isContentLoading() { return this.$apollo.queries.blobContent.loading; }, @@ -92,33 +87,30 @@ export default { }; </script> <template> - <div> - <blob-embeddable v-if="embeddable" class="mb-3" :url="snippet.webUrl" /> - <article class="file-holder snippet-file-content"> - <blob-header - :blob="blob" - :active-viewer-type="viewer.type" - :has-render-error="hasRenderError" - @viewer-changed="switchViewer" - > - <template #actions> - <clone-dropdown-button - v-if="canBeCloned" - class="mr-2" - :ssh-link="snippet.sshUrlToRepo" - :http-link="snippet.httpUrlToRepo" - data-qa-selector="clone_button" - /> - </template> - </blob-header> - <blob-content - :loading="isContentLoading" - :content="blobContent" - :active-viewer="viewer" - :blob="blob" - @[$options.BLOB_RENDER_EVENT_LOAD]="forceQuery" - @[$options.BLOB_RENDER_EVENT_SHOW_SOURCE]="switchViewer" - /> - </article> - </div> + <article class="file-holder snippet-file-content"> + <blob-header + :blob="blob" + :active-viewer-type="viewer.type" + :has-render-error="hasRenderError" + @viewer-changed="switchViewer" + > + <template #actions> + <clone-dropdown-button + v-if="canBeCloned" + class="gl-mr-3" + :ssh-link="snippet.sshUrlToRepo" + :http-link="snippet.httpUrlToRepo" + data-qa-selector="clone_button" + /> + </template> + </blob-header> + <blob-content + :loading="isContentLoading" + :content="blobContent" + :active-viewer="viewer" + :blob="blob" + @[$options.BLOB_RENDER_EVENT_LOAD]="forceQuery" + @[$options.BLOB_RENDER_EVENT_SHOW_SOURCE]="switchViewer" + /> + </article> </template> diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue index 2a06296cb15..707e2b0ea30 100644 --- a/app/assets/javascripts/snippets/components/snippet_header.vue +++ b/app/assets/javascripts/snippets/components/snippet_header.vue @@ -65,14 +65,17 @@ export default { }; }, computed: { + snippetHasBinary() { + return Boolean(this.snippet.blobs.find(blob => blob.binary)); + }, personalSnippetActions() { return [ { condition: this.snippet.userPermissions.updateSnippet, text: __('Edit'), href: this.editLink, - disabled: this.snippet.blob.binary, - title: this.snippet.blob.binary + disabled: this.snippetHasBinary, + title: this.snippetHasBinary ? __('Snippets with non-text files can only be edited via Git.') : undefined, }, @@ -163,7 +166,7 @@ export default { <div class="detail-page-header"> <div class="detail-page-header-body"> <div - class="snippet-box has-tooltip d-flex align-items-center append-right-5 mb-1" + class="snippet-box has-tooltip d-flex align-items-center gl-mr-2 mb-1" data-qa-selector="snippet_container" :title="snippetVisibilityLevelDescription" data-container="body" diff --git a/app/assets/javascripts/snippets/constants.js b/app/assets/javascripts/snippets/constants.js index b3abc73557c..99ee698408d 100644 --- a/app/assets/javascripts/snippets/constants.js +++ b/app/assets/javascripts/snippets/constants.js @@ -25,3 +25,8 @@ export const SNIPPET_VISIBILITY = { export const SNIPPET_CREATE_MUTATION_ERROR = __("Can't create snippet: %{err}"); export const SNIPPET_UPDATE_MUTATION_ERROR = __("Can't update snippet: %{err}"); +export const SNIPPET_BLOB_CONTENT_FETCH_ERROR = __("Can't fetch content for the blob: %{err}"); + +export const SNIPPET_BLOB_ACTION_CREATE = 'create'; +export const SNIPPET_BLOB_ACTION_UPDATE = 'update'; +export const SNIPPET_BLOB_ACTION_MOVE = 'move'; diff --git a/app/assets/javascripts/snippets/fragments/project.fragment.graphql b/app/assets/javascripts/snippets/fragments/project.fragment.graphql index 7d65789c67b..64bb2315c1b 100644 --- a/app/assets/javascripts/snippets/fragments/project.fragment.graphql +++ b/app/assets/javascripts/snippets/fragments/project.fragment.graphql @@ -1,6 +1,6 @@ -fragment Project on Snippet { +fragment SnippetProject on Snippet { project { fullPath webUrl } -}
\ No newline at end of file +} diff --git a/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql b/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql index e7765dfd8ba..2cca71708ca 100644 --- a/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql +++ b/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql @@ -11,7 +11,7 @@ fragment SnippetBase on Snippet { webUrl httpUrlToRepo sshUrlToRepo - blob { + blobs { binary name path diff --git a/app/assets/javascripts/snippets/mixins/snippets.js b/app/assets/javascripts/snippets/mixins/snippets.js index 837c41cdf6b..91331cdf339 100644 --- a/app/assets/javascripts/snippets/mixins/snippets.js +++ b/app/assets/javascripts/snippets/mixins/snippets.js @@ -1,5 +1,7 @@ import GetSnippetQuery from '../queries/snippet.query.graphql'; +const blobsDefault = []; + export const getSnippetMixin = { apollo: { snippet: { @@ -11,6 +13,7 @@ export const getSnippetMixin = { }, update: data => data.snippets.edges[0]?.node, result(res) { + this.blobs = res.data.snippets.edges[0]?.node?.blobs || blobsDefault; if (this.onSnippetFetch) { this.onSnippetFetch(res); } @@ -27,6 +30,7 @@ export const getSnippetMixin = { return { snippet: {}, newSnippet: false, + blobs: blobsDefault, }; }, computed: { diff --git a/app/assets/javascripts/snippets/mutations/deleteSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/deleteSnippet.mutation.graphql index 0c829cbdee6..f43d53661f4 100644 --- a/app/assets/javascripts/snippets/mutations/deleteSnippet.mutation.graphql +++ b/app/assets/javascripts/snippets/mutations/deleteSnippet.mutation.graphql @@ -1,5 +1,5 @@ mutation DeleteSnippet($id: ID!) { - destroySnippet(input: {id: $id}) { + destroySnippet(input: { id: $id }) { errors } -}
\ No newline at end of file +} diff --git a/app/assets/javascripts/snippets/queries/projectPermissions.query.graphql b/app/assets/javascripts/snippets/queries/projectPermissions.query.graphql index 288bd0889bf..03c81460fb5 100644 --- a/app/assets/javascripts/snippets/queries/projectPermissions.query.graphql +++ b/app/assets/javascripts/snippets/queries/projectPermissions.query.graphql @@ -4,4 +4,4 @@ query CanCreateProjectSnippet($fullPath: ID!) { createSnippet } } -}
\ No newline at end of file +} diff --git a/app/assets/javascripts/snippets/queries/snippet.query.graphql b/app/assets/javascripts/snippets/queries/snippet.query.graphql index c58a5168ba3..b23ab862439 100644 --- a/app/assets/javascripts/snippets/queries/snippet.query.graphql +++ b/app/assets/javascripts/snippets/queries/snippet.query.graphql @@ -7,7 +7,7 @@ query GetSnippetQuery($ids: [ID!]) { edges { node { ...SnippetBase - ...Project + ...SnippetProject author { ...Author } diff --git a/app/assets/javascripts/snippets/queries/userPermissions.query.graphql b/app/assets/javascripts/snippets/queries/userPermissions.query.graphql index f5b97b3d0f0..c3e5519e266 100644 --- a/app/assets/javascripts/snippets/queries/userPermissions.query.graphql +++ b/app/assets/javascripts/snippets/queries/userPermissions.query.graphql @@ -4,4 +4,4 @@ query CanCreatePersonalSnippet { createSnippet } } -}
\ No newline at end of file +} 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 e9efef40632..84a16f327d9 100644 --- a/app/assets/javascripts/static_site_editor/components/edit_area.vue +++ b/app/assets/javascripts/static_site_editor/components/edit_area.vue @@ -5,6 +5,8 @@ import EditHeader from './edit_header.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 { DEFAULT_IMAGE_UPLOAD_PATH } from '../constants'; +import imageRepository from '../image_repository'; export default { components: { @@ -31,46 +33,47 @@ export default { required: false, default: '', }, + imageRoot: { + type: String, + required: false, + default: DEFAULT_IMAGE_UPLOAD_PATH, + validator: prop => prop.endsWith('/'), + }, }, data() { return { saveable: false, parsedSource: parseSourceFile(this.content), editorMode: EDITOR_TYPES.wysiwyg, + isModified: false, }; }, + imageRepository: imageRepository(), computed: { editableContent() { - return this.parsedSource.editable; - }, - editableKey() { - return this.isWysiwygMode ? 'body' : 'raw'; + return this.parsedSource.content(this.isWysiwygMode); }, isWysiwygMode() { return this.editorMode === EDITOR_TYPES.wysiwyg; }, - modified() { - return this.isWysiwygMode - ? this.parsedSource.isModifiedBody() - : this.parsedSource.isModifiedRaw(); - }, }, methods: { - syncSource() { - if (this.isWysiwygMode) { - this.parsedSource.syncBody(); - return; - } - - this.parsedSource.syncRaw(); + onInputChange(newVal) { + this.parsedSource.sync(newVal, this.isWysiwygMode); + this.isModified = this.parsedSource.isModified(); }, onModeChange(mode) { this.editorMode = mode; - this.syncSource(); + this.$refs.editor.resetInitialValue(this.editableContent); + }, + onUploadImage({ file, imageUrl }) { + this.$options.imageRepository.add(file, imageUrl); }, onSubmit() { - this.syncSource(); - this.$emit('submit', { content: this.editableContent.raw }); + this.$emit('submit', { + content: this.parsedSource.content(), + images: this.$options.imageRepository.getAll(), + }); }, }, }; @@ -79,16 +82,20 @@ export default { <div class="d-flex flex-grow-1 flex-column h-100"> <edit-header class="py-2" :title="title" /> <rich-content-editor - v-model="editableContent[editableKey]" + ref="editor" + :content="editableContent" :initial-edit-type="editorMode" + :image-root="imageRoot" class="mb-9 h-100" @modeChange="onModeChange" + @input="onInputChange" + @uploadImage="onUploadImage" /> - <unsaved-changes-confirm-dialog :modified="modified" /> + <unsaved-changes-confirm-dialog :modified="isModified" /> <publish-toolbar class="gl-fixed gl-left-0 gl-bottom-0 gl-w-full" :return-url="returnUrl" - :saveable="modified" + :saveable="isModified" :saving-changes="savingChanges" @submit="onSubmit" /> diff --git a/app/assets/javascripts/static_site_editor/constants.js b/app/assets/javascripts/static_site_editor/constants.js index 947347922f2..49db9ab7ca5 100644 --- a/app/assets/javascripts/static_site_editor/constants.js +++ b/app/assets/javascripts/static_site_editor/constants.js @@ -19,3 +19,5 @@ export const DEFAULT_HEADING = s__('StaticSiteEditor|Static site editor'); export const TRACKING_ACTION_CREATE_COMMIT = 'create_commit'; export const TRACKING_ACTION_CREATE_MERGE_REQUEST = 'create_merge_request'; export const TRACKING_ACTION_INITIALIZE_EDITOR = 'initialize_editor'; + +export const DEFAULT_IMAGE_UPLOAD_PATH = 'source/images/uploads/'; diff --git a/app/assets/javascripts/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql b/app/assets/javascripts/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql index 2840d419966..cd130aa7dbb 100644 --- a/app/assets/javascripts/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql +++ b/app/assets/javascripts/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql @@ -1,5 +1,5 @@ mutation submitContentChanges($input: SubmitContentChangesInput) { - submitContentChanges(input: $input) @client { + submitContentChanges(input: $input) @client { branch commit mergeRequest diff --git a/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql b/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql index fdbf4459aee..946d80efff0 100644 --- a/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql +++ b/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql @@ -3,7 +3,7 @@ query appData { isSupportedContent project sourcePath - username, + username returnUrl } } diff --git a/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql b/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql index e36d244ae57..cfe30c601ed 100644 --- a/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql +++ b/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql @@ -1,6 +1,6 @@ query sourceContent($project: ID!, $sourcePath: String!) { project(fullPath: $project) { - fullPath, + fullPath file(path: $sourcePath) @client { title content diff --git a/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js b/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js index 6c4e3a4d973..0cb26f88785 100644 --- a/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js +++ b/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js @@ -3,10 +3,10 @@ import savedContentMetaQuery from '../queries/saved_content_meta.query.graphql'; const submitContentChangesResolver = ( _, - { input: { project: projectId, username, sourcePath, content } }, + { input: { project: projectId, username, sourcePath, content, images } }, { cache }, ) => { - return submitContentChanges({ projectId, username, sourcePath, content }).then( + return submitContentChanges({ projectId, username, sourcePath, content, images }).then( savedContentMeta => { cache.writeQuery({ query: savedContentMetaQuery, diff --git a/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql b/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql index 59da2e27144..78cc1746cdb 100644 --- a/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql +++ b/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql @@ -22,7 +22,7 @@ type AppData { username: String! } -type SubmitContentChangesInput { +input SubmitContentChangesInput { project: String! sourcePath: String! content: String! diff --git a/app/assets/javascripts/static_site_editor/image_repository.js b/app/assets/javascripts/static_site_editor/image_repository.js new file mode 100644 index 00000000000..541d581bda8 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/image_repository.js @@ -0,0 +1,20 @@ +import { __ } from '~/locale'; +import Flash from '~/flash'; +import { getBinary } from './services/image_service'; + +const imageRepository = () => { + const images = new Map(); + const flash = message => new Flash(message); + + const add = (file, url) => { + getBinary(file) + .then(content => images.set(url, content)) + .catch(() => flash(__('Something went wrong while inserting your image. Please try again.'))); + }; + + const getAll = () => images; + + return { add, getAll }; +}; + +export default imageRepository; diff --git a/app/assets/javascripts/static_site_editor/pages/home.vue b/app/assets/javascripts/static_site_editor/pages/home.vue index a1314c8a478..156b815e07a 100644 --- a/app/assets/javascripts/static_site_editor/pages/home.vue +++ b/app/assets/javascripts/static_site_editor/pages/home.vue @@ -67,11 +67,11 @@ export default { onDismissError() { this.submitChangesError = null; }, - onSubmit({ content }) { + onSubmit({ content, images }) { this.content = content; - this.submitChanges(); + this.submitChanges(images); }, - submitChanges() { + submitChanges(images) { this.isSavingChanges = true; this.$apollo @@ -83,6 +83,7 @@ export default { username: this.appData.username, sourcePath: this.appData.sourcePath, content: this.content, + images, }, }, }) diff --git a/app/assets/javascripts/static_site_editor/services/image_service.js b/app/assets/javascripts/static_site_editor/services/image_service.js new file mode 100644 index 00000000000..edc69d0579a --- /dev/null +++ b/app/assets/javascripts/static_site_editor/services/image_service.js @@ -0,0 +1,9 @@ +// eslint-disable-next-line import/prefer-default-export +export const getBinary = file => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => resolve(reader.result.split(',')[1]); + reader.onerror = error => reject(error); + }); +}; diff --git a/app/assets/javascripts/static_site_editor/services/parse_source_file.js b/app/assets/javascripts/static_site_editor/services/parse_source_file.js index f32c693411f..126dfe81b90 100644 --- a/app/assets/javascripts/static_site_editor/services/parse_source_file.js +++ b/app/assets/javascripts/static_site_editor/services/parse_source_file.js @@ -22,33 +22,43 @@ const parseSourceFile = raw => { return buildPayload(source, '', '', source); }; - const computedRaw = () => `${editable.header}${editable.spacing}${editable.body}`; - - const syncBody = () => { + const syncEditable = () => { /* We re-parse as markdown editing could have added non-body changes (preFrontMatter, frontMatter, or spacing). - Re-parsing additionally gets us the desired body that was extracted from the mutated editable.raw - Additionally we intentionally mutate the existing editable's key values as opposed to reassigning the object itself so consumers of the potentially reactive property stay in sync. + Re-parsing additionally gets us the desired body that was extracted from the potentially mutated editable.raw */ - Object.assign(editable, parse(editable.raw)); + editable = parse(editable.raw); + }; + + const syncBodyToRaw = () => { + editable.raw = `${editable.header}${editable.spacing}${editable.body}`; + }; + + const sync = (newVal, isBodyToRaw) => { + const editableKey = isBodyToRaw ? 'body' : 'raw'; + editable[editableKey] = newVal; + + if (isBodyToRaw) { + syncBodyToRaw(); + } + + syncEditable(); }; - const syncRaw = () => { - editable.raw = computedRaw(); + const content = (isBody = false) => { + const editableKey = isBody ? 'body' : 'raw'; + return editable[editableKey]; }; - const isModifiedRaw = () => initial.raw !== editable.raw; - const isModifiedBody = () => initial.raw !== computedRaw(); + const isModified = () => initial.raw !== editable.raw; initial = parse(raw); editable = parse(raw); return { - editable, - isModifiedRaw, - isModifiedBody, - syncRaw, - syncBody, + content, + isModified, + sync, }; }; diff --git a/app/assets/javascripts/static_site_editor/services/submit_content_changes.js b/app/assets/javascripts/static_site_editor/services/submit_content_changes.js index fce7c1f918f..da62d3fa4fc 100644 --- a/app/assets/javascripts/static_site_editor/services/submit_content_changes.js +++ b/app/assets/javascripts/static_site_editor/services/submit_content_changes.js @@ -21,7 +21,32 @@ const createBranch = (projectId, branch) => throw new Error(SUBMIT_CHANGES_BRANCH_ERROR); }); -const commitContent = (projectId, message, branch, sourcePath, content) => { +const createImageActions = (images, markdown) => { + const actions = []; + + if (!markdown) { + return actions; + } + + images.forEach((imageContent, filePath) => { + const imageExistsInMarkdown = path => new RegExp(`!\\[([^[\\]\\n]*)\\](\\(${path})\\)`); // matches the image markdown syntax: ![<any-string-except-newline>](<path>) + + if (imageExistsInMarkdown(filePath).test(markdown)) { + actions.push( + convertObjectPropsToSnakeCase({ + encoding: 'base64', + action: 'create', + content: imageContent, + filePath, + }), + ); + } + }); + + return actions; +}; + +const commitContent = (projectId, message, branch, sourcePath, content, images) => { Tracking.event(document.body.dataset.page, TRACKING_ACTION_CREATE_COMMIT); return Api.commitMultiple( @@ -35,6 +60,7 @@ const commitContent = (projectId, message, branch, sourcePath, content) => { filePath: sourcePath, content, }), + ...createImageActions(images, content), ], }), ).catch(() => { @@ -62,7 +88,7 @@ const createMergeRequest = ( }); }; -const submitContentChanges = ({ username, projectId, sourcePath, content }) => { +const submitContentChanges = ({ username, projectId, sourcePath, content, images }) => { const branch = generateBranchName(username); const mergeRequestTitle = sprintf(s__(`StaticSiteEditor|Update %{sourcePath} file`), { sourcePath, @@ -73,7 +99,7 @@ const submitContentChanges = ({ username, projectId, sourcePath, content }) => { .then(({ data: { web_url: url } }) => { Object.assign(meta, { branch: { label: branch, url } }); - return commitContent(projectId, mergeRequestTitle, branch, sourcePath, content); + return commitContent(projectId, mergeRequestTitle, branch, sourcePath, content, images); }) .then(({ data: { short_id: label, web_url: url } }) => { Object.assign(meta, { commit: { label, url } }); diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js index bde00d72620..290de55e6f9 100644 --- a/app/assets/javascripts/user_popovers.js +++ b/app/assets/javascripts/user_popovers.js @@ -1,5 +1,7 @@ import Vue from 'vue'; +import sanitize from 'sanitize-html'; + import UsersCache from './lib/utils/users_cache'; import UserPopover from './vue_shared/components/user_popover/user_popover.vue'; @@ -38,6 +40,7 @@ const populateUserInfo = user => { name: userData.name, location: userData.location, bio: userData.bio, + bioHtml: sanitize(userData.bio_html), workInformation: userData.work_information, loaded: true, }); diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js index 2dbe5a8171e..f72de8c2f4d 100644 --- a/app/assets/javascripts/users_select/index.js +++ b/app/assets/javascripts/users_select/index.js @@ -21,8 +21,8 @@ function UsersSelect(currentUser, els, options = {}) { const $els = $(els || '.js-user-search'); this.users = this.users.bind(this); this.user = this.user.bind(this); - this.usersPath = '/autocomplete/users.json'; - this.userPath = '/autocomplete/users/:id.json'; + this.usersPath = '/-/autocomplete/users.json'; + this.userPath = '/-/autocomplete/users/:id.json'; if (currentUser != null) { if (typeof currentUser === 'object') { this.currentUser = currentUser; @@ -263,7 +263,7 @@ function UsersSelect(currentUser, els, options = {}) { const userId = parseInt(input.value, 10); const { avatarUrl, avatar_url, name, username, canMerge } = input.dataset; return { - avatar_url: avatarUrl || avatar_url, + avatar_url: avatarUrl || avatar_url || gon.default_avatar_url, id: userId, name, username, 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 new file mode 100644 index 00000000000..0f9d1b8395b --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue @@ -0,0 +1,212 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { s__ } from '~/locale'; +import eventHub from '../../event_hub'; +import approvalsMixin from '../../mixins/approvals'; +import MrWidgetContainer from '../mr_widget_container.vue'; +import MrWidgetIcon from '../mr_widget_icon.vue'; +import ApprovalsSummary from './approvals_summary.vue'; +import ApprovalsSummaryOptional from './approvals_summary_optional.vue'; +import { FETCH_LOADING, FETCH_ERROR, APPROVE_ERROR, UNAPPROVE_ERROR } from './messages'; + +export default { + name: 'MRWidgetApprovals', + components: { + MrWidgetContainer, + MrWidgetIcon, + ApprovalsSummary, + ApprovalsSummaryOptional, + GlButton, + }, + mixins: [approvalsMixin], + props: { + mr: { + type: Object, + required: true, + }, + service: { + type: Object, + required: true, + }, + isOptionalDefault: { + type: Boolean, + required: false, + default: null, + }, + approveDefault: { + type: Function, + required: false, + default: null, + }, + modalId: { + type: String, + required: false, + default: null, + }, + requirePasswordToApprove: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + fetchingApprovals: true, + hasApprovalAuthError: false, + isApproving: false, + }; + }, + computed: { + isBasic() { + return this.mr.approvalsWidgetType === 'base'; + }, + isApproved() { + return Boolean(this.approvals.approved); + }, + isOptional() { + return this.isOptionalDefault !== null ? this.isOptionalDefault : !this.approvedBy.length; + }, + hasAction() { + return Boolean(this.action); + }, + approvals() { + return this.mr.approvals || {}; + }, + approvedBy() { + return this.approvals.approved_by ? this.approvals.approved_by.map(x => x.user) : []; + }, + userHasApproved() { + return Boolean(this.approvals.user_has_approved); + }, + userCanApprove() { + return Boolean(this.approvals.user_can_approve); + }, + showApprove() { + return !this.userHasApproved && this.userCanApprove && this.mr.isOpen; + }, + showUnapprove() { + return this.userHasApproved && !this.userCanApprove && this.mr.state !== 'merged'; + }, + approvalText() { + return this.isApproved && this.approvedBy.length > 0 + ? s__('mrWidget|Approve additionally') + : s__('mrWidget|Approve'); + }, + action() { + // Use the default approve action, only if we aren't using the auth component for it + if (this.showApprove) { + return { + text: this.approvalText, + category: this.isApproved ? 'secondary' : 'primary', + variant: 'info', + action: () => this.approve(), + }; + } else if (this.showUnapprove) { + return { + text: s__('mrWidget|Revoke approval'), + variant: 'warning', + category: 'secondary', + action: () => this.unapprove(), + }; + } + + return null; + }, + }, + created() { + this.refreshApprovals() + .then(() => { + this.fetchingApprovals = false; + }) + .catch(() => createFlash(FETCH_ERROR)); + }, + methods: { + approve() { + if (this.requirePasswordToApprove) { + this.$root.$emit('bv::show::modal', this.modalId); + return; + } + + this.updateApproval( + () => this.service.approveMergeRequest(), + () => createFlash(APPROVE_ERROR), + ); + }, + approveWithAuth(data) { + this.updateApproval( + () => this.service.approveMergeRequestWithAuth(data), + error => { + if (error && error.response && error.response.status === 401) { + this.hasApprovalAuthError = true; + return; + } + createFlash(APPROVE_ERROR); + }, + ); + }, + unapprove() { + this.updateApproval( + () => this.service.unapproveMergeRequest(), + () => createFlash(UNAPPROVE_ERROR), + ); + }, + updateApproval(serviceFn, errFn) { + this.isApproving = true; + this.clearError(); + return serviceFn() + .then(data => { + this.mr.setApprovals(data); + eventHub.$emit('MRWidgetUpdateRequested'); + this.$emit('updated'); + }) + .catch(errFn) + .then(() => { + this.isApproving = false; + }); + }, + }, + FETCH_LOADING, +}; +</script> +<template> + <mr-widget-container> + <div class="js-mr-approvals d-flex align-items-start align-items-md-center"> + <mr-widget-icon name="approval" /> + <div v-if="fetchingApprovals">{{ $options.FETCH_LOADING }}</div> + <template v-else> + <gl-button + v-if="action" + :variant="action.variant" + :category="action.category" + :loading="isApproving" + class="mr-3" + data-qa-selector="approve_button" + @click="action.action" + > + {{ action.text }} + </gl-button> + <approvals-summary-optional + v-if="isOptional" + :can-approve="hasAction" + :help-path="mr.approvalsHelpPath" + /> + <approvals-summary + v-else + :approved="isApproved" + :approvals-left="approvals.approvals_left || 0" + :rules-left="approvals.approvalRuleNamesLeft" + :approvers="approvedBy" + /> + <slot + :is-approving="isApproving" + :approve-with-auth="approveWithAuth" + :hasApproval-auth-error="hasApprovalAuthError" + ></slot> + </template> + </div> + <template #footer> + <slot name="footer"></slot> + </template> + </mr-widget-container> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue new file mode 100644 index 00000000000..fb342a5d340 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue @@ -0,0 +1,70 @@ +<script> +import { n__, sprintf } from '~/locale'; +import { toNounSeriesText } from '~/lib/utils/grammar'; +import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue'; +import { APPROVED_MESSAGE } from '~/vue_merge_request_widget/components/approvals/messages'; + +export default { + components: { + UserAvatarList, + }, + props: { + approved: { + type: Boolean, + required: true, + }, + approvalsLeft: { + type: Number, + required: true, + }, + rulesLeft: { + type: Array, + required: false, + default: () => [], + }, + approvers: { + type: Array, + required: false, + default: () => [], + }, + }, + computed: { + message() { + if (this.approved) { + return APPROVED_MESSAGE; + } + + if (!this.rulesLeft.length) { + return n__('Requires approval.', 'Requires %d more approvals.', this.approvalsLeft); + } + + return sprintf( + n__( + 'Requires approval from %{names}.', + 'Requires %{count} more approvals from %{names}.', + this.approvalsLeft, + ), + { + names: toNounSeriesText(this.rulesLeft), + count: this.approvalsLeft, + }, + false, + ); + }, + hasApprovers() { + return Boolean(this.approvers.length); + }, + }, + APPROVED_MESSAGE, +}; +</script> + +<template> + <div data-qa-selector="approvals_summary_content"> + <strong>{{ message }}</strong> + <template v-if="hasApprovers"> + <span>{{ s__('mrWidget|Approved by') }}</span> + <user-avatar-list class="d-inline-block align-middle" :items="approvers" /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue new file mode 100644 index 00000000000..66af0c5a83e --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue @@ -0,0 +1,50 @@ +<script> +import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import { + OPTIONAL, + OPTIONAL_CAN_APPROVE, +} from '~/vue_merge_request_widget/components/approvals/messages'; + +export default { + components: { + GlLink, + Icon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + canApprove: { + type: Boolean, + required: true, + }, + helpPath: { + type: String, + required: false, + default: '', + }, + }, + computed: { + message() { + return this.canApprove ? OPTIONAL_CAN_APPROVE : OPTIONAL; + }, + }, +}; +</script> + +<template> + <div class="d-flex align-items-center"> + <span class="text-muted">{{ message }}</span> + <gl-link + v-if="canApprove && helpPath" + v-gl-tooltip + :href="helpPath" + :title="__('About this feature')" + target="_blank" + class="d-flex-center pl-1" + > + <icon name="question" /> + </gl-link> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/messages.js b/app/assets/javascripts/vue_merge_request_widget/components/approvals/messages.js new file mode 100644 index 00000000000..1d9368f71aa --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/messages.js @@ -0,0 +1,11 @@ +import { __, s__ } from '~/locale'; + +export const FETCH_LOADING = __('Checking approval status'); +export const FETCH_ERROR = s__( + 'mrWidget|An error occurred while retrieving approval data for this merge request.', +); +export const APPROVE_ERROR = s__('mrWidget|An error occurred while submitting your approval.'); +export const UNAPPROVE_ERROR = s__('mrWidget|An error occurred while removing your approval.'); +export const APPROVED_MESSAGE = s__('mrWidget|Merge request approved.'); +export const OPTIONAL_CAN_APPROVE = s__('mrWidget|No approval required; you can still approve'); +export const OPTIONAL = s__('mrWidget|No approval required'); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/loading.vue b/app/assets/javascripts/vue_merge_request_widget/components/loading.vue index 78dc28ee92b..cd4e31e0dae 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/loading.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/loading.vue @@ -9,7 +9,7 @@ export default { </script> <template> - <div class="prepend-top-default"> + <div class="gl-mt-3"> <div class="mr-widget-heading p-3"> <gl-skeleton-loader :width="577" :height="12"> <rect width="86" height="12" rx="2" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue index 294871ca5c2..24174c29d51 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue @@ -1,11 +1,11 @@ <script> -import { GlDeprecatedButton, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; export default { components: { - GlDeprecatedButton, + GlButton, GlLoadingIcon, Icon, }, @@ -58,16 +58,17 @@ export default { </div> <template v-else> - <gl-deprecated-button - class="btn-blank btn s32 square append-right-default" + <button + class="btn-blank btn s32 square gl-mr-3" + type="button" :aria-label="ariaLabel" :disabled="isLoading" @click="toggleCollapsed" > <gl-loading-icon v-if="isLoading" /> <icon v-else :name="arrowIconName" class="js-icon" /> - </gl-deprecated-button> - <gl-deprecated-button + </button> + <gl-button variant="link" class="js-title" :disabled="isLoading" @@ -76,7 +77,7 @@ export default { > <template v-if="isCollapsed">{{ title }}</template> <template v-else>{{ __('Collapse') }}</template> - </gl-deprecated-button> + </gl-button> </template> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue index 84937aa9510..598b08f4c16 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue @@ -27,7 +27,7 @@ export default { return this.author.webUrl || this.author.web_url; }, avatarUrl() { - return this.author.avatarUrl || this.author.avatar_url; + return this.author.avatarUrl || this.author.avatar_url || gl.mrWidgetData.defaultAvatarUrl; }, }, }; @@ -40,6 +40,6 @@ export default { class="author-link inline" > <img :src="avatarUrl" class="avatar avatar-inline s16" /> - <span v-if="showAuthorName" class="author"> {{ author.name }} </span> + <span v-if="showAuthorName" class="author">{{ author.name }}</span> </a> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_expandable_section.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_expandable_section.vue new file mode 100644 index 00000000000..fd999540f4a --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_expandable_section.vue @@ -0,0 +1,72 @@ +<script> +import { __ } from '~/locale'; +import { GlButton, GlCollapse, GlIcon } from '@gitlab/ui'; + +/** + * Renders header section with icon and expand button + * Renders expanable content section with grey background + */ +export default { + name: 'MrWidgetExpanableSection', + components: { + GlButton, + GlCollapse, + GlIcon, + }, + props: { + iconName: { + type: String, + required: false, + default: 'status_warning', + }, + }, + data() { + return { + contentIsVisible: false, + }; + }, + computed: { + collapseButtonText() { + if (this.contentIsVisible) { + return __('Collapse'); + } + + return __('Expand'); + }, + }, + methods: { + updateContentVisibility() { + this.contentIsVisible = !this.contentIsVisible; + }, + }, +}; +</script> + +<template> + <div> + <div class="mr-widget-body gl-display-flex"> + <span + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-mr-3 gl-align-self-start gl-mt-1" + > + <gl-icon :name="iconName" :size="24" /> + </span> + + <div class="gl-display-flex gl-flex-fill-1 gl-flex-direction-column gl-md-flex-direction-row"> + <slot name="header"></slot> + + <div> + <gl-button @click="updateContentVisibility"> + {{ collapseButtonText }} + </gl-button> + </div> + </div> + </div> + + <gl-collapse + :visible="contentIsVisible" + class="gl-bg-gray-10 gl-border-t-solid gl-border-gray-100 gl-border-1" + > + <slot name="content"></slot> + </gl-collapse> + </div> +</template> 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 0464c4b9c15..897f706290d 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 @@ -1,4 +1,5 @@ <script> +import Mousetrap from 'mousetrap'; import { escape } from 'lodash'; import { n__, s__, sprintf } from '~/locale'; import { mergeUrlParams, webIDEUrl } from '~/lib/utils/url_utility'; @@ -74,10 +75,21 @@ export default { : ''; }, }, + mounted() { + Mousetrap.bind('b', this.copyBranchName); + }, + beforeDestroy() { + Mousetrap.unbind('b'); + }, + methods: { + copyBranchName() { + this.$refs.copyBranchNameButton.$el.click(); + }, + }, }; </script> <template> - <div class="d-flex mr-source-target append-bottom-default"> + <div class="d-flex mr-source-target gl-mb-3"> <mr-widget-icon name="git-merge" /> <div class="git-merge-container d-flex"> <div class="normal"> @@ -89,6 +101,7 @@ export default { class="label-branch label-truncate js-source-branch" v-html="mr.sourceBranchLink" /><clipboard-button + ref="copyBranchNameButton" :text="branchNameClipboardData" :title="__('Copy branch name')" css-class="btn-default btn-transparent btn-clipboard" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue index 57d4d8b7ae6..e1659d9a167 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue @@ -13,7 +13,7 @@ export default { </script> <template> - <div class="circle-icon-container append-right-default align-self-start align-self-lg-center"> + <div class="circle-icon-container gl-mr-3 align-self-start align-self-lg-center"> <icon :name="name" :size="24" /> </div> </template> 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 6df53311ef0..a096eb1a1fe 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 @@ -1,21 +1,22 @@ <script> /* eslint-disable vue/require-default-prop */ -import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import { GlIcon, GlLink, GlLoadingIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; import mrWidgetPipelineMixin from 'ee_else_ce/vue_merge_request_widget/mixins/mr_widget_pipeline'; -import { sprintf, s__ } from '~/locale'; -import PipelineStage from '~/pipelines/components/stage.vue'; +import { s__ } from '~/locale'; +import PipelineStage from '~/pipelines/components/pipelines_list/stage.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import Icon from '~/vue_shared/components/icon.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; export default { name: 'MRWidgetPipeline', components: { - PipelineStage, CiIcon, - Icon, - TooltipOnTruncate, GlLink, + GlLoadingIcon, + GlIcon, + GlSprintf, + PipelineStage, + TooltipOnTruncate, LinkedPipelinesMiniList: () => import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'), }, @@ -54,7 +55,11 @@ export default { type: String, required: false, }, - troubleshootingDocsPath: { + mrTroubleshootingDocsPath: { + type: String, + required: true, + }, + ciTroubleshootingDocsPath: { type: String, required: true, }, @@ -64,10 +69,7 @@ export default { return this.pipeline && Object.keys(this.pipeline).length > 0; }, hasCIError() { - return (this.hasCi && !this.ciStatus) || this.hasPipelineMustSucceedConflict; - }, - hasPipelineMustSucceedConflict() { - return !this.hasCi && this.pipelineMustSucceed; + return this.hasPipeline && !this.ciStatus; }, status() { return this.pipeline.details && this.pipeline.details.status @@ -82,22 +84,6 @@ export default { hasCommitInfo() { return this.pipeline.commit && Object.keys(this.pipeline.commit).length > 0; }, - errorText() { - if (this.hasPipelineMustSucceedConflict) { - return s__('Pipeline|No pipeline has been run for this commit.'); - } - - return sprintf( - s__( - 'Pipeline|Could not retrieve the pipeline status. For troubleshooting steps, read the %{linkStart}documentation%{linkEnd}.', - ), - { - linkStart: `<a href="${this.troubleshootingDocsPath}">`, - linkEnd: '</a>', - }, - false, - ); - }, isTriggeredByMergeRequest() { return Boolean(this.pipeline.merge_request); }, @@ -118,31 +104,69 @@ export default { return ''; }, }, + errorText: s__( + 'Pipeline|Could not retrieve the pipeline status. For troubleshooting steps, read the %{linkStart}documentation%{linkEnd}.', + ), + monitoringPipelineText: s__('Pipeline|Checking pipeline status.'), }; </script> <template> - <div class="ci-widget media js-ci-widget"> - <template v-if="!hasPipeline || hasCIError"> - <div class="add-border ci-status-icon ci-status-icon-failed ci-error js-ci-error"> - <icon :size="24" name="status_failed_borderless" /> + <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" + > + <gl-sprintf :message="$options.errorText"> + <template #link="{content}"> + <gl-link :href="mrTroubleshootingDocsPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> + </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> + <gl-link + :href="ciTroubleshootingDocsPath" + target="_blank" + class="gl-display-flex gl-align-items-center gl-ml-2" + tabindex="0" + > + <gl-icon + name="question" + :small="12" + tabindex="0" + role="text" + :aria-label="__('Link to go to GitLab pipeline documentation')" + /> + </gl-link> </div> - <div class="media-body prepend-left-default" v-html="errorText"></div> </template> <template v-else-if="hasPipeline"> - <a :href="status.details_path" class="align-self-start append-right-default"> + <a :href="status.details_path" class="align-self-start gl-mr-3"> <ci-icon :status="status" :size="24" :borderless="true" class="add-border" /> </a> <div class="ci-widget-container d-flex"> <div class="ci-widget-content"> <div class="media-body"> <div - class="font-weight-bold js-pipeline-info-container" + class="gl-font-weight-bold" + data-testid="pipeline-info-container" data-qa-selector="merge_request_pipeline_info_content" > {{ pipeline.details.name }} <gl-link :href="pipeline.path" - class="pipeline-id font-weight-normal pipeline-number" + class="pipeline-id gl-font-weight-normal pipeline-number" + data-testid="pipeline-id" data-qa-selector="pipeline_link" >#{{ pipeline.id }}</gl-link > @@ -151,7 +175,8 @@ export default { {{ s__('Pipeline|for') }} <gl-link :href="pipeline.commit.commit_path" - class="commit-sha js-commit-link font-weight-normal" + class="commit-sha gl-font-weight-normal" + data-testid="commit-link" >{{ pipeline.commit.short_id }}</gl-link > </template> @@ -160,18 +185,18 @@ export default { <tooltip-on-truncate :title="sourceBranch" truncate-target="child" - class="label-branch label-truncate font-weight-normal" + class="label-branch label-truncate gl-font-weight-normal" v-html="sourceBranchLink" /> </template> </div> - <div v-if="pipeline.coverage" class="coverage"> + <div v-if="pipeline.coverage" class="coverage" data-testid="pipeline-coverage"> {{ s__('Pipeline|Coverage') }} {{ pipeline.coverage }}% <span v-if="pipelineCoverageDelta" - class="js-pipeline-coverage-delta" :class="coverageDeltaClass" + data-testid="pipeline-coverage-delta" > ({{ pipelineCoverageDelta }}%) </span> @@ -189,13 +214,13 @@ export default { :class="{ 'has-downstream': hasDownstream(i), }" - class="stage-container dropdown js-mini-pipeline-graph mr-widget-pipeline-stages" + class="stage-container dropdown mr-widget-pipeline-stages" + data-testid="widget-mini-pipeline-graph" > <pipeline-stage :stage="stage" /> </div> </template> </span> - <linked-pipelines-mini-list v-if="triggered.length" :triggered="triggered" /> </span> </div> 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 8fba0e2981f..5c307b5ff0c 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 @@ -82,7 +82,8 @@ export default { :pipeline-must-succeed="mr.onlyAllowMergeIfPipelineSucceeds" :source-branch="branch" :source-branch-link="branchLink" - :troubleshooting-docs-path="mr.troubleshootingDocsPath" + :mr-troubleshooting-docs-path="mr.mrTroubleshootingDocsPath" + :ci-troubleshooting-docs-path="mr.ciTroubleshootingDocsPath" /> <template #footer> <div v-if="mr.exposedArtifactsPath" class="js-exposed-artifacts"> 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 d0df8309dc7..82566682bca 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 @@ -33,7 +33,7 @@ export default { </script> <template> <div class="d-flex align-self-start"> - <div class="square s24 h-auto d-flex-center append-right-default"> + <div class="square s24 h-auto d-flex-center gl-mr-3"> <div v-if="isLoading" class="mr-widget-icon d-inline-flex"> <gl-loading-icon size="md" class="mr-loading-icon d-inline-flex" /> </div> 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 9942861d9e4..de01821a292 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,22 +1,31 @@ <script> -import { GlLink, GlSprintf } from '@gitlab/ui'; +import { GlLink, GlSprintf, GlButton } from '@gitlab/ui'; import MrWidgetIcon from './mr_widget_icon.vue'; -import PipelineTourState from './states/mr_widget_pipeline_tour.vue'; +import Tracking from '~/tracking'; +import { s__ } from '~/locale'; + +const trackingMixin = Tracking.mixin(); +const TRACK_LABEL = 'no_pipeline_noticed'; export default { name: 'MRWidgetSuggestPipeline', iconName: 'status_notfound', - popoverTarget: 'suggest-popover', - popoverContainer: 'suggest-pipeline', - trackLabel: 'no_pipeline_noticed', + trackLabel: TRACK_LABEL, linkTrackValue: 30, linkTrackEvent: 'click_link', + showTrackValue: 10, + showTrackEvent: 'click_button', + helpContent: s__( + `mrWidget|Use %{linkStart}CI pipelines to test your code%{linkEnd} by simply adding a GitLab CI configuration file to your project. It only takes a minute to make your code more secure and robust.`, + ), + helpURL: 'https://about.gitlab.com/blog/2019/07/12/guide-to-ci-cd-pipelines/', components: { GlLink, GlSprintf, + GlButton, MrWidgetIcon, - PipelineTourState, }, + mixins: [trackingMixin], props: { pipelinePath: { type: String, @@ -31,45 +40,89 @@ export default { required: true, }, }, + computed: { + tracking() { + return { + label: TRACK_LABEL, + property: this.humanAccess, + }; + }, + }, + mounted() { + this.track(); + }, }; </script> <template> - <div :id="$options.popoverContainer" class="d-flex mr-pipeline-suggest append-bottom-default"> - <mr-widget-icon :name="$options.iconName" /> - <div :id="$options.popoverTarget"> - <gl-sprintf - :message=" - s__(`mrWidget|%{prefixToLinkStart}No pipeline%{prefixToLinkEnd} + <div class="mr-widget-body mr-pipeline-suggest gl-mb-3"> + <div class="gl-display-flex gl-align-items-center"> + <mr-widget-icon :name="$options.iconName" /> + <div> + <gl-sprintf + :message=" + s__(`mrWidget|%{prefixToLinkStart}No pipeline%{prefixToLinkEnd} %{addPipelineLinkStart}Add the .gitlab-ci.yml file%{addPipelineLinkEnd} to create one.`) - " - > - <template #prefixToLink="{content}"> + " + > + <template #prefixToLink="{content}"> + <strong> + {{ content }} + </strong> + </template> + <template #addPipelineLink="{content}"> + <gl-link + :href="pipelinePath" + class="gl-ml-1" + data-testid="add-pipeline-link" + :data-track-property="humanAccess" + :data-track-value="$options.linkTrackValue" + :data-track-event="$options.linkTrackEvent" + :data-track-label="$options.trackLabel" + > + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </div> + </div> + <div class="row"> + <div class="col-md-5 order-md-last col-12 gl-mt-5 mt-md-n3 svg-content svg-225"> + <img data-testid="pipeline-image" :src="pipelineSvgPath" /> + </div> + <div class="col-md-7 order-md-first col-12"> + <div class="ml-6 gl-pt-5"> <strong> - {{ content }} + {{ s__('mrWidget|Are you adding technical debt or code vulnerabilities?') }} </strong> - </template> - <template #addPipelineLink="{content}"> - <gl-link + <p class="gl-mt-2"> + <gl-sprintf :message="$options.helpContent"> + <template #link="{ content }"> + <gl-link + data-testid="help" + :href="$options.helpURL" + target="_blank" + class="font-size-inherit" + >{{ content }} + </gl-link> + </template> + </gl-sprintf> + </p> + <gl-button + data-testid="ok" + category="primary" + class="gl-mt-2" + variant="info" :href="pipelinePath" - class="ml-2 js-add-pipeline-path" :data-track-property="humanAccess" - :data-track-value="$options.linkTrackValue" - :data-track-event="$options.linkTrackEvent" + :data-track-value="$options.showTrackValue" + :data-track-event="$options.showTrackEvent" :data-track-label="$options.trackLabel" > - {{ content }} - </gl-link> - </template> - </gl-sprintf> - <pipeline-tour-state - :pipeline-path="pipelinePath" - :pipeline-svg-path="pipelineSvgPath" - :human-access="humanAccess" - :popover-target="$options.popoverTarget" - :popover-container="$options.popoverContainer" - :track-label="$options.trackLabel" - /> + {{ __('Show me how to add a pipeline') }} + </gl-button> + </div> + </div> </div> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_terraform_plan.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_terraform_plan.vue deleted file mode 100644 index 2ef5e81b36b..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_terraform_plan.vue +++ /dev/null @@ -1,139 +0,0 @@ -<script> -import { __ } from '~/locale'; -import { GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; -import axios from '~/lib/utils/axios_utils'; -import flash from '~/flash'; -import Poll from '~/lib/utils/poll'; - -export default { - name: 'MRWidgetTerraformPlan', - components: { - GlIcon, - GlLink, - GlLoadingIcon, - GlSprintf, - }, - props: { - endpoint: { - type: String, - required: true, - }, - }, - data() { - return { - loading: true, - plans: {}, - }; - }, - computed: { - addNum() { - return Number(this.plan.create); - }, - changeNum() { - return Number(this.plan.update); - }, - deleteNum() { - return Number(this.plan.delete); - }, - logUrl() { - return this.plan.job_path; - }, - plan() { - const firstPlanKey = Object.keys(this.plans)[0]; - return this.plans[firstPlanKey] ?? {}; - }, - validPlanValues() { - return this.addNum + this.changeNum + this.deleteNum >= 0; - }, - }, - created() { - this.fetchPlans(); - }, - methods: { - fetchPlans() { - this.loading = true; - - const poll = new Poll({ - resource: { - fetchPlans: () => axios.get(this.endpoint), - }, - data: this.endpoint, - method: 'fetchPlans', - successCallback: ({ data }) => { - this.plans = data; - - if (Object.keys(this.plan).length) { - this.loading = false; - poll.stop(); - } - }, - errorCallback: () => { - this.plans = {}; - this.loading = false; - flash(__('An error occurred while loading terraform report')); - }, - }); - - poll.makeRequest(); - }, - }, -}; -</script> - -<template> - <section class="mr-widget-section"> - <div class="mr-widget-body media d-flex flex-row"> - <span class="append-right-default align-self-start align-self-lg-center"> - <gl-icon name="status_warning" :size="24" /> - </span> - - <div class="d-flex flex-fill flex-column flex-md-row"> - <div class="terraform-mr-plan-text normal d-flex flex-column flex-lg-row"> - <p class="m-0 pr-1">{{ __('A terraform report was generated in your pipelines.') }}</p> - - <gl-loading-icon v-if="loading" size="md" /> - - <p v-else-if="validPlanValues" class="m-0"> - <gl-sprintf - :message=" - __( - 'Reported Resource Changes: %{addNum} to add, %{changeNum} to change, %{deleteNum} to delete', - ) - " - > - <template #addNum> - <strong>{{ addNum }}</strong> - </template> - - <template #changeNum> - <strong>{{ changeNum }}</strong> - </template> - - <template #deleteNum> - <strong>{{ deleteNum }}</strong> - </template> - </gl-sprintf> - </p> - - <p v-else class="m-0">{{ __('Changes are unknown') }}</p> - </div> - - <div class="terraform-mr-plan-actions"> - <gl-link - v-if="logUrl" - :href="logUrl" - target="_blank" - data-track-event="click_terraform_mr_plan_button" - data-track-label="mr_widget_terraform_mr_plan_button" - data-track-property="terraform_mr_plan_button" - class="btn btn-sm js-terraform-report-link" - rel="noopener" - > - {{ __('View full log') }} - <gl-icon name="external-link" /> - </gl-link> - </div> - </div> - </div> - </section> -</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue index acd8037cfb2..44bdc4a3be8 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue @@ -29,7 +29,7 @@ export default { <textarea :id="inputId" :value="value" - class="form-control js-gfm-input append-bottom-default commit-message-edit" + class="form-control js-gfm-input gl-mb-3 commit-message-edit" dir="auto" required="required" rows="7" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue index e4f4032776b..d52e6d38ac6 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue @@ -83,7 +83,7 @@ export default { <gl-deprecated-button :aria-label="ariaLabel" variant="blank" - class="commit-edit-toggle square s24 append-right-default" + class="commit-edit-toggle square s24 gl-mr-3" @click.stop="toggle()" > <icon :name="collapseIcon" :size="16" /> 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 92848e86e76..f02e0ac84da 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 @@ -87,7 +87,7 @@ export default { <status-icon status="success" /> <div class="media-body"> <h4 class="d-flex align-items-start"> - <span class="append-right-10"> + <span class="gl-mr-3"> <span class="js-status-text-before-author">{{ statusTextBeforeAuthor }}</span> <mr-widget-author :author="mr.setToAutoMergeBy" /> <span class="js-status-text-after-author">{{ statusTextAfterAuthor }}</span> @@ -113,9 +113,7 @@ export default { {{ s__('mrWidget|The source branch will be deleted') }} </p> <p v-else class="d-flex align-items-start"> - <span class="append-right-10">{{ - s__('mrWidget|The source branch will not be deleted') - }}</span> + <span class="gl-mr-3">{{ s__('mrWidget|The source branch will not be deleted') }}</span> <a v-if="canRemoveSourceBranch" :disabled="isRemovingSourceBranch" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue index a5e3115397a..e02be6dc2f7 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue @@ -12,7 +12,7 @@ export default { <div class="mr-widget-body media"> <status-icon :show-disabled-button="true" status="loading" /> <div class="media-body space-children"> - <span class="bold"> {{ s__('mrWidget|Checking ability to merge automatically…') }} </span> + <span class="bold"> {{ s__('mrWidget|Checking if merge request can be merged…') }} </span> </div> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_tour.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_tour.vue deleted file mode 100644 index f6bfb178437..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_tour.vue +++ /dev/null @@ -1,136 +0,0 @@ -<script> -import { GlPopover, GlDeprecatedButton } from '@gitlab/ui'; -import Icon from '~/vue_shared/components/icon.vue'; -import Cookies from 'js-cookie'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import Tracking from '~/tracking'; - -const trackingMixin = Tracking.mixin(); - -const cookieKey = 'suggest_pipeline_dismissed'; - -export default { - name: 'MRWidgetPipelineTour', - dismissTrackValue: 20, - showTrackValue: 10, - trackEvent: 'click_button', - components: { - GlPopover, - GlDeprecatedButton, - Icon, - }, - mixins: [trackingMixin], - props: { - pipelinePath: { - type: String, - required: true, - }, - pipelineSvgPath: { - type: String, - required: true, - }, - humanAccess: { - type: String, - required: true, - }, - popoverTarget: { - type: String, - required: true, - }, - popoverContainer: { - type: String, - required: true, - }, - trackLabel: { - type: String, - required: true, - }, - }, - data() { - return { - popoverDismissed: parseBoolean(Cookies.get(cookieKey)), - tracking: { - label: this.trackLabel, - property: this.humanAccess, - }, - }; - }, - mounted() { - this.trackOnShow(); - }, - methods: { - trackOnShow() { - if (!this.popoverDismissed) { - this.track(); - } - }, - dismissPopover() { - this.popoverDismissed = true; - Cookies.set(cookieKey, this.popoverDismissed, { expires: 365 }); - }, - }, -}; -</script> -<template> - <gl-popover - v-if="!popoverDismissed" - show - :target="popoverTarget" - :container="popoverContainer" - placement="rightbottom" - > - <template #title> - <button - class="btn-blank float-right mt-1" - type="button" - :aria-label="__('Close')" - :data-track-property="humanAccess" - :data-track-value="$options.dismissTrackValue" - :data-track-event="$options.trackEvent" - :data-track-label="trackLabel" - @click="dismissPopover" - > - <icon name="close" aria-hidden="true" /> - </button> - {{ s__('mrWidget|Are you adding technical debt or code vulnerabilities?') }} - </template> - <div class="svg-content svg-150 pt-1"> - <img :src="pipelineSvgPath" /> - </div> - <p> - {{ - s__( - 'mrWidget|Detect issues before deployment with a CI pipeline that continuously tests your code. We created a quick guide that will show you how to create one. Make your code more secure and more robust in just a minute.', - ) - }} - </p> - <gl-deprecated-button - ref="ok" - category="primary" - class="mt-2 mb-0" - variant="info" - block - :href="pipelinePath" - :data-track-property="humanAccess" - :data-track-value="$options.showTrackValue" - :data-track-event="$options.trackEvent" - :data-track-label="trackLabel" - > - {{ __('Show me how') }} - </gl-deprecated-button> - <gl-deprecated-button - ref="no-thanks" - category="secondary" - class="mt-2 mb-0" - variant="info" - block - :data-track-property="humanAccess" - :data-track-value="$options.dismissTrackValue" - :data-track-event="$options.trackEvent" - :data-track-label="trackLabel" - @click="dismissPopover" - > - {{ __("No thanks, don't show this again") }} - </gl-deprecated-button> - </gl-popover> -</template> 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 82be5eeb5ff..cc43135f50a 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 @@ -45,7 +45,8 @@ export default { isMakingRequest: false, isMergingImmediately: false, commitMessage: this.mr.commitMessage, - squashBeforeMerge: this.mr.squash, + squashBeforeMerge: this.mr.squashIsSelected, + isSquashReadOnly: this.mr.squashIsReadonly, successSvg, warningSvg, squashCommitMessage: this.mr.squashCommitMessage, @@ -106,7 +107,12 @@ export default { return this.isMergeButtonDisabled; }, shouldShowSquashBeforeMerge() { - const { commitsCount, enableSquashBeforeMerge } = this.mr; + const { commitsCount, enableSquashBeforeMerge, squashIsReadonly, squashIsSelected } = this.mr; + + if (squashIsReadonly && !squashIsSelected) { + return false; + } + return enableSquashBeforeMerge && commitsCount > 1; }, shouldShowMergeControls() { @@ -344,21 +350,24 @@ export default { v-if="shouldShowSquashBeforeMerge" v-model="squashBeforeMerge" :help-path="mr.squashBeforeMergeHelpPath" - :is-disabled="isMergeButtonDisabled" + :is-disabled="isSquashReadOnly" /> </template> <template v-else> <div class="bold js-resolve-mr-widget-items-message"> - <gl-sprintf + <div v-if="hasPipelineMustSucceedConflict" - :message="pipelineMustSucceedConflictText" + class="gl-display-flex gl-align-items-center" > - <template #link="{ content }"> - <gl-link :href="mr.pipelineMustSucceedDocsPath" target="_blank"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> + <gl-sprintf :message="pipelineMustSucceedConflictText" /> + <gl-link + :href="mr.pipelineMustSucceedDocsPath" + target="_blank" + class="gl-display-flex gl-ml-2" + > + <gl-icon name="question" /> + </gl-link> + </div> <gl-sprintf v-else :message="mergeDisabledText" /> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue index 5305894873f..efd58341a2d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue @@ -1,6 +1,7 @@ <script> import Icon from '~/vue_shared/components/icon.vue'; import tooltip from '~/vue_shared/directives/tooltip'; +import { __ } from '~/locale'; export default { components: { @@ -25,12 +26,22 @@ export default { default: false, }, }, + computed: { + tooltipTitle() { + return this.isDisabled ? __('Required in this project.') : false; + }, + }, }; </script> <template> <div class="inline"> - <label> + <label + v-tooltip + :class="{ 'gl-text-gray-600': isDisabled }" + data-testid="squashLabel" + :data-title="tooltipTitle" + > <input :checked="value" :disabled="isDisabled" 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 new file mode 100644 index 00000000000..f6e21dc1ec1 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/terraform/mr_widget_terraform_container.vue @@ -0,0 +1,140 @@ +<script> +import { n__ } from '~/locale'; +import { GlSkeletonLoading, GlSprintf } from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import MrWidgetExpanableSection from '../mr_widget_expandable_section.vue'; +import Poll from '~/lib/utils/poll'; +import TerraformPlan from './terraform_plan.vue'; + +export default { + name: 'MRWidgetTerraformContainer', + components: { + GlSkeletonLoading, + GlSprintf, + MrWidgetExpanableSection, + TerraformPlan, + }, + props: { + endpoint: { + type: String, + required: true, + }, + }, + data() { + return { + loading: true, + plansObject: {}, + poll: null, + }; + }, + computed: { + inValidPlanCountText() { + if (this.numberOfInvalidPlans === 0) { + return null; + } + + return n__( + 'Terraform|%{number} Terraform report failed to generate', + 'Terraform|%{number} Terraform reports failed to generate', + this.numberOfInvalidPlans, + ); + }, + numberOfInvalidPlans() { + return Object.values(this.plansObject).filter(plan => plan.tf_report_error).length; + }, + numberOfPlans() { + return Object.keys(this.plansObject).length; + }, + numberOfValidPlans() { + return this.numberOfPlans - this.numberOfInvalidPlans; + }, + validPlanCountText() { + if (this.numberOfValidPlans === 0) { + return null; + } + + return n__( + 'Terraform|%{number} Terraform report was generated in your pipelines', + 'Terraform|%{number} Terraform reports were generated in your pipelines', + this.numberOfValidPlans, + ); + }, + }, + created() { + this.fetchPlans(); + }, + beforeDestroy() { + this.poll.stop(); + }, + methods: { + fetchPlans() { + this.loading = true; + + this.poll = new Poll({ + resource: { + fetchPlans: () => axios.get(this.endpoint), + }, + data: this.endpoint, + method: 'fetchPlans', + successCallback: ({ data }) => { + this.plansObject = data; + + if (this.numberOfPlans > 0) { + this.loading = false; + this.poll.stop(); + } + }, + errorCallback: () => { + this.plansObject = { bad_plan: { tf_report_error: 'api_error' } }; + this.loading = false; + this.poll.stop(); + }, + }); + + this.poll.makeRequest(); + }, + }, +}; +</script> + +<template> + <section class="mr-widget-section"> + <div v-if="loading" class="mr-widget-body"> + <gl-skeleton-loading /> + </div> + + <mr-widget-expanable-section v-else> + <template #header> + <div + data-testid="terraform-header-text" + class="gl-flex-fill-1 gl-display-flex gl-flex-direction-column" + > + <p v-if="validPlanCountText" class="gl-m-0"> + <gl-sprintf :message="validPlanCountText"> + <template #number> + <strong>{{ numberOfValidPlans }}</strong> + </template> + </gl-sprintf> + </p> + + <p v-if="inValidPlanCountText" class="gl-m-0"> + <gl-sprintf :message="inValidPlanCountText"> + <template #number> + <strong>{{ numberOfInvalidPlans }}</strong> + </template> + </gl-sprintf> + </p> + </div> + </template> + + <template #content> + <terraform-plan + v-for="(plan, key) in plansObject" + :key="key" + :plan="plan" + class="mr-widget-body" + /> + </template> + </mr-widget-expanable-section> + </section> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue b/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue new file mode 100644 index 00000000000..dc16d46dd8e --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue @@ -0,0 +1,111 @@ +<script> +import { s__ } from '~/locale'; +import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; + +export default { + name: 'TerraformPlan', + components: { + GlIcon, + GlLink, + GlSprintf, + }, + props: { + plan: { + required: true, + type: Object, + }, + }, + computed: { + addNum() { + return Number(this.plan.create); + }, + changeNum() { + return Number(this.plan.update); + }, + deleteNum() { + return Number(this.plan.delete); + }, + iconType() { + return this.validPlanValues ? 'doc-changes' : 'warning'; + }, + reportChangeText() { + if (this.validPlanValues) { + return s__( + 'Terraform|Reported Resource Changes: %{addNum} to add, %{changeNum} to change, %{deleteNum} to delete', + ); + } + + return s__('Terraform|Generating the report caused an error.'); + }, + 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.'); + } + + return this.plan.job_name + ? s__('Terraform|The Terraform report %{name} failed to generate.') + : s__('Terraform|A Terraform report failed to generate.'); + }, + validPlanValues() { + return this.addNum + this.changeNum + this.deleteNum >= 0; + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex"> + <span + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-mr-3 gl-align-self-start gl-mt-1" + > + <gl-icon :name="iconType" :size="18" data-testid="change-type-icon" /> + </span> + + <div class="gl-display-flex gl-flex-fill-1 gl-flex-direction-column flex-md-row"> + <div class="gl-flex-fill-1 gl-display-flex gl-flex-direction-column"> + <p class="gl-m-0 gl-pr-1"> + <gl-sprintf :message="reportHeaderText"> + <template #name> + <strong>{{ plan.job_name }}</strong> + </template> + </gl-sprintf> + </p> + + <p class="gl-m-0"> + <gl-sprintf :message="reportChangeText"> + <template #addNum> + <strong>{{ addNum }}</strong> + </template> + + <template #changeNum> + <strong>{{ changeNum }}</strong> + </template> + + <template #deleteNum> + <strong>{{ deleteNum }}</strong> + </template> + </gl-sprintf> + </p> + </div> + + <div> + <gl-link + v-if="plan.job_path" + :href="plan.job_path" + target="_blank" + data-testid="terraform-report-link" + data-track-event="click_terraform_mr_plan_button" + data-track-label="mr_widget_terraform_mr_plan_button" + data-track-property="terraform_mr_plan_button" + class="btn btn-sm" + rel="noopener" + > + {{ __('View full log') }} + <gl-icon name="external-link" /> + </gl-link> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js index 6f6d145815e..1002bb728a0 100644 --- a/app/assets/javascripts/vue_merge_request_widget/constants.js +++ b/app/assets/javascripts/vue_merge_request_widget/constants.js @@ -1,3 +1,4 @@ +export const SUCCESS = 'success'; export const WARNING = 'warning'; export const DANGER = 'danger'; diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js index 7a9ef7e496e..068829912bf 100644 --- a/app/assets/javascripts/vue_merge_request_widget/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/index.js @@ -1,15 +1,23 @@ import Vue from 'vue'; import MrWidgetOptions from 'ee_else_ce/vue_merge_request_widget/mr_widget_options.vue'; import Translate from '../vue_shared/translate'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; Vue.use(Translate); +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); export default () => { if (gl.mrWidget) return; gl.mrWidgetData.gitlabLogo = gon.gitlab_logo; + gl.mrWidgetData.defaultAvatarUrl = gon.default_avatar_url; - const vm = new Vue(MrWidgetOptions); + const vm = new Vue({ ...MrWidgetOptions, apolloProvider }); window.gl.mrWidget = { checkStatus: vm.checkStatus, diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js new file mode 100644 index 00000000000..e50555ca875 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js @@ -0,0 +1,19 @@ +import { hideFlash } from '~/flash'; + +export default { + methods: { + clearError() { + this.$emit('clearError'); + this.hasApprovalAuthError = false; + const flashEl = document.querySelector('.flash-alert'); + if (flashEl) { + hideFlash(flashEl); + } + }, + refreshApprovals() { + return this.service.fetchApprovals().then(data => { + this.mr.setApprovals(data); + }); + }, + }, +}; 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 39fa5e465b8..319b6c333f4 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 @@ -2,7 +2,7 @@ import { __ } from '~/locale'; export const MERGE_DISABLED_TEXT = __('You can only merge once the items above are resolved.'); export const PIPELINE_MUST_SUCCEED_CONFLICT_TEXT = __( - 'Pipelines must succeed for merge requests to be eligible to merge. Please enable pipelines for this project to continue. For more information, see the %{linkStart}documentation.%{linkEnd}', + 'A CI/CD pipeline must run and be successful before merge.', ); export default { 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 265ff81f39f..cff85fe232d 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 @@ -2,6 +2,7 @@ import { isEmpty } from 'lodash'; 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 { sprintf, s__, __ } from '~/locale'; import Project from '~/pages/projects/project'; @@ -36,7 +37,8 @@ import CheckingState from './components/states/mr_widget_checking.vue'; import eventHub from './event_hub'; import notify from '~/lib/utils/notify'; import SourceBranchRemovalStatus from './components/source_branch_removal_status.vue'; -import TerraformPlan from './components/mr_widget_terraform_plan.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/common_utils'; import GroupedAccessibilityReportsApp from '../reports/accessibility_report/grouped_accessibility_reports_app.vue'; @@ -75,9 +77,11 @@ export default { 'mr-widget-auto-merge-failed': AutoMergeFailed, 'mr-widget-rebase': RebaseState, SourceBranchRemovalStatus, + GroupedCodequalityReportsApp, GroupedTestReportsApp, TerraformPlan, GroupedAccessibilityReportsApp, + MrWidgetApprovals, }, props: { mrData: { @@ -96,6 +100,9 @@ export default { }; }, computed: { + shouldRenderApprovals() { + return this.mr.state !== 'nothingToMerge'; + }, componentName() { return stateMaps.stateToComponentMap[this.mr.state]; }, @@ -111,6 +118,9 @@ export default { shouldSuggestPipelines() { return gon.features?.suggestPipeline && !this.mr.hasCI && this.mr.mergeRequestAddCiConfigPath; }, + shouldRenderCodeQuality() { + return this.mr?.codeclimate?.head_path; + }, shouldRenderRelatedLinks() { return Boolean(this.mr.relatedLinks) && !this.mr.isNothingToMergeState; }, @@ -216,6 +226,9 @@ export default { mergeRequestCachedWidgetPath: store.mergeRequestCachedWidgetPath, mergeActionsContentPath: store.mergeActionsContentPath, rebasePath: store.rebasePath, + apiApprovalsPath: store.apiApprovalsPath, + apiApprovePath: store.apiApprovePath, + apiUnapprovePath: store.apiUnapprovePath, }; }, createService(store) { @@ -365,7 +378,7 @@ export default { }; </script> <template> - <div v-if="mr" class="mr-state-widget prepend-top-default"> + <div v-if="mr" class="mr-state-widget gl-mt-3"> <mr-widget-header :mr="mr" /> <mr-widget-suggest-pipeline v-if="shouldSuggestPipelines" @@ -379,11 +392,27 @@ export default { class="mr-widget-workflow" :mr="mr" /> + <mr-widget-approvals + v-if="shouldRenderApprovals" + class="mr-widget-workflow" + :mr="mr" + :service="service" + /> <div class="mr-section-container mr-widget-workflow"> + <grouped-codequality-reports-app + v-if="shouldRenderCodeQuality" + :base-path="mr.codeclimate.base_path" + :head-path="mr.codeclimate.head_path" + :head-blob-path="mr.headBlobPath" + :base-blob-path="mr.baseBlobPath" + :codequality-help-path="mr.codequalityHelpPath" + /> + <grouped-test-reports-app v-if="mr.testResultsPath" class="js-reports-container" :endpoint="mr.testResultsPath" + :pipeline-path="mr.pipeline.path" /> <terraform-plan v-if="mr.terraformReportsPath" :endpoint="mr.terraformReportsPath" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js index c620023a6d6..ee9e3cc6d08 100644 --- a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js +++ b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js @@ -3,6 +3,10 @@ import axios from '../../lib/utils/axios_utils'; export default class MRWidgetService { constructor(endpoints) { this.endpoints = endpoints; + + this.apiApprovalsPath = endpoints.apiApprovalsPath; + this.apiApprovePath = endpoints.apiApprovePath; + this.apiUnapprovePath = endpoints.apiUnapprovePath; } merge(data) { @@ -54,6 +58,18 @@ export default class MRWidgetService { return axios.post(this.endpoints.rebasePath); } + fetchApprovals() { + return axios.get(this.apiApprovalsPath).then(res => res.data); + } + + approveMergeRequest() { + return axios.post(this.apiApprovePath).then(res => res.data); + } + + unapproveMergeRequest() { + return axios.post(this.apiUnapprovePath).then(res => res.data); + } + static executeInlineAction(url) { return axios.post(url); } diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js index a2ee0bc3ca1..44e8167d6a3 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js @@ -11,12 +11,12 @@ export default function deviseState(data) { return stateKey.checking; } else if (data.has_conflicts) { return stateKey.conflicts; - } else if (data.work_in_progress) { - return stateKey.workInProgress; } else if (this.shouldBeRebased) { return stateKey.rebase; } else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) { return stateKey.pipelineFailed; + } else if (data.work_in_progress) { + return stateKey.workInProgress; } else if (this.hasMergeableDiscussionsState) { return stateKey.unresolvedDiscussions; } else if (this.isPipelineBlocked) { 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 d61e122d612..8b9799d9775 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 @@ -9,12 +9,19 @@ export default class MergeRequestStore { this.sha = data.diff_head_sha; this.gitlabLogo = data.gitlabLogo; + this.apiApprovalsPath = data.api_approvals_path; + this.apiApprovePath = data.api_approve_path; + this.apiUnapprovePath = data.api_unapprove_path; + this.hasApprovalsAvailable = data.has_approvals_available; + this.setPaths(data); this.setData(data); } setData(data, isRebased) { + this.initApprovals(); + if (isRebased) { this.sha = data.diff_head_sha; } @@ -22,7 +29,10 @@ export default class MergeRequestStore { const pipelineStatus = data.pipeline ? data.pipeline.details.status : null; this.squash = data.squash; + this.squashIsEnabledByDefault = data.squash_enabled_by_default; + this.squashIsReadonly = data.squash_readonly; this.enableSquashBeforeMerge = this.enableSquashBeforeMerge || true; + this.squashIsSelected = data.squash_readonly ? data.squash_on_merge : data.squash; this.iid = data.iid; this.title = data.title; @@ -49,6 +59,7 @@ export default class MergeRequestStore { this.squashCommitMessage = data.default_squash_commit_message; this.rebaseInProgress = data.rebase_in_progress; this.mergeRequestDiffsPath = data.diffs_path; + this.approvalsWidgetType = data.approvals_widget_type; if (data.issues_links) { const links = data.issues_links; @@ -160,7 +171,8 @@ export default class MergeRequestStore { setPaths(data) { // Paths are set on the first load of the page and not auto-refreshed this.squashBeforeMergeHelpPath = data.squash_before_merge_help_path; - this.troubleshootingDocsPath = data.troubleshooting_docs_path; + this.mrTroubleshootingDocsPath = data.mr_troubleshooting_docs_path; + this.ciTroubleshootingDocsPath = data.ci_troubleshooting_docs_path; this.pipelineMustSucceedDocsPath = data.pipeline_must_succeed_docs_path; this.mergeRequestBasicPath = data.merge_request_basic_path; this.mergeRequestWidgetPath = data.merge_request_widget_path; @@ -177,10 +189,18 @@ export default class MergeRequestStore { this.securityApprovalsHelpPagePath = data.security_approvals_help_page_path; this.eligibleApproversDocsPath = data.eligible_approvers_docs_path; this.mergeImmediatelyDocsPath = data.merge_immediately_docs_path; + this.approvalsHelpPath = data.approvals_help_path; this.mergeRequestAddCiConfigPath = data.merge_request_add_ci_config_path; this.pipelinesEmptySvgPath = data.pipelines_empty_svg_path; this.humanAccess = data.human_access; this.newPipelinePath = data.new_project_pipeline_path; + + // codeclimate + const blobPath = data.blob_path || {}; + this.headBlobPath = blobPath.head_path || ''; + this.baseBlobPath = blobPath.base_path || ''; + this.codequalityHelpPath = data.codequality_help_path; + this.codeclimate = data.codeclimate; } get isNothingToMergeState() { @@ -240,4 +260,14 @@ export default class MergeRequestStore { return undefined; } + + initApprovals() { + this.isApproved = this.isApproved || false; + this.approvals = this.approvals || null; + } + + setApprovals(data) { + this.approvals = data; + this.isApproved = data.approved || false; + } } 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 9f6f3d2d63a..d6f591ccca1 100644 --- a/app/assets/javascripts/vue_shared/components/file_finder/index.vue +++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue @@ -261,7 +261,7 @@ export default { </li> </template> <li v-else class="dropdown-menu-empty-item"> - <div class="append-right-default prepend-left-default gl-mt-3 gl-mb-3"> + <div class="gl-mr-3 gl-ml-3 gl-mt-3 gl-mb-3"> <template v-if="loading"> {{ __('Loading...') }} </template> diff --git a/app/assets/javascripts/vue_shared/components/file_finder/item.vue b/app/assets/javascripts/vue_shared/components/file_finder/item.vue index 590501a975a..79c62cd9938 100644 --- a/app/assets/javascripts/vue_shared/components/file_finder/item.vue +++ b/app/assets/javascripts/vue_shared/components/file_finder/item.vue @@ -88,7 +88,7 @@ export default { > </span> </strong> - <span class="diff-changed-file-path prepend-top-5"> + <span class="diff-changed-file-path gl-mt-2"> <span v-for="(char, charIndex) in pathWithEllipsis.split('')" :key="charIndex + char" diff --git a/app/assets/javascripts/vue_shared/components/file_icon.vue b/app/assets/javascripts/vue_shared/components/file_icon.vue index b084ebdf774..7484486d6b4 100644 --- a/app/assets/javascripts/vue_shared/components/file_icon.vue +++ b/app/assets/javascripts/vue_shared/components/file_icon.vue @@ -1,6 +1,7 @@ <script> import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; import getIconForFile from './file_icon/file_icon_map'; +import { FILE_SYMLINK_MODE } from '../constants'; /* This is a re-usable vue component for rendering a svg sprite icon @@ -24,6 +25,11 @@ export default { type: String, required: true, }, + fileMode: { + type: String, + required: false, + default: '', + }, folder: { type: Boolean, @@ -60,8 +66,12 @@ export default { }, }, computed: { + isSymlink() { + return this.fileMode === FILE_SYMLINK_MODE; + }, spriteHref() { const iconName = this.submodule ? 'folder-git' : getIconForFile(this.fileName) || 'file'; + return `${gon.sprite_file_icons}#${iconName}`; }, folderIconName() { @@ -75,13 +85,11 @@ export default { </script> <template> <span> - <svg v-if="!loading && !folder" :class="[iconSizeClass, cssClasses]"> - <use v-bind="{ 'xlink:href': spriteHref }" /></svg - ><gl-icon - v-if="!loading && folder" - :name="folderIconName" - :size="size" - class="folder-icon" - /><gl-loading-icon v-if="loading" :inline="true" /> + <gl-loading-icon v-if="loading" :inline="true" /> + <gl-icon v-else-if="isSymlink" name="symlink" :size="size" /> + <svg v-else-if="!folder" :class="[iconSizeClass, cssClasses]"> + <use v-bind="{ 'xlink:href': spriteHref }" /> + </svg> + <gl-icon v-else :name="folderIconName" :size="size" class="folder-icon" /> </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 0cc96309a92..0952e37e46e 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -118,7 +118,12 @@ export default { @mouseleave="$emit('mouseleave', $event)" > <div class="file-row-name-container"> - <span ref="textOutput" :style="levelIndentation" class="file-row-name str-truncated"> + <span + ref="textOutput" + :style="levelIndentation" + class="file-row-name str-truncated" + data-qa-selector="file_name_content" + > <file-icon class="file-row-icon" :class="{ 'text-secondary': file.type === 'tree' }" diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue index a858ffdbed5..04090213218 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue @@ -83,6 +83,7 @@ export default { return { initialRender: true, recentSearchesPromise: null, + recentSearches: [], filterValue: this.initialFilterValue, selectedSortOption, selectedSortDirection, @@ -98,6 +99,15 @@ export default { {}, ); }, + tokenTitles() { + return this.tokens.reduce( + (tokenSymbols, token) => ({ + ...tokenSymbols, + [token.type]: token.title, + }), + {}, + ); + }, sortDirectionIcon() { return this.selectedSortDirection === SortDirection.ascending ? 'sort-lowest' @@ -112,11 +122,10 @@ export default { watch: { /** * GlFilteredSearch currently doesn't emit any event when - * search field is cleared, but we still want our parent - * component to know that filters were cleared and do - * necessary data refetch, so this watcher is basically - * a dirty hack/workaround to identify if filter input - * was cleared. :( + * tokens are manually removed from search field so we'd + * never know when user actually clears all the tokens. + * This watcher listens for updates to `filterValue` on + * such instances. :( */ filterValue(value) { const [firstVal] = value; @@ -172,11 +181,9 @@ export default { this.recentSearchesStore.state.recentSearches.concat(searches), ); this.recentSearchesService.save(resultantSearches); + this.recentSearches = resultantSearches; }); }, - getRecentSearches() { - return this.recentSearchesStore?.state.recentSearches; - }, handleSortOptionClick(sortBy) { this.selectedSortOption = sortBy; this.$emit('onSort', sortBy.sortDirection[this.selectedSortDirection]); @@ -188,26 +195,22 @@ export default { : SortDirection.ascending; this.$emit('onSort', this.selectedSortOption.sortDirection[this.selectedSortDirection]); }, + handleHistoryItemSelected(filters) { + this.$emit('onFilter', filters); + }, + handleClearHistory() { + const resultantSearches = this.recentSearchesStore.setRecentSearches([]); + this.recentSearchesService.save(resultantSearches); + this.recentSearches = []; + }, handleFilterSubmit(filters) { if (this.recentSearchesStorageKey) { this.recentSearchesPromise .then(() => { if (filters.length) { - const searchTokens = filters.map(filter => { - // check filter was plain text search - if (typeof filter === 'string') { - return filter; - } - // filter was a token. - return `${filter.type}:${filter.value.operator}${this.tokenSymbols[filter.type]}${ - filter.value.data - }`; - }); - - const resultantSearches = this.recentSearchesStore.addRecentSearch( - searchTokens.join(' '), - ); + const resultantSearches = this.recentSearchesStore.addRecentSearch(filters); this.recentSearchesService.save(resultantSearches); + this.recentSearches = resultantSearches; } }) .catch(() => { @@ -226,10 +229,24 @@ export default { v-model="filterValue" :placeholder="searchInputPlaceholder" :available-tokens="tokens" - :history-items="getRecentSearches()" + :history-items="recentSearches" class="flex-grow-1" + @history-item-selected="handleHistoryItemSelected" + @clear-history="handleClearHistory" @submit="handleFilterSubmit" - /> + > + <template #history-item="{ historyItem }"> + <template v-for="(token, index) in historyItem"> + <span v-if="typeof token === 'string'" :key="index" class="gl-px-1">"{{ token }}"</span> + <span v-else :key="`${token.type}-${token.value.data}`" class="gl-px-1"> + <span v-if="tokenTitles[token.type]" + >{{ tokenTitles[token.type] }} :{{ token.value.operator }}</span + > + <strong>{{ tokenSymbols[token.type] }}{{ token.value.data }}</strong> + </span> + </template> + </template> + </gl-filtered-search> <gl-button-group class="sort-dropdown-container d-flex"> <gl-dropdown :text="selectedSortOption.title" :right="true" class="w-100"> <gl-dropdown-item diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue index 412bfa5aa7f..d50649d2581 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue @@ -46,6 +46,16 @@ export default { return this.authors.find(author => author.username.toLowerCase() === this.currentValue); }, }, + watch: { + active: { + immediate: true, + handler(newValue) { + if (!newValue && !this.authors.length) { + this.fetchAuthorBySearchTerm(this.value.data); + } + }, + }, + }, methods: { fetchAuthorBySearchTerm(searchTerm) { const fetchPromise = this.config.fetchPath @@ -89,9 +99,9 @@ export default { <span>{{ activeAuthor ? activeAuthor.name : inputValue }}</span> </template> <template #suggestions> - <gl-filtered-search-suggestion :value="$options.anyAuthor">{{ - __('Any') - }}</gl-filtered-search-suggestion> + <gl-filtered-search-suggestion :value="$options.anyAuthor"> + {{ __('Any') }} + </gl-filtered-search-suggestion> <gl-dropdown-divider /> <gl-loading-icon v-if="loading" /> <template v-else> diff --git a/app/assets/javascripts/vue_shared/components/gl_mentions.vue b/app/assets/javascripts/vue_shared/components/gl_mentions.vue index a7fba5e760b..0ef4f1eda27 100644 --- a/app/assets/javascripts/vue_shared/components/gl_mentions.vue +++ b/app/assets/javascripts/vue_shared/components/gl_mentions.vue @@ -3,18 +3,19 @@ import { escape } from 'lodash'; import Tribute from 'tributejs'; import axios from '~/lib/utils/axios_utils'; import { spriteIcon } from '~/lib/utils/common_utils'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; /** * Creates the HTML template for each row of the mentions dropdown. * - * @param original An object from the array returned from the `autocomplete_sources/members` API - * @returns {string} An HTML template + * @param original - An object from the array returned from the `autocomplete_sources/members` API + * @returns {string} - An HTML template */ function menuItemTemplate({ original }) { const rectAvatarClass = original.type === 'Group' ? 'rect-avatar' : ''; const avatarClasses = `avatar avatar-inline center s26 ${rectAvatarClass} - gl-display-inline-flex gl-align-items-center gl-justify-content-center`; + gl-display-inline-flex! gl-align-items-center gl-justify-content-center`; const avatarTag = original.avatar_url ? `<img @@ -48,6 +49,7 @@ export default { }, data() { return { + assignees: undefined, members: undefined, }; }, @@ -76,19 +78,37 @@ export default { */ getMembers(inputText, processValues) { if (this.members) { - processValues(this.members); + processValues(this.getFilteredMembers()); } else if (this.dataSources.members) { axios .get(this.dataSources.members) .then(response => { this.members = response.data; - processValues(response.data); + processValues(this.getFilteredMembers()); }) .catch(() => {}); } else { processValues([]); } }, + getFilteredMembers() { + const fullText = this.$slots.default[0].elm.value; + + if (!this.assignees) { + this.assignees = + SidebarMediator.singleton?.store?.assignees?.map(assignee => assignee.username) || []; + } + + if (fullText.startsWith('/assign @')) { + return this.members.filter(member => !this.assignees.includes(member.username)); + } + + if (fullText.startsWith('/unassign @')) { + return this.members.filter(member => this.assignees.includes(member.username)); + } + + return this.members; + }, }, render(createElement) { return createElement('div', this.$slots.default); 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 df6fadf10cd..e14f6a04d3c 100644 --- a/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue +++ b/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue @@ -52,6 +52,14 @@ export default { // $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); }, + cancel() { + this.$emit('cancel'); + this.syncHide(); + }, + ok() { + this.$emit('ok'); + this.syncHide(); + }, }, }; </script> @@ -65,5 +73,6 @@ export default { @hidden="syncHide" > <slot></slot> + <slot slot="modal-footer" name="modal-footer" :ok="ok" :cancel="cancel"></slot> </gl-modal> </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 63de1e009fd..caf13bc898b 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 @@ -82,7 +82,7 @@ export default { v-gl-tooltip name="eye-slash" :title="__('Confidential')" - class="confidential-icon append-right-4 align-self-baseline align-self-md-auto mt-xl-0" + class="confidential-icon gl-mr-2 align-self-baseline align-self-md-auto mt-xl-0" :aria-label="__('Confidential')" /> <a :href="computedPath" class="sortable-link gl-font-weight-normal">{{ title }}</a> diff --git a/app/assets/javascripts/vue_shared/components/loading_button.vue b/app/assets/javascripts/vue_shared/components/loading_button.vue index 3508c557289..59ce632c4a2 100644 --- a/app/assets/javascripts/vue_shared/components/loading_button.vue +++ b/app/assets/javascripts/vue_shared/components/loading_button.vue @@ -47,7 +47,7 @@ export default { v-if="loading" :inline="true" :class="{ - 'append-right-5': label, + 'gl-mr-2': label, }" class="js-loading-button-icon" /> diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 0e05f4a4622..f954b8eb4f4 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -4,21 +4,25 @@ import '~/behaviors/markdown/render_gfm'; import { unescape } from 'lodash'; import { __, sprintf } from '~/locale'; import { stripHtml } from '~/lib/utils/text_utility'; -import Flash from '../../../flash'; -import GLForm from '../../../gl_form'; -import markdownHeader from './header.vue'; -import markdownToolbar from './toolbar.vue'; -import icon from '../icon.vue'; +import Flash from '~/flash'; +import GLForm from '~/gl_form'; +import MarkdownHeader from './header.vue'; +import MarkdownToolbar from './toolbar.vue'; +import Icon from '../icon.vue'; +import GlMentions from '~/vue_shared/components/gl_mentions.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'; export default { components: { - markdownHeader, - markdownToolbar, - icon, + GlMentions, + MarkdownHeader, + MarkdownToolbar, + Icon, Suggestions, }, + mixins: [glFeatureFlagsMixin()], props: { isSubmitting: { type: Boolean, @@ -159,12 +163,10 @@ export default { }, }, mounted() { - /* - GLForm class handles all the toolbar buttons - */ + // GLForm class handles all the toolbar buttons return new GLForm($(this.$refs['gl-form']), { emojis: this.enableAutocomplete, - members: this.enableAutocomplete, + members: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, issues: this.enableAutocomplete, mergeRequests: this.enableAutocomplete, epics: this.enableAutocomplete, @@ -229,7 +231,7 @@ export default { <template> <div ref="gl-form" - :class="{ 'prepend-top-default append-bottom-default': addSpacingClasses }" + :class="{ 'gl-mt-3 gl-mb-3': addSpacingClasses }" class="js-vue-markdown-field md-area position-relative" > <markdown-header @@ -243,7 +245,10 @@ export default { /> <div v-show="!previewMarkdown" class="md-write-holder"> <div class="zen-backdrop"> - <slot name="textarea"></slot> + <gl-mentions v-if="glFeatures.tributeAutocomplete"> + <slot name="textarea"></slot> + </gl-mentions> + <slot v-else name="textarea"></slot> <a class="zen-control zen-control-leave js-zen-leave gl-text-gray-700" href="#" diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index aa1abb5adb6..049f5e71849 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -89,14 +89,13 @@ export default { <div class="md-header"> <ul class="nav-links clearfix"> <li :class="{ active: !previewMarkdown }" class="md-header-tab"> - <button class="js-write-link" tabindex="-1" type="button" @click="writeMarkdownTab($event)"> + <button class="js-write-link" type="button" @click="writeMarkdownTab($event)"> {{ __('Write') }} </button> </li> <li :class="{ active: previewMarkdown }" class="md-header-tab"> <button class="js-preview-link js-md-preview-button" - tabindex="-1" type="button" @click="previewMarkdownTab($event)" > 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 6dac448d5de..13c42d35b04 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue @@ -68,6 +68,7 @@ export default { :is-applying-batch="suggestion.is_applying_batch" :batch-suggestions-count="batchSuggestionsCount" :help-page-path="helpPagePath" + :inapplicable-reason="suggestion.inapplicable_reason" @apply="applySuggestion" @applyBatch="applySuggestionBatch" @addToBatch="addSuggestionToBatch" 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 e26ff51e01e..4de80e9b4c2 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 @@ -38,6 +38,11 @@ export default { type: String, required: true, }, + inapplicableReason: { + type: String, + required: false, + default: null, + }, }, data() { return { @@ -52,14 +57,7 @@ export default { return this.isApplyingSingle || this.isApplyingBatch; }, tooltipMessage() { - return this.canApply - ? __('This also resolves the discussion') - : __("Can't apply as this line has changed or the suggestion already matches its content."); - }, - tooltipMessageBatch() { - return !this.canBeBatched - ? __("Suggestions that change line count can't be added to batches, yet.") - : this.tooltipMessage; + return this.canApply ? __('This also resolves this thread') : this.inapplicableReason; }, isDisableButton() { return this.isApplying || !this.canApply; @@ -129,15 +127,14 @@ export default { </gl-deprecated-button> </div> <div v-else class="d-flex align-items-center"> - <span v-if="canBeBatched" v-gl-tooltip.viewport="tooltipMessageBatch" tabindex="0"> - <gl-deprecated-button - class="btn-inverted js-add-to-batch-btn btn-grouped" - :disabled="isDisableButton" - @click="addSuggestionToBatch" - > - {{ __('Add suggestion to batch') }} - </gl-deprecated-button> - </span> + <gl-deprecated-button + v-if="canBeBatched && !isDisableButton" + class="btn-inverted js-add-to-batch-btn btn-grouped" + :disabled="isDisableButton" + @click="addSuggestionToBatch" + > + {{ __('Add suggestion to batch') }} + </gl-deprecated-button> <span v-gl-tooltip.viewport="tooltipMessage" tabindex="0"> <gl-deprecated-button class="btn-inverted js-apply-btn btn-grouped" diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue index 330785c9319..5d47aed9643 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue @@ -61,7 +61,7 @@ export default { <span v-if="canAttachFile" class="uploading-container"> <span class="uploading-progress-container hide"> <template> - <gl-icon name="media" :size="16" /> + <gl-icon name="media" :size="16" class="gl-vertical-align-text-bottom" /> </template> <span class="attaching-file-message"></span> <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings --> @@ -71,7 +71,7 @@ export default { <span class="uploading-error-container hide"> <span class="uploading-error-icon"> <template> - <gl-icon name="media" :size="16" /> + <gl-icon name="media" :size="16" class="gl-vertical-align-text-bottom" /> </template> </span> <span class="uploading-error-message"></span> diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue index 94f78c0c085..f37dd9e171c 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue @@ -64,7 +64,6 @@ export default { :aria-label="buttonTitle" type="button" class="toolbar-btn js-md" - tabindex="-1" data-container="body" @click="() => $emit('click')" > diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue index cb3cd18e5a7..f986b105f20 100644 --- a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue +++ b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue @@ -8,6 +8,12 @@ function buildDocsLinkStart(path) { return `<a href="${escape(path)}" target="_blank" rel="noopener noreferrer">`; } +const NoteableTypeText = { + Issue: __('issue'), + Epic: __('epic'), + MergeRequest: __('merge request'), +}; + export default { components: { icon, @@ -24,12 +30,18 @@ export default { default: false, required: false, }, - lockedIssueDocsPath: { + noteableType: { + type: String, + required: false, + // eslint-disable-next-line @gitlab/require-i18n-strings + default: 'Issue', + }, + lockedNoteableDocsPath: { type: String, required: false, default: '', }, - confidentialIssueDocsPath: { + confidentialNoteableDocsPath: { type: String, required: false, default: '', @@ -45,19 +57,33 @@ export default { isLockedAndConfidential() { return this.isConfidential && this.isLocked; }, + noteableTypeText() { + return NoteableTypeText[this.noteableType]; + }, confidentialAndLockedDiscussionText() { return sprintf( __( - 'This issue is %{confidentialLinkStart}confidential%{linkEnd} and %{lockedLinkStart}locked%{linkEnd}.', + 'This %{noteableTypeText} is %{confidentialLinkStart}confidential%{linkEnd} and %{lockedLinkStart}locked%{linkEnd}.', ), { - confidentialLinkStart: buildDocsLinkStart(this.confidentialIssueDocsPath), - lockedLinkStart: buildDocsLinkStart(this.lockedIssueDocsPath), + noteableTypeText: this.noteableTypeText, + confidentialLinkStart: buildDocsLinkStart(this.confidentialNoteableDocsPath), + lockedLinkStart: buildDocsLinkStart(this.lockedNoteableDocsPath), linkEnd: '</a>', }, false, ); }, + confidentialContextText() { + return sprintf(__('This is a confidential %{noteableTypeText}.'), { + noteableTypeText: this.noteableTypeText, + }); + }, + lockedContextText() { + return sprintf(__('This %{noteableTypeText} is locked.'), { + noteableTypeText: this.noteableTypeText, + }); + }, }, }; </script> @@ -73,19 +99,15 @@ export default { </span> <span v-else-if="isConfidential" ref="confidential"> - {{ __('This is a confidential issue.') }} + {{ confidentialContextText }} {{ __('People without permission will never get a notification.') }} - <gl-link :href="confidentialIssueDocsPath" target="_blank"> - {{ __('Learn more') }} - </gl-link> + <gl-link :href="confidentialNoteableDocsPath" target="_blank">{{ __('Learn more') }}</gl-link> </span> <span v-else-if="isLocked" ref="locked"> - {{ __('This issue is locked.') }} + {{ lockedContextText }} {{ __('Only project members can comment.') }} - <gl-link :href="lockedIssueDocsPath" target="_blank"> - {{ __('Learn more') }} - </gl-link> + <gl-link :href="lockedNoteableDocsPath" target="_blank">{{ __('Learn more') }}</gl-link> </span> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue index b6271a95008..fe57d4f29ca 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -122,7 +122,7 @@ export default { ></div> <div v-if="hasMoreCommits" class="flex-list"> <div class="system-note-commit-list-toggler flex-row" @click="expanded = !expanded"> - <icon :name="toggleIcon" :size="8" class="append-right-5" /> + <icon :name="toggleIcon" :size="8" class="gl-mr-2" /> <span>{{ __('Toggle commit list') }}</span> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue b/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue index 29a4a90a59f..5f2a66ee0b7 100644 --- a/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue +++ b/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue @@ -20,7 +20,7 @@ export default { Here is an example `change` method: change(pagenum) { - gl.utils.visitUrl(`?page=${pagenum}`); + visitUrl(`?page=${pagenum}`); }, */ change: { @@ -64,7 +64,7 @@ export default { <template> <gl-pagination v-if="showPagination" - class="justify-content-center prepend-top-default" + class="justify-content-center gl-mt-3" v-bind="$attrs" :value="pageInfo.page" :per-page="pageInfo.perPage" diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue index 3d52f4176db..e053a9ddaa6 100644 --- a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue +++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue @@ -8,30 +8,25 @@ import { truncateNamespace } from '~/lib/utils/text_utility'; export default { name: 'ProjectListItem', - components: { - Icon, - ProjectAvatar, - GlDeprecatedButton, - }, + components: { Icon, ProjectAvatar, GlDeprecatedButton }, props: { project: { type: Object, required: true, - validator: p => Number.isFinite(p.id) && isString(p.name) && isString(p.name_with_namespace), - }, - selected: { - type: Boolean, - required: true, - }, - matcher: { - type: String, - required: false, - default: '', + validator: p => + (Number.isFinite(p.id) || isString(p.id)) && + isString(p.name) && + (isString(p.name_with_namespace) || isString(p.nameWithNamespace)), }, + selected: { type: Boolean, required: true }, + matcher: { type: String, required: false, default: '' }, }, computed: { + projectNameWithNamespace() { + return this.project.nameWithNamespace || this.project.name_with_namespace; + }, truncatedNamespace() { - return truncateNamespace(this.project.name_with_namespace); + return truncateNamespace(this.projectNameWithNamespace); }, highlightedProjectName() { return highlight(this.project.name, this.matcher); @@ -50,7 +45,7 @@ export default { @click="onClick" > <icon - class="prepend-left-10 append-right-10 flex-shrink-0 position-top-0 js-selected-icon" + class="gl-ml-3 gl-mr-3 flex-shrink-0 position-top-0 js-selected-icon" :class="{ 'js-selected visible': selected, 'js-unselected invisible': !selected }" name="mobile-issue-close" /> @@ -58,7 +53,7 @@ export default { <div class="d-flex flex-wrap project-namespace-name-container"> <div v-if="truncatedNamespace" - :title="project.name_with_namespace" + :title="projectNameWithNamespace" class="text-secondary text-truncate js-project-namespace" > {{ truncatedNamespace }} diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue index 15a5ce85046..0b91588a006 100644 --- a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue +++ b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue @@ -41,7 +41,8 @@ export default { }, totalResults: { type: Number, - required: true, + required: false, + default: 0, }, }, data() { @@ -87,6 +88,7 @@ export default { type="search" class="mb-3" autofocus + data-qa-selector="project_search_field" @input="onInput" /> <div class="d-flex flex-column"> @@ -106,6 +108,7 @@ export default { :project="project" :matcher="searchQuery" class="js-project-list-item" + data-qa-selector="project_list_item" @click="projectClicked(project)" /> </div> diff --git a/app/assets/javascripts/vue_shared/components/remove_member_modal.vue b/app/assets/javascripts/vue_shared/components/remove_member_modal.vue new file mode 100644 index 00000000000..88d1b15aee3 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/remove_member_modal.vue @@ -0,0 +1,78 @@ +<script> +import { GlFormCheckbox, GlModal } from '@gitlab/ui'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import csrf from '~/lib/utils/csrf'; +import { __ } from '~/locale'; + +export default { + actionCancel: { + text: __('Cancel'), + }, + csrf, + components: { + GlFormCheckbox, + GlModal, + }, + data() { + return { + modalData: {}, + }; + }, + computed: { + isAccessRequest() { + return parseBoolean(this.modalData.isAccessRequest); + }, + actionText() { + return this.isAccessRequest ? __('Deny access request') : __('Remove member'); + }, + actionPrimary() { + return { + text: this.actionText, + attributes: { + variant: 'danger', + }, + }; + }, + }, + mounted() { + document.addEventListener('click', this.handleClick); + }, + beforeDestroy() { + document.removeEventListener('click', this.handleClick); + }, + methods: { + handleClick(event) { + const removeButton = event.target.closest('.js-remove-member-button'); + if (removeButton) { + this.modalData = removeButton.dataset; + this.$refs.modal.show(); + } + }, + submitForm() { + this.$refs.form.submit(); + }, + }, +}; +</script> + +<template> + <gl-modal + ref="modal" + modal-id="remove-member-modal" + :action-cancel="$options.actionCancel" + :action-primary="actionPrimary" + :title="actionText" + data-qa-selector="remove_member_modal_content" + @primary="submitForm" + > + <form ref="form" :action="modalData.memberPath" method="post"> + <p data-testid="modal-message">{{ modalData.message }}</p> + + <input ref="method" type="hidden" name="_method" value="delete" /> + <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> + <gl-form-checkbox v-if="!isAccessRequest" name="unassign_issuables"> + {{ __('Also unassign this user from related issues and merge requests') }} + </gl-form-checkbox> + </form> + </gl-modal> +</template> diff --git a/app/assets/javascripts/vue_shared/components/resizable_chart/constants.js b/app/assets/javascripts/vue_shared/components/resizable_chart/constants.js new file mode 100644 index 00000000000..edc5ffb7b77 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/resizable_chart/constants.js @@ -0,0 +1,6 @@ +export const DEFAULT_RX = 0.4; +export const DEFAULT_BAR_WIDTH = 6; +export const DEFAULT_LABEL_WIDTH = 4; +export const DEFAULT_LABEL_HEIGHT = 5; +export const BAR_HEIGHTS = [5, 7, 9, 14, 21, 35, 50, 80]; +export const GRID_YS = [30, 60, 90]; diff --git a/app/assets/javascripts/vue_shared/components/resizable_chart/skeleton_loader.vue b/app/assets/javascripts/vue_shared/components/resizable_chart/skeleton_loader.vue new file mode 100644 index 00000000000..306fa61780f --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/resizable_chart/skeleton_loader.vue @@ -0,0 +1,95 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; + +import { + DEFAULT_RX, + DEFAULT_BAR_WIDTH, + DEFAULT_LABEL_WIDTH, + DEFAULT_LABEL_HEIGHT, + BAR_HEIGHTS, + GRID_YS, +} from './constants'; + +export default { + components: { + GlSkeletonLoader, + }, + props: { + barWidth: { + type: Number, + default: DEFAULT_BAR_WIDTH, + required: false, + }, + labelWidth: { + type: Number, + default: DEFAULT_LABEL_WIDTH, + required: false, + }, + labelHeight: { + type: Number, + default: DEFAULT_LABEL_HEIGHT, + required: false, + }, + rx: { + type: Number, + default: DEFAULT_RX, + required: false, + }, + // skeleton-loader will generate a unique key if not defined + uniqueKey: { + type: String, + default: undefined, + required: false, + }, + }, + computed: { + labelCentering() { + return (this.barWidth - this.labelWidth) / 2; + }, + }, + methods: { + getBarXPosition(index) { + const numberOfBars = this.$options.BAR_HEIGHTS.length; + const numberOfSpaces = numberOfBars + 1; + const spaceBetweenBars = (100 - numberOfSpaces * this.barWidth) / numberOfBars; + + return (0.5 + index) * (this.barWidth + spaceBetweenBars); + }, + }, + BAR_HEIGHTS, + GRID_YS, +}; +</script> +<template> + <gl-skeleton-loader :unique-key="uniqueKey"> + <rect + v-for="(y, index) in $options.GRID_YS" + :key="`grid-${index}`" + data-testid="skeleton-chart-grid" + x="0" + :y="`${y}%`" + width="100%" + height="1px" + /> + <rect + v-for="(height, index) in $options.BAR_HEIGHTS" + :key="`bar-${index}`" + data-testid="skeleton-chart-bar" + :x="`${getBarXPosition(index)}%`" + :y="`${90 - height}%`" + :width="`${barWidth}%`" + :height="`${height}%`" + :rx="`${rx}%`" + /> + <rect + v-for="(height, index) in $options.BAR_HEIGHTS" + :key="`label-${index}`" + data-testid="skeleton-chart-label" + :x="`${labelCentering + getBarXPosition(index)}%`" + :y="`${100 - labelHeight}%`" + :width="`${labelWidth}%`" + :height="`${labelHeight}%`" + :rx="`${rx}%`" + /> + </gl-skeleton-loader> +</template> diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js index 1566c2c784b..dd1da847001 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js @@ -1,5 +1,6 @@ import { __ } from '~/locale'; -import { generateToolbarItem } from './editor_service'; +import { generateToolbarItem } from './services/editor_service'; +import buildCustomHTMLRenderer from './services/build_custom_renderer'; export const CUSTOM_EVENTS = { openAddImageModal: 'gl_openAddImageModal', @@ -31,6 +32,7 @@ const TOOLBAR_ITEM_CONFIGS = [ export const EDITOR_OPTIONS = { toolbarItems: TOOLBAR_ITEM_CONFIGS.map(config => generateToolbarItem(config)), + customHTMLRenderer: buildCustomHTMLRenderer(), }; export const EDITOR_TYPES = { @@ -41,3 +43,7 @@ export const EDITOR_TYPES = { export const EDITOR_HEIGHT = '100%'; export const EDITOR_PREVIEW_STYLE = 'horizontal'; + +export const IMAGE_TABS = { UPLOAD_TAB: 0, URL_TAB: 1 }; + +export const MAX_FILE_SIZE = 2097152; // 2Mb diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue new file mode 100644 index 00000000000..0a444b2295d --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue @@ -0,0 +1,147 @@ +<script> +import { isSafeURL } from '~/lib/utils/url_utility'; +import { GlModal, GlFormGroup, GlFormInput, GlTabs, GlTab } from '@gitlab/ui'; +import { __ } from '~/locale'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { IMAGE_TABS } from '../../constants'; +import UploadImageTab from './upload_image_tab.vue'; + +export default { + components: { + UploadImageTab, + GlModal, + GlFormGroup, + GlFormInput, + GlTabs, + GlTab, + }, + mixins: [glFeatureFlagMixin()], + props: { + imageRoot: { + type: String, + required: true, + }, + }, + data() { + return { + file: null, + urlError: null, + imageUrl: null, + description: null, + tabIndex: IMAGE_TABS.UPLOAD_TAB, + uploadImageTab: null, + }; + }, + modalTitle: __('Image Details'), + okTitle: __('Insert'), + urlTabTitle: __('By URL'), + urlLabel: __('Image URL'), + descriptionLabel: __('Description'), + uploadTabTitle: __('Upload file'), + computed: { + altText() { + return this.description; + }, + }, + methods: { + show() { + this.file = null; + this.urlError = null; + this.imageUrl = null; + this.description = null; + this.tabIndex = IMAGE_TABS.UPLOAD_TAB; + + this.$refs.modal.show(); + }, + onOk(event) { + if (this.glFeatures.sseImageUploads && this.tabIndex === IMAGE_TABS.UPLOAD_TAB) { + this.submitFile(event); + return; + } + this.submitURL(event); + }, + setFile(file) { + this.file = file; + }, + submitFile(event) { + const { file, altText } = this; + const { uploadImageTab } = this.$refs; + + uploadImageTab.validateFile(); + + if (uploadImageTab.fileError) { + event.preventDefault(); + return; + } + + const imageUrl = `${this.imageRoot}${file.name}`; + + this.$emit('addImage', { imageUrl, file, altText: altText || file.name }); + }, + submitURL(event) { + if (!this.validateUrl()) { + event.preventDefault(); + return; + } + + const { imageUrl, altText } = this; + + this.$emit('addImage', { imageUrl, altText: altText || imageUrl }); + }, + validateUrl() { + if (!isSafeURL(this.imageUrl)) { + this.urlError = __('Please provide a valid URL'); + this.$refs.urlInput.$el.focus(); + return false; + } + + return true; + }, + }, +}; +</script> +<template> + <gl-modal + ref="modal" + modal-id="add-image-modal" + :title="$options.modalTitle" + :ok-title="$options.okTitle" + @ok="onOk" + > + <gl-tabs v-if="glFeatures.sseImageUploads" v-model="tabIndex"> + <!-- Upload file Tab --> + <gl-tab :title="$options.uploadTabTitle"> + <upload-image-tab ref="uploadImageTab" @input="setFile" /> + </gl-tab> + + <!-- By URL Tab --> + <gl-tab :title="$options.urlTabTitle"> + <gl-form-group + class="gl-mt-5 gl-mb-3" + :label="$options.urlLabel" + label-for="url-input" + :state="!Boolean(urlError)" + :invalid-feedback="urlError" + > + <gl-form-input id="url-input" ref="urlInput" v-model="imageUrl" /> + </gl-form-group> + </gl-tab> + </gl-tabs> + + <gl-form-group + v-else + class="gl-mt-5 gl-mb-3" + :label="$options.urlLabel" + label-for="url-input" + :state="!Boolean(urlError)" + :invalid-feedback="urlError" + > + <gl-form-input id="url-input" ref="urlInput" v-model="imageUrl" /> + </gl-form-group> + + <!-- Description Input --> + <gl-form-group :label="$options.descriptionLabel" label-for="description-input"> + <gl-form-input id="description-input" ref="descriptionInput" v-model="description" /> + </gl-form-group> + </gl-modal> +</template> diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue new file mode 100644 index 00000000000..739f8b502c9 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue @@ -0,0 +1,56 @@ +<script> +import { __ } from '~/locale'; +import { GlFormGroup } from '@gitlab/ui'; +import { MAX_FILE_SIZE } from '../../constants'; + +export default { + components: { + GlFormGroup, + }, + data() { + return { + file: null, + fileError: null, + }; + }, + fileLabel: __('Select file'), + methods: { + onInput(event) { + [this.file] = event.target.files; + + this.validateFile(); + + if (!this.fileError) { + this.$emit('input', this.file); + } + }, + validateFile() { + this.fileError = null; + + if (!this.file) { + this.fileError = __('Please choose a file'); + } else if (this.file.size > MAX_FILE_SIZE) { + this.fileError = __('Maximum file size is 2MB. Please select a smaller file.'); + } + }, + }, +}; +</script> +<template> + <gl-form-group + class="gl-mt-5 gl-mb-3" + :label="$options.fileLabel" + label-for="file-input" + :state="!Boolean(fileError)" + :invalid-feedback="fileError" + > + <input + id="file-input" + ref="fileInput" + class="gl-mt-3 gl-mb-2" + type="file" + accept="image/*" + @input="onInput" + /> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image_modal.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image_modal.vue deleted file mode 100644 index 40063065926..00000000000 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image_modal.vue +++ /dev/null @@ -1,74 +0,0 @@ -<script> -import { isSafeURL } from '~/lib/utils/url_utility'; -import { GlModal, GlFormGroup, GlFormInput } from '@gitlab/ui'; -import { __ } from '~/locale'; - -export default { - components: { - GlModal, - GlFormGroup, - GlFormInput, - }, - data() { - return { - error: null, - imageUrl: null, - altText: null, - modalTitle: __('Image Details'), - okTitle: __('Insert'), - urlLabel: __('Image URL'), - descriptionLabel: __('Description'), - }; - }, - methods: { - show() { - this.error = null; - this.imageUrl = null; - this.altText = null; - - this.$refs.modal.show(); - }, - onOk(event) { - if (!this.isValid()) { - event.preventDefault(); - return; - } - - const { imageUrl, altText } = this; - - this.$emit('addImage', { imageUrl, altText: altText || __('image') }); - }, - isValid() { - if (!isSafeURL(this.imageUrl)) { - this.error = __('Please provide a valid URL'); - this.$refs.urlInput.$el.focus(); - return false; - } - - return true; - }, - }, -}; -</script> -<template> - <gl-modal - ref="modal" - modal-id="add-image-modal" - :title="modalTitle" - :ok-title="okTitle" - @ok="onOk" - > - <gl-form-group - :label="urlLabel" - label-for="url-input" - :state="!Boolean(error)" - :invalid-feedback="error" - > - <gl-form-input id="url-input" ref="urlInput" v-model="imageUrl" /> - </gl-form-group> - - <gl-form-group :label="descriptionLabel" label-for="description-input"> - <gl-form-input id="description-input" ref="descriptionInput" v-model="altText" /> - </gl-form-group> - </gl-modal> -</template> diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue index 5c310fc059b..baeb98bec75 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue @@ -2,7 +2,7 @@ import 'codemirror/lib/codemirror.css'; import '@toast-ui/editor/dist/toastui-editor.css'; -import AddImageModal from './modals/add_image_modal.vue'; +import AddImageModal from './modals/add_image/add_image_modal.vue'; import { EDITOR_OPTIONS, EDITOR_TYPES, @@ -12,11 +12,12 @@ import { } from './constants'; import { + registerHTMLToMarkdownRenderer, addCustomEventListener, removeCustomEventListener, addImage, getMarkdown, -} from './editor_service'; +} from './services/editor_service'; export default { components: { @@ -27,7 +28,7 @@ export default { AddImageModal, }, props: { - value: { + content: { type: String, required: true, }, @@ -51,6 +52,11 @@ export default { required: false, default: EDITOR_PREVIEW_STYLE, }, + imageRoot: { + type: String, + required: true, + validator: prop => prop.endsWith('/'), + }, }, data() { return { @@ -66,51 +72,48 @@ export default { return this.$refs.editor; }, }, - watch: { - value(newVal) { - const isSameMode = this.previousMode === this.editorApi.currentMode; - if (!isSameMode) { - /* - The ToastUI Editor consumes its content via the `initial-value` prop and then internally - manages changes. If we desire the `v-model` to work as expected, we need to manually call - `setMarkdown`. However, if we do this in each v-model change we'll continually prevent - the editor from internally managing changes. Thus we use the `previousMode` flag as - confirmation to actually update its internals. This is initially designed so that front - matter is excluded from editing in wysiwyg mode, but included in markdown mode. - */ - this.editorInstance.invoke('setMarkdown', newVal); - this.previousMode = this.editorApi.currentMode; - } - }, - }, beforeDestroy() { - removeCustomEventListener( - this.editorApi, - CUSTOM_EVENTS.openAddImageModal, - this.onOpenAddImageModal, - ); - - this.editorApi.eventManager.removeEventHandler('changeMode', this.onChangeMode); + this.removeListeners(); }, methods: { + addListeners(editorApi) { + addCustomEventListener(editorApi, CUSTOM_EVENTS.openAddImageModal, this.onOpenAddImageModal); + + editorApi.eventManager.listen('changeMode', this.onChangeMode); + }, + removeListeners() { + removeCustomEventListener( + this.editorApi, + CUSTOM_EVENTS.openAddImageModal, + this.onOpenAddImageModal, + ); + + this.editorApi.eventManager.removeEventHandler('changeMode', this.onChangeMode); + }, + resetInitialValue(newVal) { + this.editorInstance.invoke('setMarkdown', newVal); + }, onContentChanged() { this.$emit('input', getMarkdown(this.editorInstance)); }, onLoad(editorApi) { this.editorApi = editorApi; - addCustomEventListener( - this.editorApi, - CUSTOM_EVENTS.openAddImageModal, - this.onOpenAddImageModal, - ); + registerHTMLToMarkdownRenderer(editorApi); - this.editorApi.eventManager.listen('changeMode', this.onChangeMode); + this.addListeners(editorApi); }, onOpenAddImageModal() { this.$refs.addImageModal.show(); }, - onAddImage(image) { + onAddImage({ imageUrl, altText, file }) { + const image = { imageUrl, altText }; + + if (file) { + this.$emit('uploadImage', { file, imageUrl }); + // TODO - ensure that the actual repo URL for the image is used in Markdown mode + } + addImage(this.editorInstance, image); }, onChangeMode(newMode) { @@ -123,7 +126,7 @@ export default { <div> <toast-editor ref="editor" - :initial-value="value" + :initial-value="content" :options="editorOptions" :preview-style="previewStyle" :initial-edit-type="initialEditType" @@ -131,6 +134,6 @@ export default { @change="onContentChanged" @load="onLoad" /> - <add-image-modal ref="addImageModal" @addImage="onAddImage" /> + <add-image-modal ref="addImageModal" :image-root="imageRoot" @addImage="onAddImage" /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js new file mode 100644 index 00000000000..70d29b5b3df --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js @@ -0,0 +1,68 @@ +import renderBlockHtml from './renderers/render_html_block'; +import renderKramdownList from './renderers/render_kramdown_list'; +import renderKramdownText from './renderers/render_kramdown_text'; +import renderIdentifierInstanceText from './renderers/render_identifier_instance_text'; +import renderIdentifierParagraph from './renderers/render_identifier_paragraph'; +import renderEmbeddedRubyText from './renderers/render_embedded_ruby_text'; +import renderFontAwesomeHtmlInline from './renderers/render_font_awesome_html_inline'; + +const htmlInlineRenderers = [renderFontAwesomeHtmlInline]; +const htmlBlockRenderers = [renderBlockHtml]; +const listRenderers = [renderKramdownList]; +const paragraphRenderers = [renderIdentifierParagraph]; +const textRenderers = [renderKramdownText, renderEmbeddedRubyText, renderIdentifierInstanceText]; + +const executeRenderer = (renderers, node, context) => { + const availableRenderer = renderers.find(renderer => renderer.canRender(node, context)); + + return availableRenderer ? availableRenderer.render(node, context) : context.origin(); +}; + +const buildCustomRendererFunctions = (customRenderers, defaults) => { + const customTypes = Object.keys(customRenderers).filter(type => !defaults[type]); + const customEntries = customTypes.map(type => { + const fn = (node, context) => executeRenderer(customRenderers[type], node, context); + return [type, fn]; + }); + + return Object.fromEntries(customEntries); +}; + +const buildCustomHTMLRenderer = ( + customRenderers = { htmlBlock: [], htmlInline: [], list: [], paragraph: [], text: [] }, +) => { + const defaults = { + htmlBlock(node, context) { + const allHtmlBlockRenderers = [...customRenderers.htmlBlock, ...htmlBlockRenderers]; + + return executeRenderer(allHtmlBlockRenderers, node, context); + }, + htmlInline(node, context) { + const allHtmlInlineRenderers = [...customRenderers.htmlInline, ...htmlInlineRenderers]; + + return executeRenderer(allHtmlInlineRenderers, node, context); + }, + list(node, context) { + const allListRenderers = [...customRenderers.list, ...listRenderers]; + + return executeRenderer(allListRenderers, node, context); + }, + paragraph(node, context) { + const allParagraphRenderers = [...customRenderers.paragraph, ...paragraphRenderers]; + + return executeRenderer(allParagraphRenderers, node, context); + }, + text(node, context) { + const allTextRenderers = [...customRenderers.text, ...textRenderers]; + + return executeRenderer(allTextRenderers, node, context); + }, + }; + + return { + ...buildCustomRendererFunctions(customRenderers, defaults), + ...defaults, + }; +}; + +export default buildCustomHTMLRenderer; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js new file mode 100644 index 00000000000..ed04765c871 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js @@ -0,0 +1,53 @@ +import { defaults, repeat } from 'lodash'; + +const DEFAULTS = { + subListIndentSpaces: 4, +}; + +const countIndentSpaces = text => { + const matches = text.match(/^\s+/m); + + return matches ? matches[0].length : 0; +}; + +const buildHTMLToMarkdownRender = (baseRenderer, formattingPreferences = {}) => { + const { subListIndentSpaces } = defaults(formattingPreferences, DEFAULTS); + // eslint-disable-next-line @gitlab/require-i18n-strings + const sublistNode = 'LI OL, LI UL'; + + return { + TEXT_NODE(node) { + return baseRenderer.getSpaceControlled( + baseRenderer.trim(baseRenderer.getSpaceCollapsedText(node.nodeValue)), + node, + ); + }, + /* + * This converter overwrites the default indented list converter + * to allow us to parameterize the number of indent spaces for + * sublists. + * + * See the original implementation in + * https://github.com/nhn/tui.editor/blob/master/libs/to-mark/src/renderer.basic.js#L161 + */ + [sublistNode](node, subContent) { + const baseResult = baseRenderer.convert(node, subContent); + // Default to 1 to prevent possible divide by 0 + const firstLevelIndentSpacesCount = countIndentSpaces(baseResult) || 1; + const reindentedList = baseResult + .split('\n') + .map(line => { + const itemIndentSpacesCount = countIndentSpaces(line); + const nestingLevel = Math.ceil(itemIndentSpacesCount / firstLevelIndentSpacesCount); + const indentSpaces = repeat(' ', subListIndentSpaces * nestingLevel); + + return line.replace(/^ +/, indentSpaces); + }) + .join('\n'); + + return reindentedList; + }, + }; +}; + +export default buildHTMLToMarkdownRender; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/editor_service.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js index 278cd50a947..6436dcaae64 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/editor_service.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js @@ -1,5 +1,6 @@ import Vue from 'vue'; -import ToolbarItem from './toolbar_item.vue'; +import ToolbarItem from '../toolbar_item.vue'; +import buildHtmlToMarkdownRenderer from './build_html_to_markdown_renderer'; const buildWrapper = propsData => { const instance = new Vue({ @@ -40,3 +41,16 @@ export const removeCustomEventListener = (editorApi, event, handler) => export const addImage = ({ editor }, image) => editor.exec('AddImage', image); export const getMarkdown = editorInstance => editorInstance.invoke('getMarkdown'); + +/** + * This function allow us to extend Toast UI HTML to Markdown renderer. It is + * a temporary measure because Toast UI does not provide an API + * to achieve this goal. + */ +export const registerHTMLToMarkdownRenderer = editorApi => { + const { renderer } = editorApi.toMarkOptions; + + Object.assign(editorApi.toMarkOptions, { + renderer: renderer.constructor.factory(renderer, buildHtmlToMarkdownRenderer(renderer)), + }); +}; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js new file mode 100644 index 00000000000..d96cadafdbb --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js @@ -0,0 +1,63 @@ +const buildToken = (type, tagName, props) => { + return { type, tagName, ...props }; +}; + +const TAG_TYPES = { + block: 'div', + inline: 'a', +}; + +// Open helpers (singular and multiple) + +const buildUneditableOpenToken = (tagType = TAG_TYPES.block) => + buildToken('openTag', tagType, { + attributes: { contenteditable: false }, + classNames: [ + 'gl-px-4 gl-py-2 gl-my-5 gl-opacity-5 gl-bg-gray-100 gl-user-select-none gl-cursor-not-allowed', + ], + }); + +export const buildUneditableOpenTokens = (token, tagType = TAG_TYPES.block) => { + return [buildUneditableOpenToken(tagType), token]; +}; + +// Close helpers (singular and multiple) + +export const buildUneditableCloseToken = (tagType = TAG_TYPES.block) => + buildToken('closeTag', tagType); + +export const buildUneditableCloseTokens = (token, tagType = TAG_TYPES.block) => { + return [token, buildUneditableCloseToken(tagType)]; +}; + +// Complete helpers (open plus close) + +export const buildTextToken = content => buildToken('text', null, { content }); + +export const buildUneditableTokens = token => { + return [...buildUneditableOpenTokens(token), buildUneditableCloseToken()]; +}; + +export const buildUneditableInlineTokens = token => { + return [ + ...buildUneditableOpenTokens(token, TAG_TYPES.inline), + buildUneditableCloseToken(TAG_TYPES.inline), + ]; +}; + +export const buildUneditableHtmlAsTextTokens = node => { + /* + Toast UI internally appends ' data-tomark-pass ' attribute flags so it can target certain + nested nodes for internal use during Markdown <=> WYSIWYG conversions. In our case, we want + to prevent HTML being rendered completely in WYSIWYG mode and thus we use a `text` vs. `html` + type when building the token. However, in doing so, we need to strip out the ` data-tomark-pass ` + to prevent their persistence within the `text` content as the user did not intend these as edits. + + https://github.com/nhn/tui.editor/blob/cc54ec224fc3a4b6e5a2b19a71650959f41adc0e/apps/editor/src/js/convertor.js#L72 + */ + const regex = / data-tomark-pass /gm; + const content = node.literal.replace(regex, ''); + const htmlAsTextToken = buildToken('text', null, { content }); + + return [buildUneditableOpenToken(), htmlAsTextToken, buildUneditableCloseToken()]; +}; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text.js new file mode 100644 index 00000000000..494057fc75b --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text.js @@ -0,0 +1,13 @@ +import { buildUneditableTokens } from './build_uneditable_token'; + +const embeddedRubyRegex = /(^<%.+%>$)/; + +const canRender = ({ literal }) => { + return embeddedRubyRegex.test(literal); +}; + +const render = (_, { origin }) => { + return buildUneditableTokens(origin()); +}; + +export default { canRender, render }; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline.js new file mode 100644 index 00000000000..572f6e3cf9d --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline.js @@ -0,0 +1,11 @@ +import { buildUneditableInlineTokens } from './build_uneditable_token'; + +const fontAwesomeRegexOpen = /<i class="fa.+>/; + +const canRender = ({ literal }) => { + return fontAwesomeRegexOpen.test(literal); +}; + +const render = (_, { origin }) => buildUneditableInlineTokens(origin()); + +export default { canRender, render }; 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 new file mode 100644 index 00000000000..b179ca61dba --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js @@ -0,0 +1,9 @@ +import { buildUneditableHtmlAsTextTokens } from './build_uneditable_token'; + +const canRender = ({ type }) => { + return type === 'htmlBlock'; +}; + +const render = node => buildUneditableHtmlAsTextTokens(node); + +export default { canRender, render }; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text.js new file mode 100644 index 00000000000..a9c3dfcd728 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text.js @@ -0,0 +1,40 @@ +import { buildTextToken, buildUneditableInlineTokens } from './build_uneditable_token'; + +/* +Use case examples: +- Majority: two bracket pairs, back-to-back, each with content (including spaces) + - `[environment terraform plans][terraform]` + - `[an issue labelled `~"master:broken"`][broken-master-issues]` +- Minority: two bracket pairs the latter being empty or only one pair with content (including spaces) + - `[this link][]` + - `[this link]` + +Regexp notes: + - `(?:\[.+?\]){1}`: Always one bracket pair with content (including spaces) + - `(?:\[\]|\[.+?\])?`: Optional second pair that may or may not contain content (including spaces) + - `(?!:)`: Never followed by a `:` which is reserved for identifier definition syntax (`[identifier]: /the-link`) + - Each of the three parts is non-captured, but the match as a whole is captured +*/ +const identifierInstanceRegex = /((?:\[.+?\]){1}(?:\[\]|\[.+?\])?(?!:))/g; + +const isIdentifierInstance = literal => { + // Reset lastIndex as global flag in regexp are stateful (https://stackoverflow.com/a/11477448) + identifierInstanceRegex.lastIndex = 0; + return identifierInstanceRegex.test(literal); +}; + +const canRender = ({ literal }) => isIdentifierInstance(literal); + +const tokenize = text => { + const matches = text.split(identifierInstanceRegex); + const tokens = matches.map(match => { + const token = buildTextToken(match); + return isIdentifierInstance(match) ? buildUneditableInlineTokens(token) : token; + }); + + return tokens.flat(); +}; + +const render = (_, { origin }) => tokenize(origin().content); + +export default { canRender, render }; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js new file mode 100644 index 00000000000..f5b4502ea3c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js @@ -0,0 +1,16 @@ +import { buildUneditableOpenTokens, buildUneditableCloseToken } from './build_uneditable_token'; + +const identifierRegex = /(^\[.+\]: .+)/; + +const isIdentifier = text => { + return identifierRegex.test(text); +}; + +const canRender = (node, context) => { + return isIdentifier(context.getChildrenText(node)); +}; + +const render = (_, { entering, origin }) => + entering ? buildUneditableOpenTokens(origin()) : buildUneditableCloseToken(); + +export default { canRender, render }; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list.js new file mode 100644 index 00000000000..491a26c81d0 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list.js @@ -0,0 +1,27 @@ +import { buildUneditableOpenTokens, buildUneditableCloseToken } from './build_uneditable_token'; + +const isKramdownTOC = ({ type, literal }) => type === 'text' && literal === 'TOC'; + +const canRender = node => { + let targetNode = node; + while (targetNode !== null) { + const { firstChild } = targetNode; + const isLeaf = firstChild === null; + if (isLeaf) { + if (isKramdownTOC(targetNode)) { + return true; + } + + break; + } + + targetNode = targetNode.firstChild; + } + + return false; +}; + +const render = (_, { entering, origin }) => + entering ? buildUneditableOpenTokens(origin()) : buildUneditableCloseToken(); + +export default { canRender, render }; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text.js new file mode 100644 index 00000000000..01384699e4f --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text.js @@ -0,0 +1,13 @@ +import { buildUneditableTokens } from './build_uneditable_token'; + +const kramdownRegex = /(^{:.+}$)/; + +const canRender = ({ literal }) => { + return kramdownRegex.test(literal); +}; + +const render = (_, { origin }) => { + return buildUneditableTokens(origin()); +}; + +export default { canRender, render }; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue index 30f7e6a5980..1be5284fa9c 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue @@ -1,7 +1,11 @@ <script> import { __, s__, sprintf } from '~/locale'; +import { GlIcon } from '@gitlab/ui'; export default { + components: { + GlIcon, + }, props: { abilityName: { type: String, @@ -72,6 +76,10 @@ export default { data-toggle="dropdown" > <span class="dropdown-toggle-text"> {{ dropdownToggleText }} </span> - <i aria-hidden="true" class="fa fa-chevron-down" data-hidden="true"> </i> + <gl-icon + name="chevron-down" + class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-700" + :size="16" + /> </button> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue index bf51fa3dc38..f0a846c4924 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue @@ -1,5 +1,11 @@ <script> -export default {}; +import { GlIcon } from '@gitlab/ui'; + +export default { + components: { + GlIcon, + }, +}; </script> <template> @@ -10,13 +16,13 @@ export default {}; class="dropdown-input-field" type="search" /> - <i aria-hidden="true" class="fa fa-search dropdown-input-search" data-hidden="true"> </i> - <i - aria-hidden="true" - class="fa fa-times dropdown-input-clear js-dropdown-input-clear" - data-hidden="true" - role="button" - > - </i> + <gl-icon + name="search" + class="dropdown-input-search gl-absolute gl-top-3 gl-right-5 gl-text-gray-500 gl-pointer-events-none" + /> + <gl-icon + name="close" + class="dropdown-input-clear js-dropdown-input-clear gl-absolute gl-top-3 gl-right-5 gl-text-gray-700" + /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js index e94e7d46f85..746e38e98e8 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js @@ -1,6 +1,7 @@ export const DropdownVariant = { Sidebar: 'sidebar', Standalone: 'standalone', + Embedded: 'embedded', }; export const LIST_BUFFER_SIZE = 5; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue index f45c14f8344..cf77aa37d14 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue @@ -8,12 +8,16 @@ export default { GlIcon, }, computed: { - ...mapGetters(['dropdownButtonText', 'isDropdownVariantStandalone']), + ...mapGetters([ + 'dropdownButtonText', + 'isDropdownVariantStandalone', + 'isDropdownVariantEmbedded', + ]), }, methods: { ...mapActions(['toggleDropdownContents']), handleButtonClick(e) { - if (this.isDropdownVariantStandalone) { + if (this.isDropdownVariantStandalone || this.isDropdownVariantEmbedded) { this.toggleDropdownContents(); e.stopPropagation(); } diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue index ba8d8391952..94671f8a109 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue @@ -88,12 +88,16 @@ export default { @click.prevent="handleColorClick(color)" /> </div> - <div class="color-input-container d-flex"> + <div class="color-input-container gl-display-flex"> <span class="dropdown-label-color-preview position-relative position-relative d-inline-block" :style="{ backgroundColor: selectedColor }" ></span> - <gl-form-input v-model.trim="selectedColor" :placeholder="__('Use custom color #FF0000')" /> + <gl-form-input + v-model.trim="selectedColor" + class="gl-rounded-top-left-none gl-rounded-bottom-left-none" + :placeholder="__('Use custom color #FF0000')" + /> </div> </div> <div class="dropdown-actions clearfix pt-2 px-2"> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue index af16088b6b9..ef506d00d9a 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue @@ -36,7 +36,7 @@ export default { 'footerCreateLabelTitle', 'footerManageLabelTitle', ]), - ...mapGetters(['selectedLabelsList', 'isDropdownVariantSidebar']), + ...mapGetters(['selectedLabelsList', 'isDropdownVariantSidebar', 'isDropdownVariantEmbedded']), visibleLabels() { if (this.searchKey) { return this.labels.filter(label => @@ -126,16 +126,19 @@ export default { <div class="labels-select-contents-list js-labels-list" @keydown="handleKeyDown"> <gl-loading-icon v-if="labelsFetchInProgress" - class="labels-fetch-loading position-absolute d-flex align-items-center w-100 h-100" + class="labels-fetch-loading position-absolute gl-display-flex gl-align-items-center w-100 h-100" size="md" /> - <div v-if="isDropdownVariantSidebar" class="dropdown-title d-flex align-items-center pt-0 pb-2"> + <div + v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded" + class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!" + > <span class="flex-grow-1">{{ labelsListTitle }}</span> <gl-button :aria-label="__('Close')" variant="link" size="small" - class="dropdown-header-button p-0" + class="dropdown-header-button gl-p-0!" icon="close" @click="toggleDropdownContents" /> @@ -165,17 +168,21 @@ export default { </li> </smart-virtual-list> </div> - <div v-if="isDropdownVariantSidebar" class="dropdown-footer"> + <div v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded" class="dropdown-footer"> <ul class="list-unstyled"> <li v-if="allowLabelCreate"> <gl-link - class="d-flex w-100 flex-row text-break-word label-item" + class="gl-display-flex w-100 flex-row text-break-word label-item" @click="toggleDropdownContentsCreateView" - >{{ footerCreateLabelTitle }}</gl-link > + {{ footerCreateLabelTitle }} + </gl-link> </li> <li> - <gl-link :href="labelsManagePath" class="d-flex flex-row text-break-word label-item"> + <gl-link + :href="labelsManagePath" + class="gl-display-flex flex-row text-break-word label-item" + > {{ footerManageLabelTitle }} </gl-link> </li> 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 f38b66fdfdf..258a87e62b9 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 @@ -74,6 +74,11 @@ export default { required: false, default: '', }, + dropdownButtonText: { + type: String, + required: false, + default: __('Label'), + }, labelsListTitle: { type: String, required: false, @@ -97,7 +102,11 @@ export default { }, computed: { ...mapState(['showDropdownButton', 'showDropdownContents']), - ...mapGetters(['isDropdownVariantSidebar', 'isDropdownVariantStandalone']), + ...mapGetters([ + 'isDropdownVariantSidebar', + 'isDropdownVariantStandalone', + 'isDropdownVariantEmbedded', + ]), dropdownButtonVisible() { return this.isDropdownVariantSidebar ? this.showDropdownButton : true; }, @@ -116,6 +125,7 @@ export default { allowLabelCreate: this.allowLabelCreate, allowMultiselect: this.allowMultiselect, allowScopedLabels: this.allowScopedLabels, + dropdownButtonText: this.dropdownButtonText, selectedLabels: this.selectedLabels, labelsFetchPath: this.labelsFetchPath, labelsManagePath: this.labelsManagePath, @@ -200,7 +210,10 @@ export default { <template> <div class="labels-select-wrapper position-relative" - :class="{ 'is-standalone': isDropdownVariantStandalone }" + :class="{ + 'is-standalone': isDropdownVariantStandalone, + 'is-embedded': isDropdownVariantEmbedded, + }" > <template v-if="isDropdownVariantSidebar"> <dropdown-value-collapsed @@ -221,7 +234,7 @@ export default { ref="dropdownContents" /> </template> - <template v-if="isDropdownVariantStandalone"> + <template v-if="isDropdownVariantStandalone || isDropdownVariantEmbedded"> <dropdown-button v-show="dropdownButtonVisible" /> <dropdown-contents v-if="dropdownButtonVisible && showDropdownContents" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js index c39222959a9..e035a866048 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js @@ -13,7 +13,7 @@ export const dropdownButtonText = (state, getters) => { : state.selectedLabels; if (!selectedLabels.length) { - return __('Label'); + return state.dropdownButtonText || __('Label'); } else if (selectedLabels.length > 1) { return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), { firstLabelName: selectedLabels[0].title, @@ -44,5 +44,12 @@ export const isDropdownVariantSidebar = state => state.variant === DropdownVaria */ export const isDropdownVariantStandalone = state => state.variant === DropdownVariant.Standalone; +/** + * Returns boolean representing whether dropdown variant + * is `embedded` + * @param {object} state + */ +export const isDropdownVariantEmbedded = state => state.variant === DropdownVariant.Embedded; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js index 6a6c0b4c0ee..3f3358d4805 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js @@ -6,6 +6,7 @@ export default () => ({ labelsCreateTitle: '', footerCreateLabelTitle: '', footerManageLabelTitle: '', + dropdownButtonText: '', // Paths namespace: '', diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue index 595baeeb14f..bd35d3fead9 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -4,8 +4,11 @@ import Icon from '~/vue_shared/components/icon.vue'; import UserAvatarImage from '../user_avatar/user_avatar_image.vue'; import { glEmojiTag } from '../../../emoji'; +const MAX_SKELETON_LINES = 4; + export default { name: 'UserPopover', + maxSkeletonLines: MAX_SKELETON_LINES, components: { Icon, GlPopover, @@ -22,11 +25,6 @@ export default { required: true, default: null, }, - loaded: { - type: Boolean, - required: false, - default: false, - }, }, computed: { statusHtml() { @@ -42,14 +40,8 @@ export default { return ''; }, - nameIsLoading() { - return !this.user.name; - }, - workInformationIsLoading() { - return !this.user.loaded && this.user.workInformation === null; - }, - locationIsLoading() { - return !this.user.loaded && this.user.location === null; + userIsLoading() { + return !this.user?.loaded; }, }, }; @@ -58,54 +50,46 @@ export default { <template> <!-- 200ms delay so not every mouseover triggers Popover --> <gl-popover :target="target" :delay="200" boundary="viewport" triggers="hover" placement="top"> - <div class="user-popover d-flex"> - <div class="p-1 flex-shrink-1"> - <user-avatar-image :img-src="user.avatarUrl" :size="60" css-classes="mr-2" /> + <div class="gl-p-3 gl-line-height-normal gl-display-flex" data-testid="user-popover"> + <div class="gl-p-2 flex-shrink-1"> + <user-avatar-image :img-src="user.avatarUrl" :size="60" css-classes="gl-mr-3!" /> </div> - <div class="p-1 w-100"> - <h5 class="m-0"> - <span v-if="user.name">{{ user.name }}</span> - <gl-skeleton-loading v-else :lines="1" class="animation-container-small mb-1" /> - </h5> - <div class="text-secondary mb-2"> - <span v-if="user.username">@{{ user.username }}</span> - <gl-skeleton-loading v-else :lines="1" class="animation-container-small mb-1" /> - </div> - <div class="text-secondary"> - <div v-if="user.bio" class="d-flex mb-1"> - <icon name="profile" class="category-icon flex-shrink-0" /> - <span ref="bio" class="ml-1">{{ user.bio }}</span> - </div> - <div v-if="user.workInformation" class="d-flex mb-1"> - <icon - v-show="!workInformationIsLoading" - name="work" - class="category-icon flex-shrink-0" - /> - <span ref="workInformation" class="ml-1">{{ user.workInformation }}</span> - </div> - <gl-skeleton-loading - v-if="workInformationIsLoading" - :lines="1" - class="animation-container-small mb-1" - /> - </div> - <div class="js-location text-secondary d-flex"> - <icon - v-show="!locationIsLoading && user.location" - name="location" - class="category-icon flex-shrink-0" - /> - <span v-if="user.location" class="ml-1">{{ user.location }}</span> + <div class="gl-p-2 gl-w-full"> + <template v-if="userIsLoading"> + <!-- `gl-skeleton-loading` does not support equal length lines --> + <!-- This can be migrated to `gl-skeleton-loader` when https://gitlab.com/gitlab-org/gitlab-ui/-/issues/872 is completed --> <gl-skeleton-loading - v-if="locationIsLoading" + v-for="n in $options.maxSkeletonLines" + :key="n" :lines="1" - class="animation-container-small mb-1" + class="animation-container-small gl-mb-2" /> - </div> - <div v-if="statusHtml" class="js-user-status mt-2"> - <span v-html="statusHtml"></span> - </div> + </template> + <template v-else> + <div class="gl-mb-3"> + <h5 class="gl-m-0"> + {{ user.name }} + </h5> + <span class="gl-text-gray-700">@{{ user.username }}</span> + </div> + <div class="gl-text-gray-700"> + <div v-if="user.bio" class="gl-display-flex gl-mb-2"> + <icon name="profile" class="gl-text-gray-600 gl-flex-shrink-0" /> + <span ref="bio" class="ml-1" v-html="user.bioHtml"></span> + </div> + <div v-if="user.workInformation" class="gl-display-flex gl-mb-2"> + <icon name="work" class="gl-text-gray-600 gl-flex-shrink-0" /> + <span ref="workInformation" class="gl-ml-2">{{ user.workInformation }}</span> + </div> + </div> + <div v-if="user.location" class="js-location gl-text-gray-700 gl-display-flex"> + <icon name="location" class="gl-text-gray-600 flex-shrink-0" /> + <span class="gl-ml-2">{{ user.location }}</span> + </div> + <div v-if="statusHtml" class="js-user-status gl-mt-3"> + <span v-html="statusHtml"></span> + </div> + </template> </div> </div> </gl-popover> diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js index 63ce4212717..235beb1f22d 100644 --- a/app/assets/javascripts/vue_shared/constants.js +++ b/app/assets/javascripts/vue_shared/constants.js @@ -6,6 +6,8 @@ const INTERVALS = { day: 'day', }; +export const FILE_SYMLINK_MODE = '120000'; + export const timeRanges = [ { label: __('30 minutes'), diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index cc4d13db150..41fb62c28e6 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -11,30 +11,30 @@ // like a table or typography then make changes in the framework/ directory. // If you need to add unique style that should affect only one page - use pages/ // directory. -@import "@gitlab/at.js/dist/css/jquery.atwho"; -@import "dropzone/dist/basic"; -@import "select2/select2"; +@import '@gitlab/at.js/dist/css/jquery.atwho'; +@import 'dropzone/dist/basic'; +@import 'select2/select2'; // GitLab UI framework -@import "framework"; +@import 'framework'; // Font icons -@import "font-awesome"; +@import 'font-awesome'; // Page specific styles (issues, projects etc): -@import "pages/**/*"; +@import 'pages/**/*'; // Component specific styles, will be moved to gitlab-ui -@import "components/**/*"; +@import 'components/**/*'; // Vendors specific styles -@import "vendors/**/*"; +@import 'vendors/**/*'; // Styles for JS behaviors. -@import "behaviors"; +@import 'behaviors'; // EE-only stylesheets -@import "application_ee"; +@import 'application_ee'; // CSS util classes /** @@ -42,7 +42,12 @@ Please check https://unpkg.com/browse/@gitlab/ui/src/scss/utilities.scss to see the available utility classes. **/ -@import "utilities"; +@import 'utilities'; // Gitlab UI util classes -@import "@gitlab/ui/src/scss/utilities"; +@import '@gitlab/ui/src/scss/utilities'; + +/* print styles */ +@media print { + @import 'print'; +} diff --git a/app/assets/stylesheets/application_dark.scss b/app/assets/stylesheets/application_dark.scss index 72196d71969..e55141e15df 100644 --- a/app/assets/stylesheets/application_dark.scss +++ b/app/assets/stylesheets/application_dark.scss @@ -1,3 +1,3 @@ -@import "./themes/dark"; +@import './themes/dark'; -@import "./application"; +@import './application'; diff --git a/app/assets/stylesheets/behaviors.scss b/app/assets/stylesheets/behaviors.scss index e3ca7f6373a..120a139ff3d 100644 --- a/app/assets/stylesheets/behaviors.scss +++ b/app/assets/stylesheets/behaviors.scss @@ -16,6 +16,7 @@ .js-toggler-container { .turn-on { display: block; } .turn-off { display: none; } + &.on { .turn-on { display: none; } .turn-off { display: block; } @@ -23,6 +24,6 @@ } // Hide element if Vue is still working on rendering it fully. -[v-cloak="true"] { +[v-cloak='true'] { display: none !important; } diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss index a6d56819140..aac32e7fb2d 100644 --- a/app/assets/stylesheets/bootstrap_migration.scss +++ b/app/assets/stylesheets/bootstrap_migration.scss @@ -6,7 +6,7 @@ $brand-info: $blue-500; $brand-warning: $orange-500; $brand-danger: $red-500; -$border-radius-base: 3px !default; +$border-radius-base: $gl-border-radius-base; $modal-body-bg: $white; $input-border: $border-color; @@ -23,7 +23,7 @@ body, // Override default font size used in non-csslab UI // Use rem to keep default font-size at 14px on body so 1rem still // fits 8px grid, but also allow users to change browser font size - font-size: .875rem; + font-size: 0.875rem; } legend { @@ -32,11 +32,12 @@ legend { } button, -html [type="button"], -[type="reset"], -[type="submit"], -[role="button"] { +html [type='button'], +[type='reset'], +[type='submit'], +[role='button'] { // Override bootstrap reboot + /* stylelint-disable-next-line property-no-vendor-prefix */ -webkit-appearance: inherit; cursor: pointer; } @@ -77,7 +78,7 @@ h5, font-size: $gl-font-size; } -input[type="file"] { +input[type='file'] { // Bootstrap 4 file input height is taller by default // which makes them look ugly line-height: 1; @@ -314,8 +315,8 @@ input[type=color].form-control { .toggle-sidebar-button { .collapse-text, - .icon-angle-double-left, - .icon-angle-double-right { + .icon-chevron-double-lg-left, + .icon-chevron-double-lg-right { color: $gl-text-color-secondary; } } diff --git a/app/assets/stylesheets/components/design_management/design.scss b/app/assets/stylesheets/components/design_management/design.scss index 380b2280490..33f03fb5949 100644 --- a/app/assets/stylesheets/components/design_management/design.scss +++ b/app/assets/stylesheets/components/design_management/design.scss @@ -10,7 +10,7 @@ } .design-pin { - transition: opacity 0.5s ease; + transition: opacity $gl-transition-duration-medium $general-hover-transition-curve; &.inactive { @include gl-opacity-5; @@ -98,7 +98,7 @@ &::before { content: ''; - border-left: 1px solid $gray-200; + border-left: 1px solid $gray-100; position: absolute; left: 28px; top: -18px; @@ -108,6 +108,9 @@ .design-note { padding: $gl-padding; list-style: none; + transition: background $gl-transition-duration-medium $general-hover-transition-curve; + border-top-left-radius: $border-radius-default; // same border radius used by .bordered-box + border-top-right-radius: $border-radius-default; a { color: inherit; @@ -146,11 +149,12 @@ } .design-dropzone-border { - border: 2px dashed $gray-200; + border: 2px dashed $gray-100; } .design-dropzone-card { - transition: border $general-hover-transition-duration $general-hover-transition-curve; + transition: border $gl-transition-duration-medium $general-hover-transition-curve; + color: $gl-text-color; &:focus, &:active { 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 aacb1f91e59..b7f6b2026fe 100644 --- a/app/assets/stylesheets/components/design_management/design_list_item.scss +++ b/app/assets/stylesheets/components/design_management/design_list_item.scss @@ -17,3 +17,8 @@ height: 230px; } } + +// This is temporary class to be removed after feature flag removal: https://gitlab.com/gitlab-org/gitlab/-/issues/223197 +.design-list-item-new { + height: 210px; +} diff --git a/app/assets/stylesheets/components/popover.scss b/app/assets/stylesheets/components/popover.scss index 1e78781f4b8..f870948cc4f 100644 --- a/app/assets/stylesheets/components/popover.scss +++ b/app/assets/stylesheets/components/popover.scss @@ -1,6 +1,6 @@ .popover { max-width: $popover-max-width; - border: 1px solid $gray-200; + border: 1px solid $gray-100; box-shadow: $popover-box-shadow; font-size: $gl-font-size-small; @@ -50,7 +50,7 @@ * due to the box-shadow include in our custom styles. */ > .arrow::before { - border-top-color: $gray-200; + border-top-color: $gray-100; bottom: 1px; } @@ -61,7 +61,7 @@ .bs-popover-bottom { > .arrow::before { - border-bottom-color: $gray-200; + border-bottom-color: $gray-100; } > .popover-header::before { @@ -70,11 +70,11 @@ } .bs-popover-right > .arrow::before { - border-right-color: $gray-200; + border-right-color: $gray-100; } .bs-popover-left > .arrow::before { - border-left-color: $gray-200; + border-left-color: $gray-100; } .popover-header { @@ -100,45 +100,6 @@ } } -.onboarding-popover { - box-shadow: 0 2px 4px $dropdown-shadow-color; - max-width: 280px; - - .popover-body { - font-size: $gl-font-size; - line-height: $gl-line-height; - padding: $gl-padding; - } - - .popover-header { - display: none; - } - - .accept-mr-label { - background-color: $accepting-mr-label-color; - color: $white; - } -} - -/** -* user_popover component -*/ -.user-popover { - padding: $gl-padding-8; - line-height: $gl-line-height; - - .category-icon { - color: $gray-600; - } -} - -.onboarding-welcome-page { - .popover { - min-width: auto; - max-width: 40%; - } -} - .suggest-gitlab-ci-yml { margin-top: -1em; diff --git a/app/assets/stylesheets/components/ref_selector.scss b/app/assets/stylesheets/components/ref_selector.scss new file mode 100644 index 00000000000..970a7b967ee --- /dev/null +++ b/app/assets/stylesheets/components/ref_selector.scss @@ -0,0 +1,17 @@ +.ref-selector { + & &-dropdown-content { + // Setting a max height is necessary to allow the dropdown's content + // to control where and how scrollbars appear. + // This content is limited to the max-height of the dropdown + // ($dropdown-max-height-lg) minus the additional padding + // on the top and bottom (2 * $gl-padding-8) + max-height: $dropdown-max-height-lg - 2 * $gl-padding-8; + } + + .dropdown-menu.show { + // Make the dropdown a little wider and longer than usual + // since it contains quite a bit of content. + width: 20rem; + max-height: $dropdown-max-height-lg; + } +} diff --git a/app/assets/stylesheets/components/related_items_list.scss b/app/assets/stylesheets/components/related_items_list.scss index 956f34f7a8b..dd749b4df1a 100644 --- a/app/assets/stylesheets/components/related_items_list.scss +++ b/app/assets/stylesheets/components/related_items_list.scss @@ -59,10 +59,6 @@ $item-remove-button-space: 42px; flex-basis: 100%; font-size: $gl-font-size-small; - &.mr-title { - font-weight: $gl-font-weight-bold; - } - .sortable-link { color: $gray-900; } @@ -77,10 +73,6 @@ $item-remove-button-space: 42px; overflow: hidden; white-space: nowrap; } - - .health-label-short { - display: none; - } } .item-body, @@ -89,10 +81,6 @@ $item-remove-button-space: 42px; max-width: 0; } - .health-label-long { - display: none; - } - .status { &-at-risk { color: $red-500; @@ -158,19 +146,16 @@ $item-remove-button-space: 42px; max-width: $item-milestone-max-width; .ic-clock { - color: $gl-text-color-secondary; margin-right: $gl-padding-4; } } .item-weight { max-width: $item-weight-max-width; - - .ic-weight { - color: $gl-text-color-secondary; - } } + .item-milestone .ic-clock, + .item-weight .ic-weight, .item-due-date .ic-calendar { color: $gl-text-color-secondary; } @@ -314,10 +299,6 @@ $item-remove-button-space: 42px; max-width: 100px; } } - - .health-label-long { - display: none; - } } /* Large devices (large desktops, 1200px and up) */ @@ -331,10 +312,6 @@ $item-remove-button-space: 42px; } } - .health-label-long { - display: none; - } - .item-contents { overflow: hidden; } @@ -376,7 +353,7 @@ $item-remove-button-space: 42px; } .health-label-long { - display: initial; + display: block; } } } diff --git a/app/assets/stylesheets/components/rich_content_editor.scss b/app/assets/stylesheets/components/rich_content_editor.scss index bedd06ec9a1..8d31b386d9e 100644 --- a/app/assets/stylesheets/components/rich_content_editor.scss +++ b/app/assets/stylesheets/components/rich_content_editor.scss @@ -2,30 +2,45 @@ * Overrides styles from ToastUI editor */ -// Toolbar buttons -.tui-editor-defaultUI-toolbar .toolbar-button { - color: $gl-gray-600; - border: 0; - - &:hover, - &:active { - color: $blue-500; +.tui-editor-defaultUI { + + // Toolbar buttons + .tui-editor-defaultUI-toolbar .toolbar-button { + color: $gl-gray-600; border: 0; + + &:hover, + &:active { + color: $blue-500; + border: 0; + } } -} -// Contextual menu's & popups -.tui-editor-defaultUI .tui-popup-wrapper { - @include gl-overflow-hidden; - @include gl-rounded-base; - @include gl-border-gray-400; + // Contextual menu's & popups + .tui-popup-wrapper { + @include gl-overflow-hidden; + @include gl-rounded-base; + @include gl-border-gray-400; - hr { - @include gl-m-0; - @include gl-bg-gray-400; + hr { + @include gl-m-0; + @include gl-bg-gray-400; + } + + button { + @include gl-text-gray-800; + } } - button { - @include gl-text-gray-800; + /** + * Overrides styles from ToastUI's Code Mirror (markdown mode) editor. + * Toast UI internally overrides some of these using the `.tui-md-` prefix. + * https://codemirror.net/doc/manual.html#styling + */ + + .te-md-container .CodeMirror * { + @include gl-font-monospace; + @include gl-font-size-monospace; + @include gl-line-height-20; } } diff --git a/app/assets/stylesheets/disable_animations.scss b/app/assets/stylesheets/disable_animations.scss index e65b49c36f3..799c6e80ec9 100644 --- a/app/assets/stylesheets/disable_animations.scss +++ b/app/assets/stylesheets/disable_animations.scss @@ -1,4 +1,5 @@ * { + /* stylelint-disable property-no-vendor-prefix */ -o-transition: none !important; -moz-transition: none !important; -ms-transition: none !important; @@ -9,6 +10,7 @@ -o-animation: none !important; -ms-animation: none !important; animation: none !important; + /* stylelint-enable property-no-vendor-prefix */ } // Disable sticky changes bar for tests diff --git a/app/assets/stylesheets/emoji_sprites.scss b/app/assets/stylesheets/emoji_sprites.scss index 8f6134c474b..01d13b30d2b 100644 --- a/app/assets/stylesheets/emoji_sprites.scss +++ b/app/assets/stylesheets/emoji_sprites.scss @@ -1,5384 +1,7176 @@ // Automatic Prettier Formatting for this big file -// scss-lint:disable EmptyLineBetweenBlocks .emoji-zzz { background-position: 0 0; } + .emoji-1234 { background-position: -20px 0; } + .emoji-1F627 { background-position: 0 -20px; } + .emoji-8ball { background-position: -20px -20px; } + .emoji-a { background-position: -40px 0; } + .emoji-ab { background-position: -40px -20px; } + .emoji-abc { background-position: 0 -40px; } + .emoji-abcd { background-position: -20px -40px; } + .emoji-accept { background-position: -40px -40px; } + .emoji-aerial_tramway { background-position: -60px 0; } + .emoji-airplane { background-position: -60px -20px; } + .emoji-airplane_arriving { background-position: -60px -40px; } + .emoji-airplane_departure { background-position: 0 -60px; } + .emoji-airplane_small { background-position: -20px -60px; } + .emoji-alarm_clock { background-position: -40px -60px; } + .emoji-alembic { background-position: -60px -60px; } + .emoji-alien { background-position: -80px 0; } + .emoji-ambulance { background-position: -80px -20px; } + .emoji-amphora { background-position: -80px -40px; } + .emoji-anchor { background-position: -80px -60px; } + .emoji-angel { background-position: 0 -80px; } + .emoji-angel_tone1 { background-position: -20px -80px; } + .emoji-angel_tone2 { background-position: -40px -80px; } + .emoji-angel_tone3 { background-position: -60px -80px; } + .emoji-angel_tone4 { background-position: -80px -80px; } + .emoji-angel_tone5 { background-position: -100px 0; } + .emoji-anger { background-position: -100px -20px; } + .emoji-anger_right { background-position: -100px -40px; } + .emoji-angry { background-position: -100px -60px; } + .emoji-ant { background-position: -100px -80px; } + .emoji-apple { background-position: 0 -100px; } + .emoji-aquarius { background-position: -20px -100px; } + .emoji-aries { background-position: -40px -100px; } + .emoji-arrow_backward { background-position: -60px -100px; } + .emoji-arrow_double_down { background-position: -80px -100px; } + .emoji-arrow_double_up { background-position: -100px -100px; } + .emoji-arrow_down { background-position: -120px 0; } + .emoji-arrow_down_small { background-position: -120px -20px; } + .emoji-arrow_forward { background-position: -120px -40px; } + .emoji-arrow_heading_down { background-position: -120px -60px; } + .emoji-arrow_heading_up { background-position: -120px -80px; } + .emoji-arrow_left { background-position: -120px -100px; } + .emoji-arrow_lower_left { background-position: 0 -120px; } + .emoji-arrow_lower_right { background-position: -20px -120px; } + .emoji-arrow_right { background-position: -40px -120px; } + .emoji-arrow_right_hook { background-position: -60px -120px; } + .emoji-arrow_up { background-position: -80px -120px; } + .emoji-arrow_up_down { background-position: -100px -120px; } + .emoji-arrow_up_small { background-position: -120px -120px; } + .emoji-arrow_upper_left { background-position: -140px 0; } + .emoji-arrow_upper_right { background-position: -140px -20px; } + .emoji-arrows_clockwise { background-position: -140px -40px; } + .emoji-arrows_counterclockwise { background-position: -140px -60px; } + .emoji-art { background-position: -140px -80px; } + .emoji-articulated_lorry { background-position: -140px -100px; } + .emoji-asterisk { background-position: -140px -120px; } + .emoji-astonished { background-position: 0 -140px; } + .emoji-athletic_shoe { background-position: -20px -140px; } + .emoji-atm { background-position: -40px -140px; } + .emoji-atom { background-position: -60px -140px; } + .emoji-avocado { background-position: -80px -140px; } + .emoji-b { background-position: -100px -140px; } + .emoji-baby { background-position: -120px -140px; } + .emoji-baby_bottle { background-position: -140px -140px; } + .emoji-baby_chick { background-position: -160px 0; } + .emoji-baby_symbol { background-position: -160px -20px; } + .emoji-baby_tone1 { background-position: -160px -40px; } + .emoji-baby_tone2 { background-position: -160px -60px; } + .emoji-baby_tone3 { background-position: -160px -80px; } + .emoji-baby_tone4 { background-position: -160px -100px; } + .emoji-baby_tone5 { background-position: -160px -120px; } + .emoji-back { background-position: -160px -140px; } + .emoji-bacon { background-position: 0 -160px; } + .emoji-badminton { background-position: -20px -160px; } + .emoji-baggage_claim { background-position: -40px -160px; } + .emoji-balloon { background-position: -60px -160px; } + .emoji-ballot_box { background-position: -80px -160px; } + .emoji-ballot_box_with_check { background-position: -100px -160px; } + .emoji-bamboo { background-position: -120px -160px; } + .emoji-banana { background-position: -140px -160px; } + .emoji-bangbang { background-position: -160px -160px; } + .emoji-bank { background-position: -180px 0; } + .emoji-bar_chart { background-position: -180px -20px; } + .emoji-barber { background-position: -180px -40px; } + .emoji-baseball { background-position: -180px -60px; } + .emoji-basketball { background-position: -180px -80px; } + .emoji-basketball_player { background-position: -180px -100px; } + .emoji-basketball_player_tone1 { background-position: -180px -120px; } + .emoji-basketball_player_tone2 { background-position: -180px -140px; } + .emoji-basketball_player_tone3 { background-position: -180px -160px; } + .emoji-basketball_player_tone4 { background-position: 0 -180px; } + .emoji-basketball_player_tone5 { background-position: -20px -180px; } + .emoji-bat { background-position: -40px -180px; } + .emoji-bath { background-position: -60px -180px; } + .emoji-bath_tone1 { background-position: -80px -180px; } + .emoji-bath_tone2 { background-position: -100px -180px; } + .emoji-bath_tone3 { background-position: -120px -180px; } + .emoji-bath_tone4 { background-position: -140px -180px; } + .emoji-bath_tone5 { background-position: -160px -180px; } + .emoji-bathtub { background-position: -180px -180px; } + .emoji-battery { background-position: -200px 0; } + .emoji-beach { background-position: -200px -20px; } + .emoji-beach_umbrella { background-position: -200px -40px; } + .emoji-bear { background-position: -200px -60px; } + .emoji-bed { background-position: -200px -80px; } + .emoji-bee { background-position: -200px -100px; } + .emoji-beer { background-position: -200px -120px; } + .emoji-beers { background-position: -200px -140px; } + .emoji-beetle { background-position: -200px -160px; } + .emoji-beginner { background-position: -200px -180px; } + .emoji-bell { background-position: 0 -200px; } + .emoji-bellhop { background-position: -20px -200px; } + .emoji-bento { background-position: -40px -200px; } + .emoji-bicyclist { background-position: -60px -200px; } + .emoji-bicyclist_tone1 { background-position: -80px -200px; } + .emoji-bicyclist_tone2 { background-position: -100px -200px; } + .emoji-bicyclist_tone3 { background-position: -120px -200px; } + .emoji-bicyclist_tone4 { background-position: -140px -200px; } + .emoji-bicyclist_tone5 { background-position: -160px -200px; } + .emoji-bike { background-position: -180px -200px; } + .emoji-bikini { background-position: -200px -200px; } + .emoji-biohazard { background-position: -220px 0; } + .emoji-bird { background-position: -220px -20px; } + .emoji-birthday { background-position: -220px -40px; } + .emoji-black_circle { background-position: -220px -60px; } + .emoji-black_heart { background-position: -220px -80px; } + .emoji-black_joker { background-position: -220px -100px; } + .emoji-black_large_square { background-position: -220px -120px; } + .emoji-black_medium_small_square { background-position: -220px -140px; } + .emoji-black_medium_square { background-position: -220px -160px; } + .emoji-black_nib { background-position: -220px -180px; } + .emoji-black_small_square { background-position: -220px -200px; } + .emoji-black_square_button { background-position: 0 -220px; } + .emoji-blossom { background-position: -20px -220px; } + .emoji-blowfish { background-position: -40px -220px; } + .emoji-blue_book { background-position: -60px -220px; } + .emoji-blue_car { background-position: -80px -220px; } + .emoji-blue_heart { background-position: -100px -220px; } + .emoji-blush { background-position: -120px -220px; } + .emoji-boar { background-position: -140px -220px; } + .emoji-bomb { background-position: -160px -220px; } + .emoji-book { background-position: -180px -220px; } + .emoji-bookmark { background-position: -200px -220px; } + .emoji-bookmark_tabs { background-position: -220px -220px; } + .emoji-books { background-position: -240px 0; } + .emoji-boom { background-position: -240px -20px; } + .emoji-boot { background-position: -240px -40px; } + .emoji-bouquet { background-position: -240px -60px; } + .emoji-bow { background-position: -240px -80px; } + .emoji-bow_and_arrow { background-position: -240px -100px; } + .emoji-bow_tone1 { background-position: -240px -120px; } + .emoji-bow_tone2 { background-position: -240px -140px; } + .emoji-bow_tone3 { background-position: -240px -160px; } + .emoji-bow_tone4 { background-position: -240px -180px; } + .emoji-bow_tone5 { background-position: -240px -200px; } + .emoji-bowling { background-position: -240px -220px; } + .emoji-boxing_glove { background-position: 0 -240px; } + .emoji-boy { background-position: -20px -240px; } + .emoji-boy_tone1 { background-position: -40px -240px; } + .emoji-boy_tone2 { background-position: -60px -240px; } + .emoji-boy_tone3 { background-position: -80px -240px; } + .emoji-boy_tone4 { background-position: -100px -240px; } + .emoji-boy_tone5 { background-position: -120px -240px; } + .emoji-bread { background-position: -140px -240px; } + .emoji-bride_with_veil { background-position: -160px -240px; } + .emoji-bride_with_veil_tone1 { background-position: -180px -240px; } + .emoji-bride_with_veil_tone2 { background-position: -200px -240px; } + .emoji-bride_with_veil_tone3 { background-position: -220px -240px; } + .emoji-bride_with_veil_tone4 { background-position: -240px -240px; } + .emoji-bride_with_veil_tone5 { background-position: -260px 0; } + .emoji-bridge_at_night { background-position: -260px -20px; } + .emoji-briefcase { background-position: -260px -40px; } + .emoji-broken_heart { background-position: -260px -60px; } + .emoji-bug { background-position: -260px -80px; } + .emoji-bulb { background-position: -260px -100px; } + .emoji-bullettrain_front { background-position: -260px -120px; } + .emoji-bullettrain_side { background-position: -260px -140px; } + .emoji-burrito { background-position: -260px -160px; } + .emoji-bus { background-position: -260px -180px; } + .emoji-busstop { background-position: -260px -200px; } + .emoji-bust_in_silhouette { background-position: -260px -220px; } + .emoji-busts_in_silhouette { background-position: -260px -240px; } + .emoji-butterfly { background-position: 0 -260px; } + .emoji-cactus { background-position: -20px -260px; } + .emoji-cake { background-position: -40px -260px; } + .emoji-calendar { background-position: -60px -260px; } + .emoji-calendar_spiral { background-position: -80px -260px; } + .emoji-call_me { background-position: -100px -260px; } + .emoji-call_me_tone1 { background-position: -120px -260px; } + .emoji-call_me_tone2 { background-position: -140px -260px; } + .emoji-call_me_tone3 { background-position: -160px -260px; } + .emoji-call_me_tone4 { background-position: -180px -260px; } + .emoji-call_me_tone5 { background-position: -200px -260px; } + .emoji-calling { background-position: -220px -260px; } + .emoji-camel { background-position: -240px -260px; } + .emoji-camera { background-position: -260px -260px; } + .emoji-camera_with_flash { background-position: -280px 0; } + .emoji-camping { background-position: -280px -20px; } + .emoji-cancer { background-position: -280px -40px; } + .emoji-candle { background-position: -280px -60px; } + .emoji-candy { background-position: -280px -80px; } + .emoji-canoe { background-position: -280px -100px; } + .emoji-capital_abcd { background-position: -280px -120px; } + .emoji-capricorn { background-position: -280px -140px; } + .emoji-card_box { background-position: -280px -160px; } + .emoji-card_index { background-position: -280px -180px; } + .emoji-carousel_horse { background-position: -280px -200px; } + .emoji-carrot { background-position: -280px -220px; } + .emoji-cartwheel { background-position: -280px -240px; } + .emoji-cartwheel_tone1 { background-position: -280px -260px; } + .emoji-cartwheel_tone2 { background-position: 0 -280px; } + .emoji-cartwheel_tone3 { background-position: -20px -280px; } + .emoji-cartwheel_tone4 { background-position: -40px -280px; } + .emoji-cartwheel_tone5 { background-position: -60px -280px; } + .emoji-cat { background-position: -80px -280px; } + .emoji-cat2 { background-position: -100px -280px; } + .emoji-cd { background-position: -120px -280px; } + .emoji-chains { background-position: -140px -280px; } + .emoji-champagne { background-position: -160px -280px; } + .emoji-champagne_glass { background-position: -180px -280px; } + .emoji-chart { background-position: -200px -280px; } + .emoji-chart_with_downwards_trend { background-position: -220px -280px; } + .emoji-chart_with_upwards_trend { background-position: -240px -280px; } + .emoji-checkered_flag { background-position: -260px -280px; } + .emoji-cheese { background-position: -280px -280px; } + .emoji-cherries { background-position: -300px 0; } + .emoji-cherry_blossom { background-position: -300px -20px; } + .emoji-chestnut { background-position: -300px -40px; } + .emoji-chicken { background-position: -300px -60px; } + .emoji-children_crossing { background-position: -300px -80px; } + .emoji-chipmunk { background-position: -300px -100px; } + .emoji-chocolate_bar { background-position: -300px -120px; } + .emoji-christmas_tree { background-position: -300px -140px; } + .emoji-church { background-position: -300px -160px; } + .emoji-cinema { background-position: -300px -180px; } + .emoji-circus_tent { background-position: -300px -200px; } + .emoji-city_dusk { background-position: -300px -220px; } + .emoji-city_sunset { background-position: -300px -240px; } + .emoji-cityscape { background-position: -300px -260px; } + .emoji-cl { background-position: -300px -280px; } + .emoji-clap { background-position: 0 -300px; } + .emoji-clap_tone1 { background-position: -20px -300px; } + .emoji-clap_tone2 { background-position: -40px -300px; } + .emoji-clap_tone3 { background-position: -60px -300px; } + .emoji-clap_tone4 { background-position: -80px -300px; } + .emoji-clap_tone5 { background-position: -100px -300px; } + .emoji-clapper { background-position: -120px -300px; } + .emoji-classical_building { background-position: -140px -300px; } + .emoji-clipboard { background-position: -160px -300px; } + .emoji-clock { background-position: -180px -300px; } + .emoji-clock1 { background-position: -200px -300px; } + .emoji-clock10 { background-position: -220px -300px; } + .emoji-clock1030 { background-position: -240px -300px; } + .emoji-clock11 { background-position: -260px -300px; } + .emoji-clock1130 { background-position: -280px -300px; } + .emoji-clock12 { background-position: -300px -300px; } + .emoji-clock1230 { background-position: -320px 0; } + .emoji-clock130 { background-position: -320px -20px; } + .emoji-clock2 { background-position: -320px -40px; } + .emoji-clock230 { background-position: -320px -60px; } + .emoji-clock3 { background-position: -320px -80px; } + .emoji-clock330 { background-position: -320px -100px; } + .emoji-clock4 { background-position: -320px -120px; } + .emoji-clock430 { background-position: -320px -140px; } + .emoji-clock5 { background-position: -320px -160px; } + .emoji-clock530 { background-position: -320px -180px; } + .emoji-clock6 { background-position: -320px -200px; } + .emoji-clock630 { background-position: -320px -220px; } + .emoji-clock7 { background-position: -320px -240px; } + .emoji-clock730 { background-position: -320px -260px; } + .emoji-clock8 { background-position: -320px -280px; } + .emoji-clock830 { background-position: -320px -300px; } + .emoji-clock9 { background-position: 0 -320px; } + .emoji-clock930 { background-position: -20px -320px; } + .emoji-closed_book { background-position: -40px -320px; } + .emoji-closed_lock_with_key { background-position: -60px -320px; } + .emoji-closed_umbrella { background-position: -80px -320px; } + .emoji-cloud { background-position: -100px -320px; } + .emoji-cloud_lightning { background-position: -120px -320px; } + .emoji-cloud_rain { background-position: -140px -320px; } + .emoji-cloud_snow { background-position: -160px -320px; } + .emoji-cloud_tornado { background-position: -180px -320px; } + .emoji-clown { background-position: -200px -320px; } + .emoji-clubs { background-position: -220px -320px; } + .emoji-cocktail { background-position: -240px -320px; } + .emoji-coffee { background-position: -260px -320px; } + .emoji-coffin { background-position: -280px -320px; } + .emoji-cold_sweat { background-position: -300px -320px; } + .emoji-comet { background-position: -320px -320px; } + .emoji-compression { background-position: -340px 0; } + .emoji-computer { background-position: -340px -20px; } + .emoji-confetti_ball { background-position: -340px -40px; } + .emoji-confounded { background-position: -340px -60px; } + .emoji-confused { background-position: -340px -80px; } + .emoji-congratulations { background-position: -340px -100px; } + .emoji-construction { background-position: -340px -120px; } + .emoji-construction_site { background-position: -340px -140px; } + .emoji-construction_worker { background-position: -340px -160px; } + .emoji-construction_worker_tone1 { background-position: -340px -180px; } + .emoji-construction_worker_tone2 { background-position: -340px -200px; } + .emoji-construction_worker_tone3 { background-position: -340px -220px; } + .emoji-construction_worker_tone4 { background-position: -340px -240px; } + .emoji-construction_worker_tone5 { background-position: -340px -260px; } + .emoji-control_knobs { background-position: -340px -280px; } + .emoji-convenience_store { background-position: -340px -300px; } + .emoji-cookie { background-position: -340px -320px; } + .emoji-cooking { background-position: 0 -340px; } + .emoji-cool { background-position: -20px -340px; } + .emoji-cop { background-position: -40px -340px; } + .emoji-cop_tone1 { background-position: -60px -340px; } + .emoji-cop_tone2 { background-position: -80px -340px; } + .emoji-cop_tone3 { background-position: -100px -340px; } + .emoji-cop_tone4 { background-position: -120px -340px; } + .emoji-cop_tone5 { background-position: -140px -340px; } + .emoji-copyright { background-position: -160px -340px; } + .emoji-corn { background-position: -180px -340px; } + .emoji-couch { background-position: -200px -340px; } + .emoji-couple { background-position: -220px -340px; } + .emoji-couple_mm { background-position: -240px -340px; } + .emoji-couple_with_heart { background-position: -260px -340px; } + .emoji-couple_ww { background-position: -280px -340px; } + .emoji-couplekiss { background-position: -300px -340px; } + .emoji-cow { background-position: -320px -340px; } + .emoji-cow2 { background-position: -340px -340px; } + .emoji-cowboy { background-position: -360px 0; } + .emoji-crab { background-position: -360px -20px; } + .emoji-crayon { background-position: -360px -40px; } + .emoji-credit_card { background-position: -360px -60px; } + .emoji-crescent_moon { background-position: -360px -80px; } + .emoji-cricket { background-position: -360px -100px; } + .emoji-crocodile { background-position: -360px -120px; } + .emoji-croissant { background-position: -360px -140px; } + .emoji-cross { background-position: -360px -160px; } + .emoji-crossed_flags { background-position: -360px -180px; } + .emoji-crossed_swords { background-position: -360px -200px; } + .emoji-crown { background-position: -360px -220px; } + .emoji-cruise_ship { background-position: -360px -240px; } + .emoji-cry { background-position: -360px -260px; } + .emoji-crying_cat_face { background-position: -360px -280px; } + .emoji-crystal_ball { background-position: -360px -300px; } + .emoji-cucumber { background-position: -360px -320px; } + .emoji-cupid { background-position: -360px -340px; } + .emoji-curly_loop { background-position: 0 -360px; } + .emoji-currency_exchange { background-position: -20px -360px; } + .emoji-curry { background-position: -40px -360px; } + .emoji-custard { background-position: -60px -360px; } + .emoji-customs { background-position: -80px -360px; } + .emoji-cyclone { background-position: -100px -360px; } + .emoji-dagger { background-position: -120px -360px; } + .emoji-dancer { background-position: -140px -360px; } + .emoji-dancer_tone1 { background-position: -160px -360px; } + .emoji-dancer_tone2 { background-position: -180px -360px; } + .emoji-dancer_tone3 { background-position: -200px -360px; } + .emoji-dancer_tone4 { background-position: -220px -360px; } + .emoji-dancer_tone5 { background-position: -240px -360px; } + .emoji-dancers { background-position: -260px -360px; } + .emoji-dango { background-position: -280px -360px; } + .emoji-dark_sunglasses { background-position: -300px -360px; } + .emoji-dart { background-position: -320px -360px; } + .emoji-dash { background-position: -340px -360px; } + .emoji-date { background-position: -360px -360px; } + .emoji-deciduous_tree { background-position: -380px 0; } + .emoji-deer { background-position: -380px -20px; } + .emoji-department_store { background-position: -380px -40px; } + .emoji-desert { background-position: -380px -60px; } + .emoji-desktop { background-position: -380px -80px; } + .emoji-diamond_shape_with_a_dot_inside { background-position: -380px -100px; } + .emoji-diamonds { background-position: -380px -120px; } + .emoji-disappointed { background-position: -380px -140px; } + .emoji-disappointed_relieved { background-position: -380px -160px; } + .emoji-dividers { background-position: -380px -180px; } + .emoji-dizzy { background-position: -380px -200px; } + .emoji-dizzy_face { background-position: -380px -220px; } + .emoji-do_not_litter { background-position: -380px -240px; } + .emoji-dog { background-position: -380px -260px; } + .emoji-dog2 { background-position: -380px -280px; } + .emoji-dollar { background-position: -380px -300px; } + .emoji-dolls { background-position: -380px -320px; } + .emoji-dolphin { background-position: -380px -340px; } + .emoji-door { background-position: -380px -360px; } + .emoji-doughnut { background-position: 0 -380px; } + .emoji-dove { background-position: -20px -380px; } + .emoji-dragon { background-position: -40px -380px; } + .emoji-dragon_face { background-position: -60px -380px; } + .emoji-dress { background-position: -80px -380px; } + .emoji-dromedary_camel { background-position: -100px -380px; } + .emoji-drooling_face { background-position: -120px -380px; } + .emoji-droplet { background-position: -140px -380px; } + .emoji-drum { background-position: -160px -380px; } + .emoji-duck { background-position: -180px -380px; } + .emoji-dvd { background-position: -200px -380px; } + .emoji-e-mail { background-position: -220px -380px; } + .emoji-eagle { background-position: -240px -380px; } + .emoji-ear { background-position: -260px -380px; } + .emoji-ear_of_rice { background-position: -280px -380px; } + .emoji-ear_tone1 { background-position: -300px -380px; } + .emoji-ear_tone2 { background-position: -320px -380px; } + .emoji-ear_tone3 { background-position: -340px -380px; } + .emoji-ear_tone4 { background-position: -360px -380px; } + .emoji-ear_tone5 { background-position: -380px -380px; } + .emoji-earth_africa { background-position: -400px 0; } + .emoji-earth_americas { background-position: -400px -20px; } + .emoji-earth_asia { background-position: -400px -40px; } + .emoji-egg { background-position: -400px -60px; } + .emoji-eggplant { background-position: -400px -80px; } + .emoji-eight { background-position: -400px -100px; } + .emoji-eight_pointed_black_star { background-position: -400px -120px; } + .emoji-eight_spoked_asterisk { background-position: -400px -140px; } + .emoji-eject { background-position: -400px -160px; } + .emoji-electric_plug { background-position: -400px -180px; } + .emoji-elephant { background-position: -400px -200px; } + .emoji-end { background-position: -400px -220px; } + .emoji-envelope { background-position: -400px -240px; } + .emoji-envelope_with_arrow { background-position: -400px -260px; } + .emoji-euro { background-position: -400px -280px; } + .emoji-european_castle { background-position: -400px -300px; } + .emoji-european_post_office { background-position: -400px -320px; } + .emoji-evergreen_tree { background-position: -400px -340px; } + .emoji-exclamation { background-position: -400px -360px; } + .emoji-expressionless { background-position: -400px -380px; } + .emoji-eye { background-position: 0 -400px; } + .emoji-eye_in_speech_bubble { background-position: -20px -400px; } + .emoji-eyeglasses { background-position: -40px -400px; } + .emoji-eyes { background-position: -60px -400px; } + .emoji-face_palm { background-position: -80px -400px; } + .emoji-face_palm_tone1 { background-position: -100px -400px; } + .emoji-face_palm_tone2 { background-position: -120px -400px; } + .emoji-face_palm_tone3 { background-position: -140px -400px; } + .emoji-face_palm_tone4 { background-position: -160px -400px; } + .emoji-face_palm_tone5 { background-position: -180px -400px; } + .emoji-factory { background-position: -200px -400px; } + .emoji-fallen_leaf { background-position: -220px -400px; } + .emoji-family { background-position: -240px -400px; } + .emoji-family_mmb { background-position: -260px -400px; } + .emoji-family_mmbb { background-position: -280px -400px; } + .emoji-family_mmg { background-position: -300px -400px; } + .emoji-family_mmgb { background-position: -320px -400px; } + .emoji-family_mmgg { background-position: -340px -400px; } + .emoji-family_mwbb { background-position: -360px -400px; } + .emoji-family_mwg { background-position: -380px -400px; } + .emoji-family_mwgb { background-position: -400px -400px; } + .emoji-family_mwgg { background-position: -420px 0; } + .emoji-family_wwb { background-position: -420px -20px; } + .emoji-family_wwbb { background-position: -420px -40px; } + .emoji-family_wwg { background-position: -420px -60px; } + .emoji-family_wwgb { background-position: -420px -80px; } + .emoji-family_wwgg { background-position: -420px -100px; } + .emoji-fast_forward { background-position: -420px -120px; } + .emoji-fax { background-position: -420px -140px; } + .emoji-fearful { background-position: -420px -160px; } + .emoji-feet { background-position: -420px -180px; } + .emoji-fencer { background-position: -420px -200px; } + .emoji-ferris_wheel { background-position: -420px -220px; } + .emoji-ferry { background-position: -420px -240px; } + .emoji-field_hockey { background-position: -420px -260px; } + .emoji-file_cabinet { background-position: -420px -280px; } + .emoji-file_folder { background-position: -420px -300px; } + .emoji-film_frames { background-position: -420px -320px; } + .emoji-fingers_crossed { background-position: -420px -340px; } + .emoji-fingers_crossed_tone1 { background-position: -420px -360px; } + .emoji-fingers_crossed_tone2 { background-position: -420px -380px; } + .emoji-fingers_crossed_tone3 { background-position: -420px -400px; } + .emoji-fingers_crossed_tone4 { background-position: 0 -420px; } + .emoji-fingers_crossed_tone5 { background-position: -20px -420px; } + .emoji-fire { background-position: -40px -420px; } + .emoji-fire_engine { background-position: -60px -420px; } + .emoji-fireworks { background-position: -80px -420px; } + .emoji-first_place { background-position: -100px -420px; } + .emoji-first_quarter_moon { background-position: -120px -420px; } + .emoji-first_quarter_moon_with_face { background-position: -140px -420px; } + .emoji-fish { background-position: -160px -420px; } + .emoji-fish_cake { background-position: -180px -420px; } + .emoji-fishing_pole_and_fish { background-position: -200px -420px; } + .emoji-fist { background-position: -220px -420px; } + .emoji-fist_tone1 { background-position: -240px -420px; } + .emoji-fist_tone2 { background-position: -260px -420px; } + .emoji-fist_tone3 { background-position: -280px -420px; } + .emoji-fist_tone4 { background-position: -300px -420px; } + .emoji-fist_tone5 { background-position: -320px -420px; } + .emoji-five { background-position: -340px -420px; } + .emoji-flag_ac { background-position: -360px -420px; } + .emoji-flag_ad { background-position: -380px -420px; } + .emoji-flag_ae { background-position: -400px -420px; } + .emoji-flag_af { background-position: -420px -420px; } + .emoji-flag_ag { background-position: -440px 0; } + .emoji-flag_ai { background-position: -440px -20px; } + .emoji-flag_al { background-position: -440px -40px; } + .emoji-flag_am { background-position: -440px -60px; } + .emoji-flag_ao { background-position: -440px -80px; } + .emoji-flag_aq { background-position: -440px -100px; } + .emoji-flag_ar { background-position: -440px -120px; } + .emoji-flag_as { background-position: -440px -140px; } + .emoji-flag_at { background-position: -440px -160px; } + .emoji-flag_au { background-position: -440px -180px; } + .emoji-flag_aw { background-position: -440px -200px; } + .emoji-flag_ax { background-position: -440px -220px; } + .emoji-flag_az { background-position: -440px -240px; } + .emoji-flag_ba { background-position: -440px -260px; } + .emoji-flag_bb { background-position: -440px -280px; } + .emoji-flag_bd { background-position: -440px -300px; } + .emoji-flag_be { background-position: -440px -320px; } + .emoji-flag_bf { background-position: -440px -340px; } + .emoji-flag_bg { background-position: -440px -360px; } + .emoji-flag_bh { background-position: -440px -380px; } + .emoji-flag_bi { background-position: -440px -400px; } + .emoji-flag_bj { background-position: -440px -420px; } + .emoji-flag_bl { background-position: 0 -440px; } + .emoji-flag_black { background-position: -20px -440px; } + .emoji-flag_bm { background-position: -40px -440px; } + .emoji-flag_bn { background-position: -60px -440px; } + .emoji-flag_bo { background-position: -80px -440px; } + .emoji-flag_bq { background-position: -100px -440px; } + .emoji-flag_br { background-position: -120px -440px; } + .emoji-flag_bs { background-position: -140px -440px; } + .emoji-flag_bt { background-position: -160px -440px; } + .emoji-flag_bv { background-position: -180px -440px; } + .emoji-flag_bw { background-position: -200px -440px; } + .emoji-flag_by { background-position: -220px -440px; } + .emoji-flag_bz { background-position: -240px -440px; } + .emoji-flag_ca { background-position: -260px -440px; } + .emoji-flag_cc { background-position: -280px -440px; } + .emoji-flag_cd { background-position: -300px -440px; } + .emoji-flag_cf { background-position: -320px -440px; } + .emoji-flag_cg { background-position: -340px -440px; } + .emoji-flag_ch { background-position: -360px -440px; } + .emoji-flag_ci { background-position: -380px -440px; } + .emoji-flag_ck { background-position: -400px -440px; } + .emoji-flag_cl { background-position: -420px -440px; } + .emoji-flag_cm { background-position: -440px -440px; } + .emoji-flag_cn { background-position: -460px 0; } + .emoji-flag_co { background-position: -460px -20px; } + .emoji-flag_cp { background-position: -460px -40px; } + .emoji-flag_cr { background-position: -460px -60px; } + .emoji-flag_cu { background-position: -460px -80px; } + .emoji-flag_cv { background-position: -460px -100px; } + .emoji-flag_cw { background-position: -460px -120px; } + .emoji-flag_cx { background-position: -460px -140px; } + .emoji-flag_cy { background-position: -460px -160px; } + .emoji-flag_cz { background-position: -460px -180px; } + .emoji-flag_de { background-position: -460px -200px; } + .emoji-flag_dg { background-position: -460px -220px; } + .emoji-flag_dj { background-position: -460px -240px; } + .emoji-flag_dk { background-position: -460px -260px; } + .emoji-flag_dm { background-position: -460px -280px; } + .emoji-flag_do { background-position: -460px -300px; } + .emoji-flag_dz { background-position: -460px -320px; } + .emoji-flag_ea { background-position: -460px -340px; } + .emoji-flag_ec { background-position: -460px -360px; } + .emoji-flag_ee { background-position: -460px -380px; } + .emoji-flag_eg { background-position: -460px -400px; } + .emoji-flag_eh { background-position: -460px -420px; } + .emoji-flag_er { background-position: -460px -440px; } + .emoji-flag_es { background-position: 0 -460px; } + .emoji-flag_et { background-position: -20px -460px; } + .emoji-flag_eu { background-position: -40px -460px; } + .emoji-flag_fi { background-position: -60px -460px; } + .emoji-flag_fj { background-position: -80px -460px; } + .emoji-flag_fk { background-position: -100px -460px; } + .emoji-flag_fm { background-position: -120px -460px; } + .emoji-flag_fo { background-position: -140px -460px; } + .emoji-flag_fr { background-position: -160px -460px; } + .emoji-flag_ga { background-position: -180px -460px; } + .emoji-flag_gb { background-position: -200px -460px; } + .emoji-flag_gd { background-position: -220px -460px; } + .emoji-flag_ge { background-position: -240px -460px; } + .emoji-flag_gf { background-position: -260px -460px; } + .emoji-flag_gg { background-position: -280px -460px; } + .emoji-flag_gh { background-position: -300px -460px; } + .emoji-flag_gi { background-position: -320px -460px; } + .emoji-flag_gl { background-position: -340px -460px; } + .emoji-flag_gm { background-position: -360px -460px; } + .emoji-flag_gn { background-position: -380px -460px; } + .emoji-flag_gp { background-position: -400px -460px; } + .emoji-flag_gq { background-position: -420px -460px; } + .emoji-flag_gr { background-position: -440px -460px; } + .emoji-flag_gs { background-position: -460px -460px; } + .emoji-flag_gt { background-position: -480px 0; } + .emoji-flag_gu { background-position: -480px -20px; } + .emoji-flag_gw { background-position: -480px -40px; } + .emoji-flag_gy { background-position: -480px -60px; } + .emoji-flag_hk { background-position: -480px -80px; } + .emoji-flag_hm { background-position: -480px -100px; } + .emoji-flag_hn { background-position: -480px -120px; } + .emoji-flag_hr { background-position: -480px -140px; } + .emoji-flag_ht { background-position: -480px -160px; } + .emoji-flag_hu { background-position: -480px -180px; } + .emoji-flag_ic { background-position: -480px -200px; } + .emoji-flag_id { background-position: -480px -220px; } + .emoji-flag_ie { background-position: -480px -240px; } + .emoji-flag_il { background-position: -480px -260px; } + .emoji-flag_im { background-position: -480px -280px; } + .emoji-flag_in { background-position: -480px -300px; } + .emoji-flag_io { background-position: -480px -320px; } + .emoji-flag_iq { background-position: -480px -340px; } + .emoji-flag_ir { background-position: -480px -360px; } + .emoji-flag_is { background-position: -480px -380px; } + .emoji-flag_it { background-position: -480px -400px; } + .emoji-flag_je { background-position: -480px -420px; } + .emoji-flag_jm { background-position: -480px -440px; } + .emoji-flag_jo { background-position: -480px -460px; } + .emoji-flag_jp { background-position: 0 -480px; } + .emoji-flag_ke { background-position: -20px -480px; } + .emoji-flag_kg { background-position: -40px -480px; } + .emoji-flag_kh { background-position: -60px -480px; } + .emoji-flag_ki { background-position: -80px -480px; } + .emoji-flag_km { background-position: -100px -480px; } + .emoji-flag_kn { background-position: -120px -480px; } + .emoji-flag_kp { background-position: -140px -480px; } + .emoji-flag_kr { background-position: -160px -480px; } + .emoji-flag_kw { background-position: -180px -480px; } + .emoji-flag_ky { background-position: -200px -480px; } + .emoji-flag_kz { background-position: -220px -480px; } + .emoji-flag_la { background-position: -240px -480px; } + .emoji-flag_lb { background-position: -260px -480px; } + .emoji-flag_lc { background-position: -280px -480px; } + .emoji-flag_li { background-position: -300px -480px; } + .emoji-flag_lk { background-position: -320px -480px; } + .emoji-flag_lr { background-position: -340px -480px; } + .emoji-flag_ls { background-position: -360px -480px; } + .emoji-flag_lt { background-position: -380px -480px; } + .emoji-flag_lu { background-position: -400px -480px; } + .emoji-flag_lv { background-position: -420px -480px; } + .emoji-flag_ly { background-position: -440px -480px; } + .emoji-flag_ma { background-position: -460px -480px; } + .emoji-flag_mc { background-position: -480px -480px; } + .emoji-flag_md { background-position: -500px 0; } + .emoji-flag_me { background-position: -500px -20px; } + .emoji-flag_mf { background-position: -500px -40px; } + .emoji-flag_mg { background-position: -500px -60px; } + .emoji-flag_mh { background-position: -500px -80px; } + .emoji-flag_mk { background-position: -500px -100px; } + .emoji-flag_ml { background-position: -500px -120px; } + .emoji-flag_mm { background-position: -500px -140px; } + .emoji-flag_mn { background-position: -500px -160px; } + .emoji-flag_mo { background-position: -500px -180px; } + .emoji-flag_mp { background-position: -500px -200px; } + .emoji-flag_mq { background-position: -500px -220px; } + .emoji-flag_mr { background-position: -500px -240px; } + .emoji-flag_ms { background-position: -500px -260px; } + .emoji-flag_mt { background-position: -500px -280px; } + .emoji-flag_mu { background-position: -500px -300px; } + .emoji-flag_mv { background-position: -500px -320px; } + .emoji-flag_mw { background-position: -500px -340px; } + .emoji-flag_mx { background-position: -500px -360px; } + .emoji-flag_my { background-position: -500px -380px; } + .emoji-flag_mz { background-position: -500px -400px; } + .emoji-flag_na { background-position: -500px -420px; } + .emoji-flag_nc { background-position: -500px -440px; } + .emoji-flag_ne { background-position: -500px -460px; } + .emoji-flag_nf { background-position: -500px -480px; } + .emoji-flag_ng { background-position: 0 -500px; } + .emoji-flag_ni { background-position: -20px -500px; } + .emoji-flag_nl { background-position: -40px -500px; } + .emoji-flag_no { background-position: -60px -500px; } + .emoji-flag_np { background-position: -80px -500px; } + .emoji-flag_nr { background-position: -100px -500px; } + .emoji-flag_nu { background-position: -120px -500px; } + .emoji-flag_nz { background-position: -140px -500px; } + .emoji-flag_om { background-position: -160px -500px; } + .emoji-flag_pa { background-position: -180px -500px; } + .emoji-flag_pe { background-position: -200px -500px; } + .emoji-flag_pf { background-position: -220px -500px; } + .emoji-flag_pg { background-position: -240px -500px; } + .emoji-flag_ph { background-position: -260px -500px; } + .emoji-flag_pk { background-position: -280px -500px; } + .emoji-flag_pl { background-position: -300px -500px; } + .emoji-flag_pm { background-position: -320px -500px; } + .emoji-flag_pn { background-position: -340px -500px; } + .emoji-flag_pr { background-position: -360px -500px; } + .emoji-flag_ps { background-position: -380px -500px; } + .emoji-flag_pt { background-position: -400px -500px; } + .emoji-flag_pw { background-position: -420px -500px; } + .emoji-flag_py { background-position: -440px -500px; } + .emoji-flag_qa { background-position: -460px -500px; } + .emoji-flag_re { background-position: -480px -500px; } + .emoji-flag_ro { background-position: -500px -500px; } + .emoji-flag_rs { background-position: -520px 0; } + .emoji-flag_ru { background-position: -520px -20px; } + .emoji-flag_rw { background-position: -520px -40px; } + .emoji-flag_sa { background-position: -520px -60px; } + .emoji-flag_sb { background-position: -520px -80px; } + .emoji-flag_sc { background-position: -520px -100px; } + .emoji-flag_sd { background-position: -520px -120px; } + .emoji-flag_se { background-position: -520px -140px; } + .emoji-flag_sg { background-position: -520px -160px; } + .emoji-flag_sh { background-position: -520px -180px; } + .emoji-flag_si { background-position: -520px -200px; } + .emoji-flag_sj { background-position: -520px -220px; } + .emoji-flag_sk { background-position: -520px -240px; } + .emoji-flag_sl { background-position: -520px -260px; } + .emoji-flag_sm { background-position: -520px -280px; } + .emoji-flag_sn { background-position: -520px -300px; } + .emoji-flag_so { background-position: -520px -320px; } + .emoji-flag_sr { background-position: -520px -340px; } + .emoji-flag_ss { background-position: -520px -360px; } + .emoji-flag_st { background-position: -520px -380px; } + .emoji-flag_sv { background-position: -520px -400px; } + .emoji-flag_sx { background-position: -520px -420px; } + .emoji-flag_sy { background-position: -520px -440px; } + .emoji-flag_sz { background-position: -520px -460px; } + .emoji-flag_ta { background-position: -520px -480px; } + .emoji-flag_tc { background-position: -520px -500px; } + .emoji-flag_td { background-position: 0 -520px; } + .emoji-flag_tf { background-position: -20px -520px; } + .emoji-flag_tg { background-position: -40px -520px; } + .emoji-flag_th { background-position: -60px -520px; } + .emoji-flag_tj { background-position: -80px -520px; } + .emoji-flag_tk { background-position: -100px -520px; } + .emoji-flag_tl { background-position: -120px -520px; } + .emoji-flag_tm { background-position: -140px -520px; } + .emoji-flag_tn { background-position: -160px -520px; } + .emoji-flag_to { background-position: -180px -520px; } + .emoji-flag_tr { background-position: -200px -520px; } + .emoji-flag_tt { background-position: -220px -520px; } + .emoji-flag_tv { background-position: -240px -520px; } + .emoji-flag_tw { background-position: -260px -520px; } + .emoji-flag_tz { background-position: -280px -520px; } + .emoji-flag_ua { background-position: -300px -520px; } + .emoji-flag_ug { background-position: -320px -520px; } + .emoji-flag_um { background-position: -340px -520px; } + .emoji-flag_us { background-position: -360px -520px; } + .emoji-flag_uy { background-position: -380px -520px; } + .emoji-flag_uz { background-position: -400px -520px; } + .emoji-flag_va { background-position: -420px -520px; } + .emoji-flag_vc { background-position: -440px -520px; } + .emoji-flag_ve { background-position: -460px -520px; } + .emoji-flag_vg { background-position: -480px -520px; } + .emoji-flag_vi { background-position: -500px -520px; } + .emoji-flag_vn { background-position: -520px -520px; } + .emoji-flag_vu { background-position: -540px 0; } + .emoji-flag_wf { background-position: -540px -20px; } + .emoji-flag_white { background-position: -540px -40px; } + .emoji-flag_ws { background-position: -540px -60px; } + .emoji-flag_xk { background-position: -540px -80px; } + .emoji-flag_ye { background-position: -540px -100px; } + .emoji-flag_yt { background-position: -540px -120px; } + .emoji-flag_za { background-position: -540px -140px; } + .emoji-flag_zm { background-position: -540px -160px; } + .emoji-flag_zw { background-position: -540px -180px; } + .emoji-flags { background-position: -540px -200px; } + .emoji-flashlight { background-position: -540px -220px; } + .emoji-fleur-de-lis { background-position: -540px -240px; } + .emoji-floppy_disk { background-position: -540px -260px; } + .emoji-flower_playing_cards { background-position: -540px -280px; } + .emoji-flushed { background-position: -540px -300px; } + .emoji-fog { background-position: -540px -320px; } + .emoji-foggy { background-position: -540px -340px; } + .emoji-football { background-position: -540px -360px; } + .emoji-footprints { background-position: -540px -380px; } + .emoji-fork_and_knife { background-position: -540px -400px; } + .emoji-fork_knife_plate { background-position: -540px -420px; } + .emoji-fountain { background-position: -540px -440px; } + .emoji-four { background-position: -540px -460px; } + .emoji-four_leaf_clover { background-position: -540px -480px; } + .emoji-fox { background-position: -540px -500px; } + .emoji-frame_photo { background-position: -540px -520px; } + .emoji-free { background-position: 0 -540px; } + .emoji-french_bread { background-position: -20px -540px; } + .emoji-fried_shrimp { background-position: -40px -540px; } + .emoji-fries { background-position: -60px -540px; } + .emoji-frog { background-position: -80px -540px; } + .emoji-frowning { background-position: -100px -540px; } + .emoji-frowning2 { background-position: -120px -540px; } + .emoji-fuelpump { background-position: -140px -540px; } + .emoji-full_moon { background-position: -160px -540px; } + .emoji-full_moon_with_face { background-position: -180px -540px; } + .emoji-game_die { background-position: -200px -540px; } + .emoji-gay_pride_flag { background-position: -220px -540px; } + .emoji-gear { background-position: -240px -540px; } + .emoji-gem { background-position: -260px -540px; } + .emoji-gemini { background-position: -280px -540px; } + .emoji-ghost { background-position: -300px -540px; } + .emoji-gift { background-position: -320px -540px; } + .emoji-gift_heart { background-position: -340px -540px; } + .emoji-girl { background-position: -360px -540px; } + .emoji-girl_tone1 { background-position: -380px -540px; } + .emoji-girl_tone2 { background-position: -400px -540px; } + .emoji-girl_tone3 { background-position: -420px -540px; } + .emoji-girl_tone4 { background-position: -440px -540px; } + .emoji-girl_tone5 { background-position: -460px -540px; } + .emoji-globe_with_meridians { background-position: -480px -540px; } + .emoji-goal { background-position: -500px -540px; } + .emoji-goat { background-position: -520px -540px; } + .emoji-golf { background-position: -540px -540px; } + .emoji-golfer { background-position: -560px 0; } + .emoji-gorilla { background-position: -560px -20px; } + .emoji-grapes { background-position: -560px -40px; } + .emoji-green_apple { background-position: -560px -60px; } + .emoji-green_book { background-position: -560px -80px; } + .emoji-green_heart { background-position: -560px -100px; } + .emoji-grey_exclamation { background-position: -560px -120px; } + .emoji-grey_question { background-position: -560px -140px; } + .emoji-grimacing { background-position: -560px -160px; } + .emoji-grin { background-position: -560px -180px; } + .emoji-grinning { background-position: -560px -200px; } + .emoji-guardsman { background-position: -560px -220px; } + .emoji-guardsman_tone1 { background-position: -560px -240px; } + .emoji-guardsman_tone2 { background-position: -560px -260px; } + .emoji-guardsman_tone3 { background-position: -560px -280px; } + .emoji-guardsman_tone4 { background-position: -560px -300px; } + .emoji-guardsman_tone5 { background-position: -560px -320px; } + .emoji-guitar { background-position: -560px -340px; } + .emoji-gun { background-position: -560px -360px; } + .emoji-haircut { background-position: -560px -380px; } + .emoji-haircut_tone1 { background-position: -560px -400px; } + .emoji-haircut_tone2 { background-position: -560px -420px; } + .emoji-haircut_tone3 { background-position: -560px -440px; } + .emoji-haircut_tone4 { background-position: -560px -460px; } + .emoji-haircut_tone5 { background-position: -560px -480px; } + .emoji-hamburger { background-position: -560px -500px; } + .emoji-hammer { background-position: -560px -520px; } + .emoji-hammer_pick { background-position: -560px -540px; } + .emoji-hamster { background-position: 0 -560px; } + .emoji-hand_splayed { background-position: -20px -560px; } + .emoji-hand_splayed_tone1 { background-position: -40px -560px; } + .emoji-hand_splayed_tone2 { background-position: -60px -560px; } + .emoji-hand_splayed_tone3 { background-position: -80px -560px; } + .emoji-hand_splayed_tone4 { background-position: -100px -560px; } + .emoji-hand_splayed_tone5 { background-position: -120px -560px; } + .emoji-handbag { background-position: -140px -560px; } + .emoji-handball { background-position: -160px -560px; } + .emoji-handball_tone1 { background-position: -180px -560px; } + .emoji-handball_tone2 { background-position: -200px -560px; } + .emoji-handball_tone3 { background-position: -220px -560px; } + .emoji-handball_tone4 { background-position: -240px -560px; } + .emoji-handball_tone5 { background-position: -260px -560px; } + .emoji-handshake { background-position: -280px -560px; } + .emoji-handshake_tone1 { background-position: -300px -560px; } + .emoji-handshake_tone2 { background-position: -320px -560px; } + .emoji-handshake_tone3 { background-position: -340px -560px; } + .emoji-handshake_tone4 { background-position: -360px -560px; } + .emoji-handshake_tone5 { background-position: -380px -560px; } + .emoji-hash { background-position: -400px -560px; } + .emoji-hatched_chick { background-position: -420px -560px; } + .emoji-hatching_chick { background-position: -440px -560px; } + .emoji-head_bandage { background-position: -460px -560px; } + .emoji-headphones { background-position: -480px -560px; } + .emoji-hear_no_evil { background-position: -500px -560px; } + .emoji-heart { background-position: -520px -560px; } + .emoji-heart_decoration { background-position: -540px -560px; } + .emoji-heart_exclamation { background-position: -560px -560px; } + .emoji-heart_eyes { background-position: -580px 0; } + .emoji-heart_eyes_cat { background-position: -580px -20px; } + .emoji-heartbeat { background-position: -580px -40px; } + .emoji-heartpulse { background-position: -580px -60px; } + .emoji-hearts { background-position: -580px -80px; } + .emoji-heavy_check_mark { background-position: -580px -100px; } + .emoji-heavy_division_sign { background-position: -580px -120px; } + .emoji-heavy_dollar_sign { background-position: -580px -140px; } + .emoji-heavy_minus_sign { background-position: -580px -160px; } + .emoji-heavy_multiplication_x { background-position: -580px -180px; } + .emoji-heavy_plus_sign { background-position: -580px -200px; } + .emoji-helicopter { background-position: -580px -220px; } + .emoji-helmet_with_cross { background-position: -580px -240px; } + .emoji-herb { background-position: -580px -260px; } + .emoji-hibiscus { background-position: -580px -280px; } + .emoji-high_brightness { background-position: -580px -300px; } + .emoji-high_heel { background-position: -580px -320px; } + .emoji-hockey { background-position: -580px -340px; } + .emoji-hole { background-position: -580px -360px; } + .emoji-homes { background-position: -580px -380px; } + .emoji-honey_pot { background-position: -580px -400px; } + .emoji-horse { background-position: -580px -420px; } + .emoji-horse_racing { background-position: -580px -440px; } + .emoji-horse_racing_tone1 { background-position: -580px -460px; } + .emoji-horse_racing_tone2 { background-position: -580px -480px; } + .emoji-horse_racing_tone3 { background-position: -580px -500px; } + .emoji-horse_racing_tone4 { background-position: -580px -520px; } + .emoji-horse_racing_tone5 { background-position: -580px -540px; } + .emoji-hospital { background-position: -580px -560px; } + .emoji-hot_pepper { background-position: 0 -580px; } + .emoji-hotdog { background-position: -20px -580px; } + .emoji-hotel { background-position: -40px -580px; } + .emoji-hotsprings { background-position: -60px -580px; } + .emoji-hourglass { background-position: -80px -580px; } + .emoji-hourglass_flowing_sand { background-position: -100px -580px; } + .emoji-house { background-position: -120px -580px; } + .emoji-house_abandoned { background-position: -140px -580px; } + .emoji-house_with_garden { background-position: -160px -580px; } + .emoji-hugging { background-position: -180px -580px; } + .emoji-hushed { background-position: -200px -580px; } + .emoji-ice_cream { background-position: -220px -580px; } + .emoji-ice_skate { background-position: -240px -580px; } + .emoji-icecream { background-position: -260px -580px; } + .emoji-id { background-position: -280px -580px; } + .emoji-ideograph_advantage { background-position: -300px -580px; } + .emoji-imp { background-position: -320px -580px; } + .emoji-inbox_tray { background-position: -340px -580px; } + .emoji-incoming_envelope { background-position: -360px -580px; } + .emoji-information_desk_person { background-position: -380px -580px; } + .emoji-information_desk_person_tone1 { background-position: -400px -580px; } + .emoji-information_desk_person_tone2 { background-position: -420px -580px; } + .emoji-information_desk_person_tone3 { background-position: -440px -580px; } + .emoji-information_desk_person_tone4 { background-position: -460px -580px; } + .emoji-information_desk_person_tone5 { background-position: -480px -580px; } + .emoji-information_source { background-position: -500px -580px; } + .emoji-innocent { background-position: -520px -580px; } + .emoji-interrobang { background-position: -540px -580px; } + .emoji-iphone { background-position: -560px -580px; } + .emoji-island { background-position: -580px -580px; } + .emoji-izakaya_lantern { background-position: -600px 0; } + .emoji-jack_o_lantern { background-position: -600px -20px; } + .emoji-japan { background-position: -600px -40px; } + .emoji-japanese_castle { background-position: -600px -60px; } + .emoji-japanese_goblin { background-position: -600px -80px; } + .emoji-japanese_ogre { background-position: -600px -100px; } + .emoji-jeans { background-position: -600px -120px; } + .emoji-joy { background-position: -600px -140px; } + .emoji-joy_cat { background-position: -600px -160px; } + .emoji-joystick { background-position: -600px -180px; } + .emoji-juggling { background-position: -600px -200px; } + .emoji-juggling_tone1 { background-position: -600px -220px; } + .emoji-juggling_tone2 { background-position: -600px -240px; } + .emoji-juggling_tone3 { background-position: -600px -260px; } + .emoji-juggling_tone4 { background-position: -600px -280px; } + .emoji-juggling_tone5 { background-position: -600px -300px; } + .emoji-kaaba { background-position: -600px -320px; } + .emoji-key { background-position: -600px -340px; } + .emoji-key2 { background-position: -600px -360px; } + .emoji-keyboard { background-position: -600px -380px; } + .emoji-kimono { background-position: -600px -400px; } + .emoji-kiss { background-position: -600px -420px; } + .emoji-kiss_mm { background-position: -600px -440px; } + .emoji-kiss_ww { background-position: -600px -460px; } + .emoji-kissing { background-position: -600px -480px; } + .emoji-kissing_cat { background-position: -600px -500px; } + .emoji-kissing_closed_eyes { background-position: -600px -520px; } + .emoji-kissing_heart { background-position: -600px -540px; } + .emoji-kissing_smiling_eyes { background-position: -600px -560px; } + .emoji-kiwi { background-position: -600px -580px; } + .emoji-knife { background-position: 0 -600px; } + .emoji-koala { background-position: -20px -600px; } + .emoji-koko { background-position: -40px -600px; } + .emoji-label { background-position: -60px -600px; } + .emoji-large_blue_circle { background-position: -80px -600px; } + .emoji-large_blue_diamond { background-position: -100px -600px; } + .emoji-large_orange_diamond { background-position: -120px -600px; } + .emoji-last_quarter_moon { background-position: -140px -600px; } + .emoji-last_quarter_moon_with_face { background-position: -160px -600px; } + .emoji-laughing { background-position: -180px -600px; } + .emoji-leaves { background-position: -200px -600px; } + .emoji-ledger { background-position: -220px -600px; } + .emoji-left_facing_fist { background-position: -240px -600px; } + .emoji-left_facing_fist_tone1 { background-position: -260px -600px; } + .emoji-left_facing_fist_tone2 { background-position: -280px -600px; } + .emoji-left_facing_fist_tone3 { background-position: -300px -600px; } + .emoji-left_facing_fist_tone4 { background-position: -320px -600px; } + .emoji-left_facing_fist_tone5 { background-position: -340px -600px; } + .emoji-left_luggage { background-position: -360px -600px; } + .emoji-left_right_arrow { background-position: -380px -600px; } + .emoji-leftwards_arrow_with_hook { background-position: -400px -600px; } + .emoji-lemon { background-position: -420px -600px; } + .emoji-leo { background-position: -440px -600px; } + .emoji-leopard { background-position: -460px -600px; } + .emoji-level_slider { background-position: -480px -600px; } + .emoji-levitate { background-position: -500px -600px; } + .emoji-libra { background-position: -520px -600px; } + .emoji-lifter { background-position: -540px -600px; } + .emoji-lifter_tone1 { background-position: -560px -600px; } + .emoji-lifter_tone2 { background-position: -580px -600px; } + .emoji-lifter_tone3 { background-position: -600px -600px; } + .emoji-lifter_tone4 { background-position: -620px 0; } + .emoji-lifter_tone5 { background-position: -620px -20px; } + .emoji-light_rail { background-position: -620px -40px; } + .emoji-link { background-position: -620px -60px; } + .emoji-lion_face { background-position: -620px -80px; } + .emoji-lips { background-position: -620px -100px; } + .emoji-lipstick { background-position: -620px -120px; } + .emoji-lizard { background-position: -620px -140px; } + .emoji-lock { background-position: -620px -160px; } + .emoji-lock_with_ink_pen { background-position: -620px -180px; } + .emoji-lollipop { background-position: -620px -200px; } + .emoji-loop { background-position: -620px -220px; } + .emoji-loud_sound { background-position: -620px -240px; } + .emoji-loudspeaker { background-position: -620px -260px; } + .emoji-love_hotel { background-position: -620px -280px; } + .emoji-love_letter { background-position: -620px -300px; } + .emoji-low_brightness { background-position: -620px -320px; } + .emoji-lying_face { background-position: -620px -340px; } + .emoji-m { background-position: -620px -360px; } + .emoji-mag { background-position: -620px -380px; } + .emoji-mag_right { background-position: -620px -400px; } + .emoji-mahjong { background-position: -620px -420px; } + .emoji-mailbox { background-position: -620px -440px; } + .emoji-mailbox_closed { background-position: -620px -460px; } + .emoji-mailbox_with_mail { background-position: -620px -480px; } + .emoji-mailbox_with_no_mail { background-position: -620px -500px; } + .emoji-man { background-position: -620px -520px; } + .emoji-man_dancing { background-position: -620px -540px; } + .emoji-man_dancing_tone1 { background-position: -620px -560px; } + .emoji-man_dancing_tone2 { background-position: -620px -580px; } + .emoji-man_dancing_tone3 { background-position: -620px -600px; } + .emoji-man_dancing_tone4 { background-position: 0 -620px; } + .emoji-man_dancing_tone5 { background-position: -20px -620px; } + .emoji-man_in_tuxedo { background-position: -40px -620px; } + .emoji-man_in_tuxedo_tone1 { background-position: -60px -620px; } + .emoji-man_in_tuxedo_tone2 { background-position: -80px -620px; } + .emoji-man_in_tuxedo_tone3 { background-position: -100px -620px; } + .emoji-man_in_tuxedo_tone4 { background-position: -120px -620px; } + .emoji-man_in_tuxedo_tone5 { background-position: -140px -620px; } + .emoji-man_tone1 { background-position: -160px -620px; } + .emoji-man_tone2 { background-position: -180px -620px; } + .emoji-man_tone3 { background-position: -200px -620px; } + .emoji-man_tone4 { background-position: -220px -620px; } + .emoji-man_tone5 { background-position: -240px -620px; } + .emoji-man_with_gua_pi_mao { background-position: -260px -620px; } + .emoji-man_with_gua_pi_mao_tone1 { background-position: -280px -620px; } + .emoji-man_with_gua_pi_mao_tone2 { background-position: -300px -620px; } + .emoji-man_with_gua_pi_mao_tone3 { background-position: -320px -620px; } + .emoji-man_with_gua_pi_mao_tone4 { background-position: -340px -620px; } + .emoji-man_with_gua_pi_mao_tone5 { background-position: -360px -620px; } + .emoji-man_with_turban { background-position: -380px -620px; } + .emoji-man_with_turban_tone1 { background-position: -400px -620px; } + .emoji-man_with_turban_tone2 { background-position: -420px -620px; } + .emoji-man_with_turban_tone3 { background-position: -440px -620px; } + .emoji-man_with_turban_tone4 { background-position: -460px -620px; } + .emoji-man_with_turban_tone5 { background-position: -480px -620px; } + .emoji-mans_shoe { background-position: -500px -620px; } + .emoji-map { background-position: -520px -620px; } + .emoji-maple_leaf { background-position: -540px -620px; } + .emoji-martial_arts_uniform { background-position: -560px -620px; } + .emoji-mask { background-position: -580px -620px; } + .emoji-massage { background-position: -600px -620px; } + .emoji-massage_tone1 { background-position: -620px -620px; } + .emoji-massage_tone2 { background-position: -640px 0; } + .emoji-massage_tone3 { background-position: -640px -20px; } + .emoji-massage_tone4 { background-position: -640px -40px; } + .emoji-massage_tone5 { background-position: -640px -60px; } + .emoji-meat_on_bone { background-position: -640px -80px; } + .emoji-medal { background-position: -640px -100px; } + .emoji-mega { background-position: -640px -120px; } + .emoji-melon { background-position: -640px -140px; } + .emoji-menorah { background-position: -640px -160px; } + .emoji-mens { background-position: -640px -180px; } + .emoji-metal { background-position: -640px -200px; } + .emoji-metal_tone1 { background-position: -640px -220px; } + .emoji-metal_tone2 { background-position: -640px -240px; } + .emoji-metal_tone3 { background-position: -640px -260px; } + .emoji-metal_tone4 { background-position: -640px -280px; } + .emoji-metal_tone5 { background-position: -640px -300px; } + .emoji-metro { background-position: -640px -320px; } + .emoji-microphone { background-position: -640px -340px; } + .emoji-microphone2 { background-position: -640px -360px; } + .emoji-microscope { background-position: -640px -380px; } + .emoji-middle_finger { background-position: -640px -400px; } + .emoji-middle_finger_tone1 { background-position: -640px -420px; } + .emoji-middle_finger_tone2 { background-position: -640px -440px; } + .emoji-middle_finger_tone3 { background-position: -640px -460px; } + .emoji-middle_finger_tone4 { background-position: -640px -480px; } + .emoji-middle_finger_tone5 { background-position: -640px -500px; } + .emoji-military_medal { background-position: -640px -520px; } + .emoji-milk { background-position: -640px -540px; } + .emoji-milky_way { background-position: -640px -560px; } + .emoji-minibus { background-position: -640px -580px; } + .emoji-minidisc { background-position: -640px -600px; } + .emoji-mobile_phone_off { background-position: -640px -620px; } + .emoji-money_mouth { background-position: 0 -640px; } + .emoji-money_with_wings { background-position: -20px -640px; } + .emoji-moneybag { background-position: -40px -640px; } + .emoji-monkey { background-position: -60px -640px; } + .emoji-monkey_face { background-position: -80px -640px; } + .emoji-monorail { background-position: -100px -640px; } + .emoji-mortar_board { background-position: -120px -640px; } + .emoji-mosque { background-position: -140px -640px; } + .emoji-motor_scooter { background-position: -160px -640px; } + .emoji-motorboat { background-position: -180px -640px; } + .emoji-motorcycle { background-position: -200px -640px; } + .emoji-motorway { background-position: -220px -640px; } + .emoji-mount_fuji { background-position: -240px -640px; } + .emoji-mountain { background-position: -260px -640px; } + .emoji-mountain_bicyclist { background-position: -280px -640px; } + .emoji-mountain_bicyclist_tone1 { background-position: -300px -640px; } + .emoji-mountain_bicyclist_tone2 { background-position: -320px -640px; } + .emoji-mountain_bicyclist_tone3 { background-position: -340px -640px; } + .emoji-mountain_bicyclist_tone4 { background-position: -360px -640px; } + .emoji-mountain_bicyclist_tone5 { background-position: -380px -640px; } + .emoji-mountain_cableway { background-position: -400px -640px; } + .emoji-mountain_railway { background-position: -420px -640px; } + .emoji-mountain_snow { background-position: -440px -640px; } + .emoji-mouse { background-position: -460px -640px; } + .emoji-mouse2 { background-position: -480px -640px; } + .emoji-mouse_three_button { background-position: -500px -640px; } + .emoji-movie_camera { background-position: -520px -640px; } + .emoji-moyai { background-position: -540px -640px; } + .emoji-mrs_claus { background-position: -560px -640px; } + .emoji-mrs_claus_tone1 { background-position: -580px -640px; } + .emoji-mrs_claus_tone2 { background-position: -600px -640px; } + .emoji-mrs_claus_tone3 { background-position: -620px -640px; } + .emoji-mrs_claus_tone4 { background-position: -640px -640px; } + .emoji-mrs_claus_tone5 { background-position: -660px 0; } + .emoji-muscle { background-position: -660px -20px; } + .emoji-muscle_tone1 { background-position: -660px -40px; } + .emoji-muscle_tone2 { background-position: -660px -60px; } + .emoji-muscle_tone3 { background-position: -660px -80px; } + .emoji-muscle_tone4 { background-position: -660px -100px; } + .emoji-muscle_tone5 { background-position: -660px -120px; } + .emoji-mushroom { background-position: -660px -140px; } + .emoji-musical_keyboard { background-position: -660px -160px; } + .emoji-musical_note { background-position: -660px -180px; } + .emoji-musical_score { background-position: -660px -200px; } + .emoji-mute { background-position: -660px -220px; } + .emoji-nail_care { background-position: -660px -240px; } + .emoji-nail_care_tone1 { background-position: -660px -260px; } + .emoji-nail_care_tone2 { background-position: -660px -280px; } + .emoji-nail_care_tone3 { background-position: -660px -300px; } + .emoji-nail_care_tone4 { background-position: -660px -320px; } + .emoji-nail_care_tone5 { background-position: -660px -340px; } + .emoji-name_badge { background-position: -660px -360px; } + .emoji-nauseated_face { background-position: -660px -380px; } + .emoji-necktie { background-position: -660px -400px; } + .emoji-negative_squared_cross_mark { background-position: -660px -420px; } + .emoji-nerd { background-position: -660px -440px; } + .emoji-neutral_face { background-position: -660px -460px; } + .emoji-new { background-position: -660px -480px; } + .emoji-new_moon { background-position: -660px -500px; } + .emoji-new_moon_with_face { background-position: -660px -520px; } + .emoji-newspaper { background-position: -660px -540px; } + .emoji-newspaper2 { background-position: -660px -560px; } + .emoji-ng { background-position: -660px -580px; } + .emoji-night_with_stars { background-position: -660px -600px; } + .emoji-nine { background-position: -660px -620px; } + .emoji-no_bell { background-position: -660px -640px; } + .emoji-no_bicycles { background-position: 0 -660px; } + .emoji-no_entry { background-position: -20px -660px; } + .emoji-no_entry_sign { background-position: -40px -660px; } + .emoji-no_good { background-position: -60px -660px; } + .emoji-no_good_tone1 { background-position: -80px -660px; } + .emoji-no_good_tone2 { background-position: -100px -660px; } + .emoji-no_good_tone3 { background-position: -120px -660px; } + .emoji-no_good_tone4 { background-position: -140px -660px; } + .emoji-no_good_tone5 { background-position: -160px -660px; } + .emoji-no_mobile_phones { background-position: -180px -660px; } + .emoji-no_mouth { background-position: -200px -660px; } + .emoji-no_pedestrians { background-position: -220px -660px; } + .emoji-no_smoking { background-position: -240px -660px; } + .emoji-non-potable_water { background-position: -260px -660px; } + .emoji-nose { background-position: -280px -660px; } + .emoji-nose_tone1 { background-position: -300px -660px; } + .emoji-nose_tone2 { background-position: -320px -660px; } + .emoji-nose_tone3 { background-position: -340px -660px; } + .emoji-nose_tone4 { background-position: -360px -660px; } + .emoji-nose_tone5 { background-position: -380px -660px; } + .emoji-notebook { background-position: -400px -660px; } + .emoji-notebook_with_decorative_cover { background-position: -420px -660px; } + .emoji-notepad_spiral { background-position: -440px -660px; } + .emoji-notes { background-position: -460px -660px; } + .emoji-nut_and_bolt { background-position: -480px -660px; } + .emoji-o { background-position: -500px -660px; } + .emoji-o2 { background-position: -520px -660px; } + .emoji-ocean { background-position: -540px -660px; } + .emoji-octagonal_sign { background-position: -560px -660px; } + .emoji-octopus { background-position: -580px -660px; } + .emoji-oden { background-position: -600px -660px; } + .emoji-office { background-position: -620px -660px; } + .emoji-oil { background-position: -640px -660px; } + .emoji-ok { background-position: -660px -660px; } + .emoji-ok_hand { background-position: -680px 0; } + .emoji-ok_hand_tone1 { background-position: -680px -20px; } + .emoji-ok_hand_tone2 { background-position: -680px -40px; } + .emoji-ok_hand_tone3 { background-position: -680px -60px; } + .emoji-ok_hand_tone4 { background-position: -680px -80px; } + .emoji-ok_hand_tone5 { background-position: -680px -100px; } + .emoji-ok_woman { background-position: -680px -120px; } + .emoji-ok_woman_tone1 { background-position: -680px -140px; } + .emoji-ok_woman_tone2 { background-position: -680px -160px; } + .emoji-ok_woman_tone3 { background-position: -680px -180px; } + .emoji-ok_woman_tone4 { background-position: -680px -200px; } + .emoji-ok_woman_tone5 { background-position: -680px -220px; } + .emoji-older_man { background-position: -680px -240px; } + .emoji-older_man_tone1 { background-position: -680px -260px; } + .emoji-older_man_tone2 { background-position: -680px -280px; } + .emoji-older_man_tone3 { background-position: -680px -300px; } + .emoji-older_man_tone4 { background-position: -680px -320px; } + .emoji-older_man_tone5 { background-position: -680px -340px; } + .emoji-older_woman { background-position: -680px -360px; } + .emoji-older_woman_tone1 { background-position: -680px -380px; } + .emoji-older_woman_tone2 { background-position: -680px -400px; } + .emoji-older_woman_tone3 { background-position: -680px -420px; } + .emoji-older_woman_tone4 { background-position: -680px -440px; } + .emoji-older_woman_tone5 { background-position: -680px -460px; } + .emoji-om_symbol { background-position: -680px -480px; } + .emoji-on { background-position: -680px -500px; } + .emoji-oncoming_automobile { background-position: -680px -520px; } + .emoji-oncoming_bus { background-position: -680px -540px; } + .emoji-oncoming_police_car { background-position: -680px -560px; } + .emoji-oncoming_taxi { background-position: -680px -580px; } + .emoji-one { background-position: -680px -600px; } + .emoji-open_file_folder { background-position: -680px -620px; } + .emoji-open_hands { background-position: -680px -640px; } + .emoji-open_hands_tone1 { background-position: -680px -660px; } + .emoji-open_hands_tone2 { background-position: 0 -680px; } + .emoji-open_hands_tone3 { background-position: -20px -680px; } + .emoji-open_hands_tone4 { background-position: -40px -680px; } + .emoji-open_hands_tone5 { background-position: -60px -680px; } + .emoji-open_mouth { background-position: -80px -680px; } + .emoji-ophiuchus { background-position: -100px -680px; } + .emoji-orange_book { background-position: -120px -680px; } + .emoji-orthodox_cross { background-position: -140px -680px; } + .emoji-outbox_tray { background-position: -160px -680px; } + .emoji-owl { background-position: -180px -680px; } + .emoji-ox { background-position: -200px -680px; } + .emoji-package { background-position: -220px -680px; } + .emoji-page_facing_up { background-position: -240px -680px; } + .emoji-page_with_curl { background-position: -260px -680px; } + .emoji-pager { background-position: -280px -680px; } + .emoji-paintbrush { background-position: -300px -680px; } + .emoji-palm_tree { background-position: -320px -680px; } + .emoji-pancakes { background-position: -340px -680px; } + .emoji-panda_face { background-position: -360px -680px; } + .emoji-paperclip { background-position: -380px -680px; } + .emoji-paperclips { background-position: -400px -680px; } + .emoji-park { background-position: -420px -680px; } + .emoji-parking { background-position: -440px -680px; } + .emoji-part_alternation_mark { background-position: -460px -680px; } + .emoji-partly_sunny { background-position: -480px -680px; } + .emoji-passport_control { background-position: -500px -680px; } + .emoji-pause_button { background-position: -520px -680px; } + .emoji-peace { background-position: -540px -680px; } + .emoji-peach { background-position: -560px -680px; } + .emoji-peanuts { background-position: -580px -680px; } + .emoji-pear { background-position: -600px -680px; } + .emoji-pen_ballpoint { background-position: -620px -680px; } + .emoji-pen_fountain { background-position: -640px -680px; } + .emoji-pencil { background-position: -660px -680px; } + .emoji-pencil2 { background-position: -680px -680px; } + .emoji-penguin { background-position: -700px 0; } + .emoji-pensive { background-position: -700px -20px; } + .emoji-performing_arts { background-position: -700px -40px; } + .emoji-persevere { background-position: -700px -60px; } + .emoji-person_frowning { background-position: -700px -80px; } + .emoji-person_frowning_tone1 { background-position: -700px -100px; } + .emoji-person_frowning_tone2 { background-position: -700px -120px; } + .emoji-person_frowning_tone3 { background-position: -700px -140px; } + .emoji-person_frowning_tone4 { background-position: -700px -160px; } + .emoji-person_frowning_tone5 { background-position: -700px -180px; } + .emoji-person_with_blond_hair { background-position: -700px -200px; } + .emoji-person_with_blond_hair_tone1 { background-position: -700px -220px; } + .emoji-person_with_blond_hair_tone2 { background-position: -700px -240px; } + .emoji-person_with_blond_hair_tone3 { background-position: -700px -260px; } + .emoji-person_with_blond_hair_tone4 { background-position: -700px -280px; } + .emoji-person_with_blond_hair_tone5 { background-position: -700px -300px; } + .emoji-person_with_pouting_face { background-position: -700px -320px; } + .emoji-person_with_pouting_face_tone1 { background-position: -700px -340px; } + .emoji-person_with_pouting_face_tone2 { background-position: -700px -360px; } + .emoji-person_with_pouting_face_tone3 { background-position: -700px -380px; } + .emoji-person_with_pouting_face_tone4 { background-position: -700px -400px; } + .emoji-person_with_pouting_face_tone5 { background-position: -700px -420px; } + .emoji-pick { background-position: -700px -440px; } + .emoji-pig { background-position: -700px -460px; } + .emoji-pig2 { background-position: -700px -480px; } + .emoji-pig_nose { background-position: -700px -500px; } + .emoji-pill { background-position: -700px -520px; } + .emoji-pineapple { background-position: -700px -540px; } + .emoji-ping_pong { background-position: -700px -560px; } + .emoji-pisces { background-position: -700px -580px; } + .emoji-pizza { background-position: -700px -600px; } + .emoji-place_of_worship { background-position: -700px -620px; } + .emoji-play_pause { background-position: -700px -640px; } + .emoji-point_down { background-position: -700px -660px; } + .emoji-point_down_tone1 { background-position: -700px -680px; } + .emoji-point_down_tone2 { background-position: 0 -700px; } + .emoji-point_down_tone3 { background-position: -20px -700px; } + .emoji-point_down_tone4 { background-position: -40px -700px; } + .emoji-point_down_tone5 { background-position: -60px -700px; } + .emoji-point_left { background-position: -80px -700px; } + .emoji-point_left_tone1 { background-position: -100px -700px; } + .emoji-point_left_tone2 { background-position: -120px -700px; } + .emoji-point_left_tone3 { background-position: -140px -700px; } + .emoji-point_left_tone4 { background-position: -160px -700px; } + .emoji-point_left_tone5 { background-position: -180px -700px; } + .emoji-point_right { background-position: -200px -700px; } + .emoji-point_right_tone1 { background-position: -220px -700px; } + .emoji-point_right_tone2 { background-position: -240px -700px; } + .emoji-point_right_tone3 { background-position: -260px -700px; } + .emoji-point_right_tone4 { background-position: -280px -700px; } + .emoji-point_right_tone5 { background-position: -300px -700px; } + .emoji-point_up { background-position: -320px -700px; } + .emoji-point_up_2 { background-position: -340px -700px; } + .emoji-point_up_2_tone1 { background-position: -360px -700px; } + .emoji-point_up_2_tone2 { background-position: -380px -700px; } + .emoji-point_up_2_tone3 { background-position: -400px -700px; } + .emoji-point_up_2_tone4 { background-position: -420px -700px; } + .emoji-point_up_2_tone5 { background-position: -440px -700px; } + .emoji-point_up_tone1 { background-position: -460px -700px; } + .emoji-point_up_tone2 { background-position: -480px -700px; } + .emoji-point_up_tone3 { background-position: -500px -700px; } + .emoji-point_up_tone4 { background-position: -520px -700px; } + .emoji-point_up_tone5 { background-position: -540px -700px; } + .emoji-police_car { background-position: -560px -700px; } + .emoji-poodle { background-position: -580px -700px; } + .emoji-poop { background-position: -600px -700px; } + .emoji-popcorn { background-position: -620px -700px; } + .emoji-post_office { background-position: -640px -700px; } + .emoji-postal_horn { background-position: -660px -700px; } + .emoji-postbox { background-position: -680px -700px; } + .emoji-potable_water { background-position: -700px -700px; } + .emoji-potato { background-position: -720px 0; } + .emoji-pouch { background-position: -720px -20px; } + .emoji-poultry_leg { background-position: -720px -40px; } + .emoji-pound { background-position: -720px -60px; } + .emoji-pouting_cat { background-position: -720px -80px; } + .emoji-pray { background-position: -720px -100px; } + .emoji-pray_tone1 { background-position: -720px -120px; } + .emoji-pray_tone2 { background-position: -720px -140px; } + .emoji-pray_tone3 { background-position: -720px -160px; } + .emoji-pray_tone4 { background-position: -720px -180px; } + .emoji-pray_tone5 { background-position: -720px -200px; } + .emoji-prayer_beads { background-position: -720px -220px; } + .emoji-pregnant_woman { background-position: -720px -240px; } + .emoji-pregnant_woman_tone1 { background-position: -720px -260px; } + .emoji-pregnant_woman_tone2 { background-position: -720px -280px; } + .emoji-pregnant_woman_tone3 { background-position: -720px -300px; } + .emoji-pregnant_woman_tone4 { background-position: -720px -320px; } + .emoji-pregnant_woman_tone5 { background-position: -720px -340px; } + .emoji-prince { background-position: -720px -360px; } + .emoji-prince_tone1 { background-position: -720px -380px; } + .emoji-prince_tone2 { background-position: -720px -400px; } + .emoji-prince_tone3 { background-position: -720px -420px; } + .emoji-prince_tone4 { background-position: -720px -440px; } + .emoji-prince_tone5 { background-position: -720px -460px; } + .emoji-princess { background-position: -720px -480px; } + .emoji-princess_tone1 { background-position: -720px -500px; } + .emoji-princess_tone2 { background-position: -720px -520px; } + .emoji-princess_tone3 { background-position: -720px -540px; } + .emoji-princess_tone4 { background-position: -720px -560px; } + .emoji-princess_tone5 { background-position: -720px -580px; } + .emoji-printer { background-position: -720px -600px; } + .emoji-projector { background-position: -720px -620px; } + .emoji-punch { background-position: -720px -640px; } + .emoji-punch_tone1 { background-position: -720px -660px; } + .emoji-punch_tone2 { background-position: -720px -680px; } + .emoji-punch_tone3 { background-position: -720px -700px; } + .emoji-punch_tone4 { background-position: 0 -720px; } + .emoji-punch_tone5 { background-position: -20px -720px; } + .emoji-purple_heart { background-position: -40px -720px; } + .emoji-purse { background-position: -60px -720px; } + .emoji-pushpin { background-position: -80px -720px; } + .emoji-put_litter_in_its_place { background-position: -100px -720px; } + .emoji-question { background-position: -120px -720px; } + .emoji-rabbit { background-position: -140px -720px; } + .emoji-rabbit2 { background-position: -160px -720px; } + .emoji-race_car { background-position: -180px -720px; } + .emoji-racehorse { background-position: -200px -720px; } + .emoji-radio { background-position: -220px -720px; } + .emoji-radio_button { background-position: -240px -720px; } + .emoji-radioactive { background-position: -260px -720px; } + .emoji-rage { background-position: -280px -720px; } + .emoji-railway_car { background-position: -300px -720px; } + .emoji-railway_track { background-position: -320px -720px; } + .emoji-rainbow { background-position: -340px -720px; } + .emoji-raised_back_of_hand { background-position: -360px -720px; } + .emoji-raised_back_of_hand_tone1 { background-position: -380px -720px; } + .emoji-raised_back_of_hand_tone2 { background-position: -400px -720px; } + .emoji-raised_back_of_hand_tone3 { background-position: -420px -720px; } + .emoji-raised_back_of_hand_tone4 { background-position: -440px -720px; } + .emoji-raised_back_of_hand_tone5 { background-position: -460px -720px; } + .emoji-raised_hand { background-position: -480px -720px; } + .emoji-raised_hand_tone1 { background-position: -500px -720px; } + .emoji-raised_hand_tone2 { background-position: -520px -720px; } + .emoji-raised_hand_tone3 { background-position: -540px -720px; } + .emoji-raised_hand_tone4 { background-position: -560px -720px; } + .emoji-raised_hand_tone5 { background-position: -580px -720px; } + .emoji-raised_hands { background-position: -600px -720px; } + .emoji-raised_hands_tone1 { background-position: -620px -720px; } + .emoji-raised_hands_tone2 { background-position: -640px -720px; } + .emoji-raised_hands_tone3 { background-position: -660px -720px; } + .emoji-raised_hands_tone4 { background-position: -680px -720px; } + .emoji-raised_hands_tone5 { background-position: -700px -720px; } + .emoji-raising_hand { background-position: -720px -720px; } + .emoji-raising_hand_tone1 { background-position: -740px 0; } + .emoji-raising_hand_tone2 { background-position: -740px -20px; } + .emoji-raising_hand_tone3 { background-position: -740px -40px; } + .emoji-raising_hand_tone4 { background-position: -740px -60px; } + .emoji-raising_hand_tone5 { background-position: -740px -80px; } + .emoji-ram { background-position: -740px -100px; } + .emoji-ramen { background-position: -740px -120px; } + .emoji-rat { background-position: -740px -140px; } + .emoji-record_button { background-position: -740px -160px; } + .emoji-recycle { background-position: -740px -180px; } + .emoji-red_car { background-position: -740px -200px; } + .emoji-red_circle { background-position: -740px -220px; } + .emoji-registered { background-position: -740px -240px; } + .emoji-relaxed { background-position: -740px -260px; } + .emoji-relieved { background-position: -740px -280px; } + .emoji-reminder_ribbon { background-position: -740px -300px; } + .emoji-repeat { background-position: -740px -320px; } + .emoji-repeat_one { background-position: -740px -340px; } + .emoji-restroom { background-position: -740px -360px; } + .emoji-revolving_hearts { background-position: -740px -380px; } + .emoji-rewind { background-position: -740px -400px; } + .emoji-rhino { background-position: -740px -420px; } + .emoji-ribbon { background-position: -740px -440px; } + .emoji-rice { background-position: -740px -460px; } + .emoji-rice_ball { background-position: -740px -480px; } + .emoji-rice_cracker { background-position: -740px -500px; } + .emoji-rice_scene { background-position: -740px -520px; } + .emoji-right_facing_fist { background-position: -740px -540px; } + .emoji-right_facing_fist_tone1 { background-position: -740px -560px; } + .emoji-right_facing_fist_tone2 { background-position: -740px -580px; } + .emoji-right_facing_fist_tone3 { background-position: -740px -600px; } + .emoji-right_facing_fist_tone4 { background-position: -740px -620px; } + .emoji-right_facing_fist_tone5 { background-position: -740px -640px; } + .emoji-ring { background-position: -740px -660px; } + .emoji-robot { background-position: -740px -680px; } + .emoji-rocket { background-position: -740px -700px; } + .emoji-rofl { background-position: -740px -720px; } + .emoji-roller_coaster { background-position: 0 -740px; } + .emoji-rolling_eyes { background-position: -20px -740px; } + .emoji-rooster { background-position: -40px -740px; } + .emoji-rose { background-position: -60px -740px; } + .emoji-rosette { background-position: -80px -740px; } + .emoji-rotating_light { background-position: -100px -740px; } + .emoji-round_pushpin { background-position: -120px -740px; } + .emoji-rowboat { background-position: -140px -740px; } + .emoji-rowboat_tone1 { background-position: -160px -740px; } + .emoji-rowboat_tone2 { background-position: -180px -740px; } + .emoji-rowboat_tone3 { background-position: -200px -740px; } + .emoji-rowboat_tone4 { background-position: -220px -740px; } + .emoji-rowboat_tone5 { background-position: -240px -740px; } + .emoji-rugby_football { background-position: -260px -740px; } + .emoji-runner { background-position: -280px -740px; } + .emoji-runner_tone1 { background-position: -300px -740px; } + .emoji-runner_tone2 { background-position: -320px -740px; } + .emoji-runner_tone3 { background-position: -340px -740px; } + .emoji-runner_tone4 { background-position: -360px -740px; } + .emoji-runner_tone5 { background-position: -380px -740px; } + .emoji-running_shirt_with_sash { background-position: -400px -740px; } + .emoji-sa { background-position: -420px -740px; } + .emoji-sagittarius { background-position: -440px -740px; } + .emoji-sailboat { background-position: -460px -740px; } + .emoji-sake { background-position: -480px -740px; } + .emoji-salad { background-position: -500px -740px; } + .emoji-sandal { background-position: -520px -740px; } + .emoji-santa { background-position: -540px -740px; } + .emoji-santa_tone1 { background-position: -560px -740px; } + .emoji-santa_tone2 { background-position: -580px -740px; } + .emoji-santa_tone3 { background-position: -600px -740px; } + .emoji-santa_tone4 { background-position: -620px -740px; } + .emoji-santa_tone5 { background-position: -640px -740px; } + .emoji-satellite { background-position: -660px -740px; } + .emoji-satellite_orbital { background-position: -680px -740px; } + .emoji-saxophone { background-position: -700px -740px; } + .emoji-scales { background-position: -720px -740px; } + .emoji-school { background-position: -740px -740px; } + .emoji-school_satchel { background-position: -760px 0; } + .emoji-scissors { background-position: -760px -20px; } + .emoji-scooter { background-position: -760px -40px; } + .emoji-scorpion { background-position: -760px -60px; } + .emoji-scorpius { background-position: -760px -80px; } + .emoji-scream { background-position: -760px -100px; } + .emoji-scream_cat { background-position: -760px -120px; } + .emoji-scroll { background-position: -760px -140px; } + .emoji-seat { background-position: -760px -160px; } + .emoji-second_place { background-position: -760px -180px; } + .emoji-secret { background-position: -760px -200px; } + .emoji-see_no_evil { background-position: -760px -220px; } + .emoji-seedling { background-position: -760px -240px; } + .emoji-selfie { background-position: -760px -260px; } + .emoji-selfie_tone1 { background-position: -760px -280px; } + .emoji-selfie_tone2 { background-position: -760px -300px; } + .emoji-selfie_tone3 { background-position: -760px -320px; } + .emoji-selfie_tone4 { background-position: -760px -340px; } + .emoji-selfie_tone5 { background-position: -760px -360px; } + .emoji-seven { background-position: -760px -380px; } + .emoji-shallow_pan_of_food { background-position: -760px -400px; } + .emoji-shamrock { background-position: -760px -420px; } + .emoji-shark { background-position: -760px -440px; } + .emoji-shaved_ice { background-position: -760px -460px; } + .emoji-sheep { background-position: -760px -480px; } + .emoji-shell { background-position: -760px -500px; } + .emoji-shield { background-position: -760px -520px; } + .emoji-shinto_shrine { background-position: -760px -540px; } + .emoji-ship { background-position: -760px -560px; } + .emoji-shirt { background-position: -760px -580px; } + .emoji-shopping_bags { background-position: -760px -600px; } + .emoji-shopping_cart { background-position: -760px -620px; } + .emoji-shower { background-position: -760px -640px; } + .emoji-shrimp { background-position: -760px -660px; } + .emoji-shrug { background-position: -760px -680px; } + .emoji-shrug_tone1 { background-position: -760px -700px; } + .emoji-shrug_tone2 { background-position: -760px -720px; } + .emoji-shrug_tone3 { background-position: -760px -740px; } + .emoji-shrug_tone4 { background-position: 0 -760px; } + .emoji-shrug_tone5 { background-position: -20px -760px; } + .emoji-signal_strength { background-position: -40px -760px; } + .emoji-six { background-position: -60px -760px; } + .emoji-six_pointed_star { background-position: -80px -760px; } + .emoji-ski { background-position: -100px -760px; } + .emoji-skier { background-position: -120px -760px; } + .emoji-skull { background-position: -140px -760px; } + .emoji-skull_crossbones { background-position: -160px -760px; } + .emoji-sleeping { background-position: -180px -760px; } + .emoji-sleeping_accommodation { background-position: -200px -760px; } + .emoji-sleepy { background-position: -220px -760px; } + .emoji-slight_frown { background-position: -240px -760px; } + .emoji-slight_smile { background-position: -260px -760px; } + .emoji-slot_machine { background-position: -280px -760px; } + .emoji-small_blue_diamond { background-position: -300px -760px; } + .emoji-small_orange_diamond { background-position: -320px -760px; } + .emoji-small_red_triangle { background-position: -340px -760px; } + .emoji-small_red_triangle_down { background-position: -360px -760px; } + .emoji-smile { background-position: -380px -760px; } + .emoji-smile_cat { background-position: -400px -760px; } + .emoji-smiley { background-position: -420px -760px; } + .emoji-smiley_cat { background-position: -440px -760px; } + .emoji-smiling_imp { background-position: -460px -760px; } + .emoji-smirk { background-position: -480px -760px; } + .emoji-smirk_cat { background-position: -500px -760px; } + .emoji-smoking { background-position: -520px -760px; } + .emoji-snail { background-position: -540px -760px; } + .emoji-snake { background-position: -560px -760px; } + .emoji-sneezing_face { background-position: -580px -760px; } + .emoji-snowboarder { background-position: -600px -760px; } + .emoji-snowflake { background-position: -620px -760px; } + .emoji-snowman { background-position: -640px -760px; } + .emoji-snowman2 { background-position: -660px -760px; } + .emoji-sob { background-position: -680px -760px; } + .emoji-soccer { background-position: -700px -760px; } + .emoji-soon { background-position: -720px -760px; } + .emoji-sos { background-position: -740px -760px; } + .emoji-sound { background-position: -760px -760px; } + .emoji-space_invader { background-position: -780px 0; } + .emoji-spades { background-position: -780px -20px; } + .emoji-spaghetti { background-position: -780px -40px; } + .emoji-sparkle { background-position: -780px -60px; } + .emoji-sparkler { background-position: -780px -80px; } + .emoji-sparkles { background-position: -780px -100px; } + .emoji-sparkling_heart { background-position: -780px -120px; } + .emoji-speak_no_evil { background-position: -780px -140px; } + .emoji-speaker { background-position: -780px -160px; } + .emoji-speaking_head { background-position: -780px -180px; } + .emoji-speech_balloon { background-position: -780px -200px; } + .emoji-speech_left { background-position: -780px -220px; } + .emoji-speedboat { background-position: -780px -240px; } + .emoji-spider { background-position: -780px -260px; } + .emoji-spider_web { background-position: -780px -280px; } + .emoji-spoon { background-position: -780px -300px; } + .emoji-spy { background-position: -780px -320px; } + .emoji-spy_tone1 { background-position: -780px -340px; } + .emoji-spy_tone2 { background-position: -780px -360px; } + .emoji-spy_tone3 { background-position: -780px -380px; } + .emoji-spy_tone4 { background-position: -780px -400px; } + .emoji-spy_tone5 { background-position: -780px -420px; } + .emoji-squid { background-position: -780px -440px; } + .emoji-stadium { background-position: -780px -460px; } + .emoji-star { background-position: -780px -480px; } + .emoji-star2 { background-position: -780px -500px; } + .emoji-star_and_crescent { background-position: -780px -520px; } + .emoji-star_of_david { background-position: -780px -540px; } + .emoji-stars { background-position: -780px -560px; } + .emoji-station { background-position: -780px -580px; } + .emoji-statue_of_liberty { background-position: -780px -600px; } + .emoji-steam_locomotive { background-position: -780px -620px; } + .emoji-stew { background-position: -780px -640px; } + .emoji-stop_button { background-position: -780px -660px; } + .emoji-stopwatch { background-position: -780px -680px; } + .emoji-straight_ruler { background-position: -780px -700px; } + .emoji-strawberry { background-position: -780px -720px; } + .emoji-stuck_out_tongue { background-position: -780px -740px; } + .emoji-stuck_out_tongue_closed_eyes { background-position: -780px -760px; } + .emoji-stuck_out_tongue_winking_eye { background-position: 0 -780px; } + .emoji-stuffed_flatbread { background-position: -20px -780px; } + .emoji-sun_with_face { background-position: -40px -780px; } + .emoji-sunflower { background-position: -60px -780px; } + .emoji-sunglasses { background-position: -80px -780px; } + .emoji-sunny { background-position: -100px -780px; } + .emoji-sunrise { background-position: -120px -780px; } + .emoji-sunrise_over_mountains { background-position: -140px -780px; } + .emoji-surfer { background-position: -160px -780px; } + .emoji-surfer_tone1 { background-position: -180px -780px; } + .emoji-surfer_tone2 { background-position: -200px -780px; } + .emoji-surfer_tone3 { background-position: -220px -780px; } + .emoji-surfer_tone4 { background-position: -240px -780px; } + .emoji-surfer_tone5 { background-position: -260px -780px; } + .emoji-sushi { background-position: -280px -780px; } + .emoji-suspension_railway { background-position: -300px -780px; } + .emoji-sweat { background-position: -320px -780px; } + .emoji-sweat_drops { background-position: -340px -780px; } + .emoji-sweat_smile { background-position: -360px -780px; } + .emoji-sweet_potato { background-position: -380px -780px; } + .emoji-swimmer { background-position: -400px -780px; } + .emoji-swimmer_tone1 { background-position: -420px -780px; } + .emoji-swimmer_tone2 { background-position: -440px -780px; } + .emoji-swimmer_tone3 { background-position: -460px -780px; } + .emoji-swimmer_tone4 { background-position: -480px -780px; } + .emoji-swimmer_tone5 { background-position: -500px -780px; } + .emoji-symbols { background-position: -520px -780px; } + .emoji-synagogue { background-position: -540px -780px; } + .emoji-syringe { background-position: -560px -780px; } + .emoji-taco { background-position: -580px -780px; } + .emoji-tada { background-position: -600px -780px; } + .emoji-tanabata_tree { background-position: -620px -780px; } + .emoji-tangerine { background-position: -640px -780px; } + .emoji-taurus { background-position: -660px -780px; } + .emoji-taxi { background-position: -680px -780px; } + .emoji-tea { background-position: -700px -780px; } + .emoji-telephone { background-position: -720px -780px; } + .emoji-telephone_receiver { background-position: -740px -780px; } + .emoji-telescope { background-position: -760px -780px; } + .emoji-ten { background-position: -780px -780px; } + .emoji-tennis { background-position: -800px 0; } + .emoji-tent { background-position: -800px -20px; } + .emoji-thermometer { background-position: -800px -40px; } + .emoji-thermometer_face { background-position: -800px -60px; } + .emoji-thinking { background-position: -800px -80px; } + .emoji-third_place { background-position: -800px -100px; } + .emoji-thought_balloon { background-position: -800px -120px; } + .emoji-three { background-position: -800px -140px; } + .emoji-thumbsdown { background-position: -800px -160px; } + .emoji-thumbsdown_tone1 { background-position: -800px -180px; } + .emoji-thumbsdown_tone2 { background-position: -800px -200px; } + .emoji-thumbsdown_tone3 { background-position: -800px -220px; } + .emoji-thumbsdown_tone4 { background-position: -800px -240px; } + .emoji-thumbsdown_tone5 { background-position: -800px -260px; } + .emoji-thumbsup { background-position: -800px -280px; } + .emoji-thumbsup_tone1 { background-position: -800px -300px; } + .emoji-thumbsup_tone2 { background-position: -800px -320px; } + .emoji-thumbsup_tone3 { background-position: -800px -340px; } + .emoji-thumbsup_tone4 { background-position: -800px -360px; } + .emoji-thumbsup_tone5 { background-position: -800px -380px; } + .emoji-thunder_cloud_rain { background-position: -800px -400px; } + .emoji-ticket { background-position: -800px -420px; } + .emoji-tickets { background-position: -800px -440px; } + .emoji-tiger { background-position: -800px -460px; } + .emoji-tiger2 { background-position: -800px -480px; } + .emoji-timer { background-position: -800px -500px; } + .emoji-tired_face { background-position: -800px -520px; } + .emoji-tm { background-position: -800px -540px; } + .emoji-toilet { background-position: -800px -560px; } + .emoji-tokyo_tower { background-position: -800px -580px; } + .emoji-tomato { background-position: -800px -600px; } + .emoji-tone1 { background-position: -800px -620px; } + .emoji-tone2 { background-position: -800px -640px; } + .emoji-tone3 { background-position: -800px -660px; } + .emoji-tone4 { background-position: -800px -680px; } + .emoji-tone5 { background-position: -800px -700px; } + .emoji-tongue { background-position: -800px -720px; } + .emoji-tools { background-position: -800px -740px; } + .emoji-top { background-position: -800px -760px; } + .emoji-tophat { background-position: -800px -780px; } + .emoji-track_next { background-position: 0 -800px; } + .emoji-track_previous { background-position: -20px -800px; } + .emoji-trackball { background-position: -40px -800px; } + .emoji-tractor { background-position: -60px -800px; } + .emoji-traffic_light { background-position: -80px -800px; } + .emoji-train { background-position: -100px -800px; } + .emoji-train2 { background-position: -120px -800px; } + .emoji-tram { background-position: -140px -800px; } + .emoji-triangular_flag_on_post { background-position: -160px -800px; } + .emoji-triangular_ruler { background-position: -180px -800px; } + .emoji-trident { background-position: -200px -800px; } + .emoji-triumph { background-position: -220px -800px; } + .emoji-trolleybus { background-position: -240px -800px; } + .emoji-trophy { background-position: -260px -800px; } + .emoji-tropical_drink { background-position: -280px -800px; } + .emoji-tropical_fish { background-position: -300px -800px; } + .emoji-truck { background-position: -320px -800px; } + .emoji-trumpet { background-position: -340px -800px; } + .emoji-tulip { background-position: -360px -800px; } + .emoji-tumbler_glass { background-position: -380px -800px; } + .emoji-turkey { background-position: -400px -800px; } + .emoji-turtle { background-position: -420px -800px; } + .emoji-tv { background-position: -440px -800px; } + .emoji-twisted_rightwards_arrows { background-position: -460px -800px; } + .emoji-two { background-position: -480px -800px; } + .emoji-two_hearts { background-position: -500px -800px; } + .emoji-two_men_holding_hands { background-position: -520px -800px; } + .emoji-two_women_holding_hands { background-position: -540px -800px; } + .emoji-u5272 { background-position: -560px -800px; } + .emoji-u5408 { background-position: -580px -800px; } + .emoji-u55b6 { background-position: -600px -800px; } + .emoji-u6307 { background-position: -620px -800px; } + .emoji-u6708 { background-position: -640px -800px; } + .emoji-u6709 { background-position: -660px -800px; } + .emoji-u6e80 { background-position: -680px -800px; } + .emoji-u7121 { background-position: -700px -800px; } + .emoji-u7533 { background-position: -720px -800px; } + .emoji-u7981 { background-position: -740px -800px; } + .emoji-u7a7a { background-position: -760px -800px; } + .emoji-umbrella { background-position: -780px -800px; } + .emoji-umbrella2 { background-position: -800px -800px; } + .emoji-unamused { background-position: -820px 0; } + .emoji-underage { background-position: -820px -20px; } + .emoji-unicorn { background-position: -820px -40px; } + .emoji-unlock { background-position: -820px -60px; } + .emoji-up { background-position: -820px -80px; } + .emoji-upside_down { background-position: -820px -100px; } + .emoji-urn { background-position: -820px -120px; } + .emoji-v { background-position: -820px -140px; } + .emoji-v_tone1 { background-position: -820px -160px; } + .emoji-v_tone2 { background-position: -820px -180px; } + .emoji-v_tone3 { background-position: -820px -200px; } + .emoji-v_tone4 { background-position: -820px -220px; } + .emoji-v_tone5 { background-position: -820px -240px; } + .emoji-vertical_traffic_light { background-position: -820px -260px; } + .emoji-vhs { background-position: -820px -280px; } + .emoji-vibration_mode { background-position: -820px -300px; } + .emoji-video_camera { background-position: -820px -320px; } + .emoji-video_game { background-position: -820px -340px; } + .emoji-violin { background-position: -820px -360px; } + .emoji-virgo { background-position: -820px -380px; } + .emoji-volcano { background-position: -820px -400px; } + .emoji-volleyball { background-position: -820px -420px; } + .emoji-vs { background-position: -820px -440px; } + .emoji-vulcan { background-position: -820px -460px; } + .emoji-vulcan_tone1 { background-position: -820px -480px; } + .emoji-vulcan_tone2 { background-position: -820px -500px; } + .emoji-vulcan_tone3 { background-position: -820px -520px; } + .emoji-vulcan_tone4 { background-position: -820px -540px; } + .emoji-vulcan_tone5 { background-position: -820px -560px; } + .emoji-walking { background-position: -820px -580px; } + .emoji-walking_tone1 { background-position: -820px -600px; } + .emoji-walking_tone2 { background-position: -820px -620px; } + .emoji-walking_tone3 { background-position: -820px -640px; } + .emoji-walking_tone4 { background-position: -820px -660px; } + .emoji-walking_tone5 { background-position: -820px -680px; } + .emoji-waning_crescent_moon { background-position: -820px -700px; } + .emoji-waning_gibbous_moon { background-position: -820px -720px; } + .emoji-warning { background-position: -820px -740px; } + .emoji-wastebasket { background-position: -820px -760px; } + .emoji-watch { background-position: -820px -780px; } + .emoji-water_buffalo { background-position: -820px -800px; } + .emoji-water_polo { background-position: 0 -820px; } + .emoji-water_polo_tone1 { background-position: -20px -820px; } + .emoji-water_polo_tone2 { background-position: -40px -820px; } + .emoji-water_polo_tone3 { background-position: -60px -820px; } + .emoji-water_polo_tone4 { background-position: -80px -820px; } + .emoji-water_polo_tone5 { background-position: -100px -820px; } + .emoji-watermelon { background-position: -120px -820px; } + .emoji-wave { background-position: -140px -820px; } + .emoji-wave_tone1 { background-position: -160px -820px; } + .emoji-wave_tone2 { background-position: -180px -820px; } + .emoji-wave_tone3 { background-position: -200px -820px; } + .emoji-wave_tone4 { background-position: -220px -820px; } + .emoji-wave_tone5 { background-position: -240px -820px; } + .emoji-wavy_dash { background-position: -260px -820px; } + .emoji-waxing_crescent_moon { background-position: -280px -820px; } + .emoji-waxing_gibbous_moon { background-position: -300px -820px; } + .emoji-wc { background-position: -320px -820px; } + .emoji-weary { background-position: -340px -820px; } + .emoji-wedding { background-position: -360px -820px; } + .emoji-whale { background-position: -380px -820px; } + .emoji-whale2 { background-position: -400px -820px; } + .emoji-wheel_of_dharma { background-position: -420px -820px; } + .emoji-wheelchair { background-position: -440px -820px; } + .emoji-white_check_mark { background-position: -460px -820px; } + .emoji-white_circle { background-position: -480px -820px; } + .emoji-white_flower { background-position: -500px -820px; } + .emoji-white_large_square { background-position: -520px -820px; } + .emoji-white_medium_small_square { background-position: -540px -820px; } + .emoji-white_medium_square { background-position: -560px -820px; } + .emoji-white_small_square { background-position: -580px -820px; } + .emoji-white_square_button { background-position: -600px -820px; } + .emoji-white_sun_cloud { background-position: -620px -820px; } + .emoji-white_sun_rain_cloud { background-position: -640px -820px; } + .emoji-white_sun_small_cloud { background-position: -660px -820px; } + .emoji-wilted_rose { background-position: -680px -820px; } + .emoji-wind_blowing_face { background-position: -700px -820px; } + .emoji-wind_chime { background-position: -720px -820px; } + .emoji-wine_glass { background-position: -740px -820px; } + .emoji-wink { background-position: -760px -820px; } + .emoji-wolf { background-position: -780px -820px; } + .emoji-woman { background-position: -800px -820px; } + .emoji-woman_tone1 { background-position: -820px -820px; } + .emoji-woman_tone2 { background-position: -840px 0; } + .emoji-woman_tone3 { background-position: -840px -20px; } + .emoji-woman_tone4 { background-position: -840px -40px; } + .emoji-woman_tone5 { background-position: -840px -60px; } + .emoji-womans_clothes { background-position: -840px -80px; } + .emoji-womans_hat { background-position: -840px -100px; } + .emoji-womens { background-position: -840px -120px; } + .emoji-worried { background-position: -840px -140px; } + .emoji-wrench { background-position: -840px -160px; } + .emoji-wrestlers { background-position: -840px -180px; } + .emoji-wrestlers_tone1 { background-position: -840px -200px; } + .emoji-wrestlers_tone2 { background-position: -840px -220px; } + .emoji-wrestlers_tone3 { background-position: -840px -240px; } + .emoji-wrestlers_tone4 { background-position: -840px -260px; } + .emoji-wrestlers_tone5 { background-position: -840px -280px; } + .emoji-writing_hand { background-position: -840px -300px; } + .emoji-writing_hand_tone1 { background-position: -840px -320px; } + .emoji-writing_hand_tone2 { background-position: -840px -340px; } + .emoji-writing_hand_tone3 { background-position: -840px -360px; } + .emoji-writing_hand_tone4 { background-position: -840px -380px; } + .emoji-writing_hand_tone5 { background-position: -840px -400px; } + .emoji-x { background-position: -840px -420px; } + .emoji-yellow_heart { background-position: -840px -440px; } + .emoji-yen { background-position: -840px -460px; } + .emoji-yin_yang { background-position: -840px -480px; } + .emoji-yum { background-position: -840px -500px; } + .emoji-zap { background-position: -840px -520px; } + .emoji-zero { background-position: -840px -540px; } + .emoji-zipper_mouth { background-position: -840px -560px; } + .emoji-100 { background-position: -840px -580px; } @@ -5391,6 +7183,7 @@ height: 20px; width: 20px; + /* stylelint-disable media-feature-name-no-vendor-prefix */ @media only screen and (-webkit-min-device-pixel-ratio: 2), only screen and (min--moz-device-pixel-ratio: 2), only screen and (-o-min-device-pixel-ratio: 2/1), @@ -5400,4 +7193,5 @@ background-image: image-url('emoji@2x.png'); background-size: 860px 840px; } + /* stylelint-enable media-feature-name-no-vendor-prefix */ } diff --git a/app/assets/stylesheets/errors.scss b/app/assets/stylesheets/errors.scss index 89029a58d1e..f4519841ce3 100644 --- a/app/assets/stylesheets/errors.scss +++ b/app/assets/stylesheets/errors.scss @@ -15,9 +15,9 @@ $header-color: #456; body { color: $body-color; text-align: center; - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; margin: auto; - font-size: .875rem; + font-size: 0.875rem; } h1 { @@ -105,7 +105,6 @@ a { } @include media-breakpoint-up(sm) { - li { display: inline-block; padding-bottom: 0; diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 338a8c5497c..413e0dde535 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -68,6 +68,6 @@ @import 'framework/read_more'; @import 'framework/flex_grid'; @import 'framework/system_messages'; -@import "framework/spinner"; +@import 'framework/spinner'; @import 'framework/card'; @import 'framework/editor-lite'; diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss index 0eab86ff7ea..86e701604b5 100644 --- a/app/assets/stylesheets/framework/awards.scss +++ b/app/assets/stylesheets/framework/awards.scss @@ -179,7 +179,7 @@ &.user-authored { cursor: default; background-color: $gray-light; - border-color: $gray-200; + border-color: $gray-100; color: $gl-text-color-disabled; gl-emoji { diff --git a/app/assets/stylesheets/framework/broadcast_messages.scss b/app/assets/stylesheets/framework/broadcast_messages.scss index 534ada08b85..f7836213e5c 100644 --- a/app/assets/stylesheets/framework/broadcast_messages.scss +++ b/app/assets/stylesheets/framework/broadcast_messages.scss @@ -28,7 +28,7 @@ max-width: 300px; width: auto; background: $white; - border: 1px solid $gray-200; + border: 1px solid $gray-100; box-shadow: 0 1px 2px 0 rgba($black, 0.1); border-radius: $border-radius-default; z-index: 999; diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index f47d0cab31f..fd5b3f74c4a 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -171,7 +171,7 @@ @include btn-green; } - &.btn-inverted { + &.btn-inverted:not(.disabled):not(:disabled) { &.btn-success { @include btn-outline($white, $green-600, $green-500, $green-100, $green-700, $green-500, $green-200, $green-600, $green-800); } @@ -501,18 +501,19 @@ // All disabled buttons, regardless of color, type, etc %disabled { - background-color: $gray-light !important; - border-color: $gray-200 !important; - color: $gl-text-color-disabled !important; - opacity: 1 !important; - cursor: default !important; + background-color: $gray-light; + border-color: $gray-100; + color: $gl-text-color-disabled; + opacity: 1; + text-decoration: none; + cursor: default; &.cursor-not-allowed { - cursor: not-allowed !important; + cursor: not-allowed; } i { - color: $gl-text-color-disabled !important; + color: $gl-text-color-disabled; } } @@ -526,6 +527,10 @@ fieldset[disabled] .btn, &:hover { @extend %disabled; } + + &.btn-link { + background-color: transparent; + } } [readonly] { diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 849ca4a79f8..1abb7a9c06f 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -396,35 +396,16 @@ img.emoji { 🚨 Do not use these classes — they are deprecated and being removed. 🚨 See https://gitlab.com/gitlab-org/gitlab/-/issues/217418 for more details. **/ -.prepend-top-5 { margin-top: 5px; } .prepend-top-10 { margin-top: 10px; } .prepend-top-15 { margin-top: 15px; } -.prepend-top-default { margin-top: $gl-padding !important; } -.prepend-top-16 { margin-top: 16px; } .prepend-top-20 { margin-top: 20px; } -.prepend-left-5 { margin-left: 5px; } -.prepend-left-10 { margin-left: 10px; } .prepend-left-15 { margin-left: 15px; } -.prepend-left-default { margin-left: $gl-padding; } .prepend-left-20 { margin-left: 20px; } -.prepend-left-32 { margin-left: 32px; } .prepend-left-64 { margin-left: 64px; } -.append-right-2 { margin-right: 2px; } -.append-right-4 { margin-right: 4px; } -.append-right-5 { margin-right: 5px; } -.append-right-10 { margin-right: 10px; } .append-right-15 { margin-right: 15px; } -.append-right-default { margin-right: $gl-padding; } .append-right-20 { margin-right: 20px; } -.append-right-32 { margin-right: 32px; } -.append-right-48 { margin-right: 48px; } -.prepend-right-32 { margin-right: 32px; } -.append-bottom-5 { margin-bottom: 5px; } .append-bottom-10 { margin-bottom: 10px; } -.append-bottom-15 { margin-bottom: 15px; } .append-bottom-20 { margin-bottom: 20px; } -.append-bottom-default { margin-bottom: $gl-padding; } -.prepend-bottom-32 { margin-bottom: 32px; } .ml-10 { margin-left: 4.5rem; } .inline { display: inline-block; } .center { text-align: center; } @@ -560,41 +541,6 @@ img.emoji { } } -.onboarding-helper-container { - bottom: 40px; - right: 40px; - font-size: $gl-font-size-small; - background: $gray-50; - width: 200px; - border-radius: 24px; - box-shadow: 0 2px 4px $issue-boards-card-shadow; - z-index: 10000; - - .collapsible { - max-height: 0; - transition: max-height 0.5s cubic-bezier(0, 1, 0, 1); - } - - &.expanded { - border-bottom-right-radius: $border-radius-default; - border-bottom-left-radius: $border-radius-default; - - .collapsible { - max-height: 1000px; - transition: max-height 1s ease-in-out; - } - } - - .avatar { - border-color: darken($gray-normal, 10%); - - img { - width: 32px; - height: 32px; - } - } -} - .gl-font-sm { font-size: $gl-font-size-small; } .gl-font-lg { font-size: $gl-font-size-large; } .gl-font-base { font-size: $gl-font-size-14; } diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss index e4bee01f61f..7004bcc121d 100644 --- a/app/assets/stylesheets/framework/contextual_sidebar.scss +++ b/app/assets/stylesheets/framework/contextual_sidebar.scss @@ -98,11 +98,11 @@ width: $contextual-sidebar-collapsed-width - 1px; .collapse-text, - .icon-angle-double-left { + .icon-chevron-double-lg-left { display: none; } - .icon-angle-double-right { + .icon-chevron-double-lg-right { display: block; margin: 0; } @@ -381,7 +381,7 @@ margin-right: 8px; } - .icon-angle-double-right { + .icon-chevron-double-lg-right { display: none; } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 485a4879c43..32c276ea6d2 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -639,9 +639,12 @@ display: none; cursor: pointer; pointer-events: all; - right: 22px; - top: 9px; + top: $gl-padding-8; font-size: 14px; + + &:not(.gl-icon) { + right: 22px; + } } &.has-value { @@ -1084,8 +1087,20 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu { .color-input-container { .dropdown-label-color-preview { - border: 1px solid $gray-200; + border: 1px solid $gray-100; border-right: 0; + + &[style] { + border-color: transparent; + } + } + } +} + +.bulk-update { + .dropdown-toggle-text { + &.is-default { + color: $gl-text-color; } } } diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index eef6d9031f8..8fd507a45bb 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -500,16 +500,27 @@ span.idiff { border: transparent; } -.code-navigation { - border-bottom: 1px $gray-darkest dashed; +.code-navigation-line:hover { + .code-navigation { + border-bottom: 1px $gray-darkest dashed; - &:hover { - border-bottom-color: $almost-black; + &:hover { + border-bottom-color: $almost-black; + } } } -.code-navigation-popover { - max-width: 450px; +.code-navigation-popover.popover { + max-width: calc(min(#{px-to-rem(560px)}, calc(100vw - #{$gl-padding-32}))); +} + +.code-navigation-popover-container { + max-height: px-to-rem(320px); +} + +.code-navigation-popover .code { + padding-left: $grid-size * 3; + text-indent: -$grid-size * 2; } .tree-item-link { diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 9bba5c0614a..8f209d2d99a 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -26,6 +26,12 @@ margin-right: 6px; } + .bulk-update { + .filter-item { + margin-right: 0; + } + } + .sort-filter { display: inline-block; float: right; @@ -152,7 +158,7 @@ .filtered-search-token .selected, .filtered-search-term .selected { .name { - background-color: $gray-200; + background-color: $gray-100; } .operator { diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss index 44c8ace9040..ec8d5806345 100644 --- a/app/assets/stylesheets/framework/forms.scss +++ b/app/assets/stylesheets/framework/forms.scss @@ -4,6 +4,8 @@ textarea { input { border-radius: $border-radius-base; + color: $gl-text-color; + background-color: $input-bg; } input[type='text'].danger { @@ -126,10 +128,6 @@ label { display: inline; } -.wiki-content { - margin-top: 35px; -} - .form-control::placeholder { color: $gl-text-color-tertiary; } diff --git a/app/assets/stylesheets/framework/gitlab_theme.scss b/app/assets/stylesheets/framework/gitlab_theme.scss index 8d5afe1d312..288849ba438 100644 --- a/app/assets/stylesheets/framework/gitlab_theme.scss +++ b/app/assets/stylesheets/framework/gitlab_theme.scss @@ -74,19 +74,6 @@ } } - &:focus:hover, - &:focus { - &.header-user-dropdown-toggle .header-user-notification-dot { - border-color: $white; - } - } - - &:hover { - &.header-user-dropdown-toggle .header-user-notification-dot { - border-color: $nav-svg-color + 33; - } - } - &:hover, &:focus { @include media-breakpoint-up(sm) { @@ -96,6 +83,10 @@ svg { fill: currentColor; } + + &.header-user-dropdown-toggle .header-user-notification-dot { + border-color: $nav-svg-color + 33; + } } } @@ -109,6 +100,10 @@ fill: $nav-svg-color; } } + + &.header-user-dropdown-toggle .header-user-notification-dot { + border-color: $white; + } } .impersonated-user, @@ -171,7 +166,7 @@ color: $sidebar-text; } - svg { + .nav-icon-container svg { fill: $sidebar-text; } } @@ -347,7 +342,7 @@ body { .navbar-toggler, .navbar-toggler:hover { color: $gray-700; - border-left: 1px solid $gray-200; + border-left: 1px solid $gray-100; } } } @@ -365,7 +360,7 @@ body { .search-input-wrap { .search-icon { - fill: $gray-200; + fill: $gray-100; } .search-input { diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 2c7e9428ef1..50628c7de82 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -570,9 +570,9 @@ } .header-user-notification-dot { - background-color: $orange-500; - height: 10px; - width: 10px; + background-color: $orange-300; + height: 12px; + width: 12px; right: 8px; top: -8px; } diff --git a/app/assets/stylesheets/framework/images.scss b/app/assets/stylesheets/framework/images.scss index 2c9397d363c..0fae1c7d235 100644 --- a/app/assets/stylesheets/framework/images.scss +++ b/app/assets/stylesheets/framework/images.scss @@ -20,7 +20,7 @@ width: 100%; } - $image-widths: 80 130 150 250 306 394 430; + $image-widths: 80 130 150 225 250 306 394 430; @each $width in $image-widths { &.svg-#{$width} { img, diff --git a/app/assets/stylesheets/framework/issue_box.scss b/app/assets/stylesheets/framework/issue_box.scss index 385b29f8bbe..4d5032ac674 100644 --- a/app/assets/stylesheets/framework/issue_box.scss +++ b/app/assets/stylesheets/framework/issue_box.scss @@ -30,6 +30,7 @@ } &.status-box-issue-closed, + &.status-box-alert-resolved, &.status-box-mr-merged { background-color: $blue-500; } diff --git a/app/assets/stylesheets/framework/job_log.scss b/app/assets/stylesheets/framework/job_log.scss index 1a26c0283e5..2448be1bca3 100644 --- a/app/assets/stylesheets/framework/job_log.scss +++ b/app/assets/stylesheets/framework/job_log.scss @@ -5,7 +5,7 @@ font-size: 13px; word-break: break-all; word-wrap: break-word; - color: $gl-text-color-inverted; + color: color-yiq($builds-trace-bg); border-radius: $border-radius-small; min-height: 42px; background-color: $builds-trace-bg; diff --git a/app/assets/stylesheets/framework/memory_graph.scss b/app/assets/stylesheets/framework/memory_graph.scss index b0bfc4f47ff..510969e149a 100644 --- a/app/assets/stylesheets/framework/memory_graph.scss +++ b/app/assets/stylesheets/framework/memory_graph.scss @@ -1,4 +1,4 @@ .memory-graph-container { background: $white; - border: 1px solid $gray-200; + border: 1px solid $gray-100; } diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 52da1b9abfc..918ca448c21 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -137,7 +137,8 @@ transition-duration: 0.3s; } - .fa { + .fa, + svg { position: relative; top: 5px; font-size: 18px; diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss index bd262b65dc3..f85efc63645 100644 --- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss +++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss @@ -313,7 +313,7 @@ right: 0; text-align: right; - .fa { + svg { right: 5px; } } @@ -323,7 +323,7 @@ left: 0; text-align: left; - .fa { + svg { left: 5px; } } diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 1131248dd3f..9b33ed1b630 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -214,7 +214,7 @@ .health-status { .dropdown-body { .health-divider { - border-top-color: $gray-200; + border-top-color: $gray-100; } .dropdown-item:not(.health-dropdown-item) { diff --git a/app/assets/stylesheets/framework/stacked_progress_bar.scss b/app/assets/stylesheets/framework/stacked_progress_bar.scss index 0a57a74eafc..2d16fdf4ee7 100644 --- a/app/assets/stylesheets/framework/stacked_progress_bar.scss +++ b/app/assets/stylesheets/framework/stacked_progress_bar.scss @@ -36,7 +36,7 @@ } .status-neutral { - background-color: $gray-200; + background-color: $gray-100; color: $gl-gray-dark; &:hover { diff --git a/app/assets/stylesheets/framework/system_messages.scss b/app/assets/stylesheets/framework/system_messages.scss index 4f66d6bf354..10796f319bf 100644 --- a/app/assets/stylesheets/framework/system_messages.scss +++ b/app/assets/stylesheets/framework/system_messages.scss @@ -94,7 +94,8 @@ margin-bottom: 16px; } - .boards-list { + .boards-list, + .board-swimlanes { height: calc(100vh - #{$header-height + $breadcrumb-min-height + $performance-bar-height + $system-footer-height + $gl-padding-32}); } } diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss index ff6ac87db76..1504f3ee50f 100644 --- a/app/assets/stylesheets/framework/timeline.scss +++ b/app/assets/stylesheets/framework/timeline.scss @@ -27,7 +27,13 @@ .timeline-entry { color: $gl-text-color; - background-color: $white; + + // [dark-theme]: only give background color to actual notes + // in the timeline, the note form textarea has a background + // of it's own + &:not(.note-form) { + background-color: $white; + } .timeline-entry-inner { position: relative; diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 6e07a2b5de1..b5b86b807a6 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -89,7 +89,7 @@ background-color: $gray-10; border-width: 1px; border-style: solid; - border-color: $gray-200 $gray-200 $gray-400; + border-color: $gray-100 $gray-100 $gray-400; border-image: none; border-radius: 3px; box-shadow: 0 -1px 0 $gray-400 inset; @@ -181,7 +181,7 @@ background-color: $white; td { - border-color: $gray-200; + border-color: $gray-100; } } @@ -611,7 +611,7 @@ pre { word-wrap: break-word; color: $gl-text-color; background-color: $gray-light; - border: 1px solid $gray-200; + border: 1px solid $gray-100; border-radius: $border-radius-small; } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 1536c5c3022..265dceb3c61 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -113,29 +113,29 @@ $gl-gray-600: #666 !default; $gl-gray-700: #555 !default; $gl-gray-800: #333 !default; -$green-50: #f1fdf6 !default; -$green-100: #dcf5e7 !default; -$green-200: #263a2e !default; -$green-300: #75d09b !default; -$green-400: #37b96d !default; -$green-500: #1aaa55 !default; -$green-600: #168f48 !default; -$green-700: #12753a !default; -$green-800: #0e5a2d !default; +$green-50: #ecf4ee !default; +$green-100: #c3e6cd !default; +$green-200: #91d4a8 !default; +$green-300: #52b87a !default; +$green-400: #2da160 !default; +$green-500: #108548 !default; +$green-600: #217645 !default; +$green-700: #24663b !default; +$green-800: #0d532a !default; $green-900: #0a4020 !default; $green-950: #072b15 !default; -$blue-50: #f6fafe !default; -$blue-100: #e4f0fb !default; -$blue-200: #b8d6f4 !default; -$blue-300: #73afea !default; -$blue-400: #418cd8 !default; -$blue-500: #1f78d1 !default; -$blue-600: #1b69b6 !default; -$blue-700: #17599c !default; -$blue-800: #134a81 !default; -$blue-900: #0f3b66 !default; -$blue-950: #0a2744 !default; +$blue-50: #e9f3fc !default; +$blue-100: #cbe2f9 !default; +$blue-200: #9dc7f1 !default; +$blue-300: #63a6e9 !default; +$blue-400: #428fdc !default; +$blue-500: #1f75cb !default; +$blue-600: #1068bf !default; +$blue-700: #0b5cad !default; +$blue-800: #064787 !default; +$blue-900: #033464 !default; +$blue-950: #002850 !default; $orange-50: #fffaf4 !default; $orange-100: #fff1de !default; @@ -164,14 +164,14 @@ $red-950: #4d0a00 !default; $gray-10: #fafafa !default; $gray-50: #f0f0f0 !default; $gray-100: #dbdbdb !default; -$gray-200: #dfdfdf !default; +$gray-200: #bfbfbf !default; $gray-300: #ccc !default; $gray-400: #bababa !default; $gray-500: #a7a7a7 !default; $gray-600: #919191 !default; $gray-700: #707070 !default; $gray-800: #4f4f4f !default; -$gray-900: #2e2e2e !default; +$gray-900: #303030 !default; $gray-950: #1f1f1f !default; $greens: ( @@ -333,7 +333,7 @@ $border-gray-normal-dashed: darken($gray-normal, $darken-border-dashed-factor); /* * UI elements */ -$border-color: $gray-200; +$border-color: $gray-100; $shadow-color: $t-gray-a-08; $well-expand-item: #e8f2f7; $well-inner-border: #eef0f2; @@ -479,9 +479,9 @@ $gl-btn-active-gradient: inset 0 2px 3px $gl-btn-active-background; $added: #63c363; $deleted: #f77; $line-added: #ecfdf0; -$line-added-dark: #c7f0d2; +$line-added-dark: #c7f0d2 !default; $line-removed: #fbe9eb; -$line-removed-dark: #fac5cd; +$line-removed-dark: #fac5cd !default; $line-number-old: #f9d7dc; $line-number-new: #ddfbe6; $line-number-select: #fbf2da; @@ -711,7 +711,6 @@ $input-lg-width: 320px; */ $document-index-color: #888; $help-shortcut-header-color: #333; -$accepting-mr-label-color: #69d100; /* * Issues @@ -868,7 +867,7 @@ $priority-label-empty-state-width: 114px; Popovers */ $popover-max-width: 384px; -$popover-box-shadow: 0 2px 3px 1px $gray-200; +$popover-box-shadow: 0 2px 3px 1px $gray-100; /* Issues Analytics diff --git a/app/assets/stylesheets/framework/variables_overrides.scss b/app/assets/stylesheets/framework/variables_overrides.scss index c7a50bdb5a3..acfda718e77 100644 --- a/app/assets/stylesheets/framework/variables_overrides.scss +++ b/app/assets/stylesheets/framework/variables_overrides.scss @@ -5,23 +5,23 @@ $secondary: $gray-light; $input-disabled-bg: $gray-light; -$input-border-color: $gray-200; +$input-border-color: $gray-100; $input-color: $gl-text-color; $input-font-size: $gl-font-size; $font-family-sans-serif: $regular-font; $font-family-monospace: $monospace-font; $btn-line-height: 20px; $table-accent-bg: $gray-light; -$table-border-color: $gray-200; +$table-border-color: $gray-100; $card-border-color: $border-color; -$card-cap-bg: $gray-light; +$card-cap-bg: $gray-light !default; $success: $green-500; $info: $blue-500; $warning: $orange-500; $danger: $red-500; $zindex-modal-backdrop: 1040; $nav-divider-margin-y: ($grid-size / 2); -$dropdown-divider-bg: $gray-200; +$dropdown-divider-bg: $gray-100; $dropdown-item-padding-y: 8px; $dropdown-item-padding-x: 12px; $popover-max-width: 300px; diff --git a/app/assets/stylesheets/highlight/themes/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss index 5ab762a5104..8d965ea4309 100644 --- a/app/assets/stylesheets/highlight/themes/dark.scss +++ b/app/assets/stylesheets/highlight/themes/dark.scss @@ -1,6 +1,6 @@ /* https://github.com/MozMorris/tomorrow-pygments */ -@import "../common"; +@import '../common'; /* * Dark syntax colors @@ -223,11 +223,20 @@ $dark-il: #de935f; .cs { color: $dark-cs; } /* Comment.Special */ .gd { color: $dark-gd; } /* Generic.Deleted */ .ge { font-style: italic; } /* Generic.Emph */ - .gh { color: $dark-gh; font-weight: $gl-font-weight-bold; } /* Generic.Heading */ + .gh { /* Generic.Heading */ + color: $dark-gh; + font-weight: $gl-font-weight-bold; + } .gi { color: $dark-gi; } /* Generic.Inserted */ - .gp { color: $dark-gp; font-weight: $gl-font-weight-bold; } /* Generic.Prompt */ + .gp { /* Generic.Prompt */ + color: $dark-gp; + font-weight: $gl-font-weight-bold; + } .gs { font-weight: $gl-font-weight-bold; } /* Generic.Strong */ - .gu { color: $dark-gu; font-weight: $gl-font-weight-bold; } /* Generic.Subheading */ + .gu { /* Generic.Subheading */ + color: $dark-gu; + font-weight: $gl-font-weight-bold; + } .kc { color: $dark-kc; } /* Keyword.Constant */ .kd { color: $dark-kd; } /* Keyword.Declaration */ .kn { color: $dark-kn; } /* Keyword.Namespace */ diff --git a/app/assets/stylesheets/highlight/themes/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss index 348ef69cc4f..5ef2b9dcc36 100644 --- a/app/assets/stylesheets/highlight/themes/monokai.scss +++ b/app/assets/stylesheets/highlight/themes/monokai.scss @@ -1,6 +1,6 @@ /* https://github.com/richleland/pygments-css/blob/master/monokai.css */ -@import "../common"; +@import '../common'; /* * Monokai Colors @@ -211,7 +211,10 @@ $monokai-gi: #a6e22e; .hll { background-color: $monokai-hll; } .c { color: $monokai-c; } /* Comment */ - .err { color: $monokai-err-color; background-color: $monokai-err-bg; } /* Error */ + .err { /* Error */ + color: $monokai-err-color; + background-color: $monokai-err-bg; + } .k { color: $monokai-k; } /* Keyword */ .l { color: $monokai-l; } /* Literal */ .n { color: $monokai-n; } /* Name */ diff --git a/app/assets/stylesheets/highlight/themes/none.scss b/app/assets/stylesheets/highlight/themes/none.scss index 2fc5d7f7a85..fb548a00526 100644 --- a/app/assets/stylesheets/highlight/themes/none.scss +++ b/app/assets/stylesheets/highlight/themes/none.scss @@ -2,7 +2,7 @@ * None Syntax Colors */ -@import "../common"; +@import '../common'; @mixin match-line { color: $black-transparent; @@ -10,7 +10,7 @@ } .code.none { - // Line numbers + // Line numbers .line-numbers, .diff-line-num { background-color: $gray-light; @@ -44,7 +44,6 @@ $none-expanded-bg: #e0e0e0; .line_holder { - &.match .line_content, .new-nonewline.line_content, .old-nonewline.line_content { @@ -149,12 +148,12 @@ background-color: $white-normal; } - // Search result highlight + // Search result highlight span.highlight_word { background-color: $white-normal; } - // Links to URLs, emails, or dependencies + // Links to URLs, emails, or dependencies .line a { color: $gl-text-color; text-decoration: underline; diff --git a/app/assets/stylesheets/highlight/themes/solarized-dark.scss b/app/assets/stylesheets/highlight/themes/solarized-dark.scss index f5b36480f18..190a6e6156a 100644 --- a/app/assets/stylesheets/highlight/themes/solarized-dark.scss +++ b/app/assets/stylesheets/highlight/themes/solarized-dark.scss @@ -1,6 +1,6 @@ /* https://gist.github.com/qguv/7936275 */ -@import "../common"; +@import '../common'; /* * Solarized dark colors @@ -244,13 +244,19 @@ $solarized-dark-il: #2aa198; .c1 { color: $solarized-dark-c1; } /* Comment.Single */ .cs { color: $solarized-dark-cs; } /* Comment.Special */ .gd { color: $solarized-dark-gd; } /* Generic.Deleted */ - .ge { color: $solarized-dark-ge; font-style: italic; } /* Generic.Emph */ + .ge { /* Generic.Emph */ + color: $solarized-dark-ge; + font-style: italic; + } .gr { color: $solarized-dark-gr; } /* Generic.Error */ .gh { color: $solarized-dark-gh; } /* Generic.Heading */ .gi { color: $solarized-dark-gi; } /* Generic.Inserted */ .go { color: $solarized-dark-go; } /* Generic.Output */ .gp { color: $solarized-dark-gp; } /* Generic.Prompt */ - .gs { color: $solarized-dark-gs; font-weight: $gl-font-weight-bold; } /* Generic.Strong */ + .gs { /* Generic.Strong */ + color: $solarized-dark-gs; + font-weight: $gl-font-weight-bold; + } .gu { color: $solarized-dark-gu; } /* Generic.Subheading */ .gt { color: $solarized-dark-gt; } /* Generic.Traceback */ .kc { color: $solarized-dark-kc; } /* Keyword.Constant */ diff --git a/app/assets/stylesheets/highlight/themes/solarized-light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss index 993370642c3..71d8dd06834 100644 --- a/app/assets/stylesheets/highlight/themes/solarized-light.scss +++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss @@ -1,6 +1,6 @@ /* https://gist.github.com/qguv/7936275 */ -@import "../common"; +@import '../common'; /* * Solarized light syntax colors @@ -252,13 +252,19 @@ $solarized-light-il: #2aa198; .c1 { color: $solarized-light-c1; } /* Comment.Single */ .cs { color: $solarized-light-cs; } /* Comment.Special */ .gd { color: $solarized-light-gd; } /* Generic.Deleted */ - .ge { color: $solarized-light-ge; font-style: italic; } /* Generic.Emph */ + .ge { /* Generic.Emph */ + color: $solarized-light-ge; + font-style: italic; + } .gr { color: $solarized-light-gr; } /* Generic.Error */ .gh { color: $solarized-light-gh; } /* Generic.Heading */ .gi { color: $solarized-light-gi; } /* Generic.Inserted */ .go { color: $solarized-light-go; } /* Generic.Output */ .gp { color: $solarized-light-gp; } /* Generic.Prompt */ - .gs { color: $solarized-light-gs; font-weight: $gl-font-weight-bold; } /* Generic.Strong */ + .gs { /* Generic.Strong */ + color: $solarized-light-gs; + font-weight: $gl-font-weight-bold; + } .gu { color: $solarized-light-gu; } /* Generic.Subheading */ .gt { color: $solarized-light-gt; } /* Generic.Traceback */ .kc { color: $solarized-light-kc; } /* Keyword.Constant */ diff --git a/app/assets/stylesheets/highlight/themes/white.scss b/app/assets/stylesheets/highlight/themes/white.scss index 7239086f649..6362dd734f6 100644 --- a/app/assets/stylesheets/highlight/themes/white.scss +++ b/app/assets/stylesheets/highlight/themes/white.scss @@ -1,3 +1,3 @@ .code.white { - @import "../white_base"; + @import '../white_base'; } diff --git a/app/assets/stylesheets/mailer.scss b/app/assets/stylesheets/mailer.scss index f7d93870a25..f188b29a113 100644 --- a/app/assets/stylesheets/mailer.scss +++ b/app/assets/stylesheets/mailer.scss @@ -6,12 +6,12 @@ // stylelint-disable color-hex-length $mailer-font: 'Helvetica Neue', Helvetica, Arial, sans-serif; -$mailer-text-color: #333333; +$mailer-text-color: #333; $mailer-bg-color: #fafafa; $mailer-link-color: #3777b0; -$mailer-link-muted-color: #333333; +$mailer-link-muted-color: #333; $mailer-line-cell-bg-color: #6b4fbb; -$mailer-wrapper-cell-bg-color: #ffffff; +$mailer-wrapper-cell-bg-color: #fff; $mailer-wrapper-cell-border-color: #ededed; $mailer-header-footer-text-color: #5c5c5c; diff --git a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss index 2b82b2226c6..a8d10ea1a29 100644 --- a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss +++ b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss @@ -146,7 +146,7 @@ } pre { - border-color: var(--ide-border-color-alt, $gray-200); + border-color: var(--ide-border-color-alt, $gray-100); code { background-color: var(--ide-border-color, inherit); @@ -216,7 +216,7 @@ color: var(--ide-text-color, $gl-text-color); &:hover { - background-color: var(--ide-input-border, $gray-200); + background-color: var(--ide-input-border, $gray-100); } } @@ -300,8 +300,8 @@ } .divider { - background-color: var(--ide-dropdown-hover-background, $gray-200); - border-color: var(--ide-dropdown-hover-background, $gray-200); + background-color: var(--ide-dropdown-hover-background, $gray-100); + border-color: var(--ide-dropdown-hover-background, $gray-100); } li > a:not(.disable-hover):hover, @@ -316,7 +316,7 @@ .dropdown-title, .dropdown-input { - border-color: var(--ide-dropdown-hover-background, $gray-200) !important; + border-color: var(--ide-dropdown-hover-background, $gray-100) !important; } .btn-primary, @@ -356,7 +356,7 @@ .btn[disabled] { background-color: var(--ide-btn-default-background, $gray-light) !important; - border: 1px solid var(--ide-btn-disabled-border, $gray-200) !important; + border: 1px solid var(--ide-btn-disabled-border, $gray-100) !important; color: var(--ide-btn-disabled-color, $gl-text-color-disabled) !important; } diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index 9c92f891834..a07755724dd 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -145,7 +145,7 @@ $ide-commit-header-height: 48px; } &:not([disabled]):hover { - background-color: var(--ide-input-border, $gray-200); + background-color: var(--ide-input-border, $gray-100); } &:not([disabled]):focus { @@ -251,10 +251,6 @@ $ide-commit-header-height: 48px; padding-left: $gl-padding; } } - -.ide-status-file { - text-align: right; -} // Not great, but this is to deal with our current output .multi-file-preview-holder { height: 100%; @@ -400,7 +396,7 @@ $ide-commit-header-height: 48px; } &:active { - background: var(--ide-background, $gray-200); + background: var(--ide-background, $gray-100); } &.is-active { @@ -571,7 +567,7 @@ $ide-commit-header-height: 48px; &:focus { color: var(--ide-text-color, $gl-text-color); - background-color: var(--ide-background-hover, $gray-200); + background-color: var(--ide-background-hover, $gray-100); } &.active { @@ -1050,7 +1046,7 @@ $ide-commit-header-height: 48px; background-color: var(--ide-background, $gray-50); &:hover { - background-color: var(--ide-file-row-btn-hover-background, $gray-200); + background-color: var(--ide-file-row-btn-hover-background, $gray-100); } &:active, @@ -1101,7 +1097,7 @@ $ide-commit-header-height: 48px; &:focus { outline: 0; box-shadow: none; - border-color: var(--ide-border-color, $gray-200); + border-color: var(--ide-border-color, $gray-100); } } @@ -1144,7 +1140,7 @@ $ide-commit-header-height: 48px; } .file-row:active { - background: var(--ide-background, $gray-200); + background: var(--ide-background, $gray-100); } .file-row.is-active { diff --git a/app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss b/app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss index a58a0ed9475..0ef0834d8db 100644 --- a/app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss +++ b/app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss @@ -47,4 +47,4 @@ --ide-animation-gradient-1: var(--ide-file-row-btn-hover-background); --ide-animation-gradient-2: var(--ide-dropdown-hover-background); - } +} diff --git a/app/assets/stylesheets/pages/alert_management/details.scss b/app/assets/stylesheets/pages/alert_management/details.scss index 591a26e5941..73a4af00c5a 100644 --- a/app/assets/stylesheets/pages/alert_management/details.scss +++ b/app/assets/stylesheets/pages/alert_management/details.scss @@ -61,13 +61,13 @@ &.is-active { &:last-child { - border-bottom: 1px solid $gray-200; + border-bottom: 1px solid $gray-100; } } } } .note-header-info { - margin-top: 1px; + @include gl-mt-1; } } diff --git a/app/assets/stylesheets/pages/alert_management/list.scss b/app/assets/stylesheets/pages/alert_management/list.scss index c1ea9b7604a..e420209b1fc 100644 --- a/app/assets/stylesheets/pages/alert_management/list.scss +++ b/app/assets/stylesheets/pages/alert_management/list.scss @@ -1,4 +1,8 @@ .alert-management-list { + .new-alert { + background-color: $issues-today-bg; + } + // these styles need to be deleted once GlTable component looks in GitLab same as in @gitlab/ui table { color: $gray-700; @@ -8,14 +12,9 @@ outline: none; } - > :not([aria-sort='none']).b-table-sort-icon-left:hover::before { - content: '' !important; - } - td, th { - // TODO: There is no gl-pl-9 utlity for this padding, to be done and then removed. - padding-left: 1.25rem; + @include gl-pl-9; @include gl-py-5; @include gl-outline-none; @include gl-relative; @@ -26,24 +25,8 @@ font-weight: $gl-font-weight-bold; color: $gl-gray-600; - &:hover::before { - left: 3%; - top: 34%; - @include gl-absolute; - content: url("data:image/svg+xml,%3Csvg \ - xmlns='http://www.w3.org/2000/svg' \ - width='14' height='14' viewBox='0 0 16 \ - 16'%3E%3Cpath fill='%23BABABA' fill-rule='evenodd' \ - d='M11.707085,11.7071 L7.999975,15.4142 L4.292875,11.7071 \ - C3.902375,11.3166 3.902375,10.6834 \ - 4.292875,10.2929 C4.683375,9.90237 \ - 5.316575,9.90237 5.707075,10.2929 \ - L6.999975,11.5858 L6.999975,2 C6.999975,1.44771 \ - 7.447695,1 7.999975,1 C8.552255,1 8.999975,1.44771 \ - 8.999975,2 L8.999975,11.5858 L10.292865,10.2929 \ - C10.683395,9.90237 11.316555,9.90237 11.707085,10.2929 \ - C12.097605,10.6834 12.097605,11.3166 11.707085,11.7071 \ - Z'/%3E%3C/svg%3E%0A"); + &[aria-sort='none']:hover { + background-image: url('data:image/svg+xml, %3csvg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="4 0 8 16"%3e %3cpath style="fill: %23BABABA;" fill-rule="evenodd" d="M11.707085,11.7071 L7.999975,15.4142 L4.292875,11.7071 C3.902375,11.3166 3.902375, 10.6834 4.292875,10.2929 C4.683375,9.90237 5.316575,9.90237 5.707075,10.2929 L6.999975, 11.5858 L6.999975,2 C6.999975,1.44771 7.447695,1 7.999975,1 C8.552255,1 8.999975,1.44771 8.999975,2 L8.999975,11.5858 L10.292865,10.2929 C10.683395 ,9.90237 11.316555,9.90237 11.707085,10.2929 C12.097605,10.6834 12.097605,11.3166 11.707085,11.7071 Z"/%3e %3c/svg%3e'); } } } @@ -74,7 +57,7 @@ content: none !important; } - div { + div:not(.dropdown-title) { width: 100% !important; padding: 0 !important; } diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 3e680c59910..049660220df 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -45,7 +45,8 @@ } } -.boards-list { +.boards-list, +.board-swimlanes { height: calc(100vh - #{$issue-board-list-difference-xs}); overflow-x: scroll; min-height: 200px; @@ -82,7 +83,6 @@ } .board-title-caret { - cursor: pointer; border-radius: $border-radius-default; line-height: $gl-spacing-scale-5; height: $gl-spacing-scale-5; @@ -109,7 +109,6 @@ .board-title { flex-direction: column; height: 100%; - padding: $gl-padding-8 0; } .board-title-caret { @@ -203,8 +202,7 @@ flex-grow: 1; } -.board-delete { - color: $gray-darkest; +.board-delete.gl-button { background-color: transparent; outline: 0; @@ -579,7 +577,10 @@ } } -.board-epics-swimlanes { +.board-swimlanes { overflow-x: auto; - min-height: 600px; +} + +.board-header-collapsed-info-icon:hover { + color: $gray-900; } diff --git a/app/assets/stylesheets/pages/branches.scss b/app/assets/stylesheets/pages/branches.scss index e1715b8e1bf..3c49cc54ac4 100644 --- a/app/assets/stylesheets/pages/branches.scss +++ b/app/assets/stylesheets/pages/branches.scss @@ -23,7 +23,7 @@ .bar { height: 4px; - background-color: $gl-gray-200; + background-color: $gl-gray-100; } .count { @@ -34,7 +34,7 @@ .graph-separator { width: $graph-separator-width; height: 18px; - background-color: $gl-gray-200; + background-color: $gl-gray-100; } } diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index f50d4bc736e..02c42d5b779 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -236,7 +236,7 @@ .trigger-variables-table-cell { font-size: $gl-font-size-small; line-height: $gl-line-height; - border: 1px solid $gray-200; + border: 1px solid $gray-100; padding: $gl-padding-4 6px; width: 50%; vertical-align: top; diff --git a/app/assets/stylesheets/pages/container_registry.scss b/app/assets/stylesheets/pages/container_registry.scss deleted file mode 100644 index b88bd78cf3d..00000000000 --- a/app/assets/stylesheets/pages/container_registry.scss +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Container Registry - */ - -.container-message { - span .btn { - margin: 0; - } -} - -.container-image { - border-bottom: 1px solid $white-normal; -} - -.container-image-head { - padding: 0 16px; - line-height: 4em; - - .btn-link { - padding: 0; - - &:focus { - outline: none; - } - } -} - -.table.tags { - margin-bottom: 0; - - .registry-image-row { - .check { - padding-right: $gl-padding; - width: 5%; - } - - .action-buttons { - opacity: 0; - } - - &:hover { - .action-buttons { - opacity: 1; - } - } - } -} diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 98d74a9aaa2..fd5b3ff1dd8 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -935,11 +935,10 @@ table.code { } } -.files:not([data-can-create-note='true']) .frame { +.files:not([data-can-create-note]) .frame { cursor: auto; } -.frame, .frame.click-to-comment, .btn-transparent.image-diff-overlay-add-comment { position: relative; diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss index eb9684c7b3c..fd11d0e3a69 100644 --- a/app/assets/stylesheets/pages/editor.scss +++ b/app/assets/stylesheets/pages/editor.scss @@ -45,6 +45,7 @@ display: block; float: left; margin-right: 10px; + max-width: 250px; } .new-file-name, @@ -139,10 +140,6 @@ clear: both; } } - - .editor-ref { - max-width: 250px; - } } } diff --git a/app/assets/stylesheets/pages/environment_logs.scss b/app/assets/stylesheets/pages/environment_logs.scss index 81cec14062f..03993e5321d 100644 --- a/app/assets/stylesheets/pages/environment_logs.scss +++ b/app/assets/stylesheets/pages/environment_logs.scss @@ -31,10 +31,6 @@ width: 160px; } } - - .controllers { - @include build-controllers(16px, flex-end, false, 2, inline); - } } .log-lines, diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index b1e849143b0..a7d0d4259ea 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -115,20 +115,6 @@ font-size: 0; margin-bottom: -5px; } - - .scoped-label-wrapper { - > a { - max-width: 100%; - } - - .color-label { - padding-right: $gl-padding-24; - } - - .scoped-label { - right: 12px; - } - } } .assignee { @@ -396,7 +382,7 @@ overflow: hidden; &:hover { - background-color: $gray-200; + background-color: $gray-100; } &.issuable-sidebar-header { @@ -983,10 +969,6 @@ vertical-align: sub; } -.suggestion-item a { - color: initial; -} - .suggestion-confidential { color: $orange-600; } diff --git a/app/assets/stylesheets/pages/issues/issue_count_badge.scss b/app/assets/stylesheets/pages/issues/issue_count_badge.scss index 569f323abd8..f2283e02ad2 100644 --- a/app/assets/stylesheets/pages/issues/issue_count_badge.scss +++ b/app/assets/stylesheets/pages/issues/issue_count_badge.scss @@ -1,7 +1,5 @@ .issue-count-badge, .mr-count-badge { - display: inline-flex; - border-radius: $border-radius-base; padding: 5px $gl-padding-8; } diff --git a/app/assets/stylesheets/pages/issues/issues_list.scss b/app/assets/stylesheets/pages/issues/issues_list.scss new file mode 100644 index 00000000000..c0af7a6af6d --- /dev/null +++ b/app/assets/stylesheets/pages/issues/issues_list.scss @@ -0,0 +1,5 @@ +.svg-container.jira-logo-container { + svg { + vertical-align: text-bottom; + } +} diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index c3bac053a0a..73d2c3ca2f8 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -134,6 +134,11 @@ } } +.label-description-wrapper { + margin-right: 8px; + margin-left: 8px; +} + .prioritized-labels { margin-bottom: 30px; @@ -310,7 +315,6 @@ width: 200px; flex-shrink: 0; - .scoped-label-wrapper, .gl-label { line-height: $gl-line-height; } @@ -386,7 +390,7 @@ order: 3; width: 100%; - > .append-right-default.prepend-left-default { + > .label-description-wrapper { margin-left: 0; margin-right: 0; } @@ -415,40 +419,6 @@ color: $indigo-300; } -.scoped-label-wrapper { - max-width: 100%; - vertical-align: top; - - .badge { - text-overflow: ellipsis; - overflow-x: hidden; - } - - &.label-link .color-label a { - color: inherit; - } - - .color-label { - padding-right: $gl-padding-24; - max-width: 100%; - } - - .scoped-label { - position: absolute; - top: 4px; - right: 8px; - padding: 0; - margin: 0; - line-height: $gl-line-height; - } - - &.board-label { - .scoped-label { - top: 1px; - } - } -} - .gl-label-scoped { box-shadow: 0 0 0 2px currentColor inset; @@ -456,29 +426,3 @@ box-shadow: 0 0 0 1px inset; } } - -// Label inside title of Delete Label Modal -.modal-header .page-title { - .scoped-label-wrapper { - .scoped-label { - line-height: 20px; - } - - span.color-label { - padding-right: $gl-padding-24; - } - } -} - -// Don't hide the overflow in system messages -.system-note-message, -.issuable-details, -.md-preview-holder, -.referenced-commands, -.note-body { - .scoped-label-wrapper { - .badge { - overflow: initial; - } - } -} diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 1e5e6da4e6c..5cf2d847405 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -64,7 +64,7 @@ $mr-widget-min-height: 69px; background-color: $gray-light; &.clickable:hover { - background-color: $gl-gray-200; + background-color: $gl-gray-100; cursor: pointer; } } @@ -75,7 +75,7 @@ $mr-widget-min-height: 69px; &::before { content: ''; - border-left: 1px solid $gray-200; + border-left: 1px solid $gray-100; position: absolute; left: 28px; top: -17px; @@ -162,10 +162,6 @@ $mr-widget-min-height: 69px; .btn { font-size: $gl-font-size; - &[disabled] { - opacity: 0.3; - } - &.dropdown-toggle { .fa { color: inherit; @@ -401,6 +397,16 @@ $mr-widget-min-height: 69px; } } } + + &.mr-pipeline-suggest { + border-radius: $border-radius-default; + line-height: 20px; + border: 1px solid $border-color; + + .circle-icon-container { + color: $gl-text-color-quaternary; + } + } } .mr-widget-help { @@ -600,26 +606,6 @@ $mr-widget-min-height: 69px; } } -.mr-pipeline-suggest { - flex-wrap: wrap; - border-radius: $border-radius-default; - padding: $gl-padding; - border: 1px solid $border-color; - min-height: $mr-widget-min-height; - - @include media-breakpoint-up(md) { - align-items: center; - } - - .circle-icon-container { - color: $gl-text-color-quaternary; - } - - .popover { - z-index: 240; - } -} - .card-new-merge-request { .card-header { padding: 5px 10px; @@ -1050,3 +1036,7 @@ $mr-widget-min-height: 69px; } } } + +.diff-file-row.is-active { + background-color: $gray-50; +} diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index c3f3dbc223b..3a210d66420 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -62,7 +62,8 @@ background-color: $white; &.is-focused { - @extend .form-control:focus; + border-color: $input-focus-border-color; + box-shadow: $input-focus-box-shadow; .comment-toolbar, .nav-links { @@ -359,14 +360,6 @@ table { } } -.toolbar-button-icon { - position: relative; - top: 1px; - margin-right: $gl-padding-4; - color: inherit; - font-size: 16px; -} - .toolbar-text { font-size: 14px; line-height: 16px; @@ -489,6 +482,7 @@ table { border: 0; font-size: 14px; line-height: 16px; + vertical-align: initial; &:hover, &:focus { @@ -498,6 +492,10 @@ table { text-decoration: underline; } } + + .gl-icon:not(:last-child) { + margin-right: 0; + } } .markdown-selector { diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index e8cdfd717c0..40f0104a2bf 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -10,6 +10,7 @@ $note-form-margin-left: 72px; top: 0; bottom: 0; left: $left; + height: calc(100% - 20px); } } @@ -185,8 +186,8 @@ $note-form-margin-left: 72px; padding: $gl-padding; .dummy-avatar { - background-color: $gl-gray-200; - border: 1px solid darken($gl-gray-200, 25%); + background-color: $gl-gray-100; + border: 1px solid darken($gl-gray-100, 25%); } .note-headline-light, @@ -254,10 +255,6 @@ $note-form-margin-left: 72px; } &.is-loading { - .fa-smile-o { - display: none; - } - .fa-spinner { display: inline-block; } diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 43d766db9e0..57ad9abef4b 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -253,13 +253,6 @@ } .stage-cell { - &.table-section { - @include media-breakpoint-up(md) { - min-width: 160px; /* Hack alert: Without this the mini graph pipeline won't work properly*/ - margin-right: -4px; - } - } - .mini-pipeline-graph-dropdown-toggle { svg { height: $ci-action-icon-size; @@ -816,7 +809,7 @@ &.ci-status-icon-created, &.ci-status-icon-skipped { - @include mini-pipeline-graph-color($white, $gray-200, $gray-300, $gray-500, $gray-600, $gray-700); + @include mini-pipeline-graph-color($white, $gray-100, $gray-300, $gray-500, $gray-600, $gray-700); } } @@ -1108,7 +1101,3 @@ button.mini-pipeline-graph-dropdown-toggle { .progress-bar.bg-primary { background-color: $blue-500 !important; } - -.parent-child-label-container { - padding-top: $gl-padding-4; -} diff --git a/app/assets/stylesheets/pages/profiles/preferences.scss b/app/assets/stylesheets/pages/profiles/preferences.scss index 3bab84af492..12386fa66ec 100644 --- a/app/assets/stylesheets/pages/profiles/preferences.scss +++ b/app/assets/stylesheets/pages/profiles/preferences.scss @@ -19,11 +19,6 @@ $ui-light-bg: #dfdfdf; $ui-dark-mode-bg: #1f1f1f; - label { - margin: 0 $gl-padding-32 $gl-padding 0; - text-align: center; - } - .preview { font-size: 0; height: 48px; diff --git a/app/assets/stylesheets/pages/prometheus.scss b/app/assets/stylesheets/pages/prometheus.scss index 26db1fb9f58..6461d09bb47 100644 --- a/app/assets/stylesheets/pages/prometheus.scss +++ b/app/assets/stylesheets/pages/prometheus.scss @@ -34,7 +34,7 @@ .draggable { &.draggable-enabled { .draggable-panel { - border: $gray-200 1px solid; + border: $gray-100 1px solid; border-radius: $border-radius-default; margin: -1px; cursor: grab; diff --git a/app/assets/stylesheets/pages/runners.scss b/app/assets/stylesheets/pages/runners.scss index dc3811bab65..66d2f76c558 100644 --- a/app/assets/stylesheets/pages/runners.scss +++ b/app/assets/stylesheets/pages/runners.scss @@ -45,8 +45,7 @@ color: $gl-text-color-secondary; } - .fa-pause, - .fa-play { + .fa-pause { font-size: 11px; } } diff --git a/app/assets/stylesheets/pages/service_desk.scss b/app/assets/stylesheets/pages/service_desk.scss new file mode 100644 index 00000000000..34ab5eb1b74 --- /dev/null +++ b/app/assets/stylesheets/pages/service_desk.scss @@ -0,0 +1,7 @@ +.service-desk-issues { + .non-empty-state { + text-align: left; + padding-bottom: $gl-padding-top; + border-bottom: 1px solid $border-color; + } +} diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index d26c07ce51b..f1df9099d82 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -347,7 +347,7 @@ .btn-clipboard { background-color: $white; - border: 1px solid $gray-200; + border: 1px solid $gray-100; } .deploy-token-help-block { diff --git a/app/assets/stylesheets/pages/sherlock.scss b/app/assets/stylesheets/pages/sherlock.scss index 2bf0bedb1f5..55b0b5295af 100644 --- a/app/assets/stylesheets/pages/sherlock.scss +++ b/app/assets/stylesheets/pages/sherlock.scss @@ -13,10 +13,8 @@ table .sherlock-code { } .sherlock-line-samples-table { - margin-bottom: 0 !important; - - thead tr th, - tbody tr td { + thead th, + tbody td { font-size: 13px !important; text-align: right; padding: 0 10px !important; diff --git a/app/assets/stylesheets/pages/wiki.scss b/app/assets/stylesheets/pages/wiki.scss index 640968ff678..8c4bfdf68cc 100644 --- a/app/assets/stylesheets/pages/wiki.scss +++ b/app/assets/stylesheets/pages/wiki.scss @@ -147,3 +147,7 @@ ul.wiki-pages-list.content-list { } } } + +.empty-state-wiki .text-content { + max-width: 490px; // Widen to allow for the Confluence button +} diff --git a/app/assets/stylesheets/performance_bar.scss b/app/assets/stylesheets/performance_bar.scss index 4eef4d361a1..daeab80d373 100644 --- a/app/assets/stylesheets/performance_bar.scss +++ b/app/assets/stylesheets/performance_bar.scss @@ -61,7 +61,7 @@ padding: 4px 6px; font-family: Consolas, 'Liberation Mono', Courier, monospace; line-height: 1; - color: $gl-gray-200; + color: $gl-gray-100; border-radius: 3px; box-shadow: 0 1px 0 $perf-bar-bucket-box-shadow-from, inset 0 1px 2px $perf-bar-bucket-box-shadow-to; diff --git a/app/assets/stylesheets/snippets.scss b/app/assets/stylesheets/snippets.scss index d410a16a1d9..e5d5ed0d48f 100644 --- a/app/assets/stylesheets/snippets.scss +++ b/app/assets/stylesheets/snippets.scss @@ -1,8 +1,8 @@ -@import "framework/variables"; +@import 'framework/variables'; .gitlab-embed-snippets { - @import "highlight/embedded"; - @import "framework/images"; + @import 'highlight/embedded'; + @import 'framework/images'; $border-style: 1px solid $border-color; @@ -15,6 +15,7 @@ .gl-snippet-icon { display: inline-block; + /* stylelint-disable-next-line function-url-quotes */ background: url(asset_path('ext_snippet_icons/ext_snippet_icons.png')) no-repeat; overflow: hidden; text-align: left; diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss index 1f2a7645495..e2b4d6b8e7a 100644 --- a/app/assets/stylesheets/themes/_dark.scss +++ b/app/assets/stylesheets/themes/_dark.scss @@ -103,6 +103,8 @@ $input-focus-bg: $gray-100; $input-color: $gray-900; $input-group-addon-bg: $gray-900; +$card-cap-bg: $gray-50; + $tooltip-bg: $gray-800; $tooltip-color: $gray-10; @@ -115,6 +117,14 @@ $secondary: $gray-600; $issues-today-bg: #333838; $issues-today-border: #333a40; +$yiq-text-dark: $gray-50; +$yiq-text-light: $gray-950; + +// Commit Diff Colors +$line-added-dark: $green-200; +$line-removed-dark: $red-200; + +// Misc component overrides that should live elsewhere .gl-label { filter: brightness(0.9) contrast(1.1); } diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 176d64272c2..38842ec167e 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -43,6 +43,7 @@ @for $i from 1 through 12 { #{'.tab-width-#{$i}'} { + /* stylelint-disable-next-line property-no-vendor-prefix */ -moz-tab-size: $i; tab-size: $i; } @@ -100,3 +101,23 @@ .gl-pl-7 { padding-left: $gl-spacing-scale-7; } + +.gl-transition-property-stroke-opacity { + transition-property: stroke-opacity; +} + +.gl-transition-property-stroke { + transition-property: stroke; +} + +.gl-top-66vh { + top: 66vh; +} + +// Remove when https://gitlab.com/gitlab-org/gitlab-ui/-/issues/871 +// gets fixed on GitLab UI +.gl-sm-w-auto\! { + @media (min-width: $breakpoint-sm) { + width: auto !important; + } +} diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 94c82c25357..41a6616d10c 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -227,6 +227,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController :raw_blob_request_limit, :namespace_storage_size_limit, :issues_create_limit, + :default_branch_name, disabled_oauth_sign_in_sources: [], import_sources: [], repository_storages: [], diff --git a/app/controllers/admin/clusters_controller.rb b/app/controllers/admin/clusters_controller.rb index 5b1902fad51..9a642e53d86 100644 --- a/app/controllers/admin/clusters_controller.rb +++ b/app/controllers/admin/clusters_controller.rb @@ -10,6 +10,11 @@ class Admin::ClustersController < Clusters::ClustersController def clusterable @clusterable ||= InstanceClusterablePresenter.fabricate(Clusters::Instance.new, current_user: current_user) end -end -Admin::ClustersController.prepend_if_ee('EE::Admin::ClustersController') + def metrics_dashboard_params + { + cluster: cluster, + cluster_type: :admin + } + end +end diff --git a/app/controllers/admin/jobs_controller.rb b/app/controllers/admin/jobs_controller.rb index a3a18a115e9..7b50a45a9cd 100644 --- a/app/controllers/admin/jobs_controller.rb +++ b/app/controllers/admin/jobs_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Admin::JobsController < Admin::ApplicationController + BUILDS_PER_PAGE = 30 + def index # We need all builds for tabs counters @all_builds = Ci::JobsFinder.new(current_user: current_user).execute @@ -8,7 +10,7 @@ class Admin::JobsController < Admin::ApplicationController @scope = params[:scope] @builds = Ci::JobsFinder.new(current_user: current_user, params: params).execute @builds = @builds.eager_load_everything - @builds = @builds.page(params[:page]).per(30) + @builds = @builds.page(params[:page]).per(BUILDS_PER_PAGE).without_count end def cancel_all diff --git a/app/controllers/admin/services_controller.rb b/app/controllers/admin/services_controller.rb index 08ef992e604..e0137accd2d 100644 --- a/app/controllers/admin/services_controller.rb +++ b/app/controllers/admin/services_controller.rb @@ -4,13 +4,18 @@ class Admin::ServicesController < Admin::ApplicationController include ServiceParams before_action :service, only: [:edit, :update] + before_action :whitelist_query_limiting, only: [:index] + before_action only: :edit do + push_frontend_feature_flag(:integration_form_refactor, default_enabled: true) + end def index @services = Service.find_or_create_templates.sort_by(&:title) + @existing_instance_types = Service.instances.pluck(:type) # rubocop: disable CodeReuse/ActiveRecord end def edit - unless service.present? + if service.nil? || Service.instance_exists_for?(service.type) redirect_to admin_application_settings_services_path, alert: "Service is unknown or it doesn't exist" end @@ -34,4 +39,8 @@ class Admin::ServicesController < Admin::ApplicationController @service ||= Service.find_by(id: params[:id], template: true) end # rubocop: enable CodeReuse/ActiveRecord + + def whitelist_query_limiting + Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab/-/issues/220357') + end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 79a164a5574..2595b646964 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -22,6 +22,7 @@ class ApplicationController < ActionController::Base include Impersonation include Gitlab::Logging::CloudflareHelper include Gitlab::Utils::StrongMemoize + include ControllerWithFeatureCategory before_action :authenticate_user!, except: [:route_not_found] before_action :enforce_terms!, if: :should_enforce_terms? @@ -305,7 +306,7 @@ class ApplicationController < ActionController::Base return if session[:impersonator_id] || !current_user&.allow_password_authentication? if current_user&.password_expired? - return redirect_to new_profile_password_path + redirect_to new_profile_password_path end end @@ -329,13 +330,6 @@ class ApplicationController < ActionController::Base end end - def event_filter - @event_filter ||= - EventFilter.new(params[:event_filter].presence || cookies[:event_filter]).tap do |new_event_filter| - cookies[:event_filter] = new_event_filter.filter - end - end - # JSON for infinite scroll via Pager object def pager_json(partial, count, locals = {}) html = render_to_string( @@ -370,7 +364,7 @@ class ApplicationController < ActionController::Base def require_email if current_user && current_user.temp_oauth_email? && session[:impersonator_id].nil? - return redirect_to profile_path, notice: _('Please complete your profile with email address') + redirect_to profile_path, notice: _('Please complete your profile with email address') end end diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index 0df201ab506..99fa17e202a 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -4,10 +4,6 @@ class AutocompleteController < ApplicationController skip_before_action :authenticate_user!, only: [:users, :award_emojis, :merge_request_target_branches] def users - project = Autocomplete::ProjectFinder - .new(current_user, params) - .execute - group = Autocomplete::GroupFinder .new(current_user, project, params) .execute @@ -50,8 +46,20 @@ class AutocompleteController < ApplicationController end end + def deploy_keys_with_owners + deploy_keys = DeployKeys::CollectKeysService.new(project, current_user).execute + + render json: DeployKeySerializer.new.represent(deploy_keys, { with_owner: true, user: current_user }) + end + private + def project + @project ||= Autocomplete::ProjectFinder + .new(current_user, params) + .execute + end + def target_branch_params params.permit(:group_id, :project_id).select { |_, v| v.present? } end diff --git a/app/controllers/chaos_controller.rb b/app/controllers/chaos_controller.rb index ac008165c16..e0d1f313fc7 100644 --- a/app/controllers/chaos_controller.rb +++ b/app/controllers/chaos_controller.rb @@ -45,7 +45,6 @@ class ChaosController < ActionController::Base unless Devise.secure_compare(chaos_secret_configured, chaos_secret_request) render plain: "To experience chaos, please set a valid `X-Chaos-Secret` header or `token` param", status: :unauthorized - return end end diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb index 46dec5f3287..2e8b3d764ca 100644 --- a/app/controllers/clusters/clusters_controller.rb +++ b/app/controllers/clusters/clusters_controller.rb @@ -2,6 +2,8 @@ class Clusters::ClustersController < Clusters::BaseController include RoutableActions + include Metrics::Dashboard::PrometheusApiProxy + include MetricsDashboard before_action :cluster, only: [:cluster_status, :show, :update, :destroy, :clear_cache] before_action :generate_gcp_authorize_url, only: [:new] @@ -290,6 +292,29 @@ class Clusters::ClustersController < Clusters::BaseController @gcp_cluster = cluster.present(current_user: current_user) end + def proxyable + cluster.cluster + end + + # During first iteration of dashboard variables implementation + # cluster health case was omitted. Existing service for now is tied to + # environment, which is not always present for cluster health dashboard. + # It is planned to break coupling to environment https://gitlab.com/gitlab-org/gitlab/-/issues/213833. + # It is also planned to move cluster health to metrics dashboard section https://gitlab.com/gitlab-org/gitlab/-/issues/220214 + # but for now I've used dummy class to stub variable substitution service, as there are no variables + # in cluster health dashboard + def proxy_variable_substitution_service + @empty_service ||= Class.new(BaseService) do + def initialize(proxyable, params) + @proxyable, @params = proxyable, params + end + + def execute + success(params: @params) + end + end + end + def user_cluster cluster = Clusters::BuildService.new(clusterable.subject).execute cluster.build_platform_kubernetes diff --git a/app/controllers/concerns/controller_with_feature_category.rb b/app/controllers/concerns/controller_with_feature_category.rb new file mode 100644 index 00000000000..f8985cf0950 --- /dev/null +++ b/app/controllers/concerns/controller_with_feature_category.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module ControllerWithFeatureCategory + extend ActiveSupport::Concern + include Gitlab::ClassAttributes + + class_methods do + def feature_category(category, config = {}) + validate_config!(config) + + category_config = Config.new(category, config[:only], config[:except], config[:if], config[:unless]) + # Add the config to the beginning. That way, the last defined one takes precedence. + feature_category_configuration.unshift(category_config) + end + + def feature_category_for_action(action) + category_config = feature_category_configuration.find { |config| config.matches?(action) } + + category_config&.category || superclass_feature_category_for_action(action) + end + + private + + def validate_config!(config) + invalid_keys = config.keys - [:only, :except, :if, :unless] + if invalid_keys.any? + raise ArgumentError, "unknown arguments: #{invalid_keys} " + end + + if config.key?(:only) && config.key?(:except) + raise ArgumentError, "cannot configure both `only` and `except`" + end + end + + def feature_category_configuration + class_attributes[:feature_category_config] ||= [] + end + + def superclass_feature_category_for_action(action) + return unless superclass.respond_to?(:feature_category_for_action) + + superclass.feature_category_for_action(action) + end + end +end diff --git a/app/controllers/concerns/controller_with_feature_category/config.rb b/app/controllers/concerns/controller_with_feature_category/config.rb new file mode 100644 index 00000000000..624691ee4f6 --- /dev/null +++ b/app/controllers/concerns/controller_with_feature_category/config.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module ControllerWithFeatureCategory + class Config + attr_reader :category + + def initialize(category, only, except, if_proc, unless_proc) + @category = category.to_sym + @only, @except = only&.map(&:to_s), except&.map(&:to_s) + @if_proc, @unless_proc = if_proc, unless_proc + end + + def matches?(action) + included?(action) && !excluded?(action) && + if_proc?(action) && !unless_proc?(action) + end + + private + + attr_reader :only, :except, :if_proc, :unless_proc + + def if_proc?(action) + if_proc.nil? || if_proc.call(action) + end + + def unless_proc?(action) + unless_proc.present? && unless_proc.call(action) + end + + def included?(action) + only.nil? || only.include?(action) + end + + def excluded?(action) + except.present? && except.include?(action) + end + end +end diff --git a/app/controllers/concerns/filters_events.rb b/app/controllers/concerns/filters_events.rb new file mode 100644 index 00000000000..c82d0318fd3 --- /dev/null +++ b/app/controllers/concerns/filters_events.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module FiltersEvents + def event_filter + @event_filter ||= new_event_filter.tap { |ef| cookies[:event_filter] = ef.filter } + end + + private + + def new_event_filter + active_filter = params[:event_filter].presence || cookies[:event_filter] + EventFilter.new(active_filter) + end +end diff --git a/app/controllers/concerns/integrations_actions.rb b/app/controllers/concerns/integrations_actions.rb index cc9db7936e8..46febc44807 100644 --- a/app/controllers/concerns/integrations_actions.rb +++ b/app/controllers/concerns/integrations_actions.rb @@ -8,6 +8,9 @@ module IntegrationsActions before_action :not_found, unless: :integrations_enabled? before_action :integration, only: [:edit, :update, :test] + before_action only: :edit do + push_frontend_feature_flag(:integration_form_refactor, default_enabled: true) + end end def edit @@ -51,9 +54,8 @@ module IntegrationsActions end def integration - # Using instance variable `@service` still required as it's used in ServiceParams - # and app/views/shared/_service_settings.html.haml. Should be removed once - # those 2 are refactored to use `@integration`. + # Using instance variable `@service` still required as it's used in ServiceParams. + # Should be removed once that is refactored to use `@integration`. @integration = @service ||= find_or_initialize_integration(params[:id]) # rubocop:disable Gitlab/ModuleWithInstanceVariables end diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 98fa8202e25..c4dbce00593 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -110,9 +110,13 @@ module IssuableActions def bulk_update result = Issuable::BulkUpdateService.new(parent, current_user, bulk_update_params).execute(resource_name) - quantity = result[:count] - render json: { notice: "#{quantity} #{resource_name.pluralize(quantity)} updated" } + if result.success? + quantity = result.payload[:count] + render json: { notice: "#{quantity} #{resource_name.pluralize(quantity)} updated" } + elsif result.error? + render json: { errors: result.message }, status: result.http_status + end end # rubocop:disable CodeReuse/ActiveRecord @@ -193,13 +197,13 @@ module IssuableActions def authorize_destroy_issuable! unless can?(current_user, :"destroy_#{issuable.to_ability_name}", issuable) - return access_denied! + access_denied! end end def authorize_admin_issuable! unless can?(current_user, :"admin_#{resource_name}", parent) - return access_denied! + access_denied! end end diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index 9ef067e8797..4f61e5ed711 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -81,34 +81,36 @@ module IssuableCollections # rubocop:disable Gitlab/ModuleWithInstanceVariables def finder_options - params[:state] = default_state if params[:state].blank? - - options = { - scope: params[:scope], - state: params[:state], - confidential: Gitlab::Utils.to_boolean(params[:confidential]), - sort: set_sort_order - } - - # Used by view to highlight active option - @sort = options[:sort] - - # When a user looks for an exact iid, we do not filter by search but only by iid - if params[:search] =~ /^#(?<iid>\d+)\z/ - options[:iids] = Regexp.last_match[:iid] - params[:search] = nil + strong_memoize(:finder_options) do + params[:state] = default_state if params[:state].blank? + + options = { + scope: params[:scope], + state: params[:state], + confidential: Gitlab::Utils.to_boolean(params[:confidential]), + sort: set_sort_order + } + + # Used by view to highlight active option + @sort = options[:sort] + + # When a user looks for an exact iid, we do not filter by search but only by iid + if params[:search] =~ /^#(?<iid>\d+)\z/ + options[:iids] = Regexp.last_match[:iid] + params[:search] = nil + end + + if @project + options[:project_id] = @project.id + options[:attempt_project_search_optimizations] = true + elsif @group + options[:group_id] = @group.id + options[:include_subgroups] = true + options[:attempt_group_search_optimizations] = true + end + + params.permit(finder_type.valid_params).merge(options) end - - if @project - options[:project_id] = @project.id - options[:attempt_project_search_optimizations] = true - elsif @group - options[:group_id] = @group.id - options[:include_subgroups] = true - options[:attempt_group_search_optimizations] = true - end - - params.permit(finder_type.valid_params).merge(options) end # rubocop:enable Gitlab/ModuleWithInstanceVariables @@ -147,7 +149,10 @@ module IssuableCollections when 'Issue' common_attributes + [:project, project: :namespace] when 'MergeRequest' - common_attributes + [:target_project, :latest_merge_request_diff, source_project: :route, head_pipeline: :project, target_project: :namespace] + common_attributes + [ + :target_project, :latest_merge_request_diff, :approvals, :approved_by_users, + source_project: :route, head_pipeline: :project, target_project: :namespace + ] end end # rubocop:enable Gitlab/ModuleWithInstanceVariables diff --git a/app/controllers/concerns/known_sign_in.rb b/app/controllers/concerns/known_sign_in.rb index c0b9605de58..cacc7e4628f 100644 --- a/app/controllers/concerns/known_sign_in.rb +++ b/app/controllers/concerns/known_sign_in.rb @@ -2,19 +2,34 @@ module KnownSignIn include Gitlab::Utils::StrongMemoize + include CookiesHelper + + KNOWN_SIGN_IN_COOKIE = :known_sign_in + KNOWN_SIGN_IN_COOKIE_EXPIRY = 14.days private def verify_known_sign_in - return unless current_user + return unless Gitlab::CurrentSettings.notify_on_unknown_sign_in? && current_user + + notify_user unless known_device? || known_remote_ip? - notify_user unless known_remote_ip? + update_cookie end def known_remote_ip? known_ip_addresses.include?(request.remote_ip) end + def known_device? + cookies.encrypted[KNOWN_SIGN_IN_COOKIE] == current_user.id + end + + def update_cookie + set_secure_cookie(KNOWN_SIGN_IN_COOKIE, current_user.id, + type: COOKIE_TYPE_ENCRYPTED, httponly: true, expires: KNOWN_SIGN_IN_COOKIE_EXPIRY) + end + def sessions strong_memoize(:session) do ActiveSession.list(current_user).reject(&:is_impersonated) diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb index 4ab02005b45..8c7f156f7f8 100644 --- a/app/controllers/concerns/membership_actions.rb +++ b/app/controllers/concerns/membership_actions.rb @@ -31,7 +31,10 @@ module MembershipActions def destroy member = membershipable.members_and_requesters.find(params[:id]) - Members::DestroyService.new(current_user).execute(member) + # !! is used in case unassign_issuables contains empty string which would result in nil + unassign_issuables = !!ActiveRecord::Type::Boolean.new.cast(params.delete(:unassign_issuables)) + + Members::DestroyService.new(current_user).execute(member, unassign_issuables: unassign_issuables) respond_to do |format| format.html do diff --git a/app/controllers/concerns/metrics/dashboard/prometheus_api_proxy.rb b/app/controllers/concerns/metrics/dashboard/prometheus_api_proxy.rb new file mode 100644 index 00000000000..e0e3f628cc5 --- /dev/null +++ b/app/controllers/concerns/metrics/dashboard/prometheus_api_proxy.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Metrics::Dashboard::PrometheusApiProxy + extend ActiveSupport::Concern + include RenderServiceResults + + included do + before_action :authorize_read_prometheus!, only: [:prometheus_proxy] + end + + def prometheus_proxy + variable_substitution_result = + proxy_variable_substitution_service.new(proxyable, permit_params).execute + + if variable_substitution_result[:status] == :error + return error_response(variable_substitution_result) + end + + prometheus_result = Prometheus::ProxyService.new( + proxyable, + proxy_method, + proxy_path, + variable_substitution_result[:params] + ).execute + + return continue_polling_response if prometheus_result.nil? + return error_response(prometheus_result) if prometheus_result[:status] == :error + + success_response(prometheus_result) + end + + private + + def proxyable + raise NotImplementedError, "#{self.class} must implement method: #{__callee__}" + end + + def proxy_variable_substitution_service + raise NotImplementedError, "#{self.class} must implement method: #{__callee__}" + end + + def permit_params + params.permit! + end + + def proxy_method + request.method + end + + def proxy_path + params[:proxy_path] + end +end diff --git a/app/controllers/concerns/metrics_dashboard.rb b/app/controllers/concerns/metrics_dashboard.rb index 1aea0e294a5..28d0692d748 100644 --- a/app/controllers/concerns/metrics_dashboard.rb +++ b/app/controllers/concerns/metrics_dashboard.rb @@ -13,7 +13,7 @@ module MetricsDashboard result = dashboard_finder.find( project_for_dashboard, current_user, - metrics_dashboard_params.to_h.symbolize_keys + decoded_params ) if result @@ -41,7 +41,7 @@ module MetricsDashboard end def amend_dashboard(dashboard) - project_dashboard = project_for_dashboard && !dashboard[:system_dashboard] + project_dashboard = project_for_dashboard && !dashboard[:out_of_the_box_dashboard] dashboard[:can_edit] = project_dashboard ? can_edit?(dashboard) : false dashboard[:project_blob_path] = project_dashboard ? dashboard_project_blob_path(dashboard) : nil @@ -114,4 +114,14 @@ module MetricsDashboard json: result.slice(:all_dashboards, :message, :status) } end + + def decoded_params + params = metrics_dashboard_params + + if params[:dashboard_path] + params[:dashboard_path] = CGI.unescape(params[:dashboard_path]) + end + + params + end end diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index d3dfb1813e4..f4fc7decb60 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -5,6 +5,11 @@ module NotesActions include Gitlab::Utils::StrongMemoize extend ActiveSupport::Concern + # last_fetched_at is an integer number of microseconds, which is the same + # precision as PostgreSQL "timestamp" fields. It's important for them to have + # identical precision for accurate pagination + MICROSECOND = 1_000_000 + included do before_action :set_polling_interval_header, only: [:index] before_action :require_noteable!, only: [:index, :create] @@ -13,30 +18,20 @@ module NotesActions end def index - notes_json = { notes: [], last_fetched_at: Time.current.to_i } - - notes = notes_finder - .execute - .inc_relations_for_view - - if notes_filter != UserPreference::NOTES_FILTERS[:only_comments] - notes = - ResourceEvents::MergeIntoNotesService - .new(noteable, current_user, last_fetched_at: last_fetched_at) - .execute(notes) - end - + notes, meta = gather_notes notes = prepare_notes_for_rendering(notes) notes = notes.select { |n| n.readable_by?(current_user) } - - notes_json[:notes] = + notes = if use_note_serializer? note_serializer.represent(notes) else notes.map { |note| note_json(note) } end - render json: notes_json + # We know there's more data, so tell the frontend to poll again after 1ms + set_polling_interval_header(interval: 1) if meta[:more] + + render json: meta.merge(notes: notes) end # rubocop:disable Gitlab/ModuleWithInstanceVariables @@ -101,6 +96,48 @@ module NotesActions private + # Lower bound (last_fetched_at as specified in the request) is already set in + # the finder. Here, we select between returning all notes since then, or a + # page's worth of notes. + def gather_notes + if Feature.enabled?(:paginated_notes, project) + gather_some_notes + else + gather_all_notes + end + end + + def gather_all_notes + now = Time.current + notes = merge_resource_events(notes_finder.execute.inc_relations_for_view) + + [notes, { last_fetched_at: (now.to_i * MICROSECOND) + now.usec }] + end + + def gather_some_notes + paginator = Gitlab::UpdatedNotesPaginator.new( + notes_finder.execute.inc_relations_for_view, + last_fetched_at: last_fetched_at + ) + + notes = paginator.notes + + # Fetch all the synthetic notes in the same time range as the real notes. + # Although we don't limit the number, their text is under our control so + # should be fairly cheap to process. + notes = merge_resource_events(notes, fetch_until: paginator.next_fetched_at) + + [notes, paginator.metadata] + end + + def merge_resource_events(notes, fetch_until: nil) + return notes if notes_filter == UserPreference::NOTES_FILTERS[:only_comments] + + ResourceEvents::MergeIntoNotesService + .new(noteable, current_user, last_fetched_at: last_fetched_at, fetch_until: fetch_until) + .execute(notes) + end + def note_html(note) render_to_string( "shared/notes/_note", @@ -226,11 +263,11 @@ module NotesActions end def update_note_params - params.require(:note).permit(:note) + params.require(:note).permit(:note, :position) end - def set_polling_interval_header - Gitlab::PollingInterval.set_header(response, interval: 6_000) + def set_polling_interval_header(interval: 6000) + Gitlab::PollingInterval.set_header(response, interval: interval) end def noteable @@ -242,7 +279,14 @@ module NotesActions end def last_fetched_at - request.headers['X-Last-Fetched-At'] + strong_memoize(:last_fetched_at) do + microseconds = request.headers['X-Last-Fetched-At'].to_i + + seconds = microseconds / MICROSECOND + frac = microseconds % MICROSECOND + + Time.zone.at(seconds, frac) + end end def notes_filter diff --git a/app/controllers/concerns/renders_member_access.rb b/app/controllers/concerns/renders_member_access.rb index 955ac1a1bc8..745830181c1 100644 --- a/app/controllers/concerns/renders_member_access.rb +++ b/app/controllers/concerns/renders_member_access.rb @@ -7,12 +7,6 @@ module RendersMemberAccess groups end - def prepare_projects_for_rendering(projects) - preload_max_member_access_for_collection(Project, projects) - - projects - end - private # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/controllers/concerns/renders_projects_list.rb b/app/controllers/concerns/renders_projects_list.rb new file mode 100644 index 00000000000..be45c676ad6 --- /dev/null +++ b/app/controllers/concerns/renders_projects_list.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module RendersProjectsList + def prepare_projects_for_rendering(projects) + preload_max_member_access_for_collection(Project, projects) + + # Call the forks count method on every project, so the BatchLoader would load them all at + # once when the entities are rendered + projects.each(&:forks_count) + + projects + end +end diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb index e78fa8f8250..a19c43a227a 100644 --- a/app/controllers/concerns/service_params.rb +++ b/app/controllers/concerns/service_params.rb @@ -22,8 +22,8 @@ module ServiceParams :comment_on_event_enabled, :comment_detail, :confidential_issues_events, + :confluence_url, :default_irc_uri, - :description, :device, :disable_diffs, :drone_url, @@ -31,6 +31,7 @@ module ServiceParams :external_wiki_url, :google_iap_service_account_json, :google_iap_audience_client_id, + :inherit_from_id, # We're using `issues_events` and `merge_requests_events` # in the view so we still need to explicitly state them # here. `Service#event_names` would only give @@ -61,7 +62,6 @@ module ServiceParams :sound, :subdomain, :teamcity_url, - :title, :token, :type, :url, diff --git a/app/controllers/concerns/snippets/blobs_actions.rb b/app/controllers/concerns/snippets/blobs_actions.rb new file mode 100644 index 00000000000..db56ce8f193 --- /dev/null +++ b/app/controllers/concerns/snippets/blobs_actions.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Snippets::BlobsActions + extend ActiveSupport::Concern + + include Gitlab::Utils::StrongMemoize + include ExtractsRef + include Snippets::SendBlob + + included do + before_action :authorize_read_snippet!, only: [:raw] + before_action :ensure_repository + before_action :ensure_blob + end + + def raw + send_snippet_blob(snippet, blob) + end + + private + + def repository_container + snippet + end + + # rubocop:disable Gitlab/ModuleWithInstanceVariables + def blob + strong_memoize(:blob) do + assign_ref_vars + + next unless @commit + + @repo.blob_at(@commit.id, @path) + end + end + # rubocop:enable Gitlab/ModuleWithInstanceVariables + + def ensure_blob + render_404 unless blob + end + + def ensure_repository + unless snippet.repo_exists? + Gitlab::AppLogger.error(message: "Snippet raw blob attempt with no repo", snippet: snippet.id) + + respond_422 + end + end + + def snippet_id + params[:snippet_id] + end +end diff --git a/app/controllers/concerns/snippets/send_blob.rb b/app/controllers/concerns/snippets/send_blob.rb new file mode 100644 index 00000000000..4f432430aaa --- /dev/null +++ b/app/controllers/concerns/snippets/send_blob.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Snippets::SendBlob + include SendsBlob + + def send_snippet_blob(snippet, blob) + workhorse_set_content_type! + + send_blob( + snippet.repository, + blob, + inline: content_disposition == 'inline', + allow_caching: snippet.public? + ) + end + + private + + def content_disposition + @disposition ||= params[:inline] == 'false' ? 'attachment' : 'inline' + end +end diff --git a/app/controllers/concerns/snippets_actions.rb b/app/controllers/concerns/snippets_actions.rb index 51fc12398d9..048b18c5c61 100644 --- a/app/controllers/concerns/snippets_actions.rb +++ b/app/controllers/concerns/snippets_actions.rb @@ -2,11 +2,13 @@ module SnippetsActions extend ActiveSupport::Concern - include SendsBlob + include RendersNotes include RendersBlob include PaginatedCollection include Gitlab::NoteableMetadata + include Snippets::SendBlob + include SnippetsSort included do skip_before_action :verify_authenticity_token, @@ -25,6 +27,10 @@ module SnippetsActions render 'edit' end + # This endpoint is being replaced by Snippets::BlobController#raw + # Support for old raw links will be maintainted via this action but + # it will only return the first blob found, + # see: https://gitlab.com/gitlab-org/gitlab/-/issues/217775 def raw workhorse_set_content_type! @@ -39,12 +45,7 @@ module SnippetsActions filename: Snippet.sanitized_file_name(blob.name) ) else - send_blob( - snippet.repository, - blob, - inline: content_disposition == 'inline', - allow_caching: snippet.public? - ) + send_snippet_blob(snippet, blob) end end @@ -106,10 +107,6 @@ module SnippetsActions private - def content_disposition - @disposition ||= params[:inline] == 'false' ? 'attachment' : 'inline' - end - # rubocop:disable Gitlab/ModuleWithInstanceVariables def blob return unless snippet diff --git a/app/controllers/concerns/snippets_sort.rb b/app/controllers/concerns/snippets_sort.rb new file mode 100644 index 00000000000..f122c843af7 --- /dev/null +++ b/app/controllers/concerns/snippets_sort.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module SnippetsSort + extend ActiveSupport::Concern + + def sort_param + params[:sort].presence || 'updated_desc' + end +end diff --git a/app/controllers/concerns/wiki_actions.rb b/app/controllers/concerns/wiki_actions.rb index 7eef12fadfe..a5182000f5b 100644 --- a/app/controllers/concerns/wiki_actions.rb +++ b/app/controllers/concerns/wiki_actions.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true module WikiActions + include DiffHelper + include PreviewMarkdown include SendsBlob include Gitlab::Utils::StrongMemoize extend ActiveSupport::Concern @@ -11,16 +13,23 @@ module WikiActions before_action :authorize_admin_wiki!, only: :destroy before_action :wiki - before_action :page, only: [:show, :edit, :update, :history, :destroy] + before_action :page, only: [:show, :edit, :update, :history, :destroy, :diff] before_action :load_sidebar, except: [:pages] + before_action :set_content_class before_action only: [:show, :edit, :update] do @valid_encoding = valid_encoding? end before_action only: [:edit, :update], unless: :valid_encoding? do - redirect_to wiki_page_path(wiki, page) + if params[:id].present? + redirect_to wiki_page_path(wiki, page || params[:id]) + else + redirect_to wiki_path(wiki) + end end + + helper_method :view_file_button, :diff_file_html_data end def new @@ -133,6 +142,19 @@ module WikiActions # rubocop:enable Gitlab/ModuleWithInstanceVariables # rubocop:disable Gitlab/ModuleWithInstanceVariables + def diff + return render_404 unless page + + apply_diff_view_cookie! + + @diffs = page.diffs(diff_options) + @diff_notes_disabled = true + + render 'shared/wikis/diff' + end + # rubocop:disable Gitlab/ModuleWithInstanceVariables + + # rubocop:disable Gitlab/ModuleWithInstanceVariables def destroy WikiPages::DestroyService.new(container: container, current_user: current_user).execute(page) @@ -203,7 +225,7 @@ module WikiActions def page_params keys = [:id] - keys << :version_id if params[:action] == 'show' + keys << :version_id if %w[show diff].include?(params[:action]) params.values_at(*keys) end @@ -229,4 +251,25 @@ module WikiActions wiki.repository.blob_at(commit.id, params[:id]) end end + + def set_content_class + @content_class = 'limit-container-width' unless fluid_layout # rubocop:disable Gitlab/ModuleWithInstanceVariables + end + + # Override CommitsHelper#view_file_button + def view_file_button(commit_sha, *args) + path = wiki_page_path(wiki, page, version_id: page.version.id) + + helpers.link_to(path, class: 'btn') do + helpers.raw(_('View page @ ')) + helpers.content_tag(:span, Commit.truncate_sha(commit_sha), class: 'commit-sha') + end + end + + # Override DiffHelper#diff_file_html_data + def diff_file_html_data(_project, _diff_file_path, diff_commit_id) + { + blob_diff_path: wiki_page_path(wiki, page, action: :diff, version_id: diff_commit_id), + view: diff_view + } + end end diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index 25c48fadf49..ad64b6c4f94 100644 --- a/app/controllers/dashboard/projects_controller.rb +++ b/app/controllers/dashboard/projects_controller.rb @@ -3,9 +3,10 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController include ParamsBackwardCompatibility include RendersMemberAccess - include OnboardingExperimentHelper + include RendersProjectsList include SortingHelper include SortingPreference + include FiltersEvents prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) } before_action :set_non_archived_param diff --git a/app/controllers/dashboard/snippets_controller.rb b/app/controllers/dashboard/snippets_controller.rb index aa09fcdbe61..a8ca3dbd0e7 100644 --- a/app/controllers/dashboard/snippets_controller.rb +++ b/app/controllers/dashboard/snippets_controller.rb @@ -3,6 +3,7 @@ class Dashboard::SnippetsController < Dashboard::ApplicationController include PaginatedCollection include Gitlab::NoteableMetadata + include SnippetsSort skip_cross_project_access_check :index @@ -11,7 +12,7 @@ class Dashboard::SnippetsController < Dashboard::ApplicationController .new(current_user, author: current_user) .execute - @snippets = SnippetsFinder.new(current_user, author: current_user, scope: params[:scope]) + @snippets = SnippetsFinder.new(current_user, author: current_user, scope: params[:scope], sort: sort_param) .execute .page(params[:page]) .inc_author diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb index 8a8064b24c2..db40b0bed77 100644 --- a/app/controllers/dashboard/todos_controller.rb +++ b/app/controllers/dashboard/todos_controller.rb @@ -3,11 +3,14 @@ class Dashboard::TodosController < Dashboard::ApplicationController include ActionView::Helpers::NumberHelper include PaginatedCollection + include Analytics::UniqueVisitsHelper before_action :authorize_read_project!, only: :index before_action :authorize_read_group!, only: :index before_action :find_todos, only: [:index, :destroy_all] + track_unique_visits :index, target_id: 'u_analytics_todos' + def index @sort = params[:sort] @todos = @todos.page(params[:page]) diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index dd9e6488bc5..07cc31fb7d3 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -2,6 +2,7 @@ class DashboardController < Dashboard::ApplicationController include IssuableCollectionsAction + include FiltersEvents prepend_before_action(only: [:issues]) { authenticate_sessionless_user!(:rss) } prepend_before_action(only: [:issues_calendar]) { authenticate_sessionless_user!(:ics) } diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb index 705a586d614..f1f41e67a4c 100644 --- a/app/controllers/explore/projects_controller.rb +++ b/app/controllers/explore/projects_controller.rb @@ -4,6 +4,7 @@ class Explore::ProjectsController < Explore::ApplicationController include PageLimiter include ParamsBackwardCompatibility include RendersMemberAccess + include RendersProjectsList include SortingHelper include SortingPreference diff --git a/app/controllers/groups/application_controller.rb b/app/controllers/groups/application_controller.rb index 84c8d7ada43..9c2e361e92f 100644 --- a/app/controllers/groups/application_controller.rb +++ b/app/controllers/groups/application_controller.rb @@ -30,25 +30,25 @@ class Groups::ApplicationController < ApplicationController def authorize_admin_group! unless can?(current_user, :admin_group, group) - return render_404 + render_404 end end def authorize_create_deploy_token! unless can?(current_user, :create_deploy_token, group) - return render_404 + render_404 end end def authorize_destroy_deploy_token! unless can?(current_user, :destroy_deploy_token, group) - return render_404 + render_404 end end def authorize_admin_group_member! unless can?(current_user, :admin_group_member, group) - return render_403 + render_403 end end diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb index c618ee8566a..23d4f0d24e9 100644 --- a/app/controllers/groups/boards_controller.rb +++ b/app/controllers/groups/boards_controller.rb @@ -8,7 +8,6 @@ class Groups::BoardsController < Groups::ApplicationController before_action :assign_endpoint_vars before_action do push_frontend_feature_flag(:multi_select_board, default_enabled: true) - push_frontend_feature_flag(:sfc_issue_boards, default_enabled: true) push_frontend_feature_flag(:boards_with_swimlanes, group, default_enabled: false) end diff --git a/app/controllers/groups/clusters_controller.rb b/app/controllers/groups/clusters_controller.rb index 2165dee45fb..33bfc24885f 100644 --- a/app/controllers/groups/clusters_controller.rb +++ b/app/controllers/groups/clusters_controller.rb @@ -17,6 +17,12 @@ class Groups::ClustersController < Clusters::ClustersController def group @group ||= find_routable!(Group, params[:group_id] || params[:id]) end -end -Groups::ClustersController.prepend_if_ee('EE::Groups::ClustersController') + def metrics_dashboard_params + { + cluster: cluster, + cluster_type: :group, + group: group + } + end +end diff --git a/app/controllers/groups/runners_controller.rb b/app/controllers/groups/runners_controller.rb index 635c248024e..edebffe2912 100644 --- a/app/controllers/groups/runners_controller.rb +++ b/app/controllers/groups/runners_controller.rb @@ -23,9 +23,13 @@ class Groups::RunnersController < Groups::ApplicationController end def destroy - @runner.destroy + if @runner.belongs_to_more_than_one_project? + redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), status: :found, alert: _('Runner was not deleted because it is assigned to multiple projects.') + else + @runner.destroy - redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), status: :found + redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), status: :found + end end def resume @@ -47,7 +51,9 @@ class Groups::RunnersController < Groups::ApplicationController private def runner - @runner ||= @group.runners.find(params[:id]) + @runner ||= Ci::RunnersFinder.new(current_user: current_user, group: @group, params: {}).execute + .except(:limit, :offset) + .find(params[:id]) end def runner_params diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb index 18f336eae78..bf3a38ce57b 100644 --- a/app/controllers/groups/settings/ci_cd_controller.rb +++ b/app/controllers/groups/settings/ci_cd_controller.rb @@ -11,7 +11,15 @@ module Groups end before_action :define_variables, only: [:show] + NUMBER_OF_RUNNERS_PER_PAGE = 4 + def show + runners_finder = Ci::RunnersFinder.new(current_user: current_user, group: @group, params: params) + # We need all runners for count + @all_group_runners = runners_finder.execute.except(:limit, :offset) + @group_runners = runners_finder.execute.page(params[:page]).per(NUMBER_OF_RUNNERS_PER_PAGE) + + @sort = runners_finder.sort_key end def update diff --git a/app/controllers/groups/variables_controller.rb b/app/controllers/groups/variables_controller.rb index 11e3cfb01e4..02b015e8e53 100644 --- a/app/controllers/groups/variables_controller.rb +++ b/app/controllers/groups/variables_controller.rb @@ -9,7 +9,7 @@ module Groups def show respond_to do |format| format.json do - render status: :ok, json: { variables: GroupVariableSerializer.new.represent(@group.variables) } + render status: :ok, json: { variables: ::Ci::GroupVariableSerializer.new.represent(@group.variables) } end end end @@ -29,7 +29,7 @@ module Groups private def render_group_variables - render status: :ok, json: { variables: GroupVariableSerializer.new.represent(@group.variables) } + render status: :ok, json: { variables: ::Ci::GroupVariableSerializer.new.represent(@group.variables) } end def render_error diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index fba374dbb44..2162d397da3 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -7,6 +7,7 @@ class GroupsController < Groups::ApplicationController include PreviewMarkdown include RecordUserLastActivity include SendFileUpload + include FiltersEvents extend ::Gitlab::Utils::Override respond_to :html diff --git a/app/controllers/ide_controller.rb b/app/controllers/ide_controller.rb index 2bf7bdd1ae0..2c17f5b5542 100644 --- a/app/controllers/ide_controller.rb +++ b/app/controllers/ide_controller.rb @@ -8,6 +8,7 @@ class IdeController < ApplicationController before_action do push_frontend_feature_flag(:build_service_proxy) + push_frontend_feature_flag(:schema_linting) end def index diff --git a/app/controllers/import/base_controller.rb b/app/controllers/import/base_controller.rb index afdea4f7c9d..bc05030f8af 100644 --- a/app/controllers/import/base_controller.rb +++ b/app/controllers/import/base_controller.rb @@ -30,7 +30,7 @@ class Import::BaseController < ApplicationController end def incompatible_repos - [] + raise NotImplementedError end def provider_name @@ -87,15 +87,6 @@ class Import::BaseController < ApplicationController end # rubocop: enable CodeReuse/ActiveRecord - # rubocop: disable CodeReuse/ActiveRecord - def find_jobs(import_type) - current_user.created_projects - .with_import_state - .where(import_type: import_type) - .to_json(only: [:id], methods: [:import_status]) - end - # rubocop: enable CodeReuse/ActiveRecord - # deprecated: being replaced by app/services/import/base_service.rb def find_or_create_namespace(names, owner) names = params[:target_namespace].presence || names diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb index 4886aeb5e3f..0ffd9ef8bdd 100644 --- a/app/controllers/import/bitbucket_controller.rb +++ b/app/controllers/import/bitbucket_controller.rb @@ -22,23 +22,8 @@ class Import::BitbucketController < Import::BaseController redirect_to status_import_bitbucket_url end - # rubocop: disable CodeReuse/ActiveRecord def status - return super if Feature.enabled?(:new_import_ui) - - bitbucket_client = Bitbucket::Client.new(credentials) - repos = bitbucket_client.repos(filter: sanitized_filter_param) - @repos, @incompatible_repos = repos.partition { |repo| repo.valid? } - - @already_added_projects = find_already_added_projects('bitbucket') - already_added_projects_names = @already_added_projects.pluck(:import_source) - - @repos.to_a.reject! { |repo| already_added_projects_names.include?(repo.full_name) } - end - # rubocop: enable CodeReuse/ActiveRecord - - def jobs - render json: find_jobs('bitbucket') + super end def realtime_changes diff --git a/app/controllers/import/bitbucket_server_controller.rb b/app/controllers/import/bitbucket_server_controller.rb index 9aa8110257d..bee78cb3283 100644 --- a/app/controllers/import/bitbucket_server_controller.rb +++ b/app/controllers/import/bitbucket_server_controller.rb @@ -7,6 +7,7 @@ class Import::BitbucketServerController < Import::BaseController before_action :verify_bitbucket_server_import_enabled before_action :bitbucket_auth, except: [:new, :configure] + before_action :normalize_import_params, only: [:create] before_action :validate_import_params, only: [:create] rescue_from BitbucketServer::Connection::ConnectionError, with: :bitbucket_connection_error @@ -34,48 +35,25 @@ class Import::BitbucketServerController < Import::BaseController return render json: { errors: _("Project %{project_repo} could not be found") % { project_repo: "#{@project_key}/#{@repo_slug}" } }, status: :unprocessable_entity end - project_name = params[:new_name].presence || repo.name - namespace_path = params[:new_namespace].presence || current_user.username - target_namespace = find_or_create_namespace(namespace_path, current_user) + result = Import::BitbucketServerService.new(client, current_user, params).execute(credentials) - if current_user.can?(:create_projects, target_namespace) - project = Gitlab::BitbucketServerImport::ProjectCreator.new(@project_key, @repo_slug, repo, project_name, target_namespace, current_user, credentials).execute - - if project.persisted? - render json: ProjectSerializer.new.represent(project, serializer: :import) - else - render json: { errors: project_save_error(project) }, status: :unprocessable_entity - end + if result[:status] == :success + render json: ProjectSerializer.new.represent(result[:project], serializer: :import) else - render json: { errors: _('This namespace has already been taken! Please choose another one.') }, status: :unprocessable_entity + render json: { errors: result[:message] }, status: result[:http_status] end end def configure session[personal_access_token_key] = params[:personal_access_token] - session[bitbucket_server_username_key] = params[:bitbucket_username] + session[bitbucket_server_username_key] = params[:bitbucket_server_username] session[bitbucket_server_url_key] = params[:bitbucket_server_url] redirect_to status_import_bitbucket_server_path end - # rubocop: disable CodeReuse/ActiveRecord def status - return super if Feature.enabled?(:new_import_ui) - - @collection = client.repos(page_offset: page_offset, limit: limit_per_page, filter: sanitized_filter_param) - @repos, @incompatible_repos = @collection.partition { |repo| repo.valid? } - - # Use the import URL to filter beyond what BaseService#find_already_added_projects - @already_added_projects = filter_added_projects('bitbucket_server', @repos.map(&:browse_url)) - already_added_projects_names = @already_added_projects.pluck(:import_source) - - @repos.reject! { |repo| already_added_projects_names.include?(repo.browse_url) } - end - # rubocop: enable CodeReuse/ActiveRecord - - def jobs - render json: find_jobs('bitbucket_server') + super end def realtime_changes @@ -126,9 +104,15 @@ class Import::BitbucketServerController < Import::BaseController @bitbucket_repos ||= client.repos(page_offset: page_offset, limit: limit_per_page, filter: sanitized_filter_param).to_a end + def normalize_import_params + project_key, repo_slug = params[:repo_id].split('/') + params[:bitbucket_server_project] = project_key + params[:bitbucket_server_repo] = repo_slug + end + def validate_import_params - @project_key = params[:project] - @repo_slug = params[:repository] + @project_key = params[:bitbucket_server_project] + @repo_slug = params[:bitbucket_server_repo] return render_validation_error('Missing project key') unless @project_key.present? && @repo_slug.present? return render_validation_error('Missing repository slug') unless @repo_slug.present? diff --git a/app/controllers/import/fogbugz_controller.rb b/app/controllers/import/fogbugz_controller.rb index 91779a5d6cc..a34bc9c953f 100644 --- a/app/controllers/import/fogbugz_controller.rb +++ b/app/controllers/import/fogbugz_controller.rb @@ -50,14 +50,7 @@ class Import::FogbugzController < Import::BaseController return redirect_to new_import_fogbugz_path end - return super if Feature.enabled?(:new_import_ui) - - @repos = client.repos - - @already_added_projects = find_already_added_projects('fogbugz') - already_added_projects_names = @already_added_projects.pluck(:import_source) - - @repos.reject! { |repo| already_added_projects_names.include? repo.name } + super end # rubocop: enable CodeReuse/ActiveRecord @@ -65,10 +58,6 @@ class Import::FogbugzController < Import::BaseController super end - def jobs - render json: find_jobs('fogbugz') - end - def create repo = client.repo(params[:repo_id]) fb_session = { uri: session[:fogbugz_uri], token: session[:fogbugz_token] } @@ -96,6 +85,11 @@ class Import::FogbugzController < Import::BaseController end # rubocop: enable CodeReuse/ActiveRecord + override :incompatible_repos + def incompatible_repos + [] + end + override :provider_name def provider_name :fogbugz diff --git a/app/controllers/import/gitea_controller.rb b/app/controllers/import/gitea_controller.rb index 42c23fb29a7..efeff8439e4 100644 --- a/app/controllers/import/gitea_controller.rb +++ b/app/controllers/import/gitea_controller.rb @@ -21,15 +21,17 @@ class Import::GiteaController < Import::GithubController super end - private + protected - def host_key - :"#{provider}_host_url" + override :provider_name + def provider_name + :gitea end - override :provider - def provider - :gitea + private + + def host_key + :"#{provider_name}_host_url" end override :provider_url diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb index 097edcd6075..ac6b8c06d66 100644 --- a/app/controllers/import/github_controller.rb +++ b/app/controllers/import/github_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Import::GithubController < Import::BaseController + extend ::Gitlab::Utils::Override + include ImportHelper include ActionView::Helpers::SanitizeHelper @@ -34,18 +36,11 @@ class Import::GithubController < Import::BaseController # Improving in https://gitlab.com/gitlab-org/gitlab-foss/issues/55585 client_repos - respond_to do |format| - format.json do - render json: { imported_projects: serialized_imported_projects, - provider_repos: serialized_provider_repos, - namespaces: serialized_namespaces } - end - format.html - end + super end def create - result = Import::GithubService.new(client, current_user, import_params).execute(access_params, provider) + result = Import::GithubService.new(client, current_user, import_params).execute(access_params, provider_name) if result[:status] == :success render json: serialized_imported_projects(result[:project]) @@ -55,44 +50,51 @@ class Import::GithubController < Import::BaseController end def realtime_changes - Gitlab::PollingInterval.set_header(response, interval: 3_000) - - render json: already_added_projects.to_json(only: [:id], methods: [:import_status]) + super end - private + protected - def import_params - params.permit(permitted_import_params) - end + # rubocop: disable CodeReuse/ActiveRecord + override :importable_repos + def importable_repos + already_added_projects_names = already_added_projects.pluck(:import_source) - def permitted_import_params - [:repo_id, :new_name, :target_namespace] + client_repos.reject { |repo| already_added_projects_names.include?(repo.full_name) } end + # rubocop: enable CodeReuse/ActiveRecord - def serialized_imported_projects(projects = already_added_projects) - ProjectSerializer.new.represent(projects, serializer: :import, provider_url: provider_url) + override :incompatible_repos + def incompatible_repos + [] end - def serialized_provider_repos - repos = client_repos.reject { |repo| already_added_project_names.include? repo.full_name } - Import::ProviderRepoSerializer.new(current_user: current_user).represent(repos, provider: provider, provider_url: provider_url) + override :provider_name + def provider_name + :github end - def serialized_namespaces - NamespaceSerializer.new.represent(namespaces) + override :provider_url + def provider_url + strong_memoize(:provider_url) do + provider = Gitlab::Auth::OAuth::Provider.config_for('github') + + provider&.dig('url').presence || 'https://github.com' + end end - def already_added_projects - @already_added_projects ||= filtered(find_already_added_projects(provider)) + private + + def import_params + params.permit(permitted_import_params) end - def already_added_project_names - @already_added_projects_names ||= already_added_projects.pluck(:import_source) # rubocop:disable CodeReuse/ActiveRecord + def permitted_import_params + [:repo_id, :new_name, :target_namespace] end - def namespaces - current_user.manageable_groups_with_routes + def serialized_imported_projects(projects = already_added_projects) + ProjectSerializer.new.represent(projects, serializer: :import, provider_url: provider_url) end def expire_etag_cache @@ -118,29 +120,29 @@ class Import::GithubController < Import::BaseController end def import_enabled? - __send__("#{provider}_import_enabled?") # rubocop:disable GitlabSecurity/PublicSend + __send__("#{provider_name}_import_enabled?") # rubocop:disable GitlabSecurity/PublicSend end def realtime_changes_path - public_send("realtime_changes_import_#{provider}_path", format: :json) # rubocop:disable GitlabSecurity/PublicSend + public_send("realtime_changes_import_#{provider_name}_path", format: :json) # rubocop:disable GitlabSecurity/PublicSend end def new_import_url - public_send("new_import_#{provider}_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend + public_send("new_import_#{provider_name}_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend end def status_import_url - public_send("status_import_#{provider}_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend + public_send("status_import_#{provider_name}_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend end def callback_import_url - public_send("users_import_#{provider}_callback_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend + public_send("users_import_#{provider_name}_callback_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend end def provider_unauthorized session[access_token_key] = nil redirect_to new_import_url, - alert: "Access denied to your #{Gitlab::ImportSources.title(provider.to_s)} account." + alert: "Access denied to your #{Gitlab::ImportSources.title(provider_name.to_s)} account." end def provider_rate_limit(exception) @@ -151,29 +153,16 @@ class Import::GithubController < Import::BaseController end def access_token_key - :"#{provider}_access_token" + :"#{provider_name}_access_token" end def access_params { github_access_token: session[access_token_key] } end - # The following methods are overridden in subclasses - def provider - :github - end - - def provider_url - strong_memoize(:provider_url) do - provider = Gitlab::Auth::OAuth::Provider.config_for('github') - - provider&.dig('url').presence || 'https://github.com' - end - end - # rubocop: disable CodeReuse/ActiveRecord def logged_in_with_provider? - current_user.identities.exists?(provider: provider) + current_user.identities.exists?(provider: provider_name) end # rubocop: enable CodeReuse/ActiveRecord @@ -202,12 +191,6 @@ class Import::GithubController < Import::BaseController def filter_attribute :name end - - def filtered(collection) - return collection unless sanitized_filter_param - - collection.select { |item| item[filter_attribute].include?(sanitized_filter_param) } - end end Import::GithubController.prepend_if_ee('EE::Import::GithubController') diff --git a/app/controllers/import/gitlab_controller.rb b/app/controllers/import/gitlab_controller.rb index a95a67e208c..cc68eb02741 100644 --- a/app/controllers/import/gitlab_controller.rb +++ b/app/controllers/import/gitlab_controller.rb @@ -16,21 +16,8 @@ class Import::GitlabController < Import::BaseController redirect_to status_import_gitlab_url end - # rubocop: disable CodeReuse/ActiveRecord def status - return super if Feature.enabled?(:new_import_ui) - - @repos = client.projects(starting_page: 1, page_limit: MAX_PROJECT_PAGES, per_page: PER_PAGE_PROJECTS) - - @already_added_projects = find_already_added_projects('gitlab') - already_added_projects_names = @already_added_projects.pluck(:import_source) - - @repos = @repos.to_a.reject { |repo| already_added_projects_names.include? repo["path_with_namespace"] } - end - # rubocop: enable CodeReuse/ActiveRecord - - def jobs - render json: find_jobs('gitlab') + super end def create @@ -63,6 +50,11 @@ class Import::GitlabController < Import::BaseController end # rubocop: enable CodeReuse/ActiveRecord + override :incompatible_repos + def incompatible_repos + [] + end + override :provider_name def provider_name :gitlab diff --git a/app/controllers/instance_statistics/cohorts_controller.rb b/app/controllers/instance_statistics/cohorts_controller.rb index 4b4e39db2e1..0de62a56b01 100644 --- a/app/controllers/instance_statistics/cohorts_controller.rb +++ b/app/controllers/instance_statistics/cohorts_controller.rb @@ -1,8 +1,12 @@ # frozen_string_literal: true class InstanceStatistics::CohortsController < InstanceStatistics::ApplicationController + include Analytics::UniqueVisitsHelper + before_action :authenticate_usage_ping_enabled_or_admin! + track_unique_visits :index, target_id: 'i_analytics_cohorts' + def index if Gitlab::CurrentSettings.usage_ping_enabled cohorts_results = Rails.cache.fetch('cohorts', expires_in: 1.day) do diff --git a/app/controllers/instance_statistics/dev_ops_score_controller.rb b/app/controllers/instance_statistics/dev_ops_score_controller.rb index 238f7fa7707..b98a1bf7f99 100644 --- a/app/controllers/instance_statistics/dev_ops_score_controller.rb +++ b/app/controllers/instance_statistics/dev_ops_score_controller.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true class InstanceStatistics::DevOpsScoreController < InstanceStatistics::ApplicationController + include Analytics::UniqueVisitsHelper + + track_unique_visits :index, target_id: 'i_analytics_dev_ops_score' + # rubocop: disable CodeReuse/ActiveRecord def index @metric = DevOpsScore::Metric.order(:created_at).last&.present diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index a78d87eceea..5bd9ac7f275 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -1,12 +1,17 @@ # frozen_string_literal: true class InvitesController < ApplicationController + include Gitlab::Utils::StrongMemoize + before_action :member skip_before_action :authenticate_user!, only: :decline + helper_method :member?, :current_user_matches_invite? + respond_to :html def show + accept if skip_invitation_prompt? end def accept @@ -38,6 +43,20 @@ class InvitesController < ApplicationController private + def skip_invitation_prompt? + !member? && current_user_matches_invite? + end + + def current_user_matches_invite? + @member.invite_email == current_user.email + end + + def member? + strong_memoize(:is_member) do + @member.source.users.include?(current_user) + end + end + def member return @member if defined?(@member) diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb index 2c3e60d12b7..6532501733a 100644 --- a/app/controllers/oauth/applications_controller.rb +++ b/app/controllers/oauth/applications_controller.rb @@ -17,6 +17,8 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController before_action :add_gon_variables before_action :load_scopes, only: [:index, :create, :edit, :update] + around_action :set_locale + helper_method :can? layout 'profile' @@ -70,4 +72,8 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController params[:owner] = current_user end end + + def set_locale(&block) + Gitlab::I18n.with_user_locale(current_user, &block) + end end diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index 4c595313cb6..706a4843117 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -200,7 +200,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController def fail_login(user) error_message = user.errors.full_messages.to_sentence - return redirect_to omniauth_error_path(oauth['provider'], error: error_message) + redirect_to omniauth_error_path(oauth['provider'], error: error_message) end def fail_auth0_login diff --git a/app/controllers/profiles/keys_controller.rb b/app/controllers/profiles/keys_controller.rb index b9cb71ae89a..99e1b9027fa 100644 --- a/app/controllers/profiles/keys_controller.rb +++ b/app/controllers/profiles/keys_controller.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class Profiles::KeysController < Profiles::ApplicationController - skip_before_action :authenticate_user!, only: [:get_keys] - def index @keys = current_user.keys.order_id_desc @key = Key.new @@ -33,25 +31,6 @@ class Profiles::KeysController < Profiles::ApplicationController end end - # Get all keys of a user(params[:username]) in a text format - # Helpful for sysadmins to put in respective servers - def get_keys - if params[:username].present? - begin - user = UserFinder.new(params[:username]).find_by_username - if user.present? - render plain: user.all_ssh_keys.join("\n") - else - return render_404 - end - rescue => e - render html: e.message - end - else - return render_404 - end - end - private def key_params diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb index f1c07cd9a1d..30f25e8fdaa 100644 --- a/app/controllers/profiles/personal_access_tokens_controller.rb +++ b/app/controllers/profiles/personal_access_tokens_controller.rb @@ -40,14 +40,18 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController params.require(:personal_access_token).permit(:name, :expires_at, scopes: []) end - # rubocop: disable CodeReuse/ActiveRecord def set_index_vars @scopes = Gitlab::Auth.available_scopes_for(current_user) @inactive_personal_access_tokens = finder(state: 'inactive').execute - @active_personal_access_tokens = finder(state: 'active').execute.order(:expires_at) + @active_personal_access_tokens = active_personal_access_tokens @new_personal_access_token = PersonalAccessToken.redis_getdel(current_user.id) end - # rubocop: enable CodeReuse/ActiveRecord + + def active_personal_access_tokens + finder(state: 'active', sort: 'expires_at_asc').execute + end end + +Profiles::PersonalAccessTokensController.prepend_if_ee('EE::Profiles::PersonalAccessTokensController') diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb index 1477d79c911..8653fe3b6ed 100644 --- a/app/controllers/profiles/preferences_controller.rb +++ b/app/controllers/profiles/preferences_controller.rb @@ -48,6 +48,7 @@ class Profiles::PreferencesController < Profiles::ApplicationController :time_display_relative, :time_format_in_24h, :show_whitespace_in_diffs, + :view_diffs_file_by_file, :tab_width, :sourcegraph_enabled, :render_whitespace_in_code diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb index b1f285f76d7..518d414be1b 100644 --- a/app/controllers/projects/application_controller.rb +++ b/app/controllers/projects/application_controller.rb @@ -42,7 +42,7 @@ class Projects::ApplicationController < ApplicationController def authorize_action!(action) unless can?(current_user, action, project) - return access_denied! + access_denied! end end @@ -81,10 +81,6 @@ class Projects::ApplicationController < ApplicationController end end - def apply_diff_view_cookie! - set_secure_cookie(:diff_view, params.delete(:view), permanent: true) if params[:view].present? - end - def require_pages_enabled! not_found unless @project.pages_available? end diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 14dca1bdc30..7f14522e61b 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -9,6 +9,7 @@ class Projects::BlobController < Projects::ApplicationController include ActionView::Helpers::SanitizeHelper include RedirectsForMissingPathOnTree include SourcegraphDecorator + include DiffHelper prepend_before_action :authenticate_user!, only: [:edit] @@ -129,7 +130,7 @@ class Projects::BlobController < Projects::ApplicationController end end - return redirect_to_tree_root_for_missing_path(@project, @ref, @path) + redirect_to_tree_root_for_missing_path(@project, @ref, @path) end end @@ -207,14 +208,14 @@ class Projects::BlobController < Projects::ApplicationController def set_last_commit_sha @last_commit_sha = Gitlab::Git::Commit - .last_for_path(@repository, @ref, @path).sha + .last_for_path(@repository, @ref, @path, literal_pathspec: true).sha end def show_html environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit } environment_params[:find_latest] = true @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last - @last_commit = @repository.last_commit_for_path(@commit.id, @blob.path) + @last_commit = @repository.last_commit_for_path(@commit.id, @blob.path, literal_pathspec: true) @code_navigation_path = Gitlab::CodeNavigationPath.new(@project, @blob.commit_id).full_json_path_for(@blob.path) render 'show' diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb index 8fa823e0be1..db05da0bb7f 100644 --- a/app/controllers/projects/boards_controller.rb +++ b/app/controllers/projects/boards_controller.rb @@ -9,7 +9,6 @@ class Projects::BoardsController < Projects::ApplicationController before_action :assign_endpoint_vars before_action do push_frontend_feature_flag(:multi_select_board, default_enabled: true) - push_frontend_feature_flag(:sfc_issue_boards, default_enabled: true) end private diff --git a/app/controllers/projects/ci/lints_controller.rb b/app/controllers/projects/ci/lints_controller.rb index b50afa12da0..73b3eb9c205 100644 --- a/app/controllers/projects/ci/lints_controller.rb +++ b/app/controllers/projects/ci/lints_controller.rb @@ -14,7 +14,7 @@ class Projects::Ci::LintsController < Projects::ApplicationController @errors = result.errors if result.valid? - @config_processor = result.content + @config_processor = result.config @stages = @config_processor.stages @builds = @config_processor.builds @jobs = @config_processor.jobs diff --git a/app/controllers/projects/clusters_controller.rb b/app/controllers/projects/clusters_controller.rb index 079d30127d6..8acf5235c1a 100644 --- a/app/controllers/projects/clusters_controller.rb +++ b/app/controllers/projects/clusters_controller.rb @@ -23,6 +23,13 @@ class Projects::ClustersController < Clusters::ClustersController def repository @repository ||= project.repository end -end -Projects::ClustersController.prepend_if_ee('EE::Projects::ClustersController') + def metrics_dashboard_params + params.permit(:embedded, :group, :title, :y_label).merge( + { + cluster: cluster, + cluster_type: :project + } + ) + end +end diff --git a/app/controllers/projects/confluences_controller.rb b/app/controllers/projects/confluences_controller.rb new file mode 100644 index 00000000000..d563b34a362 --- /dev/null +++ b/app/controllers/projects/confluences_controller.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class Projects::ConfluencesController < Projects::ApplicationController + before_action :ensure_confluence + + def show + end + + private + + def ensure_confluence + render_404 unless project.has_confluence? + end +end diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb index f13c75ac4cc..898d888c978 100644 --- a/app/controllers/projects/cycle_analytics_controller.rb +++ b/app/controllers/projects/cycle_analytics_controller.rb @@ -4,10 +4,13 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController include ActionView::Helpers::DateHelper include ActionView::Helpers::TextHelper include CycleAnalyticsParams + include Analytics::UniqueVisitsHelper before_action :whitelist_query_limiting, only: [:show] before_action :authorize_read_cycle_analytics! + track_unique_visits :show, target_id: 'p_analytics_valuestream' + def show @cycle_analytics = ::CycleAnalytics::ProjectLevel.new(@project, options: options(cycle_analytics_project_params)) diff --git a/app/controllers/projects/deployments_controller.rb b/app/controllers/projects/deployments_controller.rb index 766e2f86ea2..1344cf775e4 100644 --- a/app/controllers/projects/deployments_controller.rb +++ b/app/controllers/projects/deployments_controller.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true class Projects::DeploymentsController < Projects::ApplicationController - before_action :authorize_read_environment! before_action :authorize_read_deployment! # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/controllers/projects/environments/prometheus_api_controller.rb b/app/controllers/projects/environments/prometheus_api_controller.rb index 98fcc594d6e..f0bb5360f84 100644 --- a/app/controllers/projects/environments/prometheus_api_controller.rb +++ b/app/controllers/projects/environments/prometheus_api_controller.rb @@ -1,51 +1,17 @@ # frozen_string_literal: true class Projects::Environments::PrometheusApiController < Projects::ApplicationController - include RenderServiceResults + include Metrics::Dashboard::PrometheusApiProxy - before_action :authorize_read_prometheus! - before_action :environment - - def proxy - variable_substitution_result = - variable_substitution_service.new(environment, permit_params).execute - - if variable_substitution_result[:status] == :error - return error_response(variable_substitution_result) - end - - prometheus_result = Prometheus::ProxyService.new( - environment, - proxy_method, - proxy_path, - variable_substitution_result[:params] - ).execute - - return continue_polling_response if prometheus_result.nil? - return error_response(prometheus_result) if prometheus_result[:status] == :error - - success_response(prometheus_result) - end + before_action :proxyable private - def variable_substitution_service - Prometheus::ProxyVariableSubstitutionService - end - - def permit_params - params.permit! - end - - def environment - @environment ||= project.environments.find(params[:id]) + def proxyable + @proxyable ||= project.environments.find(params[:id]) end - def proxy_method - request.method - end - - def proxy_path - params[:proxy_path] + def proxy_variable_substitution_service + Prometheus::ProxyVariableSubstitutionService end end diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index 4d774123ef1..d5da24a76de 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true class Projects::EnvironmentsController < Projects::ApplicationController + # Metrics dashboard code is getting decoupled from environments and is being moved + # into app/controllers/projects/metrics_dashboard_controller.rb + # See https://gitlab.com/gitlab-org/gitlab/-/issues/226002 for more details. + include MetricsDashboard layout 'project' diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb index ebc81976529..b93f6384e0c 100644 --- a/app/controllers/projects/forks_controller.rb +++ b/app/controllers/projects/forks_controller.rb @@ -3,6 +3,7 @@ class Projects::ForksController < Projects::ApplicationController include ContinueParams include RendersMemberAccess + include RendersProjectsList include Gitlab::Utils::StrongMemoize # Authorize diff --git a/app/controllers/projects/graphs_controller.rb b/app/controllers/projects/graphs_controller.rb index a8b90f8685f..9b889f9e837 100644 --- a/app/controllers/projects/graphs_controller.rb +++ b/app/controllers/projects/graphs_controller.rb @@ -2,12 +2,15 @@ class Projects::GraphsController < Projects::ApplicationController include ExtractsPath + include Analytics::UniqueVisitsHelper # Authorize before_action :require_non_empty_project before_action :assign_ref_vars before_action :authorize_read_repository_graphs! + track_unique_visits :charts, target_id: 'p_analytics_repo' + def show respond_to do |format| format.html diff --git a/app/controllers/projects/imports_controller.rb b/app/controllers/projects/imports_controller.rb index 67a7daf8445..deba71c9dd3 100644 --- a/app/controllers/projects/imports_controller.rb +++ b/app/controllers/projects/imports_controller.rb @@ -5,7 +5,8 @@ class Projects::ImportsController < Projects::ApplicationController include ImportUrlParams # Authorize - before_action :authorize_admin_project! + before_action :authorize_admin_project!, only: [:new, :create] + before_action :require_namespace_project_creation_permission, only: :show before_action :require_no_repo, only: [:new, :create] before_action :redirect_if_progress, only: [:new, :create] before_action :redirect_if_no_import, only: :show @@ -51,6 +52,10 @@ class Projects::ImportsController < Projects::ApplicationController end end + def require_namespace_project_creation_permission + render_404 unless current_user.can?(:admin_project, @project) || current_user.can?(:create_projects, @project.namespace) + end + def redirect_if_progress if @project.import_in_progress? redirect_to project_import_path(@project) diff --git a/app/controllers/projects/incident_management/pager_duty_incidents_controller.rb b/app/controllers/projects/incident_management/pager_duty_incidents_controller.rb new file mode 100644 index 00000000000..dac1640dd08 --- /dev/null +++ b/app/controllers/projects/incident_management/pager_duty_incidents_controller.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Projects + module IncidentManagement + class PagerDutyIncidentsController < Projects::ApplicationController + respond_to :json + + skip_before_action :verify_authenticity_token + skip_before_action :project + + prepend_before_action :project_without_auth + + def create + result = webhook_processor.execute(params[:token]) + + head result.http_status + end + + private + + def project_without_auth + @project ||= Project + .find_by_full_path("#{params[:namespace_id]}/#{params[:project_id]}") + end + + def webhook_processor + ::IncidentManagement::PagerDuty::ProcessWebhookService.new(project, nil, payload) + end + + def payload + @payload ||= params.permit![:pager_duty_incident].to_h + end + end + end +end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 693329848de..12b5a538bc9 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -11,11 +11,11 @@ class Projects::IssuesController < Projects::ApplicationController include RecordUserLastActivity def issue_except_actions - %i[index calendar new create bulk_update import_csv export_csv] + %i[index calendar new create bulk_update import_csv export_csv service_desk] end def set_issuables_index_only_actions - %i[index calendar] + %i[index calendar service_desk] end prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) } @@ -46,10 +46,17 @@ class Projects::IssuesController < Projects::ApplicationController 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) end before_action only: :show do push_frontend_feature_flag(:real_time_issue_sidebar, @project) + push_frontend_feature_flag(:confidential_apollo_sidebar, @project) + end + + before_action only: :index do + push_frontend_feature_flag(:scoped_labels, @project) end around_action :allow_gitaly_ref_name_caching, only: [:discussions] @@ -216,6 +223,11 @@ class Projects::IssuesController < Projects::ApplicationController redirect_to project_issues_path(project) end + def service_desk + @issues = @issuables # rubocop:disable Gitlab/ModuleWithInstanceVariables + @users.push(User.support_bot) # rubocop:disable Gitlab/ModuleWithInstanceVariables + end + protected def sorting_field @@ -313,6 +325,17 @@ class Projects::IssuesController < Projects::ApplicationController private + def finder_options + options = super + + return options unless service_desk? + + options.reject! { |key| key == 'author_username' || key == 'author_id' } + options[:author_id] = User.support_bot + + options + end + def branch_link(branch) project_compare_path(project, from: project.default_branch, to: branch[:name]) end @@ -330,6 +353,10 @@ class Projects::IssuesController < Projects::ApplicationController def rate_limiter ::Gitlab::ApplicationRateLimiter end + + def service_desk? + action_name == 'service_desk' + end end Projects::IssuesController.prepend_if_ee('EE::Projects::IssuesController') diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index e1f6cbe3dca..3f7f8da3478 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -11,9 +11,6 @@ class Projects::JobsController < Projects::ApplicationController before_action :authorize_erase_build!, only: [:erase] before_action :authorize_use_build_terminal!, only: [:terminal, :terminal_websocket_authorize] before_action :verify_api_request!, only: :terminal_websocket_authorize - before_action only: [:show] do - push_frontend_feature_flag(:job_log_json, project, default_enabled: true) - end before_action :authorize_create_proxy_build!, only: :proxy_websocket_authorize before_action :verify_proxy_request!, only: :proxy_websocket_authorize @@ -55,15 +52,10 @@ class Projects::JobsController < Projects::ApplicationController format.json do build.trace.being_watched! - # TODO: when the feature flag is removed we should not pass - # content_format to serialize method. - content_format = Feature.enabled?(:job_log_json, @project, default_enabled: true) ? :json : :html - build_trace = Ci::BuildTrace.new( build: @build, stream: stream, - state: params[:state], - content_format: content_format) + state: params[:state]) render json: BuildTraceSerializer .new(project: @project, current_user: @current_user) diff --git a/app/controllers/projects/logs_controller.rb b/app/controllers/projects/logs_controller.rb index ba509235417..b9027b3a2cb 100644 --- a/app/controllers/projects/logs_controller.rb +++ b/app/controllers/projects/logs_controller.rb @@ -2,15 +2,16 @@ module Projects class LogsController < Projects::ApplicationController + include ::Gitlab::Utils::StrongMemoize + before_action :authorize_read_pod_logs! - before_action :environment before_action :ensure_deployments, only: %i(k8s elasticsearch) def index - if environment.nil? - render :empty_logs - else + if environment || cluster render :index + else + render :empty_logs end end @@ -39,8 +40,9 @@ module Projects end end - def index_params - params.permit(:environment_name) + # cluster is selected either via environment or directly by id + def cluster_params + params.permit(:environment_name, :cluster_id) end def k8s_params @@ -52,22 +54,36 @@ module Projects end def environment - @environment ||= if index_params.key?(:environment_name) - EnvironmentsFinder.new(project, current_user, name: index_params[:environment_name]).find.first - else - project.default_environment - end + strong_memoize(:environment) do + if cluster_params.key?(:environment_name) + EnvironmentsFinder.new(project, current_user, name: cluster_params[:environment_name]).find.first + else + project.default_environment + end + end end def cluster - environment.deployment_platform&.cluster + strong_memoize(:cluster) do + if gitlab_managed_apps_logs? + clusters = ClusterAncestorsFinder.new(project, current_user).execute + clusters.find { |cluster| cluster.id == cluster_params[:cluster_id].to_i } + else + environment&.deployment_platform&.cluster + end + end end def namespace - environment.deployment_namespace + if gitlab_managed_apps_logs? + Gitlab::Kubernetes::Helm::NAMESPACE + else + environment.deployment_namespace + end end def ensure_deployments + return if gitlab_managed_apps_logs? return if cluster && namespace.present? render status: :bad_request, json: { @@ -75,5 +91,9 @@ module Projects message: _('Environment does not have deployments') } end + + def gitlab_managed_apps_logs? + cluster_params.key?(:cluster_id) + end end end diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb index b7e99cb7ed0..0bb4e0fb5ee 100644 --- a/app/controllers/projects/merge_requests/application_controller.rb +++ b/app/controllers/projects/merge_requests/application_controller.rb @@ -48,12 +48,9 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont end def set_pipeline_variables - @pipelines = - if can?(current_user, :read_pipeline, @merge_request.source_project) - @merge_request.all_pipelines - else - Ci::Pipeline.none - end + @pipelines = Ci::PipelinesForMergeRequestFinder + .new(@merge_request, current_user) + .execute end def close_merge_request_if_no_source_project diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb index 28aa1b300aa..3e077c1af37 100644 --- a/app/controllers/projects/merge_requests/creations_controller.rb +++ b/app/controllers/projects/merge_requests/creations_controller.rb @@ -32,13 +32,13 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap end def pipelines - @pipelines = @merge_request.all_pipelines + @pipelines = Ci::PipelinesForMergeRequestFinder.new(@merge_request, current_user).execute Gitlab::PollingInterval.set_header(response, interval: 10_000) render json: { pipelines: PipelineSerializer - .new(project: @project, current_user: @current_user) + .new(project: @project, current_user: current_user) .represent(@pipelines) } end diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index 1bf143c9a91..98b0abc89e9 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -8,6 +8,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic before_action :commit before_action :define_diff_vars before_action :define_diff_comment_vars, except: [:diffs_batch, :diffs_metadata] + before_action :update_diff_discussion_positions! around_action :allow_gitaly_ref_name_caching @@ -171,4 +172,12 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic @notes.concat(draft_notes) end + + def update_diff_discussion_positions! + return unless Feature.enabled?(:merge_ref_head_comments, @merge_request.target_project, default_enabled: true) + return unless Feature.enabled?(:merge_red_head_comments_position_on_demand, @merge_request.target_project, default_enabled: true) + return if @merge_request.has_any_diff_note_positions? + + Discussions::CaptureDiffNotePositionsService.new(@merge_request).execute + end end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 6c1ffc35276..e65e5531b88 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -35,15 +35,23 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:mr_commit_neighbor_nav, @project, default_enabled: true) push_frontend_feature_flag(:multiline_comments, @project) push_frontend_feature_flag(:file_identifier_hash) - push_frontend_feature_flag(:batch_suggestions, @project) + push_frontend_feature_flag(:batch_suggestions, @project, default_enabled: true) end before_action do push_frontend_feature_flag(:vue_issuable_sidebar, @project.group) + push_frontend_feature_flag(:junit_pipeline_view, @project.group) end around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :discussions] + feature_category :source_code_management, + unless: -> (action) { action.ends_with?("_reports") } + feature_category :code_testing, + only: [:test_reports, :coverage_reports, :terraform_reports] + feature_category :accessibility_testing, + only: [:accessibility_reports] + def index @merge_requests = @issuables @@ -76,7 +84,9 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo @issuable_sidebar = serializer.represent(@merge_request, serializer: 'sidebar') @current_user_data = UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestUserEntity).to_json @show_whitespace_default = current_user.nil? || current_user.show_whitespace_in_diffs + @file_by_file_default = Feature.enabled?(:view_diffs_file_by_file) && current_user&.view_diffs_file_by_file @coverage_path = coverage_reports_project_merge_request_path(@project, @merge_request, format: :json) if @merge_request.has_coverage_reports? + @endpoint_metadata_url = endpoint_metadata_url(@project, @merge_request) set_pipeline_variables @@ -108,8 +118,8 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo # or from cache if already merged @commits = set_commits_for_rendering( - @merge_request.recent_commits.with_latest_pipeline(@merge_request.source_branch), - commits_count: @merge_request.commits_count + @merge_request.recent_commits.with_latest_pipeline(@merge_request.source_branch).with_markdown_cache, + commits_count: @merge_request.commits_count ) render json: { html: view_to_html_string('projects/merge_requests/_commits') } @@ -178,7 +188,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end def update - @merge_request = ::MergeRequests::UpdateService.new(project, current_user, merge_request_params).execute(@merge_request) + @merge_request = ::MergeRequests::UpdateService.new(project, current_user, merge_request_update_params).execute(@merge_request) respond_to do |format| format.html do @@ -312,6 +322,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo private + def merge_request_update_params + merge_request_params.merge!(params.permit(:merge_request_diff_head_sha)) + end + def head_pipeline strong_memoize(:head_pipeline) do pipeline = @merge_request.head_pipeline @@ -422,6 +436,13 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo def authorize_read_actual_head_pipeline! return render_404 unless can?(current_user, :read_build, merge_request.actual_head_pipeline) end + + def endpoint_metadata_url(project, merge_request) + params = request.query_parameters + params[:view] = cookies[:diff_view] if params[:view].blank? && cookies[:diff_view].present? + + diffs_metadata_project_json_merge_request_path(project, merge_request, 'json', params) + end end Projects::MergeRequestsController.prepend_if_ee('EE::Projects::MergeRequestsController') diff --git a/app/controllers/projects/metrics_dashboard_controller.rb b/app/controllers/projects/metrics_dashboard_controller.rb new file mode 100644 index 00000000000..235ee1dfbf2 --- /dev/null +++ b/app/controllers/projects/metrics_dashboard_controller.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true +module Projects + class MetricsDashboardController < Projects::ApplicationController + # Metrics dashboard code is in the process of being decoupled from environments + # and is getting moved to this controller. Some code may be duplicated from + # app/controllers/projects/environments_controller.rb + # See https://gitlab.com/gitlab-org/gitlab/-/issues/226002 for more details. + + before_action :authorize_metrics_dashboard! + before_action do + push_frontend_feature_flag(:prometheus_computed_alerts) + end + + def show + if environment + render 'projects/environments/metrics' + else + render_404 + end + end + + private + + def environment + @environment ||= + if params[:environment] + project.environments.find(params[:environment]) + else + project.default_environment + end + end + end +end diff --git a/app/controllers/projects/pipelines/application_controller.rb b/app/controllers/projects/pipelines/application_controller.rb new file mode 100644 index 00000000000..92887750813 --- /dev/null +++ b/app/controllers/projects/pipelines/application_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# Abstract class encapsulating common logic for creating new controllers in a pipeline context + +module Projects + module Pipelines + class ApplicationController < Projects::ApplicationController + include Gitlab::Utils::StrongMemoize + + before_action :pipeline + before_action :authorize_read_pipeline! + + private + + def pipeline + strong_memoize(:pipeline) do + project.all_pipelines.find(params[:pipeline_id]).tap do |pipeline| + render_404 unless can?(current_user, :read_pipeline, pipeline) + end + end + end + end + end +end diff --git a/app/controllers/projects/pipelines/stages_controller.rb b/app/controllers/projects/pipelines/stages_controller.rb new file mode 100644 index 00000000000..ce08b49ce9f --- /dev/null +++ b/app/controllers/projects/pipelines/stages_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Projects + module Pipelines + class StagesController < Projects::Pipelines::ApplicationController + before_action :authorize_update_pipeline! + + def play_manual + ::Ci::PlayManualStageService + .new(@project, current_user, pipeline: pipeline) + .execute(stage) + + respond_to do |format| + format.json do + render json: StageSerializer + .new(project: @project, current_user: @current_user) + .represent(stage) + end + end + end + + private + + def stage + @pipeline_stage ||= pipeline.find_stage_by_name!(params[:stage_name]) + end + end + end +end diff --git a/app/controllers/projects/pipelines/tests_controller.rb b/app/controllers/projects/pipelines/tests_controller.rb new file mode 100644 index 00000000000..f03274bf32e --- /dev/null +++ b/app/controllers/projects/pipelines/tests_controller.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Projects + module Pipelines + class TestsController < Projects::Pipelines::ApplicationController + before_action :validate_feature_flag! + before_action :authorize_read_build! + before_action :builds, only: [:show] + + def summary + respond_to do |format| + format.json do + render json: TestReportSummarySerializer + .new(project: project, current_user: @current_user) + .represent(pipeline.test_report_summary) + end + end + end + + def show + respond_to do |format| + format.json do + render json: TestSuiteSerializer + .new(project: project, current_user: @current_user) + .represent(test_suite, details: true) + end + end + end + + private + + def validate_feature_flag! + render_404 unless Feature.enabled?(:build_report_summary, project) + end + + # rubocop: disable CodeReuse/ActiveRecord + def builds + pipeline.latest_builds.where(id: build_params) + end + + def build_params + return [] unless params[:build_ids] + + params[:build_ids].split(",") + end + + def test_suite + if builds.present? + builds.map do |build| + build.collect_test_reports!(Gitlab::Ci::Reports::TestReports.new) + end.sum + else + render_404 + end + end + # rubocop: enable CodeReuse/ActiveRecord + end + end +end diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 0b6c0db211e..d8e11ddd423 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -2,6 +2,7 @@ class Projects::PipelinesController < Projects::ApplicationController include ::Gitlab::Utils::StrongMemoize + include Analytics::UniqueVisitsHelper before_action :whitelist_query_limiting, only: [:create, :retry] before_action :pipeline, except: [:index, :new, :create, :charts] @@ -12,14 +13,20 @@ class Projects::PipelinesController < Projects::ApplicationController before_action :authorize_update_pipeline!, only: [:retry, :cancel] before_action do push_frontend_feature_flag(:junit_pipeline_view, project) + push_frontend_feature_flag(:build_report_summary, project) push_frontend_feature_flag(:filter_pipelines_search, project, default_enabled: true) - push_frontend_feature_flag(:dag_pipeline_tab, project, default_enabled: false) + push_frontend_feature_flag(:dag_pipeline_tab, project, default_enabled: true) push_frontend_feature_flag(:pipelines_security_report_summary, project) end before_action :ensure_pipeline, only: [:show] + # Will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/225596 + before_action :redirect_for_legacy_scope_filter, only: [:index], if: -> { request.format.html? } + around_action :allow_gitaly_ref_name_caching, only: [:index, :show] + track_unique_visits :charts, target_id: 'p_analytics_pipelines' + wrap_parameters Ci::Pipeline POLLING_INTERVAL = 10_000 @@ -31,9 +38,6 @@ class Projects::PipelinesController < Projects::ApplicationController .page(params[:page]) .per(30) - @running_count = limited_pipelines_count(project, 'running') - @pending_count = limited_pipelines_count(project, 'pending') - @finished_count = limited_pipelines_count(project, 'finished') @pipelines_count = limited_pipelines_count(project) respond_to do |format| @@ -44,10 +48,7 @@ class Projects::PipelinesController < Projects::ApplicationController render json: { pipelines: serialize_pipelines, count: { - all: @pipelines_count, - running: @running_count, - pending: @pending_count, - finished: @finished_count + all: @pipelines_count } } end @@ -186,7 +187,7 @@ class Projects::PipelinesController < Projects::ApplicationController format.json do render json: TestReportSerializer .new(current_user: @current_user) - .represent(pipeline_test_report, project: project) + .represent(pipeline_test_report, project: project, details: true) end end end @@ -226,6 +227,12 @@ class Projects::PipelinesController < Projects::ApplicationController render_404 unless pipeline end + def redirect_for_legacy_scope_filter + return unless %w[running pending].include?(params[:scope]) + + redirect_to url_for(safe_params.except(:scope).merge(status: safe_params[:scope])), status: :moved_permanently + end + # rubocop: disable CodeReuse/ActiveRecord def pipeline @pipeline ||= if params[:id].blank? && params[:latest] diff --git a/app/controllers/projects/refs_controller.rb b/app/controllers/projects/refs_controller.rb index a2581e72257..db770d3e438 100644 --- a/app/controllers/projects/refs_controller.rb +++ b/app/controllers/projects/refs_controller.rb @@ -11,10 +11,6 @@ class Projects::RefsController < Projects::ApplicationController before_action :assign_ref_vars before_action :authorize_download_code! - before_action only: [:logs_tree] do - push_frontend_feature_flag(:vue_file_list_lfs_badge, default_enabled: true) - end - def switch respond_to do |format| format.html do @@ -57,22 +53,11 @@ class Projects::RefsController < Projects::ApplicationController render json: logs end - - # Deprecated due to https://gitlab.com/gitlab-org/gitlab/-/issues/36863 - # Will be removed soon https://gitlab.com/gitlab-org/gitlab/-/merge_requests/29895 - format.js do - @logs, _ = tree_summary.summarize - @more_log_url = more_url(tree_summary.next_offset) if tree_summary.more? - end end end private - def more_url(offset) - logs_file_project_ref_path(@project, @ref, @path, offset: offset) - end - def validate_ref_id return not_found! if params[:id].present? && params[:id] !~ Gitlab::PathRegex.git_reference_regex end diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb index d3285b64dab..d58755c2655 100644 --- a/app/controllers/projects/releases_controller.rb +++ b/app/controllers/projects/releases_controller.rb @@ -13,6 +13,7 @@ class Projects::ReleasesController < Projects::ApplicationController push_frontend_feature_flag(:release_asset_link_type, project, default_enabled: true) end before_action :authorize_update_release!, only: %i[edit update] + before_action :authorize_create_release!, only: :new def index respond_to do |format| @@ -25,11 +26,11 @@ class Projects::ReleasesController < Projects::ApplicationController def show return render_404 unless Feature.enabled?(:release_show_page, project, default_enabled: true) + end - respond_to do |format| - format.html do - render :show - end + def new + unless Feature.enabled?(:new_release_page, project) + redirect_to(new_project_tag_path(@project)) end end @@ -37,22 +38,12 @@ class Projects::ReleasesController < Projects::ApplicationController redirect_to link.url end - protected + private def releases ReleasesFinder.new(@project, current_user).execute end - def edit - respond_to do |format| - format.html do - render :edit - end - end - end - - private - def authorize_update_release! access_denied! unless can?(current_user, :update_release, release) end diff --git a/app/controllers/projects/service_desk_controller.rb b/app/controllers/projects/service_desk_controller.rb new file mode 100644 index 00000000000..bcd190bbc2c --- /dev/null +++ b/app/controllers/projects/service_desk_controller.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class Projects::ServiceDeskController < Projects::ApplicationController + before_action :authorize_admin_project! + + def show + json_response + end + + def update + Projects::UpdateService.new(project, current_user, { service_desk_enabled: params[:service_desk_enabled] }).execute + + result = ServiceDeskSettings::UpdateService.new(project, current_user, setting_params).execute + + if result[:status] == :success + json_response + else + render json: { message: result[:message] }, status: :unprocessable_entity + end + end + + private + + def setting_params + params.permit(:issue_template_key, :outgoing_name, :project_key) + end + + def json_response + respond_to do |format| + service_desk_settings = project.service_desk_setting + + service_desk_attributes = + { + service_desk_address: project.service_desk_address, + service_desk_enabled: project.service_desk_enabled, + issue_template_key: service_desk_settings&.issue_template_key, + template_file_missing: service_desk_settings&.issue_template_missing?, + outgoing_name: service_desk_settings&.outgoing_name, + project_key: service_desk_settings&.project_key + } + + format.json { render json: service_desk_attributes } + end + end +end diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb index 710ad546e64..6b7e253595c 100644 --- a/app/controllers/projects/services_controller.rb +++ b/app/controllers/projects/services_controller.rb @@ -12,7 +12,8 @@ class Projects::ServicesController < Projects::ApplicationController before_action :set_deprecation_notice_for_prometheus_service, only: [:edit, :update] before_action :redirect_deprecated_prometheus_service, only: [:update] before_action only: :edit do - push_frontend_feature_flag(:integration_form_refactor) + push_frontend_feature_flag(:integration_form_refactor, default_enabled: true) + push_frontend_feature_flag(:jira_issues_integration, @project, { default_enabled: true }) end respond_to :html @@ -20,17 +21,19 @@ class Projects::ServicesController < Projects::ApplicationController layout "project_settings" def edit + @admin_integration = Service.instance_for(service.type) end def update @service.attributes = service_params[:service] + @service.inherit_from_id = nil if service_params[:service][:inherit_from_id].blank? saved = @service.save(context: :manual_change) respond_to do |format| format.html do if saved - target_url = safe_redirect_path(params[:redirect_to]).presence || project_settings_integrations_path(@project) + target_url = safe_redirect_path(params[:redirect_to]).presence || edit_project_service_path(@project, @service) redirect_to target_url, notice: success_message else render 'edit' @@ -60,7 +63,7 @@ class Projects::ServicesController < Projects::ApplicationController return { error: true, message: _('Validations failed.'), service_response: @service.errors.full_messages.join(','), test_failed: false } end - result = Integrations::Test::ProjectService.new(@service, current_user, params[:event]).execute + result = ::Integrations::Test::ProjectService.new(@service, current_user, params[:event]).execute unless result[:success] return { error: true, message: _('Test failed.'), service_response: result[:message].to_s, test_failed: true } diff --git a/app/controllers/projects/settings/operations_controller.rb b/app/controllers/projects/settings/operations_controller.rb index c2292511e0f..d7a6f1b0139 100644 --- a/app/controllers/projects/settings/operations_controller.rb +++ b/app/controllers/projects/settings/operations_controller.rb @@ -6,13 +6,13 @@ module Projects before_action :authorize_admin_operations! before_action :authorize_read_prometheus_alerts!, only: [:reset_alerting_token] - respond_to :json, only: [:reset_alerting_token] + before_action do + push_frontend_feature_flag(:pagerduty_webhook, project) + end - helper_method :error_tracking_setting + respond_to :json, only: [:reset_alerting_token, :reset_pagerduty_token] - def show - render locals: { prometheus_service: prometheus_service } - end + helper_method :error_tracking_setting def update result = ::Projects::Operations::UpdateService.new(project, current_user, update_params).execute @@ -42,14 +42,29 @@ module Projects end end + def reset_pagerduty_token + result = ::Projects::Operations::UpdateService + .new(project, current_user, pagerduty_token_params) + .execute + + if result[:status] == :success + pagerduty_token = project.incident_management_setting&.pagerduty_token + webhook_url = project_incidents_pagerduty_url(project, token: pagerduty_token) + + render json: { pagerduty_webhook_url: webhook_url, pagerduty_token: pagerduty_token } + else + render json: {}, status: :unprocessable_entity + end + end + private def alerting_params { alerting_setting_attributes: { regenerate_token: true } } end - def prometheus_service - project.find_or_initialize_service(::PrometheusService.to_param) + def pagerduty_token_params + { incident_management_setting_attributes: { regenerate_token: true } } end def render_update_response(result) diff --git a/app/controllers/projects/snippets/blobs_controller.rb b/app/controllers/projects/snippets/blobs_controller.rb new file mode 100644 index 00000000000..148fc7c96f8 --- /dev/null +++ b/app/controllers/projects/snippets/blobs_controller.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class Projects::Snippets::BlobsController < Projects::Snippets::ApplicationController + include Snippets::BlobsActions +end diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb index 5ee6abef804..49840e847f2 100644 --- a/app/controllers/projects/snippets_controller.rb +++ b/app/controllers/projects/snippets_controller.rb @@ -15,11 +15,11 @@ class Projects::SnippetsController < Projects::Snippets::ApplicationController before_action :authorize_admin_snippet!, only: [:destroy] def index - @snippet_counts = Snippets::CountService + @snippet_counts = ::Snippets::CountService .new(current_user, project: @project) .execute - @snippets = SnippetsFinder.new(current_user, project: @project, scope: params[:scope]) + @snippets = SnippetsFinder.new(current_user, project: @project, scope: params[:scope], sort: sort_param) .execute .page(params[:page]) .inc_author @@ -35,7 +35,7 @@ class Projects::SnippetsController < Projects::Snippets::ApplicationController def create create_params = snippet_params.merge(spammable_params) - service_response = Snippets::CreateService.new(project, current_user, create_params).execute + service_response = ::Snippets::CreateService.new(project, current_user, create_params).execute @snippet = service_response.payload[:snippet] handle_repository_error(:new) diff --git a/app/controllers/projects/stages_controller.rb b/app/controllers/projects/stages_controller.rb deleted file mode 100644 index c8db5b1277f..00000000000 --- a/app/controllers/projects/stages_controller.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -class Projects::StagesController < Projects::PipelinesController - before_action :authorize_update_pipeline! - - def play_manual - ::Ci::PlayManualStageService - .new(@project, current_user, pipeline: pipeline) - .execute(stage) - - respond_to do |format| - format.json do - render json: StageSerializer - .new(project: @project, current_user: @current_user) - .represent(stage) - end - end - end - - private - - def stage - @pipeline_stage ||= pipeline.find_stage_by_name!(params[:stage_name]) - end -end diff --git a/app/controllers/projects/static_site_editor_controller.rb b/app/controllers/projects/static_site_editor_controller.rb index 74f28c3da67..9ec50ff8196 100644 --- a/app/controllers/projects/static_site_editor_controller.rb +++ b/app/controllers/projects/static_site_editor_controller.rb @@ -9,6 +9,9 @@ class Projects::StaticSiteEditorController < Projects::ApplicationController prepend_before_action :authenticate_user!, only: [:show] before_action :assign_ref_and_path, only: [:show] before_action :authorize_edit_tree!, only: [:show] + before_action do + push_frontend_feature_flag(:sse_image_uploads) + end def show @config = Gitlab::StaticSiteEditor::Config.new(@repository, @ref, @path, params[:return_url]) diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index 9cb345724cc..638e1a05c18 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -15,26 +15,14 @@ class Projects::TreeController < Projects::ApplicationController before_action :authorize_download_code! before_action :authorize_edit_tree!, only: [:create_dir] - before_action only: [:show] do - push_frontend_feature_flag(:vue_file_list_lfs_badge, default_enabled: true) - end - def show - return render_404 unless @repository.commit(@ref) + return render_404 unless @commit if tree.entries.empty? if @repository.blob_at(@commit.id, @path) - return redirect_to project_blob_path(@project, File.join(@ref, @path)) + redirect_to project_blob_path(@project, File.join(@ref, @path)) elsif @path.present? - return redirect_to_tree_root_for_missing_path(@project, @ref, @path) - end - end - - respond_to do |format| - format.html do - lfs_blob_ids if Feature.disabled?(:vue_file_list, @project, default_enabled: true) - - @last_commit = @repository.last_commit_for_path(@commit.id, @tree.path) || @commit + redirect_to_tree_root_for_missing_path(@project, @ref, @path) end end end diff --git a/app/controllers/projects/variables_controller.rb b/app/controllers/projects/variables_controller.rb index 1dffc57fcf0..2cc030d18fc 100644 --- a/app/controllers/projects/variables_controller.rb +++ b/app/controllers/projects/variables_controller.rb @@ -6,7 +6,7 @@ class Projects::VariablesController < Projects::ApplicationController def show respond_to do |format| format.json do - render status: :ok, json: { variables: VariableSerializer.new.represent(@project.variables) } + render status: :ok, json: { variables: ::Ci::VariableSerializer.new.represent(@project.variables) } end end end @@ -26,7 +26,7 @@ class Projects::VariablesController < Projects::ApplicationController private def render_variables - render status: :ok, json: { variables: VariableSerializer.new.represent(@project.variables) } + render status: :ok, json: { variables: ::Ci::VariableSerializer.new.represent(@project.variables) } end def render_error diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb index 85e643aa212..d0aa733cadb 100644 --- a/app/controllers/projects/wikis_controller.rb +++ b/app/controllers/projects/wikis_controller.rb @@ -2,7 +2,6 @@ class Projects::WikisController < Projects::ApplicationController include WikiActions - include PreviewMarkdown alias_method :container, :project diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index f0ddd62e996..a5666cb70ac 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -8,6 +8,7 @@ class ProjectsController < Projects::ApplicationController include SendFileUpload include RecordUserLastActivity include ImportUrlParams + include FiltersEvents prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) } @@ -21,7 +22,6 @@ class ProjectsController < Projects::ApplicationController before_action :assign_ref_vars, if: -> { action_name == 'show' && repo_exists? } before_action :tree, if: -> { action_name == 'show' && repo_exists? && project_view_files? } - before_action :lfs_blob_ids, if: :show_blob_ids?, only: :show before_action :project_export_enabled, only: [:export, :download_export, :remove_export, :generate_new_export] before_action :present_project, only: [:edit] before_action :authorize_download_code!, only: [:refs] @@ -38,6 +38,7 @@ class ProjectsController < Projects::ApplicationController before_action only: [:new, :create] do frontend_experimentation_tracking_data(:new_create_project_ui, 'click_tab') push_frontend_feature_flag(:new_create_project_ui) if experiment_enabled?(:new_create_project_ui) + push_frontend_feature_flag(:service_desk_custom_address, @project) end layout :determine_layout @@ -301,10 +302,6 @@ class ProjectsController < Projects::ApplicationController private - def show_blob_ids? - repo_exists? && project_view_files? && Feature.disabled?(:vue_file_list, @project, default_enabled: true) - end - # Render project landing depending of which features are available # So if page is not available in the list it renders the next page # @@ -395,6 +392,7 @@ class ProjectsController < Projects::ApplicationController :initialize_with_readme, :autoclose_referenced_issues, :suggestion_commit_message, + :service_desk_enabled, project_feature_attributes: %i[ builds_access_level @@ -409,6 +407,7 @@ class ProjectsController < Projects::ApplicationController ], project_setting_attributes: %i[ show_default_award_emojis + squash_option ] ] end diff --git a/app/controllers/registrations/experience_levels_controller.rb b/app/controllers/registrations/experience_levels_controller.rb index 515d6b3f9aa..97239b1bbac 100644 --- a/app/controllers/registrations/experience_levels_controller.rb +++ b/app/controllers/registrations/experience_levels_controller.rb @@ -33,12 +33,13 @@ module Registrations def hide_advanced_issues return unless current_user.user_preference.novice? + return unless learn_gitlab.available? - settings = cookies[:onboarding_issues_settings] - return unless settings + Boards::UpdateService.new(learn_gitlab.project, current_user, label_ids: [learn_gitlab.label.id]).execute(learn_gitlab.board) + end - modified_settings = Gitlab::Json.parse(settings).merge(hideAdvanced: true) - cookies[:onboarding_issues_settings] = modified_settings.to_json + def learn_gitlab + @learn_gitlab ||= LearnGitlab.new(current_user) end end end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 6ab2924a8b5..b1c1fe3ba74 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -64,8 +64,8 @@ class RegistrationsController < Devise::RegistrationsController if result[:status] == :success track_experiment_event(:signup_flow, 'end') # We want this event to be tracked when the user is _in_ the experimental group - track_experiment_event(:onboarding_issues, 'signed_up') if ::Gitlab.com? && !helpers.in_subscription_flow? && !helpers.in_invitation_flow? - return redirect_to new_users_sign_up_group_path if experiment_enabled?(:onboarding_issues) && !helpers.in_subscription_flow? && !helpers.in_invitation_flow? + track_experiment_event(:onboarding_issues, 'signed_up') if ::Gitlab.com? && show_onboarding_issues_experiment? + return redirect_to new_users_sign_up_group_path if experiment_enabled?(:onboarding_issues) && show_onboarding_issues_experiment? set_flash_message! :notice, :signed_up redirect_to path_for_signed_in_user(current_user) @@ -210,6 +210,10 @@ class RegistrationsController < Devise::RegistrationsController 'devise' end end + + def show_onboarding_issues_experiment? + !helpers.in_subscription_flow? && !helpers.in_invitation_flow? && !helpers.in_oauth_flow? + end end RegistrationsController.prepend_if_ee('EE::RegistrationsController') diff --git a/app/controllers/root_controller.rb b/app/controllers/root_controller.rb index 24452f9a188..14469877e14 100644 --- a/app/controllers/root_controller.rb +++ b/app/controllers/root_controller.rb @@ -13,10 +13,15 @@ class RootController < Dashboard::ProjectsController before_action :redirect_unlogged_user, if: -> { current_user.nil? } before_action :redirect_logged_user, if: -> { current_user.present? } + # We only need to load the projects when the user is logged in but did not + # configure a dashboard. In which case we render projects. We can do that straight + # from the #index action. + skip_before_action :projects def index # n+1: https://gitlab.com/gitlab-org/gitlab-foss/issues/40260 Gitlab::GitalyClient.allow_n_plus_1_calls do + projects super end end diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 217f08dd648..ff6d9350a5c 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -51,6 +51,21 @@ class SearchController < ApplicationController render json: { count: count } end + # rubocop: disable CodeReuse/ActiveRecord + def autocomplete + term = params[:term] + + if params[:project_id].present? + @project = Project.find_by(id: params[:project_id]) + @project = nil unless can?(current_user, :read_project, @project) + end + + @ref = params[:project_ref] if params[:project_ref].present? + + render json: search_autocomplete_opts(term).to_json + end + # rubocop: enable CodeReuse/ActiveRecord + private def preload_method diff --git a/app/controllers/snippets/blobs_controller.rb b/app/controllers/snippets/blobs_controller.rb new file mode 100644 index 00000000000..d7c4bbcf8f2 --- /dev/null +++ b/app/controllers/snippets/blobs_controller.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Snippets::BlobsController < Snippets::ApplicationController + include Snippets::BlobsActions + + skip_before_action :authenticate_user!, only: [:raw] +end diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb index 87d87390e57..e68b821459d 100644 --- a/app/controllers/snippets_controller.rb +++ b/app/controllers/snippets_controller.rb @@ -21,7 +21,7 @@ class SnippetsController < Snippets::ApplicationController if params[:username].present? @user = UserFinder.new(params[:username]).find_by_username! - @snippets = SnippetsFinder.new(current_user, author: @user, scope: params[:scope]) + @snippets = SnippetsFinder.new(current_user, author: @user, scope: params[:scope], sort: sort_param) .execute .page(params[:page]) .inc_author diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 5ee97885071..95ea31fa977 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -3,6 +3,7 @@ class UsersController < ApplicationController include RoutableActions include RendersMemberAccess + include RendersProjectsList include ControllerWithCrossProjectAccessCheck include Gitlab::NoteableMetadata @@ -36,6 +37,12 @@ class UsersController < ApplicationController end end + # Get all keys of a user(params[:username]) in a text format + # Helpful for sysadmins to put in respective servers + def ssh_keys + render plain: user.all_ssh_keys.join("\n") + end + def activity respond_to do |format| format.html { render 'show' } diff --git a/app/finders/branches_finder.rb b/app/finders/branches_finder.rb index 8001c70a9b2..2eee90a512a 100644 --- a/app/finders/branches_finder.rb +++ b/app/finders/branches_finder.rb @@ -5,11 +5,15 @@ class BranchesFinder < GitRefsFinder super(repository, params) end - def execute - branches = repository.branches_sorted_by(sort) - branches = by_search(branches) - branches = by_names(branches) - branches + def execute(gitaly_pagination: false) + if gitaly_pagination && names.blank? && search.blank? + repository.branches_sorted_by(sort, pagination_params) + else + branches = repository.branches_sorted_by(sort) + branches = by_search(branches) + branches = by_names(branches) + branches + end end private @@ -18,6 +22,18 @@ class BranchesFinder < GitRefsFinder @params[:names].presence end + def per_page + @params[:per_page].presence + end + + def page_token + "#{Gitlab::Git::BRANCH_REF_PREFIX}#{@params[:page_token]}" if @params[:page_token] + end + + def pagination_params + { limit: per_page, page_token: page_token } + end + def by_names(branches) return branches unless names diff --git a/app/finders/ci/pipelines_finder.rb b/app/finders/ci/pipelines_finder.rb index 9e71e92b456..7347a83d294 100644 --- a/app/finders/ci/pipelines_finder.rb +++ b/app/finders/ci/pipelines_finder.rb @@ -71,7 +71,7 @@ module Ci # rubocop: disable CodeReuse/ActiveRecord def by_status(items) - return items unless HasStatus::AVAILABLE_STATUSES.include?(params[:status]) + return items unless Ci::HasStatus::AVAILABLE_STATUSES.include?(params[:status]) items.where(status: params[:status]) end diff --git a/app/finders/ci/pipelines_for_merge_request_finder.rb b/app/finders/ci/pipelines_for_merge_request_finder.rb index c01a68d6749..93d139652b9 100644 --- a/app/finders/ci/pipelines_for_merge_request_finder.rb +++ b/app/finders/ci/pipelines_for_merge_request_finder.rb @@ -7,14 +7,29 @@ module Ci EVENT = 'merge_request_event' - def initialize(merge_request) + def initialize(merge_request, current_user) @merge_request = merge_request + @current_user = current_user end - attr_reader :merge_request + attr_reader :merge_request, :current_user - delegate :commit_shas, :source_project, :source_branch, to: :merge_request + delegate :commit_shas, :target_project, :source_project, :source_branch, to: :merge_request + # Fetch all pipelines that the user can read. + def execute + if can_read_pipeline_in_target_project? && can_read_pipeline_in_source_project? + all + elsif can_read_pipeline_in_source_project? + all.for_project(merge_request.source_project) + elsif can_read_pipeline_in_target_project? + all.for_project(merge_request.target_project) + else + Ci::Pipeline.none + end + end + + # Fetch all pipelines without permission check. def all strong_memoize(:all_pipelines) do next Ci::Pipeline.none unless source_project @@ -35,13 +50,13 @@ module Ci def pipelines_using_cte cte = Gitlab::SQL::CTE.new(:shas, merge_request.all_commits.select(:sha)) - source_pipelines_join = cte.table[:sha].eq(Ci::Pipeline.arel_table[:source_sha]) - source_pipelines = filter_by(triggered_by_merge_request, cte, source_pipelines_join) - detached_pipelines = filter_by_sha(triggered_by_merge_request, cte) + source_sha_join = cte.table[:sha].eq(Ci::Pipeline.arel_table[:source_sha]) + merged_result_pipelines = filter_by(triggered_by_merge_request, cte, source_sha_join) + detached_merge_request_pipelines = filter_by_sha(triggered_by_merge_request, cte) pipelines_for_branch = filter_by_sha(triggered_for_branch, cte) Ci::Pipeline.with(cte.to_arel) # rubocop: disable CodeReuse/ActiveRecord - .from_union([source_pipelines, detached_pipelines, pipelines_for_branch]) + .from_union([merged_result_pipelines, detached_merge_request_pipelines, pipelines_for_branch]) end def filter_by_sha(pipelines, cte) @@ -65,8 +80,7 @@ module Ci # NOTE: this method returns only parent merge request pipelines. # Child merge request pipelines have a different source. def triggered_by_merge_request - source_project.ci_pipelines - .where(source: :merge_request_event, merge_request: merge_request) # rubocop: disable CodeReuse/ActiveRecord + Ci::Pipeline.triggered_by_merge_request(merge_request) end def triggered_for_branch @@ -86,5 +100,17 @@ module Ci pipelines.order(Arel.sql(query)) # rubocop: disable CodeReuse/ActiveRecord end + + def can_read_pipeline_in_target_project? + strong_memoize(:can_read_pipeline_in_target_project) do + Ability.allowed?(current_user, :read_pipeline, target_project) + end + end + + def can_read_pipeline_in_source_project? + strong_memoize(:can_read_pipeline_in_source_project) do + Ability.allowed?(current_user, :read_pipeline, source_project) + end + end end end diff --git a/app/finders/ci/runner_jobs_finder.rb b/app/finders/ci/runner_jobs_finder.rb index ffcdb407e7e..9dc3c2a2427 100644 --- a/app/finders/ci/runner_jobs_finder.rb +++ b/app/finders/ci/runner_jobs_finder.rb @@ -21,7 +21,7 @@ module Ci # rubocop: disable CodeReuse/ActiveRecord def by_status(items) - return items unless HasStatus::AVAILABLE_STATUSES.include?(params[:status]) + return items unless Ci::HasStatus::AVAILABLE_STATUSES.include?(params[:status]) items.where(status: params[:status]) end diff --git a/app/finders/ci/variables_finder.rb b/app/finders/ci/variables_finder.rb new file mode 100644 index 00000000000..d933643ffb2 --- /dev/null +++ b/app/finders/ci/variables_finder.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Ci + class VariablesFinder + attr_reader :project, :params + + def initialize(project, params) + @project, @params = project, params + + raise ArgumentError, 'Please provide params[:key]' if params[:key].blank? + end + + def execute + variables = project.variables + variables = by_key(variables) + variables = by_environment_scope(variables) + variables + end + + private + + def by_key(variables) + variables.by_key(params[:key]) + end + + def by_environment_scope(variables) + environment_scope = params.dig(:filter, :environment_scope) + environment_scope.present? ? variables.by_environment_scope(environment_scope) : variables + end + end +end diff --git a/app/finders/events_finder.rb b/app/finders/events_finder.rb index 004fbc4cd22..4c619f3d7ea 100644 --- a/app/finders/events_finder.rb +++ b/app/finders/events_finder.rb @@ -54,17 +54,10 @@ class EventsFinder if current_user && scope == 'all' EventCollection.new(current_user.authorized_projects).all_project_events else - # EventCollection is responsible for applying the feature flag - apply_feature_flags(source.events) + source.events end end - def apply_feature_flags(events) - return events if ::Feature.enabled?(:wiki_events) - - events.not_wiki_page - end - # rubocop: disable CodeReuse/ActiveRecord def by_current_user_access(events) events.merge(Project.public_or_visible_to_user(current_user)) diff --git a/app/finders/group_projects_finder.rb b/app/finders/group_projects_finder.rb index dd8b2f29425..5f24b15156c 100644 --- a/app/finders/group_projects_finder.rb +++ b/app/finders/group_projects_finder.rb @@ -19,6 +19,9 @@ # personal: boolean # search: string # non_archived: boolean +# with_issues_enabled: boolean +# with_merge_requests_enabled: boolean +# min_access_level: int # class GroupProjectsFinder < ProjectsFinder DEFAULT_PROJECTS_LIMIT = 100 @@ -42,6 +45,12 @@ class GroupProjectsFinder < ProjectsFinder private + def filter_projects(collection) + projects = super + projects = by_feature_availability(projects) + projects + end + def limit(collection) limit = options[:limit] @@ -49,35 +58,37 @@ class GroupProjectsFinder < ProjectsFinder end def init_collection - projects = if current_user - collection_with_user - else - collection_without_user - end + projects = + if only_shared? + [shared_projects] + elsif only_owned? + [owned_projects] + else + [owned_projects, shared_projects] + end + + projects.map! do |project_relation| + filter_by_visibility(project_relation) + end union(projects) end - def collection_with_user - if only_shared? - [shared_projects.public_or_visible_to_user(current_user)] - elsif only_owned? - [owned_projects.public_or_visible_to_user(current_user)] - else - [ - owned_projects.public_or_visible_to_user(current_user), - shared_projects.public_or_visible_to_user(current_user) - ] - end + def by_feature_availability(projects) + projects = projects.with_issues_available_for_user(current_user) if params[:with_issues_enabled].present? + projects = projects.with_merge_requests_available_for_user(current_user) if params[:with_merge_requests_enabled].present? + projects end - def collection_without_user - if only_shared? - [shared_projects.public_only] - elsif only_owned? - [owned_projects.public_only] + def filter_by_visibility(relation) + if current_user + if min_access_level? + relation.visible_to_user_and_access_level(current_user, params[:min_access_level]) + else + relation.public_or_visible_to_user(current_user) + end else - [shared_projects.public_only, owned_projects.public_only] + relation.public_only end end diff --git a/app/finders/issuable_finder/params.rb b/app/finders/issuable_finder/params.rb index 5b48d0817e3..8a194f34f74 100644 --- a/app/finders/issuable_finder/params.rb +++ b/app/finders/issuable_finder/params.rb @@ -110,7 +110,9 @@ class IssuableFinder def group strong_memoize(:group) do - if params[:group_id].present? + if params[:group_id].is_a?(Group) + params[:group_id] + elsif params[:group_id].present? Group.find(params[:group_id]) else nil diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb index 72695a9d501..2b2e6b377b4 100644 --- a/app/finders/issues_finder.rb +++ b/app/finders/issues_finder.rb @@ -24,6 +24,7 @@ # created_before: datetime # updated_after: datetime # updated_before: datetime +# confidential: boolean # class IssuesFinder < IssuableFinder CONFIDENTIAL_ACCESS_LEVEL = Gitlab::Access::REPORTER diff --git a/app/finders/issues_finder/params.rb b/app/finders/issues_finder/params.rb index cd92b79265d..668d969f7c0 100644 --- a/app/finders/issues_finder/params.rb +++ b/app/finders/issues_finder/params.rb @@ -27,19 +27,14 @@ class IssuesFinder end def user_can_see_all_confidential_issues? - return @user_can_see_all_confidential_issues if defined?(@user_can_see_all_confidential_issues) - - return @user_can_see_all_confidential_issues = false if current_user.blank? - return @user_can_see_all_confidential_issues = true if current_user.can_read_all_resources? - - @user_can_see_all_confidential_issues = - if project? && project - project.team.max_member_access(current_user.id) >= CONFIDENTIAL_ACCESS_LEVEL - elsif group - group.max_member_access_for_user(current_user) >= CONFIDENTIAL_ACCESS_LEVEL + strong_memoize(:user_can_see_all_confidential_issues) do + parent = project? ? project : group + if parent + Ability.allowed?(current_user, :read_confidential_issues, parent) else - false + Ability.allowed?(current_user, :read_all_resources) end + end end def user_cannot_see_confidential_issues? diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb index 8e57014f66e..1a3f011d9eb 100644 --- a/app/finders/notes_finder.rb +++ b/app/finders/notes_finder.rb @@ -158,13 +158,16 @@ class NotesFinder end # Notes changed since last fetch - # Uses overlapping intervals to avoid worrying about race conditions def since_fetch_at(notes) return notes unless @params[:last_fetched_at] # Default to 0 to remain compatible with old clients - last_fetched_at = Time.at(@params.fetch(:last_fetched_at, 0).to_i) - notes.updated_after(last_fetched_at - FETCH_OVERLAP) + last_fetched_at = @params.fetch(:last_fetched_at, Time.at(0)) + + # Use overlapping intervals to avoid worrying about race conditions + last_fetched_at -= FETCH_OVERLAP + + notes.updated_after(last_fetched_at) end def notes_filter? diff --git a/app/finders/packages/composer/packages_finder.rb b/app/finders/packages/composer/packages_finder.rb new file mode 100644 index 00000000000..e63b2ee03fa --- /dev/null +++ b/app/finders/packages/composer/packages_finder.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +module Packages + module Composer + class PackagesFinder < Packages::GroupPackagesFinder + def initialize(current_user, group, params = {}) + @current_user = current_user + @group = group + @params = params + end + + def execute + packages_for_group_projects.composer.preload_composer + end + end + end +end diff --git a/app/finders/packages/conan/package_file_finder.rb b/app/finders/packages/conan/package_file_finder.rb new file mode 100644 index 00000000000..edf35388a36 --- /dev/null +++ b/app/finders/packages/conan/package_file_finder.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Packages + module Conan + class PackageFileFinder < ::Packages::PackageFileFinder + private + + def package_files + files = super + files = by_conan_file_type(files) + files = by_conan_package_reference(files) + files + end + + def by_conan_file_type(files) + return files unless params[:conan_file_type] + + files.with_conan_file_type(params[:conan_file_type]) + end + + def by_conan_package_reference(files) + return files unless params[:conan_package_reference] + + files.with_conan_package_reference(params[:conan_package_reference]) + end + end + end +end diff --git a/app/finders/packages/conan/package_finder.rb b/app/finders/packages/conan/package_finder.rb new file mode 100644 index 00000000000..26e9182f4e1 --- /dev/null +++ b/app/finders/packages/conan/package_finder.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Packages + module Conan + class PackageFinder + attr_reader :current_user, :query + + def initialize(current_user, params) + @current_user = current_user + @query = params[:query] + end + + def execute + packages_for_current_user.with_name_like(query).order_name_asc if query + end + + private + + def packages + Packages::Package.conan + end + + def packages_for_current_user + packages.for_projects(projects_visible_to_current_user) + end + + def projects_visible_to_current_user + ::Project.public_or_visible_to_user(current_user) + end + end + end +end diff --git a/app/finders/packages/go/module_finder.rb b/app/finders/packages/go/module_finder.rb new file mode 100644 index 00000000000..ed8bd5599d9 --- /dev/null +++ b/app/finders/packages/go/module_finder.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Packages + module Go + class ModuleFinder + include Gitlab::Golang + + attr_reader :project, :module_name + + def initialize(project, module_name) + module_name = Pathname.new(module_name).cleanpath.to_s + + @project = project + @module_name = module_name + end + + def execute + return if @module_name.blank? || !@module_name.start_with?(local_module_prefix) + + module_path = @module_name[local_module_prefix.length..].split('/') + project_path = project.full_path.split('/') + module_project_path = module_path.shift(project_path.length) + return unless module_project_path == project_path + + Packages::Go::Module.new(@project, @module_name, module_path.join('/')) + end + end + end +end diff --git a/app/finders/packages/go/version_finder.rb b/app/finders/packages/go/version_finder.rb new file mode 100644 index 00000000000..8e2fab8ba35 --- /dev/null +++ b/app/finders/packages/go/version_finder.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Packages + module Go + class VersionFinder + include Gitlab::Golang + + attr_reader :mod + + def initialize(mod) + @mod = mod + end + + def execute + @mod.project.repository.tags + .filter { |tag| semver_tag? tag } + .map { |tag| @mod.version_by(ref: tag) } + .filter { |ver| ver.valid? } + end + + def find(target) + case target + when String + if pseudo_version? target + semver = parse_semver(target) + commit = pseudo_version_commit(@mod.project, semver) + Packages::Go::ModuleVersion.new(@mod, :pseudo, commit, name: target, semver: semver) + else + @mod.version_by(ref: target) + end + + when Gitlab::Git::Ref + @mod.version_by(ref: target) + + when ::Commit, Gitlab::Git::Commit + @mod.version_by(commit: target) + + else + raise ArgumentError.new 'not a valid target' + end + end + end + end +end diff --git a/app/finders/packages/group_packages_finder.rb b/app/finders/packages/group_packages_finder.rb new file mode 100644 index 00000000000..ffc8c35fbcc --- /dev/null +++ b/app/finders/packages/group_packages_finder.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Packages + class GroupPackagesFinder + attr_reader :current_user, :group, :params + + InvalidPackageTypeError = Class.new(StandardError) + + def initialize(current_user, group, params = { exclude_subgroups: false, order_by: 'created_at', sort: 'asc' }) + @current_user = current_user + @group = group + @params = params + end + + def execute + return ::Packages::Package.none unless group + + packages_for_group_projects + end + + private + + def packages_for_group_projects + packages = ::Packages::Package + .for_projects(group_projects_visible_to_current_user) + .processed + .has_version + .sort_by_attribute("#{params[:order_by]}_#{params[:sort]}") + + packages = filter_by_package_type(packages) + packages = filter_by_package_name(packages) + packages + end + + def group_projects_visible_to_current_user + ::Project + .in_namespace(groups) + .public_or_visible_to_user(current_user, Gitlab::Access::REPORTER) + .with_project_feature + .select { |project| Ability.allowed?(current_user, :read_package, project) } + end + + def package_type + params[:package_type].presence + end + + def groups + return [group] if exclude_subgroups? + + group.self_and_descendants + end + + def exclude_subgroups? + params[:exclude_subgroups] + end + + def filter_by_package_type(packages) + return packages unless package_type + raise InvalidPackageTypeError unless Package.package_types.key?(package_type) + + packages.with_package_type(package_type) + end + + def filter_by_package_name(packages) + return packages unless params[:package_name].present? + + packages.search_by_name(params[:package_name]) + end + end +end diff --git a/app/finders/packages/maven/package_finder.rb b/app/finders/packages/maven/package_finder.rb new file mode 100644 index 00000000000..775db12adb7 --- /dev/null +++ b/app/finders/packages/maven/package_finder.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true +module Packages + module Maven + class PackageFinder + attr_reader :path, :current_user, :project, :group + + def initialize(path, current_user, project: nil, group: nil) + @path = path + @current_user = current_user + @project = project + @group = group + end + + def execute + packages_with_path.last + end + + def execute! + packages_with_path.last! + end + + private + + def base + if project + packages_for_a_single_project + elsif group + packages_for_multiple_projects + else + packages + end + end + + def packages_with_path + base.only_maven_packages_with_path(path) + end + + # Produces a query that returns all packages. + def packages + ::Packages::Package.all + end + + # Produces a query that retrieves packages from a single project. + def packages_for_a_single_project + project.packages + end + + # Produces a query that retrieves packages from multiple projects that + # the current user can view within a group. + def packages_for_multiple_projects + ::Packages::Package.for_projects(projects_visible_to_current_user) + end + + # Returns the projects that the current user can view within a group. + def projects_visible_to_current_user + ::Project + .in_namespace(group.self_and_descendants.select(:id)) + .public_or_visible_to_user(current_user) + end + end + end +end diff --git a/app/finders/packages/npm/package_finder.rb b/app/finders/packages/npm/package_finder.rb new file mode 100644 index 00000000000..8599fd07e7f --- /dev/null +++ b/app/finders/packages/npm/package_finder.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +module Packages + module Npm + class PackageFinder + attr_reader :project, :package_name + + delegate :find_by_version, to: :execute + + def initialize(project, package_name) + @project = project + @package_name = package_name + end + + def execute + packages + end + + private + + def packages + project.packages + .npm + .with_name(package_name) + .last_of_each_version + .preload_files + end + end + end +end diff --git a/app/finders/packages/nuget/package_finder.rb b/app/finders/packages/nuget/package_finder.rb new file mode 100644 index 00000000000..e6fb6712d47 --- /dev/null +++ b/app/finders/packages/nuget/package_finder.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true +module Packages + module Nuget + class PackageFinder + MAX_PACKAGES_COUNT = 50 + + def initialize(project, package_name:, package_version: nil, limit: MAX_PACKAGES_COUNT) + @project = project + @package_name = package_name + @package_version = package_version + @limit = limit + end + + def execute + packages.limit_recent(@limit) + end + + private + + def packages + result = @project.packages + .nuget + .has_version + .processed + .with_name_like(@package_name) + result = result.with_version(@package_version) if @package_version.present? + result + end + end + end +end diff --git a/app/finders/packages/package_file_finder.rb b/app/finders/packages/package_file_finder.rb new file mode 100644 index 00000000000..d015f4adfa6 --- /dev/null +++ b/app/finders/packages/package_file_finder.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true +class Packages::PackageFileFinder + attr_reader :package, :file_name, :params + + def initialize(package, file_name, params = {}) + @package = package + @file_name = file_name + @params = params + end + + def execute + package_files.last + end + + def execute! + package_files.last! + end + + private + + def package_files + files = package.package_files + + files = by_file_name(files) + + files + end + + def by_file_name(files) + if params[:with_file_name_like] + files.with_file_name_like(file_name) + else + files.with_file_name(file_name) + end + end +end diff --git a/app/finders/packages/package_finder.rb b/app/finders/packages/package_finder.rb new file mode 100644 index 00000000000..0e911491da2 --- /dev/null +++ b/app/finders/packages/package_finder.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +module Packages + class PackageFinder + def initialize(project, package_id) + @project = project + @package_id = package_id + end + + def execute + @project + .packages + .processed + .find(@package_id) + end + end +end diff --git a/app/finders/packages/packages_finder.rb b/app/finders/packages/packages_finder.rb new file mode 100644 index 00000000000..c533cb266a2 --- /dev/null +++ b/app/finders/packages/packages_finder.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Packages + class PackagesFinder + attr_reader :params, :project + + def initialize(project, params = {}) + @project = project + @params = params + + params[:order_by] ||= 'created_at' + params[:sort] ||= 'asc' + end + + def execute + packages = project.packages.processed.has_version + packages = filter_by_package_type(packages) + packages = filter_by_package_name(packages) + packages = order_packages(packages) + packages + end + + private + + def filter_by_package_type(packages) + return packages unless params[:package_type] + + packages.with_package_type(params[:package_type]) + end + + def filter_by_package_name(packages) + return packages unless params[:package_name] + + packages.search_by_name(params[:package_name]) + end + + def order_packages(packages) + packages.sort_by_attribute("#{params[:order_by]}_#{params[:sort]}") + end + end +end diff --git a/app/finders/packages/tags_finder.rb b/app/finders/packages/tags_finder.rb new file mode 100644 index 00000000000..020b3d8072a --- /dev/null +++ b/app/finders/packages/tags_finder.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true +class Packages::TagsFinder + attr_reader :project, :package_name, :params + + delegate :find_by_name, to: :execute + + def initialize(project, package_name, params = {}) + @project = project + @package_name = package_name + @params = params + end + + def execute + packages = project.packages + .with_name(package_name) + packages = packages.with_package_type(package_type) if package_type.present? + + Packages::Tag.for_packages(packages) + end + + private + + def package_type + params[:package_type] + end +end diff --git a/app/finders/personal_access_tokens_finder.rb b/app/finders/personal_access_tokens_finder.rb index 7b15a3b0c10..e3d5f2ae8de 100644 --- a/app/finders/personal_access_tokens_finder.rb +++ b/app/finders/personal_access_tokens_finder.rb @@ -51,6 +51,8 @@ class PersonalAccessTokensFinder tokens.active when 'inactive' tokens.inactive + when 'active_or_expired' + tokens.not_revoked.expired.or(tokens.active) else tokens end diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index 8846ff54eb2..7c7cd87a7c1 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -23,6 +23,7 @@ # min_access_level: integer # last_activity_after: datetime # last_activity_before: datetime +# repository_storage: string # class ProjectsFinder < UnionFinder include CustomAttributesFilter @@ -75,6 +76,7 @@ class ProjectsFinder < UnionFinder collection = by_deleted_status(collection) collection = by_last_activity_after(collection) collection = by_last_activity_before(collection) + collection = by_repository_storage(collection) collection end @@ -197,6 +199,14 @@ class ProjectsFinder < UnionFinder end end + def by_repository_storage(items) + if params[:repository_storage].present? + items.where(repository_storage: params[:repository_storage]) # rubocop: disable CodeReuse/ActiveRecord + else + items + end + end + def sort(items) params[:sort].present? ? items.sort_by_attribute(params[:sort]) : items.projects_order_id_desc end diff --git a/app/finders/resource_milestone_event_finder.rb b/app/finders/resource_milestone_event_finder.rb index 7af34f0a4bc..f3b779c8f77 100644 --- a/app/finders/resource_milestone_event_finder.rb +++ b/app/finders/resource_milestone_event_finder.rb @@ -1,69 +1,56 @@ # frozen_string_literal: true class ResourceMilestoneEventFinder - include FinderMethods - - MAX_PER_PAGE = 100 - - attr_reader :params, :current_user, :eventable - - def initialize(current_user, eventable, params = {}) + def initialize(current_user, eventable) @current_user = current_user @eventable = eventable - @params = params end + # Returns the ResourceMilestoneEvents of the eventable + # visible to the user. + # + # @return ResourceMilestoneEvent::ActiveRecord_AssociationRelation def execute - Kaminari.paginate_array(visible_events) + eventable.resource_milestone_events.include_relations + .where(milestone_id: readable_milestone_ids) # rubocop: disable CodeReuse/ActiveRecord end private - def visible_events - @visible_events ||= visible_to_user(events) - end + attr_reader :current_user, :eventable - def events - @events ||= eventable.resource_milestone_events.include_relations.page(page).per(per_page) - end + def readable_milestone_ids + readable_milestones = events_milestones.select do |milestone| + parent_availabilities[key_for_parent(milestone.parent)] + end - def visible_to_user(events) - events.select { |event| visible_for_user?(event) } + readable_milestones.map(&:id).uniq end - def visible_for_user?(event) - milestone = event_milestones[event.milestone_id] - return if milestone.blank? + # rubocop: disable CodeReuse/ActiveRecord + def events_milestones + @events_milestones ||= Milestone.where(id: unique_milestone_ids_from_events) + .includes(:project, :group) + end + # rubocop: enable CodeReuse/ActiveRecord - parent = milestone.parent - parent_availabilities[key_for_parent(parent)] + def relevant_milestone_parents + events_milestones.map(&:parent).uniq end def parent_availabilities - @parent_availabilities ||= relevant_parents.to_h do |parent| + @parent_availabilities ||= relevant_milestone_parents.to_h do |parent| [key_for_parent(parent), Ability.allowed?(current_user, :read_milestone, parent)] end end - def key_for_parent(parent) - "#{parent.class.name}_#{parent.id}" - end - - def event_milestones - @milestones ||= events.map(&:milestone).uniq.to_h do |milestone| - [milestone.id, milestone] - end - end - - def relevant_parents - @relevant_parents ||= event_milestones.map { |_id, milestone| milestone.parent } + # rubocop: disable CodeReuse/ActiveRecord + def unique_milestone_ids_from_events + eventable.resource_milestone_events.select(:milestone_id).distinct end + # rubocop: enable CodeReuse/ActiveRecord - def per_page - [params[:per_page], MAX_PER_PAGE].compact.min - end - - def page - params[:page] || 1 + def key_for_parent(parent) + "#{parent.class.name}_#{parent.id}" end end diff --git a/app/finders/resource_state_event_finder.rb b/app/finders/resource_state_event_finder.rb new file mode 100644 index 00000000000..7f4ac3332cd --- /dev/null +++ b/app/finders/resource_state_event_finder.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class ResourceStateEventFinder + include FinderMethods + + def initialize(current_user, eventable) + @current_user = current_user + @eventable = eventable + end + + def execute + return ResourceStateEvent.none unless can_read_eventable? + + eventable.resource_state_events.includes(:user) # rubocop: disable CodeReuse/ActiveRecord + end + + def can_read_eventable? + return unless eventable + + Ability.allowed?(current_user, read_ability, eventable) + end + + private + + attr_reader :current_user, :eventable + + def read_ability + :"read_#{eventable.class.to_ability_name}" + end +end diff --git a/app/finders/snippets_finder.rb b/app/finders/snippets_finder.rb index 4f63810423b..941abb70400 100644 --- a/app/finders/snippets_finder.rb +++ b/app/finders/snippets_finder.rb @@ -43,7 +43,7 @@ class SnippetsFinder < UnionFinder include Gitlab::Utils::StrongMemoize attr_accessor :current_user, :params - delegate :explore, :only_personal, :only_project, :scope, to: :params + delegate :explore, :only_personal, :only_project, :scope, :sort, to: :params def initialize(current_user = nil, params = {}) @current_user = current_user @@ -69,7 +69,9 @@ class SnippetsFinder < UnionFinder items = init_collection items = by_ids(items) - items.with_optional_visibility(visibility_from_scope).fresh + items = items.with_optional_visibility(visibility_from_scope) + + items.order_by(sort_param) end private @@ -115,7 +117,7 @@ class SnippetsFinder < UnionFinder queries << snippets_of_authorized_projects if current_user end - find_union(queries, Snippet) + prepared_union(queries) end def snippets_for_a_single_project @@ -202,6 +204,21 @@ class SnippetsFinder < UnionFinder params[:project].is_a?(Project) ? params[:project] : Project.find_by_id(params[:project]) end end + + def sort_param + sort.presence || 'id_desc' + end + + def prepared_union(queries) + return Snippet.none if queries.empty? + return queries.first if queries.length == 1 + + # The queries are going to be part of a global `where` + # therefore we only need to retrieve the `id` column + # which will speed the query + queries.map! { |rel| rel.select(:id) } + Snippet.id_in(find_union(queries, Snippet)) + end end SnippetsFinder.prepend_if_ee('EE::SnippetsFinder') diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb index 672bbd52b07..a2054f73c9d 100644 --- a/app/finders/todos_finder.rb +++ b/app/finders/todos_finder.rb @@ -11,7 +11,7 @@ # author_id: integer # project_id; integer # state: 'pending' (default) or 'done' -# type: 'Issue' or 'MergeRequest' +# type: 'Issue' or 'MergeRequest' or ['Issue', 'MergeRequest'] # class TodosFinder @@ -40,13 +40,14 @@ class TodosFinder def execute return Todo.none if current_user.nil? + raise ArgumentError, invalid_type_message unless valid_types? items = current_user.todos items = by_action_id(items) items = by_action(items) items = by_author(items) items = by_state(items) - items = by_type(items) + items = by_types(items) items = by_group(items) # Filtering by project HAS TO be the last because we use # the project IDs yielded by the todos query thus far @@ -123,12 +124,16 @@ class TodosFinder end end - def type? - type.present? && self.class.todo_types.include?(type) + def types + @types ||= Array(params[:type]).reject(&:blank?) end - def type - params[:type] + def valid_types? + types.all? { |type| self.class.todo_types.include?(type) } + end + + def invalid_type_message + _("Unsupported todo type passed. Supported todo types are: %{todo_types}") % { todo_types: self.class.todo_types.to_a.join(', ') } end def sort(items) @@ -193,9 +198,9 @@ class TodosFinder items.with_states(params[:state]) end - def by_type(items) - if type? - items.for_type(type) + def by_types(items) + if types.any? + items.for_type(types) else items end diff --git a/app/graphql/mutations/alert_management/alerts/todo/create.rb b/app/graphql/mutations/alert_management/alerts/todo/create.rb new file mode 100644 index 00000000000..3dba96e43f1 --- /dev/null +++ b/app/graphql/mutations/alert_management/alerts/todo/create.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Mutations + module AlertManagement + module Alerts + module Todo + class Create < Base + graphql_name 'AlertTodoCreate' + + def resolve(args) + alert = authorized_find!(project_path: args[:project_path], iid: args[:iid]) + result = ::AlertManagement::Alerts::Todo::CreateService.new(alert, current_user).execute + + prepare_response(result) + end + + private + + def prepare_response(result) + { + alert: result.payload[:alert], + todo: result.payload[:todo], + errors: result.error? ? [result.message] : [] + } + end + end + end + end + end +end diff --git a/app/graphql/mutations/alert_management/base.rb b/app/graphql/mutations/alert_management/base.rb index 7fcca63db51..0de4b9409e4 100644 --- a/app/graphql/mutations/alert_management/base.rb +++ b/app/graphql/mutations/alert_management/base.rb @@ -18,6 +18,11 @@ module Mutations null: true, description: "The alert after mutation" + field :todo, + Types::TodoType, + null: true, + description: "The todo after mutation" + field :issue, Types::IssueType, null: true, diff --git a/app/graphql/mutations/alert_management/update_alert_status.rb b/app/graphql/mutations/alert_management/update_alert_status.rb index d820124d26f..ed61555fbd6 100644 --- a/app/graphql/mutations/alert_management/update_alert_status.rb +++ b/app/graphql/mutations/alert_management/update_alert_status.rb @@ -19,8 +19,8 @@ module Mutations private def update_status(alert, status) - ::AlertManagement::UpdateAlertStatusService - .new(alert, current_user, status) + ::AlertManagement::Alerts::UpdateService + .new(alert, current_user, status: status) .execute end diff --git a/app/graphql/mutations/award_emojis/add.rb b/app/graphql/mutations/award_emojis/add.rb index 85f3eb065bb..856fdd5fb14 100644 --- a/app/graphql/mutations/award_emojis/add.rb +++ b/app/graphql/mutations/award_emojis/add.rb @@ -3,7 +3,7 @@ module Mutations module AwardEmojis class Add < Base - graphql_name 'AddAwardEmoji' + graphql_name 'AwardEmojiAdd' def resolve(args) awardable = authorized_find!(id: args[:awardable_id]) diff --git a/app/graphql/mutations/award_emojis/remove.rb b/app/graphql/mutations/award_emojis/remove.rb index f8a3d0ce390..c654688c6dc 100644 --- a/app/graphql/mutations/award_emojis/remove.rb +++ b/app/graphql/mutations/award_emojis/remove.rb @@ -3,7 +3,7 @@ module Mutations module AwardEmojis class Remove < Base - graphql_name 'RemoveAwardEmoji' + graphql_name 'AwardEmojiRemove' def resolve(args) awardable = authorized_find!(id: args[:awardable_id]) diff --git a/app/graphql/mutations/award_emojis/toggle.rb b/app/graphql/mutations/award_emojis/toggle.rb index 22eab4812a1..a7714e695d2 100644 --- a/app/graphql/mutations/award_emojis/toggle.rb +++ b/app/graphql/mutations/award_emojis/toggle.rb @@ -3,7 +3,7 @@ module Mutations module AwardEmojis class Toggle < Base - graphql_name 'ToggleAwardEmoji' + graphql_name 'AwardEmojiToggle' field :toggledOn, GraphQL::BOOLEAN_TYPE, null: false, description: 'Indicates the status of the emoji. ' \ diff --git a/app/graphql/mutations/base_mutation.rb b/app/graphql/mutations/base_mutation.rb index 33f3f33a440..68e7853a9b1 100644 --- a/app/graphql/mutations/base_mutation.rb +++ b/app/graphql/mutations/base_mutation.rb @@ -7,6 +7,8 @@ module Mutations ERROR_MESSAGE = 'You cannot perform write operations on a read-only instance' + field_class ::Types::BaseField + field :errors, [GraphQL::STRING_TYPE], null: false, description: 'Errors encountered during execution of the mutation.' diff --git a/app/graphql/mutations/concerns/mutations/resolves_issuable.rb b/app/graphql/mutations/concerns/mutations/resolves_issuable.rb index 13a56f2e709..0fe2d09de6d 100644 --- a/app/graphql/mutations/concerns/mutations/resolves_issuable.rb +++ b/app/graphql/mutations/concerns/mutations/resolves_issuable.rb @@ -9,30 +9,31 @@ module Mutations end def resolve_issuable(type:, parent_path:, iid:) - parent = resolve_issuable_parent(type, parent_path) - key = type == :merge_request ? :iids : :iid - args = { key => iid.to_s } + parent = ::Gitlab::Graphql::Lazy.force(resolve_issuable_parent(type, parent_path)) + return unless parent.present? - resolver = issuable_resolver(type, parent, context) - ready, early_return = resolver.ready?(**args) - - return early_return unless ready - - resolver.resolve(**args) + finder = issuable_finder(type, iids: [iid]) + Gitlab::Graphql::Loaders::IssuableLoader.new(parent, finder).find_all.first end private - def issuable_resolver(type, parent, context) - resolver_class = "Resolvers::#{type.to_s.classify.pluralize}Resolver".constantize - - resolver_class.single.new(object: parent, context: context, field: nil) + def issuable_finder(type, args) + case type + when :merge_request + MergeRequestsFinder.new(current_user, args) + when :issue + IssuesFinder.new(current_user, args) + else + raise "Unsupported type: #{type}" + end end def resolve_issuable_parent(type, parent_path) + return unless parent_path.present? return unless type == :issue || type == :merge_request - resolve_project(full_path: parent_path) if parent_path.present? + resolve_project(full_path: parent_path) end end end diff --git a/app/graphql/mutations/container_expiration_policies/update.rb b/app/graphql/mutations/container_expiration_policies/update.rb index c210571c6ca..4bff04bb705 100644 --- a/app/graphql/mutations/container_expiration_policies/update.rb +++ b/app/graphql/mutations/container_expiration_policies/update.rb @@ -34,6 +34,16 @@ module Mutations required: false, description: copy_field_description(Types::ContainerExpirationPolicyType, :keep_n) + argument :name_regex, + Types::UntrustedRegexp, + required: false, + description: copy_field_description(Types::ContainerExpirationPolicyType, :name_regex) + + argument :name_regex_keep, + Types::UntrustedRegexp, + required: false, + description: copy_field_description(Types::ContainerExpirationPolicyType, :name_regex_keep) + field :container_expiration_policy, Types::ContainerExpirationPolicyType, null: true, diff --git a/app/graphql/mutations/issues/set_locked.rb b/app/graphql/mutations/issues/set_locked.rb new file mode 100644 index 00000000000..63a8483067a --- /dev/null +++ b/app/graphql/mutations/issues/set_locked.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module Issues + class SetLocked < Base + graphql_name 'IssueSetLocked' + + argument :locked, + GraphQL::BOOLEAN_TYPE, + required: true, + description: 'Whether or not to lock discussion on the issue' + + def resolve(project_path:, iid:, locked:) + issue = authorized_find!(project_path: project_path, iid: iid) + + ::Issues::UpdateService.new(issue.project, current_user, discussion_locked: locked) + .execute(issue) + + { + issue: issue, + errors: errors_on_object(issue) + } + end + end + end +end diff --git a/app/graphql/mutations/jira_import/start.rb b/app/graphql/mutations/jira_import/start.rb index 3df26d33711..eda28059272 100644 --- a/app/graphql/mutations/jira_import/start.rb +++ b/app/graphql/mutations/jira_import/start.rb @@ -21,12 +21,17 @@ module Mutations argument :jira_project_name, GraphQL::STRING_TYPE, required: false, description: 'Project name of the importer Jira project' + argument :users_mapping, + [Types::JiraUsersMappingInputType], + required: false, + description: 'The mapping of Jira to GitLab users' - def resolve(project_path:, jira_project_key:) + def resolve(project_path:, jira_project_key:, users_mapping:) project = authorized_find!(full_path: project_path) + mapping = users_mapping.to_ary.map { |map| map.to_hash } service_response = ::JiraImport::StartImportService - .new(context[:current_user], project, jira_project_key) + .new(context[:current_user], project, jira_project_key, mapping) .execute jira_import = service_response.success? ? service_response.payload[:import_data] : nil diff --git a/app/graphql/mutations/merge_requests/update.rb b/app/graphql/mutations/merge_requests/update.rb new file mode 100644 index 00000000000..b583fdfca9b --- /dev/null +++ b/app/graphql/mutations/merge_requests/update.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Mutations + module MergeRequests + class Update < Base + graphql_name 'MergeRequestUpdate' + + description 'Update attributes of a merge request' + + argument :title, GraphQL::STRING_TYPE, + required: false, + description: copy_field_description(Types::MergeRequestType, :title) + + argument :target_branch, GraphQL::STRING_TYPE, + required: false, + description: copy_field_description(Types::MergeRequestType, :target_branch) + + argument :description, GraphQL::STRING_TYPE, + required: false, + description: copy_field_description(Types::MergeRequestType, :description) + + def resolve(args) + merge_request = authorized_find!(args.slice(:project_path, :iid)) + attributes = args.slice(:title, :description, :target_branch).compact + + ::MergeRequests::UpdateService + .new(merge_request.project, current_user, attributes) + .execute(merge_request) + + errors = errors_on_object(merge_request) + + { + merge_request: merge_request.reset, + errors: errors + } + end + end + end +end diff --git a/app/graphql/mutations/notes/create/base.rb b/app/graphql/mutations/notes/create/base.rb index cf9f74a63d8..f081eac368e 100644 --- a/app/graphql/mutations/notes/create/base.rb +++ b/app/graphql/mutations/notes/create/base.rb @@ -18,6 +18,11 @@ module Mutations required: true, description: copy_field_description(Types::Notes::NoteType, :body) + argument :confidential, + GraphQL::BOOLEAN_TYPE, + required: false, + description: 'The confidentiality flag of a note. Default is false.' + def resolve(args) noteable = authorized_find!(id: args[:noteable_id]) @@ -40,7 +45,8 @@ module Mutations def create_note_params(noteable, args) { noteable: noteable, - note: args[:body] + note: args[:body], + confidential: args[:confidential] } end end diff --git a/app/graphql/mutations/snippets/create.rb b/app/graphql/mutations/snippets/create.rb index e1022358c09..89c21486a74 100644 --- a/app/graphql/mutations/snippets/create.rb +++ b/app/graphql/mutations/snippets/create.rb @@ -21,7 +21,7 @@ module Mutations description: 'File name of the snippet' argument :content, GraphQL::STRING_TYPE, - required: true, + required: false, description: 'Content of the snippet' argument :description, GraphQL::STRING_TYPE, @@ -40,6 +40,10 @@ module Mutations required: false, description: 'The paths to files uploaded in the snippet description' + argument :files, [Types::Snippets::FileInputType], + description: "The snippet files to create", + required: false + def resolve(args) project_path = args.delete(:project_path) @@ -49,13 +53,9 @@ module Mutations raise_resource_not_available_error! end - # We need to rename `uploaded_files` into `files` because - # it's the expected key param - args[:files] = args.delete(:uploaded_files) - service_response = ::Snippets::CreateService.new(project, - context[:current_user], - args).execute + context[:current_user], + create_params(args)).execute snippet = service_response.payload[:snippet] @@ -82,6 +82,18 @@ module Mutations def can_create_personal_snippet? Ability.allowed?(context[:current_user], :create_snippet) end + + def create_params(args) + args.tap do |create_args| + # We need to rename `files` into `snippet_actions` because + # it's the expected key param + create_args[:snippet_actions] = create_args.delete(:files)&.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 + end end end end diff --git a/app/graphql/mutations/snippets/update.rb b/app/graphql/mutations/snippets/update.rb index b6bdcb9b67b..8890158b0df 100644 --- a/app/graphql/mutations/snippets/update.rb +++ b/app/graphql/mutations/snippets/update.rb @@ -30,12 +30,16 @@ module Mutations description: 'The visibility level of the snippet', required: false + argument :files, [Types::Snippets::FileInputType], + description: 'The snippet files to update', + required: false + def resolve(args) snippet = authorized_find!(id: args.delete(:id)) result = ::Snippets::UpdateService.new(snippet.project, - context[:current_user], - args).execute(snippet) + context[:current_user], + update_params(args)).execute(snippet) snippet = result.payload[:snippet] { @@ -47,7 +51,15 @@ module Mutations private def ability_name - "update" + 'update' + end + + def update_params(args) + args.tap do |update_args| + # We need to rename `files` into `snippet_actions` because + # it's the expected key param + update_args[:snippet_actions] = update_args.delete(:files)&.map(&:to_h) + end end end end diff --git a/app/graphql/mutations/todos/mark_all_done.rb b/app/graphql/mutations/todos/mark_all_done.rb index d30d1bcbcf0..8b53658ddd5 100644 --- a/app/graphql/mutations/todos/mark_all_done.rb +++ b/app/graphql/mutations/todos/mark_all_done.rb @@ -10,8 +10,13 @@ module Mutations field :updated_ids, [GraphQL::ID_TYPE], null: false, + deprecated: { reason: 'Use todos', milestone: '13.2' }, description: 'Ids of the updated todos' + field :todos, [::Types::TodoType], + null: false, + description: 'Updated todos' + def resolve authorize!(current_user) @@ -19,6 +24,7 @@ module Mutations { updated_ids: map_to_global_ids(updated_ids), + todos: Todo.id_in(updated_ids), errors: [] } end diff --git a/app/graphql/mutations/todos/restore_many.rb b/app/graphql/mutations/todos/restore_many.rb index e95651b232f..c5e2750768c 100644 --- a/app/graphql/mutations/todos/restore_many.rb +++ b/app/graphql/mutations/todos/restore_many.rb @@ -14,7 +14,12 @@ module Mutations field :updated_ids, [GraphQL::ID_TYPE], null: false, - description: 'The ids of the updated todo items' + description: 'The ids of the updated todo items', + deprecated: { reason: 'Use todos', milestone: '13.2' } + + field :todos, [::Types::TodoType], + null: false, + description: 'Updated todos' def resolve(ids:) check_update_amount_limit!(ids) @@ -24,6 +29,7 @@ module Mutations { updated_ids: gids_of(updated_ids), + todos: Todo.id_in(updated_ids), errors: errors_on_objects(todos) } end diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb index 7daff68c069..791c6eab42f 100644 --- a/app/graphql/resolvers/base_resolver.rb +++ b/app/graphql/resolvers/base_resolver.rb @@ -83,5 +83,10 @@ module Resolvers def current_user context[:current_user] end + + # Overridden in sub-classes (see .single, .last) + def select_result(results) + results + end end end diff --git a/app/graphql/resolvers/ci_configuration/sast_resolver.rb b/app/graphql/resolvers/ci_configuration/sast_resolver.rb new file mode 100644 index 00000000000..e8c42076ea2 --- /dev/null +++ b/app/graphql/resolvers/ci_configuration/sast_resolver.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "json" + +module Resolvers + module CiConfiguration + class SastResolver < BaseResolver + SAST_UI_SCHEMA_PATH = 'app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json' + + type ::Types::CiConfiguration::Sast::Type, null: true + + def resolve(**args) + Gitlab::Json.parse(File.read(Rails.root.join(SAST_UI_SCHEMA_PATH))) + end + end + end +end diff --git a/app/graphql/resolvers/concerns/resolves_merge_requests.rb b/app/graphql/resolvers/concerns/resolves_merge_requests.rb index a2140728a27..7ed88be52b9 100644 --- a/app/graphql/resolvers/concerns/resolves_merge_requests.rb +++ b/app/graphql/resolvers/concerns/resolves_merge_requests.rb @@ -11,16 +11,10 @@ module ResolvesMergeRequests end def resolve_with_lookahead(**args) - args[:iids] = Array.wrap(args[:iids]) if args[:iids] - args.compact! + mr_finder = MergeRequestsFinder.new(current_user, args.compact) + finder = Gitlab::Graphql::Loaders::IssuableLoader.new(project, mr_finder) - if project && args.keys == [:iids] - batch_load_merge_requests(args[:iids]) - else - args[:project_id] ||= project - - apply_lookahead(MergeRequestsFinder.new(current_user, args).execute) - end.then(&(single? ? :first : :itself)) + select_result(finder.batching_find_all { |query| apply_lookahead(query) }) end def ready?(**args) @@ -35,22 +29,6 @@ module ResolvesMergeRequests private - def batch_load_merge_requests(iids) - iids.map { |iid| batch_load(iid) }.select(&:itself) # .compact doesn't work on BatchLoader - end - - # rubocop: disable CodeReuse/ActiveRecord - def batch_load(iid) - BatchLoader::GraphQL.for(iid.to_s).batch(key: project) do |iids, loader, args| - query = args[:key].merge_requests.where(iid: iids) - - apply_lookahead(query).each do |mr| - loader.call(mr.iid.to_s, mr) - end - end - end - # rubocop: enable CodeReuse/ActiveRecord - def unconditional_includes [:target_project] end diff --git a/app/graphql/resolvers/environments_resolver.rb b/app/graphql/resolvers/environments_resolver.rb index 4e9a17f1e17..1b916a89796 100644 --- a/app/graphql/resolvers/environments_resolver.rb +++ b/app/graphql/resolvers/environments_resolver.rb @@ -8,7 +8,7 @@ module Resolvers argument :search, GraphQL::STRING_TYPE, required: false, - description: 'Search query' + description: 'Search query for environment name' argument :states, [GraphQL::STRING_TYPE], required: false, diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb index f103da07666..9d0535a208f 100644 --- a/app/graphql/resolvers/issues_resolver.rb +++ b/app/graphql/resolvers/issues_resolver.rb @@ -44,7 +44,7 @@ module Resolvers description: 'Issues closed after this date' argument :search, GraphQL::STRING_TYPE, required: false, - description: 'Search query for finding issues by title or description' + description: 'Search query for issue title or description' argument :sort, Types::IssueSortEnum, description: 'Sort issues by this criteria', required: false, @@ -63,18 +63,13 @@ module Resolvers parent = object.respond_to?(:sync) ? object.sync : object return Issue.none if parent.nil? - if parent.is_a?(Group) - args[:group_id] = parent.id - else - args[:project_id] = parent.id - end - # Will need to be be made group & namespace aware with # https://gitlab.com/gitlab-org/gitlab-foss/issues/54520 - args[:iids] ||= [args[:iid]].compact - args[:attempt_project_search_optimizations] = args[:search].present? + args[:iids] ||= [args.delete(:iid)].compact if args[:iid] + args[:attempt_project_search_optimizations] = true if args[:search].present? - issues = IssuesFinder.new(context[:current_user], args).execute + finder = IssuesFinder.new(current_user, args) + issues = Gitlab::Graphql::Loaders::IssuableLoader.new(parent, finder).batching_find_all if non_stable_cursor_sort?(args[:sort]) # Certain complex sorts are not supported by the stable cursor pagination yet. @@ -97,3 +92,5 @@ module Resolvers end end end + +Resolvers::IssuesResolver.prepend_if_ee('::EE::Resolvers::IssuesResolver') diff --git a/app/graphql/resolvers/last_commit_resolver.rb b/app/graphql/resolvers/last_commit_resolver.rb index 7a433d6556f..dd89c322617 100644 --- a/app/graphql/resolvers/last_commit_resolver.rb +++ b/app/graphql/resolvers/last_commit_resolver.rb @@ -9,7 +9,7 @@ module Resolvers def resolve(**args) # Ensure merge commits can be returned by sending nil to Gitaly instead of '/' path = tree.path == '/' ? nil : tree.path - commit = Gitlab::Git::Commit.last_for_path(tree.repository, tree.sha, path) + commit = Gitlab::Git::Commit.last_for_path(tree.repository, tree.sha, path, literal_pathspec: true) ::Commit.new(commit, tree.repository.project) if commit end diff --git a/app/graphql/resolvers/milestone_resolver.rb b/app/graphql/resolvers/milestone_resolver.rb index 6c6513e0ee4..bcfbc63c31f 100644 --- a/app/graphql/resolvers/milestone_resolver.rb +++ b/app/graphql/resolvers/milestone_resolver.rb @@ -52,7 +52,7 @@ module Resolvers end def group_parameters(args) - return { group_ids: parent.id } unless include_descendants?(args) + return { group_ids: parent.id } unless args[:include_descendants].present? { group_ids: parent.self_and_descendants.public_or_visible_to_user(current_user).select(:id), @@ -60,10 +60,6 @@ module Resolvers } end - def include_descendants?(args) - args[:include_descendants].present? && Feature.enabled?(:group_milestone_descendants, parent) - end - def group_projects GroupProjectsFinder.new( group: parent, diff --git a/app/graphql/resolvers/packages_resolver.rb b/app/graphql/resolvers/packages_resolver.rb new file mode 100644 index 00000000000..519fb87183e --- /dev/null +++ b/app/graphql/resolvers/packages_resolver.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Resolvers + class PackagesResolver < BaseResolver + type Types::PackageType, null: true + + def resolve(**args) + return unless packages_available? + + ::Packages::PackagesFinder.new(object).execute + end + + private + + def packages_available? + ::Gitlab.config.packages.enabled + end + end +end diff --git a/app/graphql/resolvers/projects/jira_projects_resolver.rb b/app/graphql/resolvers/projects/jira_projects_resolver.rb index a8c3768df41..2dc712128cc 100644 --- a/app/graphql/resolvers/projects/jira_projects_resolver.rb +++ b/app/graphql/resolvers/projects/jira_projects_resolver.rb @@ -13,11 +13,10 @@ module Resolvers def resolve(name: nil, **args) authorize!(project) - response, start_cursor, end_cursor = jira_projects(name: name, **compute_pagination_params(args)) - end_cursor = nil if !!response.payload[:is_last] + response = jira_projects(name: name) if response.success? - Gitlab::Graphql::ExternallyPaginatedArray.new(start_cursor, end_cursor, *response.payload[:projects]) + response.payload[:projects] else raise Gitlab::Graphql::Errors::BaseError, response.message end @@ -35,41 +34,10 @@ module Resolvers jira_service&.project end - def compute_pagination_params(params) - after_cursor = Base64.decode64(params[:after].to_s) - before_cursor = Base64.decode64(params[:before].to_s) + def jira_projects(name:) + args = { query: name }.compact - # differentiate between 0 cursor and nil or invalid cursor that decodes into zero. - after_index = after_cursor.to_i == 0 && after_cursor != "0" ? nil : after_cursor.to_i - before_index = before_cursor.to_i == 0 && before_cursor != "0" ? nil : before_cursor.to_i - - if after_index.present? && before_index.present? - if after_index >= before_index - { start_at: 0, limit: 0 } - else - { start_at: after_index + 1, limit: before_index - after_index - 1 } - end - elsif after_index.present? - { start_at: after_index + 1, limit: nil } - elsif before_index.present? - { start_at: 0, limit: before_index - 1 } - else - { start_at: 0, limit: nil } - end - end - - def jira_projects(name:, start_at:, limit:) - args = { query: name, start_at: start_at, limit: limit }.compact - - response = Jira::Requests::Projects.new(project.jira_service, args).execute - - return [response, nil, nil] if response.error? - - projects = response.payload[:projects] - start_cursor = start_at == 0 ? nil : Base64.encode64((start_at - 1).to_s) - end_cursor = Base64.encode64((start_at + projects.size - 1).to_s) - - [response, start_cursor, end_cursor] + Jira::Requests::Projects::ListService.new(project.jira_service, args).execute end end end diff --git a/app/graphql/resolvers/projects_resolver.rb b/app/graphql/resolvers/projects_resolver.rb index 068546cd39f..f75f591b381 100644 --- a/app/graphql/resolvers/projects_resolver.rb +++ b/app/graphql/resolvers/projects_resolver.rb @@ -10,7 +10,7 @@ module Resolvers argument :search, GraphQL::STRING_TYPE, required: false, - description: 'Search criteria' + description: 'Search query for project name, path, or description' def resolve(**args) ProjectsFinder diff --git a/app/graphql/resolvers/release_resolver.rb b/app/graphql/resolvers/release_resolver.rb index 9bae8b8cd13..1edcc8c70b5 100644 --- a/app/graphql/resolvers/release_resolver.rb +++ b/app/graphql/resolvers/release_resolver.rb @@ -15,6 +15,8 @@ module Resolvers end def resolve(tag_name:) + return unless Feature.enabled?(:graphql_release_data, project, default_enabled: true) + ReleasesFinder.new( project, current_user, diff --git a/app/graphql/resolvers/releases_resolver.rb b/app/graphql/resolvers/releases_resolver.rb index b2afbb92684..85892c2abeb 100644 --- a/app/graphql/resolvers/releases_resolver.rb +++ b/app/graphql/resolvers/releases_resolver.rb @@ -12,6 +12,8 @@ module Resolvers end def resolve(**args) + return unless Feature.enabled?(:graphql_release_data, project, default_enabled: true) + ReleasesFinder.new( project, current_user diff --git a/app/graphql/types/alert_management/alert_sort_enum.rb b/app/graphql/types/alert_management/alert_sort_enum.rb index 3faac9ce53c..51e7bef0a7f 100644 --- a/app/graphql/types/alert_management/alert_sort_enum.rb +++ b/app/graphql/types/alert_management/alert_sort_enum.rb @@ -16,10 +16,10 @@ module Types value 'UPDATED_TIME_DESC', 'Created time by descending order', value: :updated_at_desc value 'EVENT_COUNT_ASC', 'Events count by ascending order', value: :event_count_asc value 'EVENT_COUNT_DESC', 'Events count by descending order', value: :event_count_desc - value 'SEVERITY_ASC', 'Severity by ascending order', value: :severity_asc - value 'SEVERITY_DESC', 'Severity by descending order', value: :severity_desc - value 'STATUS_ASC', 'Status by ascending order', value: :status_asc - value 'STATUS_DESC', 'Status by descending order', value: :status_desc + value 'SEVERITY_ASC', 'Severity from less critical to more critical', value: :severity_asc + value 'SEVERITY_DESC', 'Severity from more critical to less critical', value: :severity_desc + value 'STATUS_ASC', 'Status by order: Ignored > Resolved > Acknowledged > Triggered', value: :status_asc + value 'STATUS_DESC', 'Status by order: Triggered > Acknowledged > Resolved > Ignored', value: :status_desc end end end diff --git a/app/graphql/types/alert_management/alert_type.rb b/app/graphql/types/alert_management/alert_type.rb index 8215ccb152c..089d2426158 100644 --- a/app/graphql/types/alert_management/alert_type.rb +++ b/app/graphql/types/alert_management/alert_type.rb @@ -91,6 +91,12 @@ module Types null: true, description: 'Assignees of the alert' + field :metrics_dashboard_url, + GraphQL::STRING_TYPE, + null: true, + description: 'URL for metrics embed for the alert', + resolve: -> (alert, _args, _context) { alert.present.metrics_dashboard_url } + def notes object.ordered_notes 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..ccd1c7dd0eb --- /dev/null +++ b/app/graphql/types/ci_configuration/sast/analyzers_entity_type.rb @@ -0,0 +1,25 @@ +# 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.' + 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..b61b582ad20 --- /dev/null +++ b/app/graphql/types/ci_configuration/sast/entity_type.rb @@ -0,0 +1,34 @@ +# 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.' + 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/container_expiration_policy_type.rb b/app/graphql/types/container_expiration_policy_type.rb index da53dbcbd39..f19aa964377 100644 --- a/app/graphql/types/container_expiration_policy_type.rb +++ b/app/graphql/types/container_expiration_policy_type.rb @@ -14,8 +14,8 @@ module Types 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, GraphQL::STRING_TYPE, null: true, description: 'Tags with names matching this regex pattern will expire' - field :name_regex_keep, GraphQL::STRING_TYPE, null: true, description: 'Tags with names matching this regex pattern will be preserved' + 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/deprecated_mutations.rb b/app/graphql/types/deprecated_mutations.rb new file mode 100644 index 00000000000..a4336fa3ef3 --- /dev/null +++ b/app/graphql/types/deprecated_mutations.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + module DeprecatedMutations + extend ActiveSupport::Concern + + prepended do + mount_aliased_mutation 'AddAwardEmoji', + Mutations::AwardEmojis::Add, + deprecated: { reason: 'Use awardEmojiAdd', milestone: '13.2' } + mount_aliased_mutation 'RemoveAwardEmoji', + Mutations::AwardEmojis::Remove, + deprecated: { reason: 'Use awardEmojiRemove', milestone: '13.2' } + mount_aliased_mutation 'ToggleAwardEmoji', + Mutations::AwardEmojis::Toggle, + deprecated: { reason: 'Use awardEmojiToggle', milestone: '13.2' } + end + end +end diff --git a/app/graphql/types/diff_stats_summary_type.rb b/app/graphql/types/diff_stats_summary_type.rb new file mode 100644 index 00000000000..956400fd21b --- /dev/null +++ b/app/graphql/types/diff_stats_summary_type.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Types + # rubocop: disable Graphql/AuthorizeTypes + # Types that use DiffStatsType should have their own authorization + class DiffStatsSummaryType < BaseObject + graphql_name 'DiffStatsSummary' + + description 'Aggregated summary of changes' + + field :additions, GraphQL::INT_TYPE, null: false, + description: 'Number of lines added' + field :deletions, GraphQL::INT_TYPE, null: false, + description: 'Number of lines deleted' + field :changes, GraphQL::INT_TYPE, null: false, + description: 'Number of lines changed' + field :file_count, GraphQL::INT_TYPE, null: false, + description: 'Number of files changed' + + def changes + object[:additions] + object[:deletions] + end + end + # rubocop: enable Graphql/AuthorizeTypes +end diff --git a/app/graphql/types/diff_stats_type.rb b/app/graphql/types/diff_stats_type.rb new file mode 100644 index 00000000000..6c79a4c389d --- /dev/null +++ b/app/graphql/types/diff_stats_type.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + # rubocop: disable Graphql/AuthorizeTypes + # Types that use DiffStatsType should have their own authorization + class DiffStatsType < BaseObject + graphql_name 'DiffStats' + + description 'Changes to a single file' + + field :path, GraphQL::STRING_TYPE, null: false, + description: 'File path, relative to repository root' + field :additions, GraphQL::INT_TYPE, null: false, + description: 'Number of lines added to this file' + field :deletions, GraphQL::INT_TYPE, null: false, + description: 'Number of lines deleted from this file' + end + # rubocop: enable Graphql/AuthorizeTypes +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 124398f28e7..8bdd8afcbff 100644 --- a/app/graphql/types/error_tracking/sentry_detailed_error_type.rb +++ b/app/graphql/types/error_tracking/sentry_detailed_error_type.rb @@ -76,9 +76,15 @@ module Types description: 'Commit the error was last seen' field :first_release_short_version, GraphQL::STRING_TYPE, null: true, - description: 'Release 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' + field :first_release_version, GraphQL::STRING_TYPE, + null: true, + 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' field :gitlab_commit, GraphQL::STRING_TYPE, null: true, 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 121146133cb..f423fcb1b9f 100644 --- a/app/graphql/types/error_tracking/sentry_error_collection_type.rb +++ b/app/graphql/types/error_tracking/sentry_error_collection_type.rb @@ -17,7 +17,7 @@ module Types resolver: Resolvers::ErrorTracking::SentryErrorsResolver do argument :search_term, String, - description: 'Search term for the Sentry error.', + description: 'Search query for the Sentry error details', required: false argument :sort, String, diff --git a/app/graphql/types/global_id_type.rb b/app/graphql/types/global_id_type.rb new file mode 100644 index 00000000000..a3964ba83e1 --- /dev/null +++ b/app/graphql/types/global_id_type.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Types + class GlobalIDType < BaseScalar + graphql_name 'GlobalID' + description 'A global identifier' + + # @param value [GID] + # @return [String] + def self.coerce_result(value, _ctx) + ::Gitlab::GlobalId.as_global_id(value).to_s + end + + # @param value [String] + # @return [GID] + def self.coerce_input(value, _ctx) + gid = GlobalID.parse(value) + raise GraphQL::CoercionError, "#{value.inspect} is not a valid Global ID" if gid.nil? + raise GraphQL::CoercionError, "#{value.inspect} is not a Gitlab Global ID" unless gid.app == GlobalID.app + + gid + end + + # Construct a restricted type, that can only be inhabited by an ID of + # a given model class. + def self.[](model_class) + @id_types ||= {} + + @id_types[model_class] ||= Class.new(self) do + graphql_name "#{model_class.name.gsub(/::/, '')}ID" + description "Identifier of #{model_class.name}" + + self.define_singleton_method(:to_s) do + graphql_name + end + + self.define_singleton_method(:inspect) do + graphql_name + end + + self.define_singleton_method(:coerce_result) do |gid, ctx| + global_id = ::Gitlab::GlobalId.as_global_id(gid, model_name: model_class.name) + + if suitable?(global_id) + global_id.to_s + else + raise GraphQL::CoercionError, "Expected a #{model_class.name} ID, got #{global_id}" + end + end + + self.define_singleton_method(:suitable?) do |gid| + gid&.model_class&.ancestors&.include?(model_class) + end + + self.define_singleton_method(:coerce_input) do |string, ctx| + gid = super(string, ctx) + raise GraphQL::CoercionError, "#{string.inspect} does not represent an instance of #{model_class.name}" unless suitable?(gid) + + gid + end + end + end + end +end diff --git a/app/graphql/types/issue_connection_type.rb b/app/graphql/types/issue_connection_type.rb new file mode 100644 index 00000000000..beed392f01a --- /dev/null +++ b/app/graphql/types/issue_connection_type.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + # rubocop: disable Graphql/AuthorizeTypes + class IssueConnectionType < GraphQL::Types::Relay::BaseConnection + field :count, Integer, null: false, + description: 'Total count of collection' + + def count + object.items.size + end + end +end diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb index 73219ca9e1e..9baa0018999 100644 --- a/app/graphql/types/issue_type.rb +++ b/app/graphql/types/issue_type.rb @@ -4,6 +4,8 @@ module Types class IssueType < BaseObject graphql_name 'Issue' + connection_type_class(Types::IssueConnectionType) + implements(Types::Notes::NoteableType) authorize :read_issue @@ -12,6 +14,8 @@ module Types present_using IssuePresenter + field :id, GraphQL::ID_TYPE, null: false, + description: "ID of the issue" field :iid, GraphQL::ID_TYPE, null: false, description: "Internal ID of the issue" field :title, GraphQL::STRING_TYPE, null: false, diff --git a/app/graphql/types/jira_user_type.rb b/app/graphql/types/jira_user_type.rb index 8aa21ce669b..999526a920e 100644 --- a/app/graphql/types/jira_user_type.rb +++ b/app/graphql/types/jira_user_type.rb @@ -13,7 +13,11 @@ module Types field :jira_email, GraphQL::STRING_TYPE, null: true, description: 'Email of the Jira user, returned only for users with public emails' field :gitlab_id, GraphQL::INT_TYPE, null: true, - description: 'Id of the matched GitLab user' + description: 'ID of the matched GitLab user' + field :gitlab_username, GraphQL::STRING_TYPE, null: true, + description: 'Username of the matched GitLab user' + field :gitlab_name, GraphQL::STRING_TYPE, null: true, + description: 'Name of the matched GitLab user' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/jira_users_mapping_input_type.rb b/app/graphql/types/jira_users_mapping_input_type.rb new file mode 100644 index 00000000000..61cf1474493 --- /dev/null +++ b/app/graphql/types/jira_users_mapping_input_type.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Types + # rubocop: disable Graphql/AuthorizeTypes + class JiraUsersMappingInputType < BaseInputObject + graphql_name 'JiraUsersMappingInputType' + + argument :jira_account_id, + GraphQL::STRING_TYPE, + required: true, + description: 'Jira account id of the user' + argument :gitlab_id, + GraphQL::INT_TYPE, + required: false, + description: 'Id of the GitLab user' + end + # rubocop: enable Graphql/AuthorizeTypes +end diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index cb4ff7ea0c5..c194b467363 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -54,6 +54,13 @@ module Types description: 'Indicates if the merge has been set to be merged when its pipeline succeeds (MWPS)' field :diff_head_sha, GraphQL::STRING_TYPE, null: true, description: 'Diff head SHA of the merge request' + field :diff_stats, [Types::DiffStatsType], null: true, calls_gitaly: true, + description: 'Details about which files were changed in this merge request' do + argument :path, GraphQL::STRING_TYPE, required: false, description: 'A specific file-path' + end + + field :diff_stats_summary, Types::DiffStatsSummaryType, null: true, calls_gitaly: true, + description: 'Summary of which files were changed in this merge request' field :merge_commit_sha, GraphQL::STRING_TYPE, null: true, description: 'SHA of the merge request commit (set once merged)' field :user_notes_count, GraphQL::INT_TYPE, null: true, @@ -134,5 +141,24 @@ module Types end field :task_completion_status, Types::TaskCompletionStatus, null: false, description: Types::TaskCompletionStatus.description + + def diff_stats(path: nil) + stats = Array.wrap(object.diff_stats&.to_a) + + if path.present? + stats.select { |s| s.path == path } + else + stats + end + end + + def diff_stats_summary + nil_stats = { additions: 0, deletions: 0, file_count: 0 } + return nil_stats unless object.diff_stats.present? + + object.diff_stats.each_with_object(nil_stats) do |status, hash| + hash.merge!(additions: status.additions, deletions: status.deletions, file_count: 1) { |_, x, y| x + y } + end + end end end diff --git a/app/graphql/types/milestone_stats_type.rb b/app/graphql/types/milestone_stats_type.rb new file mode 100644 index 00000000000..ef533af59e7 --- /dev/null +++ b/app/graphql/types/milestone_stats_type.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + class MilestoneStatsType < BaseObject + graphql_name 'MilestoneStats' + description 'Contains statistics about a milestone' + + authorize :read_milestone + + field :total_issues_count, GraphQL::INT_TYPE, null: true, + description: 'Total number of issues associated with the milestone' + + field :closed_issues_count, GraphQL::INT_TYPE, null: true, + description: 'Number of closed issues associated with the milestone' + end +end diff --git a/app/graphql/types/milestone_type.rb b/app/graphql/types/milestone_type.rb index 99bd6e819d6..ca606c9da44 100644 --- a/app/graphql/types/milestone_type.rb +++ b/app/graphql/types/milestone_type.rb @@ -9,6 +9,8 @@ module Types authorize :read_milestone + alias_method :milestone, :object + field :id, GraphQL::ID_TYPE, null: false, description: 'ID of the milestone' @@ -47,5 +49,14 @@ module Types field :subgroup_milestone, GraphQL::BOOLEAN_TYPE, null: false, description: 'Indicates if milestone is at subgroup level', method: :subgroup_milestone? + + field :stats, Types::MilestoneStatsType, null: true, + description: 'Milestone statistics' + + def stats + return unless Feature.enabled?(:graphql_milestone_stats, milestone.project || milestone.group, default_enabled: true) + + milestone + end end end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 8874c56dfdb..49d51b626b2 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -10,6 +10,7 @@ module Types mount_mutation Mutations::AlertManagement::CreateAlertIssue mount_mutation Mutations::AlertManagement::UpdateAlertStatus mount_mutation Mutations::AlertManagement::Alerts::SetAssignees + mount_mutation Mutations::AlertManagement::Alerts::Todo::Create mount_mutation Mutations::AwardEmojis::Add mount_mutation Mutations::AwardEmojis::Remove mount_mutation Mutations::AwardEmojis::Toggle @@ -17,9 +18,11 @@ module Types mount_mutation Mutations::Commits::Create, calls_gitaly: true mount_mutation Mutations::Discussions::ToggleResolve mount_mutation Mutations::Issues::SetConfidential + mount_mutation Mutations::Issues::SetLocked mount_mutation Mutations::Issues::SetDueDate mount_mutation Mutations::Issues::Update mount_mutation Mutations::MergeRequests::Create + mount_mutation Mutations::MergeRequests::Update mount_mutation Mutations::MergeRequests::SetLabels mount_mutation Mutations::MergeRequests::SetLocked mount_mutation Mutations::MergeRequests::SetMilestone @@ -56,4 +59,5 @@ module Types end end +::Types::MutationType.prepend(::Types::DeprecatedMutations) ::Types::MutationType.prepend_if_ee('::EE::Types::MutationType') diff --git a/app/graphql/types/namespace_type.rb b/app/graphql/types/namespace_type.rb index 1714284a5cf..fbdf049b755 100644 --- a/app/graphql/types/namespace_type.rb +++ b/app/graphql/types/namespace_type.rb @@ -38,3 +38,5 @@ module Types resolver: ::Resolvers::NamespaceProjectsResolver end end + +Types::NamespaceType.prepend_if_ee('EE::Types::NamespaceType') diff --git a/app/graphql/types/notes/note_type.rb b/app/graphql/types/notes/note_type.rb index 8755b4ccad5..5d41f0032bd 100644 --- a/app/graphql/types/notes/note_type.rb +++ b/app/graphql/types/notes/note_type.rb @@ -27,6 +27,8 @@ module Types field :system, GraphQL::BOOLEAN_TYPE, null: false, description: 'Indicates whether this note was created by the system or by a user' + field :system_note_icon_name, GraphQL::STRING_TYPE, null: true, + description: 'Name of the icon corresponding to a system note' field :body, GraphQL::STRING_TYPE, null: false, @@ -46,6 +48,10 @@ module Types field :confidential, GraphQL::BOOLEAN_TYPE, null: true, description: 'Indicates if this note is confidential', method: :confidential? + + def system_note_icon_name + SystemNoteHelper.system_note_icon_name(object) if object.system? + end end end end diff --git a/app/graphql/types/package_type.rb b/app/graphql/types/package_type.rb new file mode 100644 index 00000000000..0604bf827a5 --- /dev/null +++ b/app/graphql/types/package_type.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + class PackageType < BaseObject + graphql_name 'Package' + description 'Represents a package' + 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 update date' + field :version, GraphQL::STRING_TYPE, null: true, description: 'The version of the package' + field :package_type, Types::PackageTypeEnum, null: false, description: 'The type of the package' + end +end diff --git a/app/graphql/types/package_type_enum.rb b/app/graphql/types/package_type_enum.rb new file mode 100644 index 00000000000..bc03b8f5f8b --- /dev/null +++ b/app/graphql/types/package_type_enum.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Types + class PackageTypeEnum < BaseEnum + ::Packages::Package.package_types.keys.each do |package_type| + value package_type.to_s.upcase, "Packages from the #{package_type} package manager", value: package_type.to_s + end + end +end diff --git a/app/graphql/types/project_statistics_type.rb b/app/graphql/types/project_statistics_type.rb index e1546d31e89..b3916e42e92 100644 --- a/app/graphql/types/project_statistics_type.rb +++ b/app/graphql/types/project_statistics_type.rb @@ -21,5 +21,7 @@ module Types description: 'Packages size of the project' field :wiki_size, GraphQL::FLOAT_TYPE, null: true, description: 'Wiki size of the project' + field :snippets_size, GraphQL::FLOAT_TYPE, null: true, + description: 'Snippets size of the project' end end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index bbfb7fc4f20..2251a0f4e0c 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -60,6 +60,12 @@ module Types field :merge_requests_ff_only_enabled, GraphQL::BOOLEAN_TYPE, null: true, description: 'Indicates if no merge commits should be created and all merges should instead be fast-forwarded, which means that merging is only allowed if the branch could be fast-forwarded.' + field :service_desk_enabled, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if the project has service desk enabled.' + + field :service_desk_address, GraphQL::STRING_TYPE, null: true, + description: 'E-mail address of the service desk.' + field :avatar_url, GraphQL::STRING_TYPE, null: true, calls_gitaly: true, description: 'URL to avatar image file of the project', resolve: -> (project, args, ctx) do @@ -153,12 +159,20 @@ module Types description: 'Environments of the project', resolver: Resolvers::EnvironmentsResolver + field :sast_ci_configuration, ::Types::CiConfiguration::Sast::Type, null: true, + description: 'SAST CI configuration for the project', + resolver: ::Resolvers::CiConfiguration::SastResolver + field :issue, Types::IssueType, null: true, description: 'A single issue of the project', resolver: Resolvers::IssuesResolver.single + field :packages, Types::PackageType.connection_type, null: true, + description: 'Packages of the project', + resolver: Resolvers::PackagesResolver + field :pipelines, Types::Ci::PipelineType.connection_type, null: true, @@ -243,15 +257,14 @@ module Types Types::ReleaseType.connection_type, null: true, description: 'Releases of the project', - resolver: Resolvers::ReleasesResolver, - feature_flag: :graphql_release_data + resolver: Resolvers::ReleasesResolver field :release, Types::ReleaseType, null: true, description: 'A single release of the project', resolver: Resolvers::ReleasesResolver.single, - feature_flag: :graphql_release_data + authorize: :download_code field :container_expiration_policy, Types::ContainerExpirationPolicyType, diff --git a/app/graphql/types/projects/services/jira_service_type.rb b/app/graphql/types/projects/services/jira_service_type.rb index e81963f752d..8bf85a14cbf 100644 --- a/app/graphql/types/projects/services/jira_service_type.rb +++ b/app/graphql/types/projects/services/jira_service_type.rb @@ -15,7 +15,7 @@ module Types null: true, connection: false, extensions: [Gitlab::Graphql::Extensions::ExternallyPaginatedArrayExtension], - description: 'List of Jira projects fetched through Jira REST API', + description: 'List of all Jira projects fetched through Jira REST API', resolver: Resolvers::Projects::JiraProjectsResolver end end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 362e4004b73..b4cbd96bfdb 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -61,10 +61,6 @@ module Types description: 'Text to echo back', resolver: Resolvers::EchoResolver - field :user, Types::UserType, null: true, - description: 'Find a user on this instance', - resolver: Resolvers::UserResolver - def design_management DesignManagementObject.new(nil) end diff --git a/app/graphql/types/release_link_type.rb b/app/graphql/types/release_asset_link_type.rb index 070f14a90df..21f1bd50cff 100644 --- a/app/graphql/types/release_link_type.rb +++ b/app/graphql/types/release_asset_link_type.rb @@ -1,8 +1,9 @@ # frozen_string_literal: true module Types - class ReleaseLinkType < BaseObject - graphql_name 'ReleaseLink' + class ReleaseAssetLinkType < BaseObject + graphql_name 'ReleaseAssetLink' + description 'Represents an asset link associated with a release' authorize :read_release @@ -12,7 +13,7 @@ module Types description: 'Name of the link' field :url, GraphQL::STRING_TYPE, null: true, description: 'URL of the link' - field :link_type, Types::ReleaseLinkTypeEnum, null: true, + field :link_type, Types::ReleaseAssetLinkTypeEnum, null: true, description: 'Type of the link: `other`, `runbook`, `image`, `package`; defaults to `other`' field :external, GraphQL::BOOLEAN_TYPE, null: true, method: :external?, description: 'Indicates the link points to an external resource' diff --git a/app/graphql/types/release_link_type_enum.rb b/app/graphql/types/release_asset_link_type_enum.rb index b364855833f..01862ada56d 100644 --- a/app/graphql/types/release_link_type_enum.rb +++ b/app/graphql/types/release_asset_link_type_enum.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true module Types - class ReleaseLinkTypeEnum < BaseEnum - graphql_name 'ReleaseLinkType' + class ReleaseAssetLinkTypeEnum < BaseEnum + graphql_name 'ReleaseAssetLinkType' description 'Type of the link: `other`, `runbook`, `image`, `package`; defaults to `other`' ::Releases::Link.link_types.keys.each do |link_type| diff --git a/app/graphql/types/release_assets_type.rb b/app/graphql/types/release_assets_type.rb index 58ad05b5365..d6042bdbc0b 100644 --- a/app/graphql/types/release_assets_type.rb +++ b/app/graphql/types/release_assets_type.rb @@ -3,6 +3,7 @@ module Types class ReleaseAssetsType < BaseObject graphql_name 'ReleaseAssets' + description 'A container for all assets associated with a release' authorize :read_release @@ -10,9 +11,9 @@ module Types present_using ReleasePresenter - field :assets_count, GraphQL::INT_TYPE, null: true, + field :count, GraphQL::INT_TYPE, null: true, method: :assets_count, description: 'Number of assets of the release' - field :links, Types::ReleaseLinkType.connection_type, null: true, + field :links, Types::ReleaseAssetLinkType.connection_type, null: true, description: 'Asset links of the release' field :sources, Types::ReleaseSourceType.connection_type, null: true, description: 'Sources of the release' diff --git a/app/graphql/types/release_links_type.rb b/app/graphql/types/release_links_type.rb new file mode 100644 index 00000000000..f61a16f5b67 --- /dev/null +++ b/app/graphql/types/release_links_type.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Types + class ReleaseLinksType < BaseObject + graphql_name 'ReleaseLinks' + + authorize :download_code + + alias_method :release, :object + + present_using ReleasePresenter + + field :self_url, GraphQL::STRING_TYPE, null: true, + description: 'HTTP URL of the release' + field :merge_requests_url, GraphQL::STRING_TYPE, null: true, + description: 'HTTP URL of the merge request page filtered by this release' + field :issues_url, GraphQL::STRING_TYPE, null: true, + description: 'HTTP URL of the issues page filtered by this release' + field :edit_url, GraphQL::STRING_TYPE, null: true, + description: "HTTP URL of the release's edit page", + authorize: :update_release + end +end diff --git a/app/graphql/types/release_source_type.rb b/app/graphql/types/release_source_type.rb index 0ec1ad85a39..891da472116 100644 --- a/app/graphql/types/release_source_type.rb +++ b/app/graphql/types/release_source_type.rb @@ -3,8 +3,9 @@ module Types class ReleaseSourceType < BaseObject graphql_name 'ReleaseSource' + description 'Represents the source code attached to a release in a particular format' - authorize :read_release_sources + authorize :download_code field :format, GraphQL::STRING_TYPE, null: true, description: 'Format of the source' diff --git a/app/graphql/types/release_type.rb b/app/graphql/types/release_type.rb index 3d8e5a93c68..a0703b96a36 100644 --- a/app/graphql/types/release_type.rb +++ b/app/graphql/types/release_type.rb @@ -3,6 +3,7 @@ module Types class ReleaseType < BaseObject graphql_name 'Release' + description 'Represents a release' authorize :read_release @@ -10,10 +11,12 @@ module Types present_using ReleasePresenter - field :tag_name, GraphQL::STRING_TYPE, null: false, method: :tag, - description: 'Name of the tag associated with the release' + field :tag_name, GraphQL::STRING_TYPE, null: true, method: :tag, + description: 'Name of the tag associated with the release', + authorize: :download_code field :tag_path, GraphQL::STRING_TYPE, null: true, - description: 'Relative web path to the tag associated with the release' + description: 'Relative web path to the tag associated with the release', + authorize: :download_code field :description, GraphQL::STRING_TYPE, null: true, description: 'Description (also known as "release notes") of the release' markdown_field :description_html, null: true @@ -25,6 +28,8 @@ module Types description: 'Timestamp of when the release was released' field :assets, Types::ReleaseAssetsType, null: true, method: :itself, description: 'Assets of the release' + field :links, Types::ReleaseLinksType, null: true, method: :itself, + description: 'Links of the release' field :milestones, Types::MilestoneType.connection_type, null: true, description: 'Milestones associated to the release' field :evidences, Types::EvidenceType.connection_type, null: true, @@ -39,8 +44,7 @@ module Types field :commit, Types::CommitType, null: true, complexity: 10, calls_gitaly: true, - description: 'The commit associated with the release', - authorize: :reporter_access + description: 'The commit associated with the release' def commit return if release.sha.nil? diff --git a/app/graphql/types/root_storage_statistics_type.rb b/app/graphql/types/root_storage_statistics_type.rb index e2d85aebc48..3acc1d9ca44 100644 --- a/app/graphql/types/root_storage_statistics_type.rb +++ b/app/graphql/types/root_storage_statistics_type.rb @@ -12,5 +12,6 @@ module Types field :build_artifacts_size, GraphQL::FLOAT_TYPE, null: false, description: 'The CI artifacts size in bytes' field :packages_size, GraphQL::FLOAT_TYPE, null: false, description: 'The packages size in bytes' field :wiki_size, GraphQL::FLOAT_TYPE, null: false, description: 'The wiki size in bytes' + field :snippets_size, GraphQL::FLOAT_TYPE, null: false, description: 'The snippets size in bytes' end end diff --git a/app/graphql/types/todo_target_enum.rb b/app/graphql/types/todo_target_enum.rb index a377c3aafdc..b797722fef8 100644 --- a/app/graphql/types/todo_target_enum.rb +++ b/app/graphql/types/todo_target_enum.rb @@ -6,6 +6,7 @@ module Types value 'ISSUE', value: 'Issue', description: 'An Issue' value 'MERGEREQUEST', value: 'MergeRequest', description: 'A MergeRequest' value 'DESIGN', value: 'DesignManagement::Design', description: 'A Design' + value 'ALERT', value: 'AlertManagement::Alert', description: 'An Alert' end end diff --git a/app/graphql/types/tree/blob_type.rb b/app/graphql/types/tree/blob_type.rb index 22349203519..36cae756a0d 100644 --- a/app/graphql/types/tree/blob_type.rb +++ b/app/graphql/types/tree/blob_type.rb @@ -17,6 +17,8 @@ module Types resolve: -> (blob, args, ctx) do Gitlab::Graphql::Loaders::BatchLfsOidLoader.new(blob.repository, blob.id).find end + field :mode, GraphQL::STRING_TYPE, null: true, + description: 'Blob mode in numeric format' # rubocop: enable Graphql/AuthorizeTypes end end diff --git a/app/graphql/types/untrusted_regexp.rb b/app/graphql/types/untrusted_regexp.rb new file mode 100644 index 00000000000..2c715ab4967 --- /dev/null +++ b/app/graphql/types/untrusted_regexp.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Types + class UntrustedRegexp < Types::BaseScalar + description 'A regexp containing patterns sourced from user input' + + def self.coerce_input(input_value, _) + return unless input_value + + Gitlab::UntrustedRegexp.new(input_value) + + input_value + rescue RegexpError => e + message = "#{input_value} is an invalid regexp: #{e.message}" + raise GraphQL::CoercionError, message + end + + def self.coerce_result(ruby_value, _) + ruby_value.to_s + end + end +end diff --git a/app/helpers/analytics/unique_visits_helper.rb b/app/helpers/analytics/unique_visits_helper.rb new file mode 100644 index 00000000000..ded7f54e44e --- /dev/null +++ b/app/helpers/analytics/unique_visits_helper.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Analytics + module UniqueVisitsHelper + extend ActiveSupport::Concern + + def visitor_id + return cookies[:visitor_id] if cookies[:visitor_id].present? + return unless current_user + + uuid = SecureRandom.uuid + cookies[:visitor_id] = { value: uuid, expires: 24.months } + uuid + end + + def track_visit(target_id) + return unless Feature.enabled?(:track_unique_visits) + return unless Gitlab::CurrentSettings.usage_ping_enabled? + return unless visitor_id + + Gitlab::Analytics::UniqueVisits.new.track_visit(visitor_id, target_id) + end + + class_methods do + def track_unique_visits(controller_actions, target_id:) + after_action only: controller_actions, if: -> { request.format.html? && request.headers['DNT'] != '1' } do + track_visit(target_id) + end + end + end + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index bdfdf5a69b3..e8bd5ad9b9b 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -335,6 +335,15 @@ module ApplicationHelper } end + def page_startup_api_calls + @api_startup_calls + end + + def add_page_startup_api_call(api_path, options: {}) + @api_startup_calls ||= {} + @api_startup_calls[api_path] = options + end + def autocomplete_data_sources(object, noteable_type) return {} unless object && noteable_type diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index e709d15a946..aa118a9bc45 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -190,6 +190,7 @@ module ApplicationSettingsHelper :container_expiration_policies_enable_historic_entries, :container_registry_token_expire_delay, :default_artifacts_expire_in, + :default_branch_name, :default_branch_protection, :default_ci_config_path, :default_group_visibility, @@ -244,6 +245,7 @@ module ApplicationSettingsHelper :metrics_method_call_threshold, :minimum_password_length, :mirror_available, + :notify_on_unknown_sign_in, :pages_domain_verification_enabled, :password_authentication_enabled_for_web, :password_authentication_enabled_for_git, @@ -319,7 +321,13 @@ module ApplicationSettingsHelper :email_restrictions_enabled, :email_restrictions, :issues_create_limit, - :raw_blob_request_limit + :raw_blob_request_limit, + :project_import_limit, + :project_export_limit, + :project_download_export_limit, + :group_import_limit, + :group_export_limit, + :group_download_export_limit ] end diff --git a/app/helpers/auto_devops_helper.rb b/app/helpers/auto_devops_helper.rb index 0f14680607e..c27f5d4ebce 100644 --- a/app/helpers/auto_devops_helper.rb +++ b/app/helpers/auto_devops_helper.rb @@ -22,4 +22,8 @@ module AutoDevopsHelper s_('CICD|instance enabled') end end + + def auto_devops_settings_path(project) + project_settings_ci_cd_path(project, anchor: 'autodevops-settings') + end end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 69fe3303840..f4238e7711a 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -52,13 +52,12 @@ module BlobHelper edit_button_tag(blob, common_classes, _('Edit'), - Feature.enabled?(:web_ide_default) ? ide_edit_path(project, ref, path) : edit_blob_path(project, ref, path, options), + edit_blob_path(project, ref, path, options), project, ref) end def ide_edit_button(project = @project, ref = @ref, path = @path, blob:) - return if Feature.enabled?(:web_ide_default) return unless blob edit_button_tag(blob, diff --git a/app/helpers/builds_helper.rb b/app/helpers/builds_helper.rb deleted file mode 100644 index 2def3488184..00000000000 --- a/app/helpers/builds_helper.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -module BuildsHelper - def build_summary(build, skip: false) - if build.has_trace? - if skip - link_to _("View job log"), pipeline_job_url(build.pipeline, build) - else - build.trace.html(last_lines: 10).html_safe - end - else - _("No job log") - end - end - - def sidebar_build_class(build, current_build) - build_class = [] - build_class << 'active' if build.id === current_build.id - build_class << 'retried' if build.retried? - build_class.join(' ') - end - - def javascript_build_options - { - page_path: project_job_path(@project, @build), - build_status: @build.status, - build_stage: @build.stage, - log_state: '' - } - end - - def build_failed_issue_options - { - title: _("Job Failed #%{build_id}") % { build_id: @build.id }, - description: project_job_url(@project, @build) - } - end -end diff --git a/app/helpers/ci/builds_helper.rb b/app/helpers/ci/builds_helper.rb new file mode 100644 index 00000000000..bfdb830f2c3 --- /dev/null +++ b/app/helpers/ci/builds_helper.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Ci + module BuildsHelper + def build_summary(build, skip: false) + if build.has_trace? + if skip + link_to _('View job log'), pipeline_job_url(build.pipeline, build) + else + build.trace.html(last_lines: 10).html_safe + end + else + _('No job log') + end + end + + def sidebar_build_class(build, current_build) + build_class = [] + build_class << 'active' if build.id === current_build.id + build_class << 'retried' if build.retried? + build_class.join(' ') + end + + def javascript_build_options + { + page_path: project_job_path(@project, @build), + build_status: @build.status, + build_stage: @build.stage, + log_state: '' + } + end + + def build_failed_issue_options + { + title: _("Job Failed #%{build_id}") % { build_id: @build.id }, + description: project_job_url(@project, @build) + } + end + end +end diff --git a/app/helpers/ci/jobs_helper.rb b/app/helpers/ci/jobs_helper.rb new file mode 100644 index 00000000000..0344413b849 --- /dev/null +++ b/app/helpers/ci/jobs_helper.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Ci + module JobsHelper + def jobs_data + { + "endpoint" => project_job_path(@project, @build, format: :json), + "project_path" => @project.full_path, + "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), + "build_status" => @build.status, + "build_stage" => @build.stage, + "log_state" => '', + "build_options" => javascript_build_options + } + end + end +end + +Ci::JobsHelper.prepend_if_ee('::EE::Ci::JobsHelper') diff --git a/app/helpers/ci/pipeline_schedules_helper.rb b/app/helpers/ci/pipeline_schedules_helper.rb new file mode 100644 index 00000000000..20e5c90a60e --- /dev/null +++ b/app/helpers/ci/pipeline_schedules_helper.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Ci + module PipelineSchedulesHelper + def timezone_data + ActiveSupport::TimeZone.all.map do |timezone| + { + name: timezone.name, + offset: timezone.now.utc_offset, + identifier: timezone.tzinfo.identifier + } + end + end + end +end diff --git a/app/helpers/ci/runners_helper.rb b/app/helpers/ci/runners_helper.rb new file mode 100644 index 00000000000..8cdb28b2874 --- /dev/null +++ b/app/helpers/ci/runners_helper.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Ci + module RunnersHelper + def runner_status_icon(runner) + status = runner.status + case status + when :not_connected + content_tag :i, nil, + class: "fa fa-warning", + title: "New runner. Has not connected yet" + + when :online, :offline, :paused + content_tag :i, nil, + class: "fa fa-circle runner-status-#{status}", + title: "Runner is #{status}, last contact was #{time_ago_in_words(runner.contacted_at)} ago" + end + end + + def runner_link(runner) + display_name = truncate(runner.display_name, length: 15) + id = "\##{runner.id}" + + if current_user && current_user.admin + link_to admin_runner_path(runner) do + display_name + id + end + else + display_name + id + end + end + + # Due to inability of performing sorting of runners by cached "contacted_at" values we have to show uncached values if sorting by "contacted_asc" is requested. + # Please refer to the following issue for more details: https://gitlab.com/gitlab-org/gitlab-foss/issues/55920 + def runner_contacted_at(runner) + if params[:sort] == 'contacted_asc' + runner.uncached_contacted_at + else + runner.contacted_at + end + end + end +end + +Ci::RunnersHelper.prepend_if_ee('EE::Ci::RunnersHelper') diff --git a/app/helpers/ci/status_helper.rb b/app/helpers/ci/status_helper.rb new file mode 100644 index 00000000000..bca49324a19 --- /dev/null +++ b/app/helpers/ci/status_helper.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +## +# DEPRECATED +# +# These helpers are deprecated in favor of detailed CI/CD statuses. +# +# See 'detailed_status?` method and `Gitlab::Ci::Status` module. +# +module Ci + module StatusHelper + def ci_label_for_status(status) + if detailed_status?(status) + return status.label + end + + label = case status + when 'success' + 'passed' + when 'success-with-warnings' + 'passed with warnings' + when 'manual' + 'waiting for manual action' + when 'scheduled' + 'waiting for delayed job' + else + status + end + translation = "CiStatusLabel|#{label}" + s_(translation) + end + + def ci_text_for_status(status) + if detailed_status?(status) + return status.text + end + + case status + when 'success' + s_('CiStatusText|passed') + when 'success-with-warnings' + s_('CiStatusText|passed') + when 'manual' + s_('CiStatusText|blocked') + when 'scheduled' + s_('CiStatusText|delayed') + else + # All states are already being translated inside the detailed statuses: + # :running => Gitlab::Ci::Status::Running + # :skipped => Gitlab::Ci::Status::Skipped + # :failed => Gitlab::Ci::Status::Failed + # :success => Gitlab::Ci::Status::Success + # :canceled => Gitlab::Ci::Status::Canceled + # The following states are customized above: + # :manual => Gitlab::Ci::Status::Manual + status_translation = "CiStatusText|#{status}" + s_(status_translation) + end + end + + def ci_status_for_statuseable(subject) + status = subject.try(:status) || 'not found' + status.humanize + end + + # rubocop:disable Metrics/CyclomaticComplexity + def ci_icon_for_status(status, size: 16) + if detailed_status?(status) + return sprite_icon(status.icon, size: size) + end + + icon_name = + case status + when 'success' + 'status_success' + when 'success-with-warnings' + 'status_warning' + when 'failed' + 'status_failed' + when 'pending' + 'status_pending' + when 'waiting_for_resource' + 'status_pending' + when 'preparing' + 'status_preparing' + when 'running' + 'status_running' + when 'play' + 'play' + when 'created' + 'status_created' + when 'skipped' + 'status_skipped' + when 'manual' + 'status_manual' + when 'scheduled' + 'status_scheduled' + else + 'status_canceled' + end + + sprite_icon(icon_name, size: size) + end + # rubocop:enable Metrics/CyclomaticComplexity + + def ci_icon_class_for_status(status) + group = detailed_status?(status) ? status.group : status.dasherize + + "ci-status-icon-#{group}" + end + + def pipeline_status_cache_key(pipeline_status) + "pipeline-status/#{pipeline_status.sha}-#{pipeline_status.status}" + end + + def render_commit_status(commit, status, ref: nil, tooltip_placement: 'left') + project = commit.project + path = pipelines_project_commit_path(project, commit, ref: ref) + + render_status_with_link( + status, + path, + tooltip_placement: tooltip_placement, + icon_size: 24) + end + + def render_status_with_link(status, path = nil, type: _('pipeline'), tooltip_placement: 'left', cssclass: '', container: 'body', icon_size: 16) + klass = "ci-status-link #{ci_icon_class_for_status(status)} d-inline-flex #{cssclass}" + title = "#{type.titleize}: #{ci_label_for_status(status)}" + data = { toggle: 'tooltip', placement: tooltip_placement, container: container } + + if path + link_to ci_icon_for_status(status, size: icon_size), path, + class: klass, title: title, data: data + else + content_tag :span, ci_icon_for_status(status, size: icon_size), + class: klass, title: title, data: data + end + end + + def detailed_status?(status) + status.respond_to?(:text) && + status.respond_to?(:group) && + status.respond_to?(:label) && + status.respond_to?(:icon) + end + end +end diff --git a/app/helpers/ci/variables_helper.rb b/app/helpers/ci/variables_helper.rb new file mode 100644 index 00000000000..b20390d58e9 --- /dev/null +++ b/app/helpers/ci/variables_helper.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Ci + module VariablesHelper + def ci_variable_protected_by_default? + Gitlab::CurrentSettings.current_application_settings.protected_ci_variables + end + + def create_deploy_token_path(entity, opts = {}) + if entity.is_a?(::Group) + create_deploy_token_group_settings_repository_path(entity, opts) + else + # TODO: change this path to 'create_deploy_token_project_settings_ci_cd_path' + # See MR comment for more detail: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27059#note_311585356 + create_deploy_token_project_settings_repository_path(entity, opts) + end + end + + def revoke_deploy_token_path(entity, token) + if entity.is_a?(::Group) + revoke_group_deploy_token_path(entity, token) + else + revoke_project_deploy_token_path(entity, token) + end + end + + def ci_variable_protected?(variable, only_key_value) + if variable && !only_key_value + variable.protected + else + ci_variable_protected_by_default? + end + end + + def ci_variable_masked?(variable, only_key_value) + if variable && !only_key_value + variable.masked + else + false + end + end + + def ci_variable_type_options + [ + %w(Variable env_var), + %w(File file) + ] + end + + def ci_variable_maskable_regex + Ci::Maskable::REGEX.inspect.sub('\\A', '^').sub('\\z', '$').sub(/^\//, '').sub(/\/[a-z]*$/, '').gsub('\/', '/') + end + end +end diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb deleted file mode 100644 index 80d1b7e7edb..00000000000 --- a/app/helpers/ci_status_helper.rb +++ /dev/null @@ -1,146 +0,0 @@ -# frozen_string_literal: true - -## -# DEPRECATED -# -# These helpers are deprecated in favor of detailed CI/CD statuses. -# -# See 'detailed_status?` method and `Gitlab::Ci::Status` module. -# -module CiStatusHelper - def ci_label_for_status(status) - if detailed_status?(status) - return status.label - end - - label = case status - when 'success' - 'passed' - when 'success-with-warnings' - 'passed with warnings' - when 'manual' - 'waiting for manual action' - when 'scheduled' - 'waiting for delayed job' - else - status - end - translation = "CiStatusLabel|#{label}" - s_(translation) - end - - def ci_text_for_status(status) - if detailed_status?(status) - return status.text - end - - case status - when 'success' - s_('CiStatusText|passed') - when 'success-with-warnings' - s_('CiStatusText|passed') - when 'manual' - s_('CiStatusText|blocked') - when 'scheduled' - s_('CiStatusText|delayed') - else - # All states are already being translated inside the detailed statuses: - # :running => Gitlab::Ci::Status::Running - # :skipped => Gitlab::Ci::Status::Skipped - # :failed => Gitlab::Ci::Status::Failed - # :success => Gitlab::Ci::Status::Success - # :canceled => Gitlab::Ci::Status::Canceled - # The following states are customized above: - # :manual => Gitlab::Ci::Status::Manual - status_translation = "CiStatusText|#{status}" - s_(status_translation) - end - end - - def ci_status_for_statuseable(subject) - status = subject.try(:status) || 'not found' - status.humanize - end - - # rubocop:disable Metrics/CyclomaticComplexity - def ci_icon_for_status(status, size: 16) - if detailed_status?(status) - return sprite_icon(status.icon, size: size) - end - - icon_name = - case status - when 'success' - 'status_success' - when 'success-with-warnings' - 'status_warning' - when 'failed' - 'status_failed' - when 'pending' - 'status_pending' - when 'waiting_for_resource' - 'status_pending' - when 'preparing' - 'status_preparing' - when 'running' - 'status_running' - when 'play' - 'play' - when 'created' - 'status_created' - when 'skipped' - 'status_skipped' - when 'manual' - 'status_manual' - when 'scheduled' - 'status_scheduled' - else - 'status_canceled' - end - - sprite_icon(icon_name, size: size) - end - # rubocop:enable Metrics/CyclomaticComplexity - - def ci_icon_class_for_status(status) - group = detailed_status?(status) ? status.group : status.dasherize - - "ci-status-icon-#{group}" - end - - def pipeline_status_cache_key(pipeline_status) - "pipeline-status/#{pipeline_status.sha}-#{pipeline_status.status}" - end - - def render_commit_status(commit, status, ref: nil, tooltip_placement: 'left') - project = commit.project - path = pipelines_project_commit_path(project, commit, ref: ref) - - render_status_with_link( - status, - path, - tooltip_placement: tooltip_placement, - icon_size: 24) - end - - def render_status_with_link(status, path = nil, type: _('pipeline'), tooltip_placement: 'left', cssclass: '', container: 'body', icon_size: 16) - klass = "ci-status-link #{ci_icon_class_for_status(status)} d-inline-flex #{cssclass}" - title = "#{type.titleize}: #{ci_label_for_status(status)}" - data = { toggle: 'tooltip', placement: tooltip_placement, container: container } - - if path - link_to ci_icon_for_status(status, size: icon_size), path, - class: klass, title: title, data: data - else - content_tag :span, ci_icon_for_status(status, size: icon_size), - class: klass, title: title, data: data - end - end - - def detailed_status?(status) - status.respond_to?(:text) && - status.respond_to?(:group) && - status.respond_to?(:label) && - status.respond_to?(:icon) - end -end diff --git a/app/helpers/ci_variables_helper.rb b/app/helpers/ci_variables_helper.rb deleted file mode 100644 index cd0718c1b82..00000000000 --- a/app/helpers/ci_variables_helper.rb +++ /dev/null @@ -1,52 +0,0 @@ -# frozen_string_literal: true - -module CiVariablesHelper - def ci_variable_protected_by_default? - Gitlab::CurrentSettings.current_application_settings.protected_ci_variables - end - - def create_deploy_token_path(entity, opts = {}) - if entity.is_a?(Group) - create_deploy_token_group_settings_repository_path(entity, opts) - else - # TODO: change this path to 'create_deploy_token_project_settings_ci_cd_path' - # See MR comment for more detail: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27059#note_311585356 - create_deploy_token_project_settings_repository_path(entity, opts) - end - end - - def revoke_deploy_token_path(entity, token) - if entity.is_a?(Group) - revoke_group_deploy_token_path(entity, token) - else - revoke_project_deploy_token_path(entity, token) - end - end - - def ci_variable_protected?(variable, only_key_value) - if variable && !only_key_value - variable.protected - else - ci_variable_protected_by_default? - end - end - - def ci_variable_masked?(variable, only_key_value) - if variable && !only_key_value - variable.masked - else - false - end - end - - def ci_variable_type_options - [ - %w(Variable env_var), - %w(File file) - ] - end - - def ci_variable_maskable_regex - Ci::Maskable::REGEX.inspect.sub('\\A', '^').sub('\\z', '$').sub(/^\//, '').sub(/\/[a-z]*$/, '').gsub('\/', '/') - end -end diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb index 1204f882707..c85d2a68f14 100644 --- a/app/helpers/clusters_helper.rb +++ b/app/helpers/clusters_helper.rb @@ -1,11 +1,6 @@ # frozen_string_literal: true module ClustersHelper - # EE overrides this - def has_multiple_clusters? - false - end - def create_new_cluster_label(provider: nil) case provider when 'aws' @@ -19,6 +14,7 @@ module ClustersHelper def js_clusters_list_data(path = nil) { + ancestor_help_path: help_page_path('user/group/clusters/index', anchor: 'cluster-precedence'), endpoint: path, img_tags: { aws: { path: image_path('illustrations/logos/amazon_eks.svg'), text: s_('ClusterIntegration|Amazon EKS') }, @@ -95,5 +91,3 @@ module ClustersHelper can?(user, :admin_cluster, cluster) end end - -ClustersHelper.prepend_if_ee('EE::ClustersHelper') diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index 2a0c2e73dd6..f8490d79427 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -79,7 +79,7 @@ module CommitsHelper # Returns a link formatted as a commit tag link def commit_tag_link(url, text) link_to(url, class: 'badge badge-gray ref-name') do - sprite_icon('tag', size: 12, css_class: 'append-right-5 vertical-align-middle') + "#{text}" + sprite_icon('tag', size: 12, css_class: 'gl-mr-2 vertical-align-middle') + "#{text}" end end @@ -181,15 +181,11 @@ module CommitsHelper 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 @ ') - link_to( - project_blob_path(project, - tree_join(commit_sha, diff_new_path)), - class: 'btn view-file js-view-file' - ) do - raw(title) + content_tag(:span, Commit.truncate_sha(commit_sha), - class: 'commit-sha') + link_to(path, class: 'btn') do + raw(title) + content_tag(:span, truncate_sha(commit_sha), class: 'commit-sha') end end diff --git a/app/helpers/cookies_helper.rb b/app/helpers/cookies_helper.rb index 3a7e9987190..938379818de 100644 --- a/app/helpers/cookies_helper.rb +++ b/app/helpers/cookies_helper.rb @@ -1,9 +1,19 @@ # frozen_string_literal: true module CookiesHelper - def set_secure_cookie(key, value, httponly: false, permanent: false) - cookie_jar = permanent ? cookies.permanent : cookies + COOKIE_TYPE_PERMANENT = :permanent + COOKIE_TYPE_ENCRYPTED = :encrypted - cookie_jar[key] = { value: value, secure: Gitlab.config.gitlab.https, httponly: httponly } + def set_secure_cookie(key, value, httponly: false, expires: nil, type: nil) + cookie_jar = case type + when COOKIE_TYPE_PERMANENT + cookies.permanent + when COOKIE_TYPE_ENCRYPTED + cookies.encrypted + else + cookies + end + + cookie_jar[key] = { value: value, secure: Gitlab.config.gitlab.https, httponly: httponly, expires: expires } end end diff --git a/app/helpers/dashboard_helper.rb b/app/helpers/dashboard_helper.rb index b38feb0fb6c..7bf3795d73a 100644 --- a/app/helpers/dashboard_helper.rb +++ b/app/helpers/dashboard_helper.rb @@ -41,7 +41,7 @@ module DashboardHelper if doc_href.present? link_to_doc = link_to(sprite_icon('question', size: 16), doc_href, - class: 'prepend-left-5', title: _('Documentation'), + class: 'gl-ml-2', title: _('Documentation'), target: '_blank', rel: 'noopener noreferrer') concat(link_to_doc) diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index 4c3c4931387..3b25de521d0 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -135,8 +135,7 @@ module DiffHelper def diff_file_html_data(project, diff_file_path, diff_commit_id) { - blob_diff_path: project_blob_diff_path(project, - tree_join(diff_commit_id, diff_file_path)), + blob_diff_path: project_blob_diff_path(project, tree_join(diff_commit_id, diff_file_path)), view: diff_view } end @@ -175,6 +174,10 @@ module DiffHelper end end + def apply_diff_view_cookie! + set_secure_cookie(:diff_view, params.delete(:view), type: CookiesHelper::COOKIE_TYPE_PERMANENT) if params[:view].present? + end + private def diff_btn(title, name, selected) diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb index 64c5fae7d96..772a5f79a4d 100644 --- a/app/helpers/dropdowns_helper.rb +++ b/app/helpers/dropdowns_helper.rb @@ -15,7 +15,10 @@ module DropdownsHelper dropdown_output = dropdown_toggle_link(toggle_text, data_attr, options) end - dropdown_output << content_tag(:div, class: "dropdown-menu dropdown-select #{options[:dropdown_class] if options.key?(:dropdown_class)}") do + content_tag_options = { class: "dropdown-menu dropdown-select #{options[:dropdown_class] if options.key?(:dropdown_class)}" } + content_tag_options[:data] = { qa_selector: "#{options[:dropdown_qa_selector]}" } if options[:dropdown_qa_selector] + + dropdown_output << content_tag(:div, content_tag_options) do output = [] if options.key?(:title) diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb index 41a255434af..b522a9dfb4f 100644 --- a/app/helpers/environments_helper.rb +++ b/app/helpers/environments_helper.rb @@ -24,7 +24,7 @@ module EnvironmentsHelper def metrics_data(project, environment) metrics_data = {} metrics_data.merge!(project_metrics_data(project)) if project - metrics_data.merge!(environment_metrics_data(environment)) if environment + metrics_data.merge!(environment_metrics_data(environment, project)) if environment metrics_data.merge!(project_and_environment_metrics_data(project, environment)) if project && environment metrics_data.merge!(static_metrics_data) @@ -36,7 +36,8 @@ module EnvironmentsHelper "environment-name": environment.name, "environments-path": project_environments_path(project, format: :json), "environment-id": environment.id, - "cluster-applications-documentation-path" => help_page_path('user/clusters/applications.md', anchor: 'elastic-stack') + "cluster-applications-documentation-path" => help_page_path('user/clusters/applications.md', anchor: 'elastic-stack'), + "clusters-path": project_clusters_path(project, format: :json) } end @@ -65,11 +66,11 @@ module EnvironmentsHelper } end - def environment_metrics_data(environment) + def environment_metrics_data(environment, project = nil) return {} unless environment { - 'metrics-dashboard-base-path' => environment_metrics_path(environment), + 'metrics-dashboard-base-path' => metrics_dashboard_base_path(environment, project), 'current-environment-name' => environment.name, 'has-metrics' => "#{environment.has_metrics?}", 'prometheus-status' => "#{environment.prometheus_status}", @@ -77,6 +78,17 @@ module EnvironmentsHelper } end + def metrics_dashboard_base_path(environment, project) + # This is needed to support our transition from environment scoped metric paths to project scoped. + if project + path = project_metrics_dashboard_path(project) + + return path if request.path.include?(path) + end + + environment_metrics_path(environment) + end + def project_and_environment_metrics_data(project, environment) return {} unless project && environment @@ -84,14 +96,16 @@ module EnvironmentsHelper 'metrics-endpoint' => additional_metrics_project_environment_path(project, environment, format: :json), 'dashboard-endpoint' => metrics_dashboard_project_environment_path(project, environment, format: :json), 'deployments-endpoint' => project_environment_deployments_path(project, environment, format: :json), - 'alerts-endpoint' => project_prometheus_alerts_path(project, environment_id: environment.id, format: :json) - + 'alerts-endpoint' => project_prometheus_alerts_path(project, environment_id: environment.id, format: :json), + 'operations-settings-path' => project_settings_operations_path(project), + 'can-access-operations-settings' => can?(current_user, :admin_operations, project).to_s } end def static_metrics_data { 'documentation-path' => help_page_path('administration/monitoring/prometheus/index.md'), + 'add-dashboard-documentation-path' => help_page_path('user/project/integrations/prometheus.md', anchor: 'adding-a-new-dashboard-to-your-project'), 'empty-getting-started-svg-path' => image_path('illustrations/monitoring/getting_started.svg'), 'empty-loading-svg-path' => image_path('illustrations/monitoring/loading.svg'), 'empty-no-data-svg-path' => image_path('illustrations/monitoring/no_data.svg'), diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index c1f343edd10..207230fd92e 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -29,7 +29,11 @@ module EventsHelper def event_action_name(event) target = if event.target_type - if event.note? + if event.design? || event.design_note? + 'design' + elsif event.wiki_page? + 'wiki page' + elsif event.note? event.note_target_type else event.target_type.titleize.downcase @@ -58,11 +62,28 @@ module EventsHelper end def event_filter_visible(feature_key) + return designs_visible? if feature_key == :designs return true unless @project @project.feature_available?(feature_key, current_user) end + def designs_visible? + if @project + design_activity_enabled?(@project) + elsif @group + design_activity_enabled?(@group) + elsif @projects + @projects.with_namespace.include_project_feature.any? { |p| design_activity_enabled?(p) } + else + true + end + end + + def design_activity_enabled?(project) + Ability.allowed?(current_user, :read_design_activity, project) + end + def comments_visible? event_filter_visible(:repository) || event_filter_visible(:merge_requests) || @@ -94,6 +115,12 @@ module EventsHelper elsif event.milestone? words << "##{event.target_iid}" if event.target_iid words << "in" + elsif event.design? + words << event.design.to_reference + words << "in" + elsif event.wiki_page? + words << event.target_title + words << "in" elsif event.target prefix = if event.merge_request? @@ -180,10 +207,19 @@ module EventsHelper def event_wiki_title_html(event) capture do - concat content_tag(:span, _('wiki page'), class: "event-target-type append-right-4") + concat content_tag(:span, _('wiki page'), class: "event-target-type gl-mr-2") concat link_to(event.target_title, event_wiki_page_target_url(event), title: event.target_title, - class: 'has-tooltip event-target-link append-right-4') + class: 'has-tooltip event-target-link gl-mr-2') + end + end + + def event_design_title_html(event) + capture do + concat content_tag(:span, _('design'), class: "event-target-type gl-mr-2") + concat link_to(event.design.reference_link_text, design_url(event.design), + title: event.target_title, + class: 'has-tooltip event-design event-target-link gl-mr-2') end end @@ -194,8 +230,8 @@ module EventsHelper def event_note_title_html(event) if event.note_target capture do - concat content_tag(:span, event.note_target_type, class: "event-target-type append-right-4") - concat link_to(event.note_target_reference, event_note_target_url(event), title: event.target_title, class: 'has-tooltip event-target-link append-right-4') + concat content_tag(:span, event.note_target_type, class: "event-target-type gl-mr-2") + concat link_to(event.note_target_reference, event_note_target_url(event), title: event.target_title, class: 'has-tooltip event-target-link gl-mr-2') end else content_tag(:strong, '(deleted)') @@ -214,6 +250,18 @@ module EventsHelper sprite_icon(icon_name, size: size) if icon_name end + DESIGN_ICONS = { + 'created' => 'upload', + 'updated' => 'pencil', + 'destroyed' => ICON_NAMES_BY_EVENT_TYPE['destroyed'], + 'archived' => 'archive' + }.freeze + + def design_event_icon(action, size: 24) + icon_name = DESIGN_ICONS[action] + sprite_icon(icon_name, size: size) if icon_name + end + def icon_for_profile_event(event) if current_path?('users#show') content_tag :div, class: "system-note-image #{event.action_name.parameterize}-icon" do @@ -228,7 +276,9 @@ module EventsHelper def inline_event_icon(event) unless current_path?('users#show') - content_tag :span, class: "system-note-image-inline d-none d-sm-flex append-right-4 #{event.action_name.parameterize}-icon align-self-center" do + content_tag :span, class: "system-note-image-inline d-none d-sm-flex gl-mr-2 #{event.action_name.parameterize}-icon align-self-center" do + next design_event_icon(event.action, size: 14) if event.design? + icon_for_event(event.action_name, size: 14) end end @@ -244,7 +294,7 @@ module EventsHelper private - def design_url(design, opts) + def design_url(design, opts = {}) designs_project_issue_url( design.project, design.issue, diff --git a/app/helpers/export_helper.rb b/app/helpers/export_helper.rb index 483b350b99b..38a4f7f1b4b 100644 --- a/app/helpers/export_helper.rb +++ b/app/helpers/export_helper.rb @@ -6,7 +6,7 @@ module ExportHelper [ _('Project and wiki repositories'), _('Project uploads'), - _('Project configuration, including services'), + _('Project configuration, excluding integrations'), _('Issues with comments, merge requests with diffs and comments, labels, milestones, snippets, and other project entities'), _('LFS objects'), _('Issue Boards'), diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index 8a9380f4771..04f34f5a3ae 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -271,6 +271,36 @@ module GitlabRoutingHelper end end + def gitlab_raw_snippet_blob_url(snippet, path, ref = nil) + params = { + snippet_id: snippet, + ref: ref || snippet.repository.root_ref, + path: path + } + + if snippet.is_a?(ProjectSnippet) + project_snippet_blob_raw_url(snippet.project, params) + else + snippet_blob_raw_url(params) + end + end + + def gitlab_raw_snippet_blob_path(blob, ref = nil) + snippet = blob.container + + params = { + snippet_id: snippet, + ref: ref || blob.repository.root_ref, + path: blob.path + } + + if snippet.is_a?(ProjectSnippet) + project_snippet_blob_raw_path(snippet.project, params) + else + snippet_blob_raw_path(params) + end + end + def gitlab_snippet_notes_path(snippet, *args) new_args = snippet_query_params(snippet, *args) snippet_notes_path(snippet, *new_args) diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index a6c3c97a873..61c9bd74451 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -176,6 +176,10 @@ module GroupsHelper links << :settings end + if can?(current_user, :read_wiki, @group) + links << :wiki + end + links end diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb index 8a32d3c8a3f..add15cc0d12 100644 --- a/app/helpers/icons_helper.rb +++ b/app/helpers/icons_helper.rb @@ -28,10 +28,12 @@ module IconsHelper end def sprite_icon_path - # SVG Sprites currently don't work across domains, so in the case of a CDN - # we have to set the current path deliberately to prevent addition of asset_host - sprite_base_url = Gitlab.config.gitlab.url if ActionController::Base.asset_host - ActionController::Base.helpers.image_path('icons.svg', host: sprite_base_url) + @sprite_icon_path ||= begin + # SVG Sprites currently don't work across domains, so in the case of a CDN + # we have to set the current path deliberately to prevent addition of asset_host + sprite_base_url = Gitlab.config.gitlab.url if ActionController::Base.asset_host + ActionController::Base.helpers.image_path('icons.svg', host: sprite_base_url) + end end def sprite_file_icons_path @@ -53,6 +55,15 @@ module IconsHelper content_tag(:svg, content_tag(:use, "", { "xlink:href" => "#{sprite_icon_path}##{icon_name}" } ), class: css_classes.empty? ? nil : css_classes.join(' ')) end + def loading_icon(container: false, color: 'orange', size: 'sm', css_class: nil) + css_classes = ['gl-spinner', "gl-spinner-#{color}", "gl-spinner-#{size}"] + css_classes << "#{css_class}" unless css_class.blank? + + spinner = content_tag(:span, "", { class: css_classes.join(' '), aria: { label: _('Loading') } }) + + container == true ? content_tag(:div, spinner, { class: 'gl-spinner-container' }) : spinner + end + def external_snippet_icon(name) content_tag(:span, "", class: "gl-snippet-icon gl-snippet-icon-#{name}") end diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb index d6145493ba6..93f5ca7258d 100644 --- a/app/helpers/ide_helper.rb +++ b/app/helpers/ide_helper.rb @@ -9,10 +9,12 @@ module IdeHelper "pipelines-empty-state-svg-path": image_path('illustrations/pipelines_empty.svg'), "promotion-svg-path": image_path('illustrations/web-ide_promotion.svg'), "ci-help-page-path" => help_page_path('ci/quick_start/README'), - "web-ide-help-page-path" => help_page_path('user/project/web_ide/index.html'), + "web-ide-help-page-path" => help_page_path('user/project/web_ide/index.md'), "clientside-preview-enabled": Gitlab::CurrentSettings.web_ide_clientside_preview_enabled?.to_s, "render-whitespace-in-code": current_user.render_whitespace_in_code.to_s, "codesandbox-bundler-url": Gitlab::CurrentSettings.web_ide_clientside_preview_bundler_url } end end + +::IdeHelper.prepend_if_ee('::EE::IdeHelper') diff --git a/app/helpers/import_helper.rb b/app/helpers/import_helper.rb index 9122ad5b35a..1ee67211ab0 100644 --- a/app/helpers/import_helper.rb +++ b/app/helpers/import_helper.rb @@ -19,7 +19,11 @@ module ImportHelper end def provider_project_link_url(provider_url, full_path) - Gitlab::Utils.append_path(provider_url, full_path) + if Gitlab::Utils.parse_url(full_path)&.absolute? + full_path + else + Gitlab::Utils.append_path(provider_url, full_path) + end end def import_will_timeout_message(_ci_cd_only) diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index a848c814742..dccb89eec79 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -367,15 +367,6 @@ module IssuablesHelper end end - def issuable_close_reopen_button_method(issuable) - case issuable - when Issue - '' - when MergeRequest - 'put' - end - end - def issuable_author_is_current_user(issuable) issuable.author == current_user end @@ -394,6 +385,14 @@ module IssuablesHelper end end + def issuable_squash_option?(issuable, project) + if issuable.persisted? + issuable.squash + else + project.squash_enabled_by_default? + end + end + private def sidebar_gutter_collapsed? diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 244b97c7196..61fe075303c 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -41,7 +41,7 @@ module IssuesHelper end def confidential_icon(issue) - icon('eye-slash') if issue.confidential? + sprite_icon('eye-slash', size: 16, css_class: 'gl-vertical-align-text-bottom') if issue.confidential? end def award_user_list(awards, current_user, limit: 10) @@ -132,7 +132,10 @@ module IssuesHelper end def show_moved_service_desk_issue_warning?(issue) - false + return false unless issue.moved_from + return false unless issue.from_service_desk? + + issue.moved_from.project.service_desk_enabled? && !issue.project.service_desk_enabled? end end diff --git a/app/helpers/jobs_helper.rb b/app/helpers/jobs_helper.rb deleted file mode 100644 index 46edba261dd..00000000000 --- a/app/helpers/jobs_helper.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module JobsHelper - def jobs_data - { - "endpoint" => project_job_path(@project, @build, format: :json), - "project_path" => @project.full_path, - "deployment_help_url" => help_page_path('user/project/clusters/index.html', anchor: 'troubleshooting-failed-deployment-jobs'), - "runner_help_url" => help_page_path('ci/runners/README.html', anchor: 'setting-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), - "build_status" => @build.status, - "build_stage" => @build.stage, - "log_state" => '', - "build_options" => javascript_build_options - } - end -end diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb index 7ab2b33de8c..ed8931fe0f2 100644 --- a/app/helpers/markup_helper.rb +++ b/app/helpers/markup_helper.rb @@ -244,7 +244,6 @@ module MarkupHelper content_tag :button, type: 'button', class: 'toolbar-btn js-md has-tooltip', - tabindex: -1, data: data, title: options[:title], aria: { label: options[:title] } do diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb index 31995c27fac..d66f67fbb60 100644 --- a/app/helpers/members_helper.rb +++ b/app/helpers/members_helper.rb @@ -48,6 +48,14 @@ module MembersHelper "#{request.path}?#{options.to_param}" end + def member_path(member) + if member.is_a?(GroupMember) + group_group_member_path(member.source, member) + else + project_project_member_path(member.source, member) + end + end + private def source_text(member) diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 7940ec1162b..caf39741543 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -118,7 +118,7 @@ module MergeRequestsHelper auto_merge_strategy: AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS, should_remove_source_branch: true, sha: merge_request.diff_head_sha, - squash: merge_request.squash + squash: merge_request.squash_on_merge? } end diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb index b9f8d81bc4e..81451e398f2 100644 --- a/app/helpers/namespaces_helper.rb +++ b/app/helpers/namespaces_helper.rb @@ -56,45 +56,6 @@ module NamespacesHelper namespaces_options(selected, options) end - def namespace_storage_alert(namespace) - return {} if current_user.nil? - - payload = Namespaces::CheckStorageSizeService.new(namespace, current_user).execute.payload - - return {} if payload.empty? - - alert_level = payload[:alert_level] - root_namespace = payload[:root_namespace] - - return {} if cookies["hide_storage_limit_alert_#{root_namespace.id}_#{alert_level}"] == 'true' - - payload - end - - def namespace_storage_alert_style(alert_level) - if alert_level == :error || alert_level == :alert - 'danger' - else - alert_level.to_s - end - end - - def namespace_storage_alert_icon(alert_level) - if alert_level == :error || alert_level == :alert - 'error' - elsif alert_level == :info - 'information-o' - else - alert_level.to_s - end - end - - def namespace_storage_usage_link(namespace) - # The usage quota page is only available in EE. This will be changed in - # the future, see https://gitlab.com/gitlab-org/gitlab/-/issues/220042. - nil - end - private # Many importers create a temporary Group, so use the real diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index 9ea0b9cb584..d849ed9d076 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -27,7 +27,7 @@ module NavHelper end elsif current_path?('jobs#show') %w[page-gutter build-sidebar right-sidebar-expanded] - elsif current_controller?('wikis') && current_action?('show', 'create', 'edit', 'update', 'history', 'git_access', 'destroy') + elsif current_controller?('wikis') && current_action?('show', 'create', 'edit', 'update', 'history', 'git_access', 'destroy', 'diff') %w[page-gutter wiki-sidebar right-sidebar-expanded] else [] diff --git a/app/helpers/notify_helper.rb b/app/helpers/notify_helper.rb new file mode 100644 index 00000000000..fb68029928c --- /dev/null +++ b/app/helpers/notify_helper.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module NotifyHelper + def merge_request_reference_link(entity, *args) + link_to(entity.to_reference, merge_request_url(entity, *args)) + end + + def issue_reference_link(entity, *args) + link_to(entity.to_reference, issue_url(entity, *args)) + end +end diff --git a/app/helpers/onboarding_experiment_helper.rb b/app/helpers/onboarding_experiment_helper.rb deleted file mode 100644 index 138fc60479d..00000000000 --- a/app/helpers/onboarding_experiment_helper.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -module OnboardingExperimentHelper - def allow_access_to_onboarding? - ::Gitlab.dev_env_or_com? && Feature.enabled?(:user_onboarding) - end -end - -OnboardingExperimentHelper.prepend_if_ee('EE::OnboardingExperimentHelper') diff --git a/app/helpers/operations_helper.rb b/app/helpers/operations_helper.rb new file mode 100644 index 00000000000..3444773fe88 --- /dev/null +++ b/app/helpers/operations_helper.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module OperationsHelper + include Gitlab::Utils::StrongMemoize + + def prometheus_service + strong_memoize(:prometheus_service) do + @project.find_or_initialize_service(::PrometheusService.to_param) + end + end + + def alerts_service + strong_memoize(:alerts_service) do + @project.find_or_initialize_service(::AlertsService.to_param) + end + end + + def alerts_settings_data(disabled: false) + { + 'prometheus_activated' => prometheus_service.manual_configuration?.to_s, + 'activated' => alerts_service.activated?.to_s, + 'prometheus_form_path' => scoped_integration_path(prometheus_service), + 'form_path' => scoped_integration_path(alerts_service), + 'prometheus_reset_key_path' => reset_alerting_token_project_settings_operations_path(@project), + 'prometheus_authorization_key' => @project.alerting_setting&.token, + 'prometheus_api_url' => prometheus_service.api_url, + 'authorization_key' => alerts_service.token, + 'prometheus_url' => notify_project_prometheus_alerts_url(@project, format: :json), + 'url' => alerts_service.url, + 'alerts_setup_url' => help_page_path('user/project/integrations/generic_alerts.md', anchor: 'setting-up-generic-alerts'), + 'alerts_usage_url' => project_alert_management_index_path(@project), + 'disabled' => disabled.to_s + } + end + + def operations_settings_data + setting = project_incident_management_setting + templates = setting.available_issue_templates.map { |t| { key: t.key, name: t.name } } + + { + operations_settings_endpoint: project_settings_operations_path(@project), + templates: templates.to_json, + create_issue: setting.create_issue.to_s, + issue_template_key: setting.issue_template_key.to_s, + send_email: setting.send_email.to_s, + pagerduty_active: setting.pagerduty_active.to_s, + pagerduty_token: setting.pagerduty_token.to_s, + pagerduty_webhook_url: project_incidents_pagerduty_url(@project, token: setting.pagerduty_token), + pagerduty_reset_key_path: reset_pagerduty_token_project_settings_operations_path(@project) + } + end +end + +OperationsHelper.prepend_if_ee('EE::OperationsHelper') diff --git a/app/helpers/pipeline_schedules_helper.rb b/app/helpers/pipeline_schedules_helper.rb deleted file mode 100644 index 0e166106b32..00000000000 --- a/app/helpers/pipeline_schedules_helper.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module PipelineSchedulesHelper - def timezone_data - ActiveSupport::TimeZone.all.map do |timezone| - { - name: timezone.name, - offset: timezone.now.utc_offset, - identifier: timezone.tzinfo.identifier - } - end - end -end diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index 7a0462e1b2c..271359fcfd1 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -70,7 +70,10 @@ module PreferencesHelper end def language_choices - Gitlab::I18n::AVAILABLE_LANGUAGES.map(&:reverse).sort + options_for_select( + Gitlab::I18n::AVAILABLE_LANGUAGES.map(&:reverse).sort, + current_user.preferred_language + ) end private diff --git a/app/helpers/projects/alert_management_helper.rb b/app/helpers/projects/alert_management_helper.rb index bc585899591..d6e8e738a1c 100644 --- a/app/helpers/projects/alert_management_helper.rb +++ b/app/helpers/projects/alert_management_helper.rb @@ -4,10 +4,11 @@ module Projects::AlertManagementHelper def alert_management_data(current_user, project) { 'project-path' => project.full_path, - 'enable-alert-management-path' => edit_project_service_path(project, AlertsService), + 'enable-alert-management-path' => project_settings_operations_path(project, anchor: 'js-alert-management-settings'), + 'populating-alerts-help-url' => help_page_url('user/project/operations/alert_management.html', anchor: 'enable-alert-management'), 'empty-alert-svg-path' => image_path('illustrations/alert-management-empty-state.svg'), - 'user-can-enable-alert-management' => can?(current_user, :admin_project, project).to_s, - 'alert-management-enabled' => (!!project.alerts_service_activated?).to_s + 'user-can-enable-alert-management' => can?(current_user, :admin_operations, project).to_s, + 'alert-management-enabled' => alert_management_enabled?(project).to_s } end @@ -15,7 +16,16 @@ module Projects::AlertManagementHelper { 'alert-id' => alert_id, 'project-path' => project.full_path, + 'project-id' => project.id, 'project-issues-path' => project_issues_path(project) } end + + private + + def alert_management_enabled?(project) + !!(project.alerts_service_activated? || project.prometheus_service_active?) + end end + +Projects::AlertManagementHelper.prepend_if_ee('EE::Projects::AlertManagementHelper') diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index bda9a69d71f..840e3ef9daa 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -180,7 +180,7 @@ module ProjectsHelper end def link_to_autodeploy_doc - link_to _('About auto deploy'), help_page_path('autodevops/index.md#auto-deploy'), target: '_blank' + link_to _('About auto deploy'), help_page_path('topics/autodevops/stages.md', anchor: 'auto-deploy'), target: '_blank' end def autodeploy_flash_notice(branch_name) @@ -384,9 +384,12 @@ module ProjectsHelper end def project_license_name(project) - project.repository.license&.name + key = "project:#{project.id}:license_name" + + Gitlab::SafeRequestStore.fetch(key) { project.repository.license&.name } rescue GRPC::Unavailable, GRPC::DeadlineExceeded, Gitlab::Git::CommandError => e Gitlab::ErrorTracking.track_exception(e) + Gitlab::SafeRequestStore[key] = nil nil end @@ -397,7 +400,7 @@ module ProjectsHelper nav_tabs = [:home] unless project.empty_repo? - nav_tabs << [:files, :commits, :network, :graphs, :forks] if can?(current_user, :download_code, project) + nav_tabs += [:files, :commits, :network, :graphs, :forks] if can?(current_user, :download_code, project) nav_tabs << :releases if can?(current_user, :read_release, project) end @@ -418,30 +421,30 @@ module ProjectsHelper nav_tabs << :operations end - if can?(current_user, :read_cycle_analytics, project) - nav_tabs << :cycle_analytics - end - tab_ability_map.each do |tab, ability| if can?(current_user, ability, project) nav_tabs << tab end end - nav_tabs << external_nav_tabs(project) + apply_external_nav_tabs(nav_tabs, project) - nav_tabs.flatten + nav_tabs end - def external_nav_tabs(project) - [].tap do |tabs| - tabs << :external_issue_tracker if project.external_issue_tracker - tabs << :external_wiki if project.external_wiki + def apply_external_nav_tabs(nav_tabs, project) + nav_tabs << :external_issue_tracker if project.external_issue_tracker + nav_tabs << :external_wiki if project.external_wiki + + if project.has_confluence? + nav_tabs.delete(:wiki) + nav_tabs << :confluence end end def tab_ability_map { + cycle_analytics: :read_cycle_analytics, environments: :read_environment, metrics_dashboards: :metrics_dashboard, milestones: :read_milestone, @@ -565,7 +568,7 @@ module ProjectsHelper end def project_child_container_class(view_path) - view_path == "projects/issues/issues" ? "prepend-top-default" : "project-show-#{view_path}" + view_path == "projects/issues/issues" ? "gl-mt-3" : "project-show-#{view_path}" end def project_issues(project) @@ -729,10 +732,6 @@ module ProjectsHelper !project.repository.gitlab_ci_yml end - def vue_file_list_enabled? - Feature.enabled?(:vue_file_list, @project, default_enabled: true) - end - def native_code_navigation_enabled?(project) Feature.enabled?(:code_navigation, project, default_enabled: true) end diff --git a/app/helpers/releases_helper.rb b/app/helpers/releases_helper.rb index 1238567a4ed..a3d944c64cc 100644 --- a/app/helpers/releases_helper.rb +++ b/app/helpers/releases_helper.rb @@ -18,21 +18,40 @@ module ReleasesHelper illustration_path: illustration, documentation_path: help_page }.tap do |data| - data[:new_release_path] = new_project_tag_path(@project) if can?(current_user, :create_release, @project) + if can?(current_user, :create_release, @project) + data[:new_release_path] = if Feature.enabled?(:new_release_page, @project) + new_project_release_path(@project) + else + new_project_tag_path(@project) + end + end end end def data_for_edit_release_page + new_edit_pages_shared_data.merge( + tag_name: @release.tag, + releases_page_path: project_releases_path(@project, anchor: @release.tag) + ) + end + + def data_for_new_release_page + new_edit_pages_shared_data.merge( + default_branch: @project.default_branch + ) + end + + private + + def new_edit_pages_shared_data { project_id: @project.id, - tag_name: @release.tag, markdown_preview_path: preview_markdown_path(@project), markdown_docs_path: help_page_path('user/markdown'), - releases_page_path: project_releases_path(@project, anchor: @release.tag), update_release_api_docs_path: help_page_path('api/releases/index.md', anchor: 'update-a-release'), release_assets_docs_path: help_page(anchor: 'release-assets'), manage_milestones_path: project_milestones_path(@project), - new_milestone_path: new_project_milestone_url(@project) + new_milestone_path: new_project_milestone_path(@project) } end end diff --git a/app/helpers/runners_helper.rb b/app/helpers/runners_helper.rb deleted file mode 100644 index d871aaa9c86..00000000000 --- a/app/helpers/runners_helper.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -module RunnersHelper - def runner_status_icon(runner) - status = runner.status - case status - when :not_connected - content_tag :i, nil, - class: "fa fa-warning", - title: "New runner. Has not connected yet" - - when :online, :offline, :paused - content_tag :i, nil, - class: "fa fa-circle runner-status-#{status}", - title: "Runner is #{status}, last contact was #{time_ago_in_words(runner.contacted_at)} ago" - end - end - - def runner_link(runner) - display_name = truncate(runner.display_name, length: 15) - id = "\##{runner.id}" - - if current_user && current_user.admin - link_to admin_runner_path(runner) do - display_name + id - end - else - display_name + id - end - end - - # Due to inability of performing sorting of runners by cached "contacted_at" values we have to show uncached values if sorting by "contacted_asc" is requested. - # Please refer to the following issue for more details: https://gitlab.com/gitlab-org/gitlab-foss/issues/55920 - def runner_contacted_at(runner) - if params[:sort] == 'contacted_asc' - runner.uncached_contacted_at - else - runner.contacted_at - end - end -end - -RunnersHelper.prepend_if_ee('EE::RunnersHelper') diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 4e3b6aad8cc..1b9876b9a6a 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -3,6 +3,28 @@ module SearchHelper SEARCH_PERMITTED_PARAMS = [:search, :scope, :project_id, :group_id, :repository_ref, :snippets].freeze + def search_autocomplete_opts(term) + return unless current_user + + resources_results = [ + groups_autocomplete(term), + projects_autocomplete(term) + ].flatten + + search_pattern = Regexp.new(Regexp.escape(term), "i") + + generic_results = project_autocomplete + default_autocomplete + help_autocomplete + generic_results.concat(default_autocomplete_admin) if current_user.admin? + generic_results.select! { |result| result[:label] =~ search_pattern } + + [ + resources_results, + generic_results + ].flatten.uniq do |item| + item[:label] + end + end + def search_entries_info(collection, scope, term) return if collection.to_a.empty? @@ -62,7 +84,7 @@ module SearchHelper }).html_safe end - # Overriden in EE + # Overridden in EE def search_blob_title(project, path) path end @@ -73,6 +95,91 @@ module SearchHelper private + # Autocomplete results for various settings pages + def default_autocomplete + [ + { category: "Settings", label: _("User settings"), url: profile_path }, + { category: "Settings", label: _("SSH Keys"), url: profile_keys_path }, + { category: "Settings", label: _("Dashboard"), url: root_path } + ] + end + + # Autocomplete results for settings pages, for admins + def default_autocomplete_admin + [ + { category: "Settings", label: _("Admin Section"), url: admin_root_path } + ] + end + + # Autocomplete results for internal help pages + def help_autocomplete + [ + { category: "Help", label: _("API Help"), url: help_page_path("api/README") }, + { category: "Help", label: _("Markdown Help"), url: help_page_path("user/markdown") }, + { category: "Help", label: _("Permissions Help"), url: help_page_path("user/permissions") }, + { category: "Help", label: _("Public Access Help"), url: help_page_path("public_access/public_access") }, + { category: "Help", label: _("Rake Tasks Help"), url: help_page_path("raketasks/README") }, + { category: "Help", label: _("SSH Keys Help"), url: help_page_path("ssh/README") }, + { category: "Help", label: _("System Hooks Help"), url: help_page_path("system_hooks/system_hooks") }, + { category: "Help", label: _("Webhooks Help"), url: help_page_path("user/project/integrations/webhooks") }, + { category: "Help", label: _("Workflow Help"), url: help_page_path("workflow/README") } + ] + end + + # Autocomplete results for the current project, if it's defined + def project_autocomplete + if @project && @project.repository.root_ref + ref = @ref || @project.repository.root_ref + + [ + { category: "In this project", label: _("Files"), url: project_tree_path(@project, ref) }, + { category: "In this project", label: _("Commits"), url: project_commits_path(@project, ref) }, + { category: "In this project", label: _("Network"), url: project_network_path(@project, ref) }, + { category: "In this project", label: _("Graph"), url: project_graph_path(@project, ref) }, + { category: "In this project", label: _("Issues"), url: project_issues_path(@project) }, + { category: "In this project", label: _("Merge Requests"), url: project_merge_requests_path(@project) }, + { category: "In this project", label: _("Milestones"), url: project_milestones_path(@project) }, + { category: "In this project", label: _("Snippets"), url: project_snippets_path(@project) }, + { category: "In this project", label: _("Members"), url: project_project_members_path(@project) }, + { category: "In this project", label: _("Wiki"), url: project_wikis_path(@project) } + ] + else + [] + end + end + + # Autocomplete results for the current user's groups + # rubocop: disable CodeReuse/ActiveRecord + def groups_autocomplete(term, limit = 5) + current_user.authorized_groups.order_id_desc.search(term).limit(limit).map do |group| + { + category: "Groups", + id: group.id, + label: "#{search_result_sanitize(group.full_name)}", + url: group_path(group), + avatar_url: group.avatar_url || '' + } + end + end + # rubocop: enable CodeReuse/ActiveRecord + + # Autocomplete results for the current user's projects + # rubocop: disable CodeReuse/ActiveRecord + def projects_autocomplete(term, limit = 5) + current_user.authorized_projects.order_id_desc.search_by_title(term) + .sorted_by_stars_desc.non_archived.limit(limit).map do |p| + { + category: "Projects", + id: p.id, + value: "#{search_result_sanitize(p.name)}", + label: "#{search_result_sanitize(p.full_name)}", + url: project_path(p), + avatar_url: p.avatar_url || '' + } + end + end + # rubocop: enable CodeReuse/ActiveRecord + def search_result_sanitize(str) Sanitize.clean(str) end diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb index fe839b92ba6..1f9cce80bed 100644 --- a/app/helpers/services_helper.rb +++ b/app/helpers/services_helper.rb @@ -4,25 +4,29 @@ module ServicesHelper def service_event_description(event) case event when "push", "push_events" - "Event will be triggered by a push to the repository" + s_("ProjectService|Event will be triggered by a push to the repository") when "tag_push", "tag_push_events" - "Event will be triggered when a new tag is pushed to the repository" + s_("ProjectService|Event will be triggered when a new tag is pushed to the repository") when "note", "note_events" - "Event will be triggered when someone adds a comment" + s_("ProjectService|Event will be triggered when someone adds a comment") when "confidential_note", "confidential_note_events" - "Event will be triggered when someone adds a comment on a confidential issue" + s_("ProjectService|Event will be triggered when someone adds a comment on a confidential issue") when "issue", "issue_events" - "Event will be triggered when an issue is created/updated/closed" - when "confidential_issue", "confidential_issues_events" - "Event will be triggered when a confidential issue is created/updated/closed" + s_("ProjectService|Event will be triggered when an issue is created/updated/closed") + when "confidential_issue", "confidential_issue_events" + s_("ProjectService|Event will be triggered when a confidential issue is created/updated/closed") when "merge_request", "merge_request_events" - "Event will be triggered when a merge request is created/updated/merged" + s_("ProjectService|Event will be triggered when a merge request is created/updated/merged") when "pipeline", "pipeline_events" - "Event will be triggered when a pipeline status changes" + s_("ProjectService|Event will be triggered when a pipeline status changes") when "wiki_page", "wiki_page_events" - "Event will be triggered when a wiki page is created/updated" + s_("ProjectService|Event will be triggered when a wiki page is created/updated") when "commit", "commit_events" - "Event will be triggered when a commit is created/updated" + s_("ProjectService|Event will be triggered when a commit is created/updated") + when "deployment" + s_("ProjectService|Event will be triggered when a deployment finishes") + when "alert" + s_("ProjectService|Event will be triggered when a new, unique alert is recorded") end end @@ -44,15 +48,8 @@ module ServicesHelper end end - def event_action_description(action) - case action - when "comment" - s_("ProjectService|Comment will be posted on each event") - end - end - - def service_save_button - button_tag(class: 'btn btn-success', type: 'submit', data: { qa_selector: 'save_changes_button' }) do + def service_save_button(disabled: false) + button_tag(class: 'btn btn-success', type: 'submit', disabled: disabled, data: { qa_selector: 'save_changes_button' }) do icon('spinner spin', class: 'hidden js-btn-spinner') + content_tag(:span, 'Save changes', class: 'js-btn-label') end @@ -90,7 +87,7 @@ module ServicesHelper def scoped_test_integration_path(integration) if @project.present? - test_project_settings_integration_path(@project, integration) + test_project_service_path(@project, integration) elsif @group.present? test_group_settings_integration_path(@group, integration) else @@ -99,25 +96,45 @@ module ServicesHelper end def integration_form_refactor? - Feature.enabled?(:integration_form_refactor, @project) + Feature.enabled?(:integration_form_refactor, @project, default_enabled: true) end - def trigger_events_for_service + def integration_form_data(integration) + { + id: integration.id, + show_active: integration.show_active_box?.to_s, + activated: (integration.active || integration.new_record?).to_s, + type: integration.to_param, + merge_request_events: integration.merge_requests_events.to_s, + commit_events: integration.commit_events.to_s, + enable_comments: integration.comment_on_event_enabled.to_s, + comment_detail: integration.comment_detail, + trigger_events: trigger_events_for_service(integration), + fields: fields_for_service(integration), + inherit_from_id: integration.inherit_from_id + } + end + + def trigger_events_for_service(integration) return [] unless integration_form_refactor? - ServiceEventSerializer.new(service: @service).represent(@service.configurable_events).to_json + ServiceEventSerializer.new(service: integration).represent(integration.configurable_events).to_json end - def fields_for_service + def fields_for_service(integration) return [] unless integration_form_refactor? - ServiceFieldSerializer.new(service: @service).represent(@service.global_fields).to_json + ServiceFieldSerializer.new(service: integration).represent(integration.global_fields).to_json end - def show_service_trigger_events? - return false if @service.is_a?(JiraService) || integration_form_refactor? + def show_service_trigger_events?(integration) + return false if integration.is_a?(JiraService) || integration_form_refactor? + + integration.configurable_events.present? + end - @service.configurable_events.present? + def project_jira_issues_integration? + false end extend self diff --git a/app/helpers/storage_helper.rb b/app/helpers/storage_helper.rb index ce810433a3a..13bf9c92d52 100644 --- a/app/helpers/storage_helper.rb +++ b/app/helpers/storage_helper.rb @@ -14,9 +14,10 @@ module StorageHelper counter_repositories: storage_counter(statistics.repository_size), counter_wikis: storage_counter(statistics.wiki_size), counter_build_artifacts: storage_counter(statistics.build_artifacts_size), - counter_lfs_objects: storage_counter(statistics.lfs_objects_size) + counter_lfs_objects: storage_counter(statistics.lfs_objects_size), + counter_snippets: storage_counter(statistics.snippets_size) } - _("Repository: %{counter_repositories} / Wikis: %{counter_wikis} / Build Artifacts: %{counter_build_artifacts} / LFS: %{counter_lfs_objects}") % counters + _("Repository: %{counter_repositories} / Wikis: %{counter_wikis} / Build Artifacts: %{counter_build_artifacts} / LFS: %{counter_lfs_objects} / Snippets: %{counter_snippets}") % counters end end diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb index 7baa615d36f..6ea6a33ba5e 100644 --- a/app/helpers/system_note_helper.rb +++ b/app/helpers/system_note_helper.rb @@ -31,7 +31,9 @@ module SystemNoteHelper 'designs_added' => 'doc-image', 'designs_modified' => 'doc-image', 'designs_removed' => 'doc-image', - 'designs_discussion_added' => 'doc-image' + 'designs_discussion_added' => 'doc-image', + 'status' => 'status', + 'alert_issue_added' => 'issues' }.freeze def system_note_icon_name(note) diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index 2b4f2f11d1e..b9a6cab07a8 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -22,6 +22,7 @@ module TodosHelper when Todo::APPROVAL_REQUIRED then "set #{todo_action_subject(todo)} as an approver for" when Todo::UNMERGEABLE then 'Could not merge' when Todo::DIRECTLY_ADDRESSED then "directly addressed #{todo_action_subject(todo)} on" + when Todo::MERGE_TRAIN_REMOVED then "Removed from Merge Train:" end end @@ -97,11 +98,13 @@ module TodosHelper 'mr' when Issue 'issue' + when AlertManagement::Alert + 'alert' end content_tag(:span, nil, class: 'target-status') do - content_tag(:span, nil, class: "status-box status-box-#{type}-#{todo.target.state.dasherize}") do - todo.target.state.capitalize + content_tag(:span, nil, class: "status-box status-box-#{type}-#{todo.target.state.to_s.dasherize}") do + todo.target.state.to_s.capitalize end end end @@ -195,6 +198,10 @@ module TodosHelper "· #{content}".html_safe end + def todo_author_display?(todo) + !todo.build_failed? && !todo.unmergeable? + end + private def todos_design_path(todo, path_options) @@ -214,7 +221,14 @@ module TodosHelper end def show_todo_state?(todo) - (todo.target.is_a?(MergeRequest) || todo.target.is_a?(Issue)) && %w(closed merged).include?(todo.target.state) + case todo.target + when MergeRequest, Issue + %w(closed merged).include?(todo.target.state) + when AlertManagement::Alert + %i(resolved).include?(todo.target.state) + else + false + end end def todo_group_options diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index 4dc00581703..90a5b6da4c7 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -191,8 +191,10 @@ module TreeHelper def vue_file_list_data(project, ref) { + can_push_code: current_user&.can?(:push_code, project) && "true", project_path: project.full_path, project_short_path: project.path, + fork_path: current_user&.fork_of(project)&.full_path, ref: ref, escaped_ref: ActionDispatch::Journey::Router::Utils.escape_path(ref), full_name: project.name_with_namespace diff --git a/app/helpers/wiki_helper.rb b/app/helpers/wiki_helper.rb index 3c983606b73..cf2d2d178e1 100644 --- a/app/helpers/wiki_helper.rb +++ b/app/helpers/wiki_helper.rb @@ -3,6 +3,30 @@ module WikiHelper include API::Helpers::RelatedResourcesHelpers + def wiki_page_title(page, action = nil) + titles = [_('Wiki')] + + if page.persisted? + titles << page.human_title + breadcrumb_title(page.human_title) + wiki_breadcrumb_dropdown_links(page.slug) + end + + titles << action if action + page_title(*titles.reverse) + add_to_breadcrumbs(_('Wiki'), wiki_path(page.wiki)) + end + + def link_to_wiki_page(page, **options) + link_to page.human_title, wiki_page_path(page.wiki, page), **options + end + + def wiki_sidebar_toggle_button + content_tag :button, class: 'btn btn-default sidebar-toggle js-sidebar-wiki-toggle', role: 'button', type: 'button' do + sprite_icon('chevron-double-lg-left') + end + end + # Produces a pure text breadcrumb for a given page. # # page_slug - The slug of a WikiPage object. @@ -71,10 +95,13 @@ module WikiHelper def wiki_empty_state_messages(wiki) case wiki.container when Project + writable_body = s_("WikiEmpty|A wiki is where you can store all the details about your project. This can include why you've created it, its principles, how to use it, and so on.") + writable_body += s_("WikiEmpty| Have a Confluence wiki already? Use that instead.") if show_enable_confluence_integration?(wiki.container) + { writable: { title: s_('WikiEmpty|The wiki lets you write documentation for your project'), - body: s_("WikiEmpty|A wiki is where you can store all the details about your project. This can include why you've created it, its principles, how to use it, and so on.") + body: writable_body }, issuable: { title: s_('WikiEmpty|This project has no wiki pages'), @@ -104,4 +131,19 @@ module WikiHelper raise NotImplementedError, "Unknown wiki container type #{wiki.container.class.name}" end end + + def wiki_page_tracking_context(page) + { + 'wiki-format' => page.format, + 'wiki-title-size' => page.title.bytesize, + 'wiki-content-size' => page.raw_content.bytesize, + 'wiki-directory-nest-level' => page.path.scan('/').count + } + end + + def show_enable_confluence_integration?(container) + container.is_a?(Project) && + current_user&.can?(:admin_project, container) && + !container.has_confluence? + end end diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb index 76b1c2d234c..c709c2950d6 100644 --- a/app/mailers/emails/merge_requests.rb +++ b/app/mailers/emails/merge_requests.rb @@ -92,6 +92,13 @@ module Emails mail_answer_thread(@merge_request, merge_request_thread_options(resolved_by_user_id, recipient_id, reason)) end + def merge_when_pipeline_succeeds_email(recipient_id, merge_request_id, mwps_set_by_user_id, reason = nil) + setup_merge_request_mail(merge_request_id, recipient_id) + + @mwps_set_by = ::User.find(mwps_set_by_user_id) + mail_answer_thread(@merge_request, merge_request_thread_options(mwps_set_by_user_id, recipient_id, reason)) + end + private def setup_merge_request_mail(merge_request_id, recipient_id, present: false) diff --git a/app/mailers/emails/service_desk.rb b/app/mailers/emails/service_desk.rb new file mode 100644 index 00000000000..29fe608472d --- /dev/null +++ b/app/mailers/emails/service_desk.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module Emails + module ServiceDesk + extend ActiveSupport::Concern + include MarkupHelper + + included do + layout 'service_desk', only: [:service_desk_thank_you_email, :service_desk_new_note_email] + end + + def service_desk_thank_you_email(issue_id) + setup_service_desk_mail(issue_id) + + email_sender = sender( + @support_bot.id, + send_from_user_email: false, + sender_name: @project.service_desk_setting&.outgoing_name + ) + options = service_desk_options(email_sender, 'thank_you') + .merge(subject: "Re: #{subject_base}") + + mail_new_thread(@issue, options) + end + + def service_desk_new_note_email(issue_id, note_id) + @note = Note.find(note_id) + setup_service_desk_mail(issue_id) + + email_sender = sender(@note.author_id) + options = service_desk_options(email_sender, 'new_note') + .merge(subject: subject_base) + + mail_answer_thread(@issue, options) + end + + private + + def setup_service_desk_mail(issue_id) + @issue = Issue.find(issue_id) + @project = @issue.project + @support_bot = User.support_bot + + @sent_notification = SentNotification.record(@issue, @support_bot.id, reply_key) + end + + def service_desk_options(email_sender, email_type) + { + from: email_sender, + to: @issue.service_desk_reply_to + }.tap do |options| + next unless template_body = template_content(email_type) + + options[:body] = template_body + options[:content_type] = 'text/html' + end + end + + def template_content(email_type) + template = Gitlab::Template::ServiceDeskTemplate.find(email_type, @project) + + text = substitute_template_replacements(template.content) + + markdown(text, project: @project) + rescue Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError + nil + end + + def substitute_template_replacements(template_body) + template_body + .gsub(/%\{\s*ISSUE_ID\s*\}/, issue_id) + .gsub(/%\{\s*ISSUE_PATH\s*\}/, issue_path) + .gsub(/%\{\s*NOTE_TEXT\s*\}/, note_text) + end + + def issue_id + "#{Issue.reference_prefix}#{@issue.iid}" + end + + def issue_path + @issue.to_reference(full: true) + end + + def note_text + @note&.note.to_s + end + + def subject_base + "#{@issue.title} (##{@issue.iid})" + end + end +end diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index 2cf72d40635..f9aba3fe4f2 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -19,6 +19,7 @@ class Notify < ApplicationMailer include Emails::Releases include Emails::Groups include Emails::Reviews + include Emails::ServiceDesk helper TimeboxesHelper helper MergeRequestsHelper diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb index cb7c6a36c27..c70ac1428cd 100644 --- a/app/mailers/previews/notify_preview.rb +++ b/app/mailers/previews/notify_preview.rb @@ -165,6 +165,22 @@ class NotifyPreview < ActionMailer::Preview Notify.unknown_sign_in_email(user, '127.0.0.1', Time.current).message end + def service_desk_new_note_email + cleanup do + note = create_note(noteable_type: 'Issue', noteable_id: issue.id, note: 'Issue note content') + + Notify.service_desk_new_note_email(issue.id, note.id).message + end + end + + def service_desk_thank_you_email + Notify.service_desk_thank_you_email(issue.id).message + end + + def merge_when_pipeline_succeeds_email + Notify.merge_when_pipeline_succeeds_email(user.id, merge_request.id, user.id).message + end + private def project diff --git a/app/models/active_session.rb b/app/models/active_session.rb index a23190cc8b3..be07c221f32 100644 --- a/app/models/active_session.rb +++ b/app/models/active_session.rb @@ -91,8 +91,11 @@ class ActiveSession key_names = session_ids.map { |session_id| key_name(user.id, session_id.public_id) } redis.srem(lookup_key_name(user.id), session_ids.map(&:public_id)) - redis.del(key_names) - redis.del(rack_session_keys(session_ids)) + + Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do + redis.del(key_names) + redis.del(rack_session_keys(session_ids)) + end end def self.cleanup(user) @@ -136,8 +139,10 @@ class ActiveSession session_keys = rack_session_keys(session_ids) session_keys.each_slice(SESSION_BATCH_SIZE).flat_map do |session_keys_batch| - redis.mget(session_keys_batch).compact.map do |raw_session| - load_raw_session(raw_session) + Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do + redis.mget(session_keys_batch).compact.map do |raw_session| + load_raw_session(raw_session) + end end end end @@ -178,7 +183,9 @@ class ActiveSession entry_keys = session_ids.map { |session_id| key_name(user_id, session_id) } - redis.mget(entry_keys) + Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do + redis.mget(entry_keys) + end end def self.active_session_entries(session_ids, user_id, redis) diff --git a/app/models/alert_management/alert.rb b/app/models/alert_management/alert.rb index af60ddd6f9a..fb166fb56b7 100644 --- a/app/models/alert_management/alert.rb +++ b/app/models/alert_management/alert.rb @@ -10,6 +10,7 @@ module AlertManagement include Sortable include Noteable include Gitlab::SQL::Pattern + include Presentable STATUSES = { triggered: 0, @@ -25,8 +26,17 @@ module AlertManagement ignored: :ignore }.freeze + OPEN_STATUSES = [ + :triggered, + :acknowledged + ].freeze + + DETAILS_IGNORED_PARAMS = %w(start_time).freeze + belongs_to :project belongs_to :issue, optional: true + belongs_to :prometheus_alert, optional: true + belongs_to :environment, optional: true has_many :alert_assignees, inverse_of: :alert has_many :assignees, through: :alert_assignees @@ -50,8 +60,12 @@ module AlertManagement validates :severity, presence: true validates :status, presence: true validates :started_at, presence: true - validates :fingerprint, uniqueness: { scope: :project }, allow_blank: true - validate :hosts_length + validates :fingerprint, allow_blank: true, uniqueness: { + scope: :project, + conditions: -> { not_resolved }, + message: -> (object, data) { _('Cannot have multiple unresolved alerts') } + }, unless: :resolved? + validate :hosts_length enum severity: { critical: 0, @@ -108,15 +122,30 @@ module AlertManagement scope :for_iid, -> (iid) { where(iid: iid) } scope :for_status, -> (status) { where(status: status) } scope :for_fingerprint, -> (project, fingerprint) { where(project: project, fingerprint: fingerprint) } + scope :for_environment, -> (environment) { where(environment: environment) } scope :search, -> (query) { fuzzy_search(query, [:title, :description, :monitoring_tool, :service]) } + scope :open, -> { with_status(OPEN_STATUSES) } + scope :not_resolved, -> { where.not(status: STATUSES[:resolved]) } + scope :with_prometheus_alert, -> { includes(:prometheus_alert) } scope :order_start_time, -> (sort_order) { order(started_at: sort_order) } scope :order_end_time, -> (sort_order) { order(ended_at: sort_order) } scope :order_event_count, -> (sort_order) { order(events: sort_order) } - scope :order_severity, -> (sort_order) { order(severity: sort_order) } - scope :order_status, -> (sort_order) { order(status: sort_order) } + + # Ascending sort order sorts severity from less critical to more critical. + # Descending sort order sorts severity from more critical to less critical. + # https://gitlab.com/gitlab-org/gitlab/-/issues/221242#what-is-the-expected-correct-behavior + scope :order_severity, -> (sort_order) { order(severity: sort_order == :asc ? :desc : :asc) } + + # Ascending sort order sorts statuses: Ignored > Resolved > Acknowledged > Triggered + # Descending sort order sorts statuses: Triggered > Acknowledged > Resolved > Ignored + # https://gitlab.com/gitlab-org/gitlab/-/issues/221242#what-is-the-expected-correct-behavior + scope :order_status, -> (sort_order) { order(status: sort_order == :asc ? :desc : :asc) } scope :counts_by_status, -> { group(:status).count } + scope :counts_by_project_id, -> { group(:project_id).count } + + alias_method :state, :status_name def self.sort_by_attribute(method) case method.to_s @@ -135,8 +164,13 @@ module AlertManagement end end + def self.last_prometheus_alert_by_project_id + ids = select(arel_table[:id].maximum).group(:project_id) + with_prometheus_alert.where(id: ids) + end + def details - details_payload = payload.except(*attributes.keys) + details_payload = payload.except(*attributes.keys, *DETAILS_IGNORED_PARAMS) Gitlab::Utils::InlineHash.merge_keys(details_payload) end @@ -161,6 +195,12 @@ module AlertManagement project.execute_services(hook_data, :alert_hooks) end + def present + return super(presenter_class: AlertManagement::PrometheusAlertPresenter) if prometheus? + + super + end + private def hook_data diff --git a/app/models/application_record.rb b/app/models/application_record.rb index c7e4d64d3d5..9ec407a10a4 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -13,6 +13,10 @@ class ApplicationRecord < ActiveRecord::Base where(id: ids) end + def self.iid_in(iids) + where(iid: iids) + end + def self.id_not_in(ids) where.not(id: ids) end @@ -34,6 +38,10 @@ class ApplicationRecord < ActiveRecord::Base false end + def self.at_most(count) + limit(count) + end + def self.safe_find_or_create_by!(*args) safe_find_or_create_by(*args).tap do |record| record.validate! unless record.persisted? diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index d24136cc04a..c489d11d462 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -50,6 +50,7 @@ module ApplicationSettingImplementation default_artifacts_expire_in: '30 days', default_branch_protection: Settings.gitlab['default_branch_protection'], default_ci_config_path: nil, + default_branch_name: nil, default_group_visibility: Settings.gitlab.default_projects_features['visibility_level'], default_project_creation: Settings.gitlab['default_project_creation'], default_project_visibility: Settings.gitlab.default_projects_features['visibility_level'], @@ -88,6 +89,7 @@ module ApplicationSettingImplementation max_attachment_size: Settings.gitlab['max_attachment_size'], max_import_size: 50, mirror_available: true, + notify_on_unknown_sign_in: true, outbound_local_requests_whitelist: [], password_authentication_enabled_for_git: true, password_authentication_enabled_for_web: Settings.gitlab['signin_enabled'], @@ -156,7 +158,13 @@ module ApplicationSettingImplementation snowplow_iglu_registry_url: nil, custom_http_clone_url_root: nil, productivity_analytics_start_date: Time.current, - snippet_size_limit: 50.megabytes + snippet_size_limit: 50.megabytes, + project_import_limit: 6, + project_export_limit: 6, + project_download_export_limit: 1, + group_import_limit: 6, + group_export_limit: 6, + group_download_export_limit: 1 } end diff --git a/app/models/approval.rb b/app/models/approval.rb new file mode 100644 index 00000000000..bc123de0b20 --- /dev/null +++ b/app/models/approval.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Approval < ApplicationRecord + belongs_to :user + belongs_to :merge_request + + validates :merge_request_id, presence: true + validates :user_id, presence: true, uniqueness: { scope: [:merge_request_id] } + + scope :with_user, -> { joins(:user) } +end diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb index 3bbd2e43a51..13fc2514f0c 100644 --- a/app/models/audit_event.rb +++ b/app/models/audit_event.rb @@ -3,8 +3,11 @@ class AuditEvent < ApplicationRecord include CreatedAtFilterable include IgnorableColumns + include BulkInsertSafe - ignore_column :updated_at, remove_with: '13.3', remove_after: '2020-08-22' + PARALLEL_PERSISTENCE_COLUMNS = [:author_name, :entity_path].freeze + + ignore_column :updated_at, remove_with: '13.4', remove_after: '2020-09-22' serialize :details, Hash # rubocop:disable Cop/ActiveRecordSerialize @@ -16,8 +19,15 @@ class AuditEvent < ApplicationRecord scope :by_entity_type, -> (entity_type) { where(entity_type: entity_type) } scope :by_entity_id, -> (entity_id) { where(entity_id: entity_id) } + scope :by_author_id, -> (author_id) { where(author_id: author_id) } after_initialize :initialize_details + # Note: The intention is to remove this once refactoring of AuditEvent + # has proceeded further. + # + # See further details in the epic: + # https://gitlab.com/groups/gitlab-org/-/epics/2765 + after_validation :parallel_persist def self.order_by(method) case method.to_s @@ -51,7 +61,11 @@ class AuditEvent < ApplicationRecord private def default_author_value - ::Gitlab::Audit::NullAuthor.for(author_id, details[:author_name]) + ::Gitlab::Audit::NullAuthor.for(author_id, (self[:author_name] || details[:author_name])) + end + + def parallel_persist + PARALLEL_PERSISTENCE_COLUMNS.each { |col| self[col] = details[col] } end end diff --git a/app/models/blob_viewer/image.rb b/app/models/blob_viewer/image.rb index cbebef46c60..97eb0489158 100644 --- a/app/models/blob_viewer/image.rb +++ b/app/models/blob_viewer/image.rb @@ -8,7 +8,7 @@ module BlobViewer self.partial_name = 'image' self.extensions = UploaderHelper::SAFE_IMAGE_EXT self.binary = true - self.switcher_icon = 'picture-o' + self.switcher_icon = 'doc-image' self.switcher_title = 'image' end end diff --git a/app/models/blob_viewer/notebook.rb b/app/models/blob_viewer/notebook.rb index 57d6d802db3..351502d451f 100644 --- a/app/models/blob_viewer/notebook.rb +++ b/app/models/blob_viewer/notebook.rb @@ -8,7 +8,7 @@ module BlobViewer self.partial_name = 'notebook' self.extensions = %w(ipynb) self.binary = false - self.switcher_icon = 'file-text-o' + self.switcher_icon = 'doc-text' self.switcher_title = 'notebook' end end diff --git a/app/models/blob_viewer/open_api.rb b/app/models/blob_viewer/open_api.rb index 963b7336c8d..0551f3bb1e3 100644 --- a/app/models/blob_viewer/open_api.rb +++ b/app/models/blob_viewer/open_api.rb @@ -8,8 +8,6 @@ module BlobViewer self.partial_name = 'openapi' self.file_types = %i(openapi) self.binary = false - # TODO: get an icon for OpenAPI - self.switcher_icon = 'file-pdf-o' - self.switcher_title = 'OpenAPI' + self.switcher_icon = 'api' end end diff --git a/app/models/blob_viewer/rich.rb b/app/models/blob_viewer/rich.rb index 0f66a672102..46f36cc2674 100644 --- a/app/models/blob_viewer/rich.rb +++ b/app/models/blob_viewer/rich.rb @@ -6,7 +6,7 @@ module BlobViewer included do self.type = :rich - self.switcher_icon = 'file-text-o' + self.switcher_icon = 'doc-text' self.switcher_title = 'rendered file' end end diff --git a/app/models/blob_viewer/svg.rb b/app/models/blob_viewer/svg.rb index 454c6a57568..60a11fbd97e 100644 --- a/app/models/blob_viewer/svg.rb +++ b/app/models/blob_viewer/svg.rb @@ -8,7 +8,7 @@ module BlobViewer self.partial_name = 'svg' self.extensions = %w(svg) self.binary = false - self.switcher_icon = 'picture-o' + self.switcher_icon = 'doc-image' self.switcher_title = 'image' end end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index b5e68b55f72..6c90645e997 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -27,7 +27,7 @@ module Ci upload_multiple_artifacts: -> (build) { build.publishes_artifacts_reports? }, refspecs: -> (build) { build.merge_request_ref? }, artifacts_exclude: -> (build) { build.supports_artifacts_exclude? }, - release_steps: -> (build) { build.release_steps? } + multi_build_steps: -> (build) { build.multi_build_steps? } }.freeze DEFAULT_RETRIES = { @@ -539,7 +539,6 @@ module Ci .concat(job_variables) .concat(environment_changed_page_variables) .concat(persisted_environment_variables) - .concat(deploy_freeze_variables) .to_runner_variables end end @@ -595,18 +594,6 @@ module Ci end end - def deploy_freeze_variables - Gitlab::Ci::Variables::Collection.new.tap do |variables| - break variables unless freeze_period? - - variables.append(key: 'CI_DEPLOY_FREEZE', value: 'true') - end - end - - def freeze_period? - Ci::FreezePeriodStatus.new(project: project).execute - end - def dependency_variables return [] if all_dependencies.empty? @@ -801,6 +788,11 @@ module Ci has_expiring_artifacts? && job_artifacts_archive.present? end + def self.keep_artifacts! + update_all(artifacts_expire_at: nil) + Ci::JobArtifact.where(job: self.select(:id)).update_all(expire_at: nil) + end + def keep_artifacts! self.update(artifacts_expire_at: nil) self.job_artifacts.update_all(expire_at: nil) @@ -885,7 +877,7 @@ module Ci Gitlab::Ci::Features.artifacts_exclude_enabled? end - def release_steps? + def multi_build_steps? options.dig(:release)&.any? && Gitlab::Ci::Features.release_generation_enabled? end diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb index 0df5ebfe843..4094bdb26dc 100644 --- a/app/models/ci/build_metadata.rb +++ b/app/models/ci/build_metadata.rb @@ -19,6 +19,7 @@ module Ci before_create :set_build_project validates :build, presence: true + validates :secrets, json_schema: { filename: 'build_metadata_secrets' } serialize :config_options, Serializers::JSON # rubocop:disable Cop/ActiveRecordSerialize serialize :config_variables, Serializers::JSON # rubocop:disable Cop/ActiveRecordSerialize diff --git a/app/models/ci/build_need.rb b/app/models/ci/build_need.rb index 0b243c20e67..b977a5f4419 100644 --- a/app/models/ci/build_need.rb +++ b/app/models/ci/build_need.rb @@ -4,6 +4,8 @@ module Ci class BuildNeed < ApplicationRecord extend Gitlab::Ci::Model + include BulkInsertSafe + belongs_to :build, class_name: "Ci::Build", foreign_key: :build_id, inverse_of: :needs validates :build, presence: true diff --git a/app/models/ci/build_trace.rb b/app/models/ci/build_trace.rb index b9db1559836..f70e1ed69ea 100644 --- a/app/models/ci/build_trace.rb +++ b/app/models/ci/build_trace.rb @@ -2,40 +2,22 @@ module Ci class BuildTrace - CONVERTERS = { - html: Gitlab::Ci::Ansi2html, - json: Gitlab::Ci::Ansi2json - }.freeze - attr_reader :trace, :build delegate :state, :append, :truncated, :offset, :size, :total, to: :trace, allow_nil: true delegate :id, :status, :complete?, to: :build, prefix: true - def initialize(build:, stream:, state:, content_format:) + def initialize(build:, stream:, state:) @build = build - @content_format = content_format if stream.valid? stream.limit - @trace = CONVERTERS.fetch(content_format).convert(stream.stream, state) + @trace = Gitlab::Ci::Ansi2json.convert(stream.stream, state) end end - def json? - @content_format == :json - end - - def html? - @content_format == :html - end - - def json_lines - @trace&.lines if json? - end - - def html_lines - @trace&.html if html? + def lines + @trace&.lines end end end diff --git a/app/models/ci/build_trace_chunks/redis.rb b/app/models/ci/build_trace_chunks/redis.rb index 813eaf5d839..c3864f78b01 100644 --- a/app/models/ci/build_trace_chunks/redis.rb +++ b/app/models/ci/build_trace_chunks/redis.rb @@ -35,7 +35,10 @@ module Ci keys = keys.map { |key| key_raw(*key) } Gitlab::Redis::SharedState.with do |redis| - redis.del(keys) + # https://gitlab.com/gitlab-org/gitlab/-/issues/224171 + Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do + redis.del(keys) + end end end diff --git a/app/models/ci/instance_variable.rb b/app/models/ci/instance_variable.rb index 8245729a884..628749b32cb 100644 --- a/app/models/ci/instance_variable.rb +++ b/app/models/ci/instance_variable.rb @@ -45,13 +45,5 @@ module Ci end end end - - private - - def validate_plan_limit_not_exceeded - if Gitlab::Ci::Features.instance_level_variables_limit_enabled? - super - end - end end end diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 8aba9356949..dbeba1ece31 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -7,10 +7,13 @@ module Ci include UpdateProjectStatistics include UsageStatistics include Sortable + include IgnorableColumns extend Gitlab::Ci::Model NotSupportedAdapterError = Class.new(StandardError) + ignore_columns :locked, remove_after: '2020-07-22', remove_with: '13.4' + TEST_REPORT_FILE_TYPES = %w[junit].freeze COVERAGE_REPORT_FILE_TYPES = %w[cobertura].freeze ACCESSIBILITY_REPORT_FILE_TYPES = %w[accessibility].freeze @@ -34,13 +37,16 @@ module Ci license_management: 'gl-license-management-report.json', license_scanning: 'gl-license-scanning-report.json', performance: 'performance.json', + browser_performance: 'browser-performance.json', + load_performance: 'load-performance.json', metrics: 'metrics.txt', lsif: 'lsif.json', dotenv: '.env', cobertura: 'cobertura-coverage.xml', terraform: 'tfplan.json', cluster_applications: 'gl-cluster-applications.json', - requirements: 'requirements.json' + requirements: 'requirements.json', + coverage_fuzzing: 'gl-coverage-fuzzing.json' }.freeze INTERNAL_TYPES = { @@ -72,8 +78,11 @@ module Ci license_management: :raw, license_scanning: :raw, performance: :raw, + browser_performance: :raw, + load_performance: :raw, terraform: :raw, - requirements: :raw + requirements: :raw, + coverage_fuzzing: :raw }.freeze DOWNLOADABLE_TYPES = %w[ @@ -91,6 +100,8 @@ module Ci lsif metrics performance + browser_performance + load_performance sast secret_detection requirements @@ -98,9 +109,7 @@ module Ci TYPE_AND_FORMAT_PAIRS = INTERNAL_TYPES.merge(REPORT_TYPES).freeze - # This is required since we cannot add a default to the database - # https://gitlab.com/gitlab-org/gitlab/-/issues/215418 - attribute :locked, :boolean, default: false + PLAN_LIMIT_PREFIX = 'ci_max_artifact_size_' belongs_to :project belongs_to :job, class_name: "Ci::Build", foreign_key: :job_id @@ -117,10 +126,9 @@ module Ci after_save :update_file_store, if: :saved_change_to_file? scope :not_expired, -> { where('expire_at IS NULL OR expire_at > ?', Time.current) } - scope :with_files_stored_locally, -> { where(file_store: [nil, ::JobArtifactUploader::Store::LOCAL]) } + scope :with_files_stored_locally, -> { where(file_store: ::JobArtifactUploader::Store::LOCAL) } scope :with_files_stored_remotely, -> { where(file_store: ::JobArtifactUploader::Store::REMOTE) } scope :for_sha, ->(sha, project_id) { joins(job: :pipeline).where(ci_pipelines: { sha: sha, project_id: project_id }) } - scope :for_ref, ->(ref, project_id) { joins(job: :pipeline).where(ci_pipelines: { ref: ref, project_id: project_id }) } scope :for_job_name, ->(name) { joins(:job).where(ci_builds: { name: name }) } scope :with_file_types, -> (file_types) do @@ -157,8 +165,7 @@ module Ci scope :expired, -> (limit) { where('expire_at < ?', Time.current).limit(limit) } scope :downloadable, -> { where(file_type: DOWNLOADABLE_TYPES) } - scope :locked, -> { where(locked: true) } - scope :unlocked, -> { where(locked: [false, nil]) } + scope :unlocked, -> { joins(job: :pipeline).merge(::Ci::Pipeline.unlocked).order(expire_at: :desc) } scope :scoped_project, -> { where('ci_job_artifacts.project_id = projects.id') } @@ -176,7 +183,7 @@ module Ci codequality: 9, ## EE-specific license_management: 10, ## EE-specific license_scanning: 101, ## EE-specific till 13.0 - performance: 11, ## EE-specific + performance: 11, ## EE-specific till 13.2 metrics: 12, ## EE-specific metrics_referee: 13, ## runner referees network_referee: 14, ## runner referees @@ -187,7 +194,10 @@ module Ci accessibility: 19, cluster_applications: 20, secret_detection: 21, ## EE-specific - requirements: 22 ## EE-specific + requirements: 22, ## EE-specific + coverage_fuzzing: 23, ## EE-specific + browser_performance: 24, ## EE-specific + load_performance: 25 ## EE-specific } enum file_format: { @@ -235,6 +245,12 @@ module Ci self.update_column(:file_store, file.object_store) end + def self.associated_file_types_for(file_type) + return unless file_types.include?(file_type) + + [file_type] + end + def self.total_size self.sum(:size) end @@ -286,6 +302,21 @@ module Ci where(job_id: job_id).trace.take&.file&.file&.exists? end + def self.max_artifact_size(type:, project:) + max_size = if Feature.enabled?(:ci_max_artifact_size_per_type, project, default_enabled: false) + limit_name = "#{PLAN_LIMIT_PREFIX}#{type}" + + project.actual_limits.limit_for( + limit_name, + alternate_limit: -> { project.closest_setting(:max_artifacts_size) } + ) + else + project.closest_setting(:max_artifacts_size) + end + + max_size&.megabytes.to_i + end + private def file_format_adapter_class diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 497e1a4d74a..d4b439d648f 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -3,7 +3,7 @@ module Ci class Pipeline < ApplicationRecord extend Gitlab::Ci::Model - include HasStatus + include Ci::HasStatus include Importable include AfterCommitQueue include Presentable @@ -51,6 +51,8 @@ module Ci has_many :latest_builds, -> { latest }, foreign_key: :commit_id, inverse_of: :pipeline, class_name: 'Ci::Build' has_many :downloadable_artifacts, -> { not_expired.downloadable }, through: :latest_builds, source: :job_artifacts + has_many :messages, class_name: 'Ci::PipelineMessage', inverse_of: :pipeline + # Merge requests for which the current pipeline is running against # the merge request's latest commit. has_many :merge_requests_as_head_pipeline, foreign_key: "head_pipeline_id", class_name: 'MergeRequest' @@ -80,6 +82,7 @@ module Ci has_one :pipeline_config, class_name: 'Ci::PipelineConfig', inverse_of: :pipeline has_many :daily_build_group_report_results, class_name: 'Ci::DailyBuildGroupReportResult', foreign_key: :last_pipeline_id + has_many :latest_builds_report_results, through: :latest_builds, source: :report_results accepts_nested_attributes_for :variables, reject_if: :persisted? @@ -110,6 +113,8 @@ module Ci # extend this `Hash` with new values. enum failure_reason: ::Ci::PipelineEnums.failure_reasons + enum locked: { unlocked: 0, artifacts_locked: 1 } + state_machine :status, initial: :created do event :enqueue do transition [:created, :manual, :waiting_for_resource, :preparing, :skipped, :scheduled] => :pending @@ -244,6 +249,14 @@ module Ci pipeline.run_after_commit { AutoDevops::DisableWorker.perform_async(pipeline.id) } end + + after_transition any => [:success] do |pipeline| + next unless Gitlab::Ci::Features.keep_latest_artifacts_for_ref_enabled?(pipeline.project) + + pipeline.run_after_commit do + Ci::PipelineSuccessUnlockArtifactsWorker.perform_async(pipeline.id) + end + end end scope :internal, -> { where(source: internal_sources) } @@ -256,7 +269,14 @@ module Ci scope :for_ref, -> (ref) { where(ref: ref) } scope :for_id, -> (id) { where(id: id) } scope :for_iid, -> (iid) { where(iid: iid) } + scope :for_project, -> (project) { where(project: project) } scope :created_after, -> (time) { where('ci_pipelines.created_at > ?', time) } + scope :created_before_id, -> (id) { where('ci_pipelines.id < ?', id) } + scope :before_pipeline, -> (pipeline) { created_before_id(pipeline.id).outside_pipeline_family(pipeline) } + + scope :outside_pipeline_family, ->(pipeline) do + where.not(id: pipeline.same_family_pipeline_ids) + end scope :with_reports, -> (reports_scope) do where('EXISTS (?)', ::Ci::Build.latest.with_reports(reports_scope).where('ci_pipelines.id=ci_builds.commit_id').select(1)) @@ -270,6 +290,15 @@ module Ci ) end + # Returns the pipelines that associated with the given merge request. + # In general, please use `Ci::PipelinesForMergeRequestFinder` instead, + # for checking permission of the actor. + scope :triggered_by_merge_request, -> (merge_request) do + ci_sources.where(source: :merge_request_event, + merge_request: merge_request, + project: [merge_request.source_project, merge_request.target_project]) + end + # Returns the pipelines in descending order (= newest first), optionally # limited to a number of references. # @@ -348,6 +377,10 @@ module Ci success.group(:project_id).select('max(id) as id') end + def self.last_finished_for_ref_id(ci_ref_id) + where(ci_ref_id: ci_ref_id).ci_sources.finished.order(id: :desc).select(:id).take + end + def self.truncate_sha(sha) sha[0...8] end @@ -440,6 +473,10 @@ module Ci end end + def triggered_pipelines_with_preloads + triggered_pipelines.preload(:source_job) + end + def legacy_stages if ::Gitlab::Ci::Features.composite_status?(project) legacy_stages_using_composite_status @@ -552,10 +589,28 @@ module Ci end end + def lazy_ref_commit + return unless ::Gitlab::Ci::Features.pipeline_latest? + + BatchLoader.for(ref).batch do |refs, loader| + next unless project.repository_exists? + + project.repository.list_commits_by_ref_name(refs).then do |commits| + commits.each { |key, commit| loader.call(key, commits[key]) } + end + end + end + def latest? return false unless git_ref && commit.present? - project.commit(git_ref) == commit + unless ::Gitlab::Ci::Features.pipeline_latest? + return project.commit(git_ref) == commit + end + + return false if lazy_ref_commit.nil? + + lazy_ref_commit.id == commit.id end def retried @@ -569,10 +624,46 @@ module Ci end end + def batch_lookup_report_artifact_for_file_type(file_type) + latest_report_artifacts + .values_at(*::Ci::JobArtifact.associated_file_types_for(file_type.to_s)) + .flatten + .compact + .last + end + + # This batch loads the latest reports for each CI job artifact + # type (e.g. sast, dast, etc.) in a single SQL query to eliminate + # the need to do N different `job_artifacts.where(file_type: + # X).last` calls. + # + # Return a hash of file type => array of 1 job artifact + def latest_report_artifacts + ::Gitlab::SafeRequestStore.fetch("pipeline:#{self.id}:latest_report_artifacts") do + # Note we use read_attribute(:project_id) to read the project + # ID instead of self.project_id. The latter appears to load + # the Project model. This extra filter doesn't appear to + # affect query plan but included to ensure we don't leak the + # wrong informaiton. + ::Ci::JobArtifact.where( + id: job_artifacts.with_reports + .select('max(ci_job_artifacts.id) as id') + .where(project_id: self.read_attribute(:project_id)) + .group(:file_type) + ) + .preload(:job) + .group_by(&:file_type) + end + end + def has_kubernetes_active? project.deployment_platform&.active? end + def freeze_period? + Ci::FreezePeriodStatus.new(project: project).execute + end + def has_warnings? number_of_warnings.positive? end @@ -607,6 +698,25 @@ module Ci yaml_errors.present? end + def add_error_message(content) + add_message(:error, content) + end + + def add_warning_message(content) + add_message(:warning, content) + end + + # We can't use `messages.error` scope here because messages should also be + # read when the pipeline is not persisted. Using the scope will return no + # results as it would query persisted data. + def error_messages + messages.select(&:error?) + end + + def warning_messages + messages.select(&:warning?) + end + # Manually set the notes for a Ci::Pipeline # There is no ActiveRecord relation between Ci::Pipeline and notes # as they are related to a commit sha. This method helps importing @@ -639,7 +749,7 @@ module Ci when 'manual' then block when 'scheduled' then delay else - raise HasStatus::UnknownStatusError, + raise Ci::HasStatus::UnknownStatusError, "Unknown status `#{new_status}`" end end @@ -683,6 +793,7 @@ module Ci end variables.append(key: 'CI_KUBERNETES_ACTIVE', value: 'true') if has_kubernetes_active? + variables.append(key: 'CI_DEPLOY_FREEZE', value: 'true') if freeze_period? if external_pull_request_event? && external_pull_request variables.concat(external_pull_request.predefined_variables) @@ -748,13 +859,10 @@ module Ci end # If pipeline is a child of another pipeline, include the parent - # and the siblings, otherwise return only itself. + # and the siblings, otherwise return only itself and children. def same_family_pipeline_ids - if (parent = parent_pipeline) - [parent.id] + parent.child_pipelines.pluck(:id) - else - [self.id] - end + parent = parent_pipeline || self + [parent.id] + parent.child_pipelines.pluck(:id) end def bridge_triggered? @@ -802,6 +910,10 @@ module Ci complete? && latest_report_builds(reports_scope).exists? end + def test_report_summary + Gitlab::Ci::Reports::TestReportSummary.new(latest_builds_report_results) + end + def test_reports Gitlab::Ci::Reports::TestReports.new.tap do |test_reports| latest_report_builds(Ci::JobArtifact.test_reports).preload(:project).find_each do |build| @@ -840,6 +952,10 @@ module Ci end end + def has_archive_artifacts? + complete? && builds.latest.with_existing_job_artifacts(Ci::JobArtifact.archive.or(Ci::JobArtifact.metadata)).exists? + end + def has_exposed_artifacts? complete? && builds.latest.with_exposed_artifacts.exists? end @@ -925,7 +1041,7 @@ module Ci stages.find_by!(name: name) end - def error_messages + def full_error_messages errors ? errors.full_messages.to_sentence : "" end @@ -964,8 +1080,6 @@ module Ci # Set scheduling type of processables if they were created before scheduling_type # data was deployed (https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22246). def ensure_scheduling_type! - return unless ::Gitlab::Ci::Features.ensure_scheduling_type_enabled? - processables.populate_scheduling_type! end @@ -977,6 +1091,12 @@ module Ci private + def add_message(severity, content) + return unless Gitlab::Ci::Features.store_pipeline_messages?(project) + + messages.build(severity: severity, content: content) + end + def pipeline_data Gitlab::DataBuilder::Pipeline.build(self) end diff --git a/app/models/ci/pipeline_enums.rb b/app/models/ci/pipeline_enums.rb index 2ccd8445aa8..352dc56aac7 100644 --- a/app/models/ci/pipeline_enums.rb +++ b/app/models/ci/pipeline_enums.rb @@ -31,7 +31,7 @@ module Ci merge_request_event: 10, external_pull_request_event: 11, parent_pipeline: 12, - ondemand_scan: 13 + ondemand_dast_scan: 13 } end @@ -45,7 +45,8 @@ module Ci webide_source: 3, remote_source: 4, external_project_source: 5, - bridge_source: 6 + bridge_source: 6, + parameter_source: 7 } end diff --git a/app/models/ci/pipeline_message.rb b/app/models/ci/pipeline_message.rb new file mode 100644 index 00000000000..a47ec554462 --- /dev/null +++ b/app/models/ci/pipeline_message.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Ci + class PipelineMessage < ApplicationRecord + extend Gitlab::Ci::Model + + MAX_CONTENT_LENGTH = 10_000 + + belongs_to :pipeline + + validates :content, presence: true + + before_save :truncate_long_content + + enum severity: { error: 0, warning: 1 } + + private + + def truncate_long_content + return if content.length <= MAX_CONTENT_LENGTH + + self.content = content.truncate(MAX_CONTENT_LENGTH) + end + end +end diff --git a/app/models/ci/ref.rb b/app/models/ci/ref.rb index be6062b6e6e..29b44575d65 100644 --- a/app/models/ci/ref.rb +++ b/app/models/ci/ref.rb @@ -43,7 +43,7 @@ module Ci end def last_finished_pipeline_id - Ci::Pipeline.where(ci_ref_id: self.id).finished.order(id: :desc).select(:id).take&.id + Ci::Pipeline.last_finished_for_ref_id(self.id)&.id end def update_status_by!(pipeline) diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 8fc273556f0..1cd6c64841b 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -239,6 +239,10 @@ module Ci runner_projects.count == 1 end + def belongs_to_more_than_one_project? + self.projects.limit(2).count(:all) > 1 + end + def assigned_to_group? runner_namespaces.any? end diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index a316b4718e0..41215601704 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -4,10 +4,10 @@ module Ci class Stage < ApplicationRecord extend Gitlab::Ci::Model include Importable - include HasStatus + include Ci::HasStatus include Gitlab::OptimisticLocking - enum status: HasStatus::STATUSES_ENUM + enum status: Ci::HasStatus::STATUSES_ENUM belongs_to :project belongs_to :pipeline @@ -98,7 +98,7 @@ module Ci when 'scheduled' then delay when 'skipped', nil then skip else - raise HasStatus::UnknownStatusError, + raise Ci::HasStatus::UnknownStatusError, "Unknown status `#{new_status}`" end end diff --git a/app/models/ci/variable.rb b/app/models/ci/variable.rb index 08d39595c61..13358b95a47 100644 --- a/app/models/ci/variable.rb +++ b/app/models/ci/variable.rb @@ -18,5 +18,7 @@ module Ci } scope :unprotected, -> { where(protected: false) } + scope :by_key, -> (key) { where(key: key) } + scope :by_environment_scope, -> (environment_scope) { where(environment_scope: environment_scope) } end end diff --git a/app/models/clusters/applications/cilium.rb b/app/models/clusters/applications/cilium.rb new file mode 100644 index 00000000000..7936b0b18de --- /dev/null +++ b/app/models/clusters/applications/cilium.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Clusters + module Applications + class Cilium < ApplicationRecord + self.table_name = 'clusters_applications_cilium' + + include ::Clusters::Concerns::ApplicationCore + include ::Clusters::Concerns::ApplicationStatus + + # Cilium can only be installed and uninstalled through the + # cluster-applications project by triggering CI pipeline for a + # management project. UI operations are not available for such + # applications. More information: + # https://docs.gitlab.com/ee/user/clusters/management_project.html + def allowed_to_uninstall? + false + end + end + end +end diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb index 24bb1df6d22..101d782db3a 100644 --- a/app/models/clusters/applications/prometheus.rb +++ b/app/models/clusters/applications/prometheus.rb @@ -17,6 +17,9 @@ module Clusters default_value_for :version, VERSION + scope :preload_cluster_platform, -> { preload(cluster: [:platform_kubernetes]) } + scope :with_clusters_with_cilium, -> { joins(:cluster).merge(Clusters::Cluster.with_available_cilium) } + attr_encrypted :alert_manager_token, mode: :per_attribute_iv, key: Settings.attr_encrypted_db_key_base_truncated, diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index 6d3b6c4ed8f..9ec7c194a26 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.17.1' + VERSION = '0.18.1' self.table_name = 'clusters_applications_runners' diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index bde7a2104ba..7641b6d2a4b 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -2,6 +2,7 @@ module Clusters class Cluster < ApplicationRecord + prepend HasEnvironmentScope include Presentable include Gitlab::Utils::StrongMemoize include FromUnion @@ -20,7 +21,8 @@ module Clusters Clusters::Applications::Jupyter.application_name => Clusters::Applications::Jupyter, Clusters::Applications::Knative.application_name => Clusters::Applications::Knative, Clusters::Applications::ElasticStack.application_name => Clusters::Applications::ElasticStack, - Clusters::Applications::Fluentd.application_name => Clusters::Applications::Fluentd + Clusters::Applications::Fluentd.application_name => Clusters::Applications::Fluentd, + Clusters::Applications::Cilium.application_name => Clusters::Applications::Cilium }.freeze DEFAULT_ENVIRONMENT = '*' KUBE_INGRESS_BASE_DOMAIN = 'KUBE_INGRESS_BASE_DOMAIN' @@ -64,6 +66,7 @@ module Clusters has_one_cluster_application :knative has_one_cluster_application :elastic_stack has_one_cluster_application :fluentd + has_one_cluster_application :cilium has_many :kubernetes_namespaces has_many :metrics_dashboard_annotations, class_name: 'Metrics::Dashboard::Annotation', inverse_of: :cluster @@ -81,6 +84,7 @@ module Clusters validate :no_groups, unless: :group_type? validate :no_projects, unless: :project_type? validate :unique_management_project_environment_scope + validate :unique_environment_scope after_save :clear_reactive_cache! @@ -129,6 +133,7 @@ module Clusters scope :with_enabled_modsecurity, -> { joins(:application_ingress).merge(::Clusters::Applications::Ingress.modsecurity_enabled) } scope :with_available_elasticstack, -> { joins(:application_elastic_stack).merge(::Clusters::Applications::ElasticStack.available) } + scope :with_available_cilium, -> { joins(:application_cilium).merge(::Clusters::Applications::Cilium.available) } scope :distinct_with_deployed_environments, -> { joins(:environments).merge(::Deployment.success).distinct } scope :preload_elasticstack, -> { preload(:application_elastic_stack) } scope :preload_environments, -> { preload(:environments) } @@ -228,7 +233,9 @@ module Clusters def calculate_reactive_cache return unless enabled? - { connection_status: retrieve_connection_status, nodes: retrieve_nodes } + gitlab_kubernetes_nodes = Gitlab::Kubernetes::Node.new(self) + + { connection_status: retrieve_connection_status, nodes: gitlab_kubernetes_nodes.all.presence } end def persisted_applications @@ -335,7 +342,11 @@ module Clusters end def local_tiller_enabled? - Feature.enabled?(:managed_apps_local_tiller, clusterable, default_enabled: false) + Feature.enabled?(:managed_apps_local_tiller, clusterable, default_enabled: true) + end + + def prometheus_adapter + application_prometheus end private @@ -352,6 +363,12 @@ module Clusters end end + def unique_environment_scope + if clusterable.present? && clusterable.clusters.where(environment_scope: environment_scope).where.not(id: id).exists? + errors.add(:environment_scope, 'cannot add duplicated environment scope') + end + end + def managed_namespace(environment) Clusters::KubernetesNamespaceFinder.new( self, @@ -383,54 +400,6 @@ module Clusters result[:status] end - def retrieve_nodes - result = ::Gitlab::Kubernetes::KubeClient.graceful_request(id) { kubeclient.get_nodes } - - return unless result[:response] - - cluster_nodes = result[:response] - - result = ::Gitlab::Kubernetes::KubeClient.graceful_request(id) { kubeclient.metrics_client.get_nodes } - nodes_metrics = result[:response].to_a - - cluster_nodes.inject([]) do |memo, node| - sliced_node = filter_relevant_node_attributes(node) - - matched_node_metric = nodes_metrics.find { |node_metric| node_metric.metadata.name == node.metadata.name } - - sliced_node_metrics = matched_node_metric ? filter_relevant_node_metrics_attributes(matched_node_metric) : {} - - memo << sliced_node.merge(sliced_node_metrics) - end - end - - def filter_relevant_node_attributes(node) - { - 'metadata' => { - 'name' => node.metadata.name - }, - 'status' => { - 'capacity' => { - 'cpu' => node.status.capacity.cpu, - 'memory' => node.status.capacity.memory - }, - 'allocatable' => { - 'cpu' => node.status.allocatable.cpu, - 'memory' => node.status.allocatable.memory - } - } - } - end - - def filter_relevant_node_metrics_attributes(node_metrics) - { - 'usage' => { - 'cpu' => node_metrics.usage.cpu, - 'memory' => node_metrics.usage.memory - } - } - end - # To keep backward compatibility with AUTO_DEVOPS_DOMAIN # environment variable, we need to ensure KUBE_INGRESS_BASE_DOMAIN # is set if AUTO_DEVOPS_DOMAIN is set on any of the following options: diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb index 444368d0ef3..7af78960e35 100644 --- a/app/models/clusters/platforms/kubernetes.rb +++ b/app/models/clusters/platforms/kubernetes.rb @@ -159,7 +159,16 @@ module Clusters if ca_pem.present? opts[:cert_store] = OpenSSL::X509::Store.new - opts[:cert_store].add_cert(OpenSSL::X509::Certificate.new(ca_pem)) + + file = Tempfile.new('cluster_ca_pem_temp') + begin + file.write(ca_pem) + file.rewind + opts[:cert_store].add_file(file.path) + ensure + file.close + file.unlink # deletes the temp file + end end opts diff --git a/app/models/commit.rb b/app/models/commit.rb index 681fe727456..53bcdf8165f 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -469,10 +469,12 @@ class Commit # We don't want to do anything for `Commit` model, so this is empty. end - WIP_REGEX = /\A\s*(((?i)(\[WIP\]|WIP:|WIP)\s|WIP$))|(fixup!|squash!)\s/.freeze + # WIP is deprecated in favor of Draft. Currently both options are supported + # https://gitlab.com/gitlab-org/gitlab/-/issues/227426 + DRAFT_REGEX = /\A\s*#{Regexp.union(Gitlab::Regex.merge_request_wip, Gitlab::Regex.merge_request_draft)}|(fixup!|squash!)\s/.freeze def work_in_progress? - !!(title =~ WIP_REGEX) + !!(title =~ DRAFT_REGEX) end def merged_merge_request?(user) diff --git a/app/models/commit_collection.rb b/app/models/commit_collection.rb index 456d32bf403..b8653f47392 100644 --- a/app/models/commit_collection.rb +++ b/app/models/commit_collection.rb @@ -53,6 +53,17 @@ class CommitCollection self end + # Returns the collection with markdown fields preloaded. + # + # Get the markdown cache from redis using pipeline to prevent n+1 requests + # when rendering the markdown of an attribute (e.g. title, full_title, + # description). + def with_markdown_cache + Commit.preload_markdown_cache!(commits) + + self + end + def unenriched commits.reject(&:gitaly_commit?) end diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 475f82f23ca..c85292feb25 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -1,11 +1,12 @@ # frozen_string_literal: true class CommitStatus < ApplicationRecord - include HasStatus + include Ci::HasStatus include Importable include AfterCommitQueue include Presentable include EnumWithNil + include BulkInsertableAssociations self.table_name = 'ci_builds' diff --git a/app/models/concerns/analytics/cycle_analytics/stage.rb b/app/models/concerns/analytics/cycle_analytics/stage.rb index 39e8408f794..f1c39dda49d 100644 --- a/app/models/concerns/analytics/cycle_analytics/stage.rb +++ b/app/models/concerns/analytics/cycle_analytics/stage.rb @@ -125,7 +125,7 @@ module Analytics def label_available_for_group?(label_id) LabelsFinder.new(nil, { group_id: group.id, include_ancestor_groups: true, only_group_labels: true }) .execute(skip_authorization: true) - .by_ids(label_id) + .id_in(label_id) .exists? end end diff --git a/app/models/concerns/approvable_base.rb b/app/models/concerns/approvable_base.rb new file mode 100644 index 00000000000..6323bd01c58 --- /dev/null +++ b/app/models/concerns/approvable_base.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module ApprovableBase + extend ActiveSupport::Concern + + included do + has_many :approvals, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent + has_many :approved_by_users, through: :approvals, source: :user + end + + def approved_by?(user) + return false unless user + + approved_by_users.include?(user) + end +end diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb index a98baeb0e3d..ac84ef94b1c 100644 --- a/app/models/concerns/avatarable.rb +++ b/app/models/concerns/avatarable.rb @@ -36,6 +36,12 @@ module Avatarable end end + class_methods do + def bot_avatar(image:) + Rails.root.join('app', 'assets', 'images', 'bot_avatars', image).open + end + end + def avatar_type unless self.avatar.image? errors.add :avatar, "file format is not supported. Please try one of the following supported formats: #{AvatarUploader::SAFE_IMAGE_EXT.join(', ')}" diff --git a/app/models/concerns/bulk_insert_safe.rb b/app/models/concerns/bulk_insert_safe.rb index e09f44e68dc..f9eb3fb875e 100644 --- a/app/models/concerns/bulk_insert_safe.rb +++ b/app/models/concerns/bulk_insert_safe.rb @@ -37,7 +37,7 @@ module BulkInsertSafe # These are the callbacks we think safe when used on models that are # written to the database in bulk - CALLBACK_NAME_WHITELIST = Set[ + ALLOWED_CALLBACKS = Set[ :initialize, :validate, :validation, @@ -179,16 +179,12 @@ module BulkInsertSafe end def _bulk_insert_callback_allowed?(name, args) - _bulk_insert_whitelisted?(name) || _bulk_insert_saved_from_belongs_to?(name, args) + ALLOWED_CALLBACKS.include?(name) || _bulk_insert_saved_from_belongs_to?(name, args) end # belongs_to associations will install a before_save hook during class loading def _bulk_insert_saved_from_belongs_to?(name, args) args.first == :before && args.second.to_s.start_with?('autosave_associated_records_for_') end - - def _bulk_insert_whitelisted?(name) - CALLBACK_NAME_WHITELIST.include?(name) - end end end diff --git a/app/models/concerns/ci/contextable.rb b/app/models/concerns/ci/contextable.rb index 7ea5382a4fa..10df5e1a8dc 100644 --- a/app/models/concerns/ci/contextable.rb +++ b/app/models/concerns/ci/contextable.rb @@ -84,8 +84,6 @@ module Ci end def secret_instance_variables - return [] unless ::Feature.enabled?(:ci_instance_level_variables, project, default_enabled: true) - project.ci_instance_variables_for(ref: git_ref) end diff --git a/app/models/concerns/ci/has_status.rb b/app/models/concerns/ci/has_status.rb new file mode 100644 index 00000000000..c52807ec501 --- /dev/null +++ b/app/models/concerns/ci/has_status.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +module Ci + module HasStatus + extend ActiveSupport::Concern + + DEFAULT_STATUS = 'created' + BLOCKED_STATUS = %w[manual scheduled].freeze + AVAILABLE_STATUSES = %w[created waiting_for_resource preparing pending running success failed canceled skipped manual scheduled].freeze + STARTED_STATUSES = %w[running success failed skipped manual scheduled].freeze + ACTIVE_STATUSES = %w[waiting_for_resource preparing pending running].freeze + COMPLETED_STATUSES = %w[success failed canceled skipped].freeze + ORDERED_STATUSES = %w[failed preparing pending running waiting_for_resource manual scheduled canceled success skipped created].freeze + PASSED_WITH_WARNINGS_STATUSES = %w[failed canceled].to_set.freeze + EXCLUDE_IGNORED_STATUSES = %w[manual failed canceled].to_set.freeze + STATUSES_ENUM = { created: 0, pending: 1, running: 2, success: 3, + failed: 4, canceled: 5, skipped: 6, manual: 7, + scheduled: 8, preparing: 9, waiting_for_resource: 10 }.freeze + + UnknownStatusError = Class.new(StandardError) + + class_methods do + def legacy_status_sql + scope_relevant = respond_to?(:exclude_ignored) ? exclude_ignored : all + scope_warnings = respond_to?(:failed_but_allowed) ? failed_but_allowed : none + + builds = scope_relevant.select('count(*)').to_sql + created = scope_relevant.created.select('count(*)').to_sql + success = scope_relevant.success.select('count(*)').to_sql + manual = scope_relevant.manual.select('count(*)').to_sql + scheduled = scope_relevant.scheduled.select('count(*)').to_sql + preparing = scope_relevant.preparing.select('count(*)').to_sql + waiting_for_resource = scope_relevant.waiting_for_resource.select('count(*)').to_sql + pending = scope_relevant.pending.select('count(*)').to_sql + running = scope_relevant.running.select('count(*)').to_sql + skipped = scope_relevant.skipped.select('count(*)').to_sql + canceled = scope_relevant.canceled.select('count(*)').to_sql + warnings = scope_warnings.select('count(*) > 0').to_sql.presence || 'false' + + Arel.sql( + "(CASE + WHEN (#{builds})=(#{skipped}) AND (#{warnings}) THEN 'success' + WHEN (#{builds})=(#{skipped}) THEN 'skipped' + WHEN (#{builds})=(#{success}) THEN 'success' + WHEN (#{builds})=(#{created}) THEN 'created' + WHEN (#{builds})=(#{preparing}) THEN 'preparing' + WHEN (#{builds})=(#{success})+(#{skipped}) THEN 'success' + WHEN (#{builds})=(#{success})+(#{skipped})+(#{canceled}) THEN 'canceled' + WHEN (#{builds})=(#{created})+(#{skipped})+(#{pending}) THEN 'pending' + WHEN (#{running})+(#{pending})>0 THEN 'running' + WHEN (#{waiting_for_resource})>0 THEN 'waiting_for_resource' + WHEN (#{manual})>0 THEN 'manual' + WHEN (#{scheduled})>0 THEN 'scheduled' + WHEN (#{preparing})>0 THEN 'preparing' + WHEN (#{created})>0 THEN 'running' + ELSE 'failed' + END)" + ) + end + + def legacy_status + all.pluck(legacy_status_sql).first + end + + # This method should not be used. + # This method performs expensive calculation of status: + # 1. By plucking all related objects, + # 2. Or executes expensive SQL query + def slow_composite_status(project:) + if ::Gitlab::Ci::Features.composite_status?(project) + Gitlab::Ci::Status::Composite + .new(all, with_allow_failure: columns_hash.key?('allow_failure')) + .status + else + legacy_status + end + end + + def started_at + all.minimum(:started_at) + end + + def finished_at + all.maximum(:finished_at) + end + + def all_state_names + state_machines.values.flat_map(&:states).flat_map { |s| s.map(&:name) } + end + + def completed_statuses + COMPLETED_STATUSES.map(&:to_sym) + end + end + + included do + validates :status, inclusion: { in: AVAILABLE_STATUSES } + + state_machine :status, initial: :created do + state :created, value: 'created' + state :waiting_for_resource, value: 'waiting_for_resource' + state :preparing, value: 'preparing' + state :pending, value: 'pending' + state :running, value: 'running' + state :failed, value: 'failed' + state :success, value: 'success' + state :canceled, value: 'canceled' + state :skipped, value: 'skipped' + state :manual, value: 'manual' + state :scheduled, value: 'scheduled' + end + + scope :created, -> { with_status(:created) } + scope :waiting_for_resource, -> { with_status(:waiting_for_resource) } + scope :preparing, -> { with_status(:preparing) } + scope :relevant, -> { without_status(:created) } + scope :running, -> { with_status(:running) } + scope :pending, -> { with_status(:pending) } + scope :success, -> { with_status(:success) } + scope :failed, -> { with_status(:failed) } + scope :canceled, -> { with_status(:canceled) } + scope :skipped, -> { with_status(:skipped) } + scope :manual, -> { with_status(:manual) } + scope :scheduled, -> { with_status(:scheduled) } + scope :alive, -> { with_status(:created, :waiting_for_resource, :preparing, :pending, :running) } + scope :alive_or_scheduled, -> { with_status(:created, :waiting_for_resource, :preparing, :pending, :running, :scheduled) } + scope :created_or_pending, -> { with_status(:created, :pending) } + scope :running_or_pending, -> { with_status(:running, :pending) } + scope :finished, -> { with_status(:success, :failed, :canceled) } + scope :failed_or_canceled, -> { with_status(:failed, :canceled) } + scope :incomplete, -> { without_statuses(completed_statuses) } + + scope :cancelable, -> do + where(status: [:running, :waiting_for_resource, :preparing, :pending, :created, :scheduled]) + end + + scope :without_statuses, -> (names) do + with_status(all_state_names - names.to_a) + end + end + + def started? + STARTED_STATUSES.include?(status) && started_at + end + + def active? + ACTIVE_STATUSES.include?(status) + end + + def complete? + COMPLETED_STATUSES.include?(status) + end + + def blocked? + BLOCKED_STATUS.include?(status) + end + + private + + def calculate_duration + if started_at && finished_at + finished_at - started_at + elsif started_at + Time.current - started_at + end + end + end +end diff --git a/app/models/concerns/ci/metadatable.rb b/app/models/concerns/ci/metadatable.rb index bd40af28bc9..26e644646b4 100644 --- a/app/models/concerns/ci/metadatable.rb +++ b/app/models/concerns/ci/metadatable.rb @@ -87,3 +87,5 @@ module Ci end end end + +Ci::Metadatable.prepend_if_ee('EE::Ci::Metadatable') diff --git a/app/models/concerns/deployment_platform.rb b/app/models/concerns/deployment_platform.rb index 3b893a56bd6..02f7711e927 100644 --- a/app/models/concerns/deployment_platform.rb +++ b/app/models/concerns/deployment_platform.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true module DeploymentPlatform - # EE would override this and utilize environment argument # rubocop:disable Gitlab/ModuleWithInstanceVariables def deployment_platform(environment: nil) @deployment_platform ||= {} @@ -20,16 +19,27 @@ module DeploymentPlatform find_instance_cluster_platform_kubernetes(environment: environment) end - # EE would override this and utilize environment argument - def find_platform_kubernetes_with_cte(_environment) - Clusters::ClustersHierarchy.new(self, include_management_project: cluster_management_project_enabled?).base_and_ancestors + def find_platform_kubernetes_with_cte(environment) + if environment + ::Clusters::ClustersHierarchy.new(self, include_management_project: cluster_management_project_enabled?) + .base_and_ancestors + .enabled + .on_environment(environment, relevant_only: true) + .first&.platform_kubernetes + else + Clusters::ClustersHierarchy.new(self, include_management_project: cluster_management_project_enabled?).base_and_ancestors .enabled.default_environment .first&.platform_kubernetes + end end - # EE would override this and utilize environment argument def find_instance_cluster_platform_kubernetes(environment: nil) - Clusters::Instance.new.clusters.enabled.default_environment + if environment + ::Clusters::Instance.new.clusters.enabled.on_environment(environment, relevant_only: true) .first&.platform_kubernetes + else + Clusters::Instance.new.clusters.enabled.default_environment + .first&.platform_kubernetes + end end end diff --git a/app/models/concerns/has_repository.rb b/app/models/concerns/has_repository.rb index 29d31b8bb4f..d909b67d7ba 100644 --- a/app/models/concerns/has_repository.rb +++ b/app/models/concerns/has_repository.rb @@ -5,7 +5,7 @@ # of directly having a repository, like project or snippet. # # It also includes `Referable`, therefore the method -# `to_reference` should be overriden in case the object +# `to_reference` should be overridden in case the object # needs any special behavior. module HasRepository extend ActiveSupport::Concern @@ -76,7 +76,11 @@ module HasRepository end def default_branch - @default_branch ||= repository.root_ref + @default_branch ||= repository.root_ref || default_branch_from_preferences + end + + def default_branch_from_preferences + empty_repo? ? Gitlab::CurrentSettings.default_branch_name : nil end def reload_default_branch diff --git a/app/models/concerns/has_status.rb b/app/models/concerns/has_status.rb deleted file mode 100644 index c885dea862f..00000000000 --- a/app/models/concerns/has_status.rb +++ /dev/null @@ -1,166 +0,0 @@ -# frozen_string_literal: true - -module HasStatus - extend ActiveSupport::Concern - - DEFAULT_STATUS = 'created' - BLOCKED_STATUS = %w[manual scheduled].freeze - AVAILABLE_STATUSES = %w[created waiting_for_resource preparing pending running success failed canceled skipped manual scheduled].freeze - STARTED_STATUSES = %w[running success failed skipped manual scheduled].freeze - ACTIVE_STATUSES = %w[waiting_for_resource preparing pending running].freeze - COMPLETED_STATUSES = %w[success failed canceled skipped].freeze - ORDERED_STATUSES = %w[failed preparing pending running waiting_for_resource manual scheduled canceled success skipped created].freeze - PASSED_WITH_WARNINGS_STATUSES = %w[failed canceled].to_set.freeze - EXCLUDE_IGNORED_STATUSES = %w[manual failed canceled].to_set.freeze - STATUSES_ENUM = { created: 0, pending: 1, running: 2, success: 3, - failed: 4, canceled: 5, skipped: 6, manual: 7, - scheduled: 8, preparing: 9, waiting_for_resource: 10 }.freeze - - UnknownStatusError = Class.new(StandardError) - - class_methods do - def legacy_status_sql - scope_relevant = respond_to?(:exclude_ignored) ? exclude_ignored : all - scope_warnings = respond_to?(:failed_but_allowed) ? failed_but_allowed : none - - builds = scope_relevant.select('count(*)').to_sql - created = scope_relevant.created.select('count(*)').to_sql - success = scope_relevant.success.select('count(*)').to_sql - manual = scope_relevant.manual.select('count(*)').to_sql - scheduled = scope_relevant.scheduled.select('count(*)').to_sql - preparing = scope_relevant.preparing.select('count(*)').to_sql - waiting_for_resource = scope_relevant.waiting_for_resource.select('count(*)').to_sql - pending = scope_relevant.pending.select('count(*)').to_sql - running = scope_relevant.running.select('count(*)').to_sql - skipped = scope_relevant.skipped.select('count(*)').to_sql - canceled = scope_relevant.canceled.select('count(*)').to_sql - warnings = scope_warnings.select('count(*) > 0').to_sql.presence || 'false' - - Arel.sql( - "(CASE - WHEN (#{builds})=(#{skipped}) AND (#{warnings}) THEN 'success' - WHEN (#{builds})=(#{skipped}) THEN 'skipped' - WHEN (#{builds})=(#{success}) THEN 'success' - WHEN (#{builds})=(#{created}) THEN 'created' - WHEN (#{builds})=(#{preparing}) THEN 'preparing' - WHEN (#{builds})=(#{success})+(#{skipped}) THEN 'success' - WHEN (#{builds})=(#{success})+(#{skipped})+(#{canceled}) THEN 'canceled' - WHEN (#{builds})=(#{created})+(#{skipped})+(#{pending}) THEN 'pending' - WHEN (#{running})+(#{pending})>0 THEN 'running' - WHEN (#{waiting_for_resource})>0 THEN 'waiting_for_resource' - WHEN (#{manual})>0 THEN 'manual' - WHEN (#{scheduled})>0 THEN 'scheduled' - WHEN (#{preparing})>0 THEN 'preparing' - WHEN (#{created})>0 THEN 'running' - ELSE 'failed' - END)" - ) - end - - def legacy_status - all.pluck(legacy_status_sql).first - end - - # This method should not be used. - # This method performs expensive calculation of status: - # 1. By plucking all related objects, - # 2. Or executes expensive SQL query - def slow_composite_status(project:) - if ::Gitlab::Ci::Features.composite_status?(project) - Gitlab::Ci::Status::Composite - .new(all, with_allow_failure: columns_hash.key?('allow_failure')) - .status - else - legacy_status - end - end - - def started_at - all.minimum(:started_at) - end - - def finished_at - all.maximum(:finished_at) - end - - def all_state_names - state_machines.values.flat_map(&:states).flat_map { |s| s.map(&:name) } - end - - def completed_statuses - COMPLETED_STATUSES.map(&:to_sym) - end - end - - included do - validates :status, inclusion: { in: AVAILABLE_STATUSES } - - state_machine :status, initial: :created do - state :created, value: 'created' - state :waiting_for_resource, value: 'waiting_for_resource' - state :preparing, value: 'preparing' - state :pending, value: 'pending' - state :running, value: 'running' - state :failed, value: 'failed' - state :success, value: 'success' - state :canceled, value: 'canceled' - state :skipped, value: 'skipped' - state :manual, value: 'manual' - state :scheduled, value: 'scheduled' - end - - scope :created, -> { with_status(:created) } - scope :waiting_for_resource, -> { with_status(:waiting_for_resource) } - scope :preparing, -> { with_status(:preparing) } - scope :relevant, -> { without_status(:created) } - scope :running, -> { with_status(:running) } - scope :pending, -> { with_status(:pending) } - scope :success, -> { with_status(:success) } - scope :failed, -> { with_status(:failed) } - scope :canceled, -> { with_status(:canceled) } - scope :skipped, -> { with_status(:skipped) } - scope :manual, -> { with_status(:manual) } - scope :scheduled, -> { with_status(:scheduled) } - scope :alive, -> { with_status(:created, :waiting_for_resource, :preparing, :pending, :running) } - scope :alive_or_scheduled, -> { with_status(:created, :waiting_for_resource, :preparing, :pending, :running, :scheduled) } - scope :created_or_pending, -> { with_status(:created, :pending) } - scope :running_or_pending, -> { with_status(:running, :pending) } - scope :finished, -> { with_status(:success, :failed, :canceled) } - scope :failed_or_canceled, -> { with_status(:failed, :canceled) } - scope :incomplete, -> { without_statuses(completed_statuses) } - - scope :cancelable, -> do - where(status: [:running, :waiting_for_resource, :preparing, :pending, :created, :scheduled]) - end - - scope :without_statuses, -> (names) do - with_status(all_state_names - names.to_a) - end - end - - def started? - STARTED_STATUSES.include?(status) && started_at - end - - def active? - ACTIVE_STATUSES.include?(status) - end - - def complete? - COMPLETED_STATUSES.include?(status) - end - - def blocked? - BLOCKED_STATUS.include?(status) - end - - private - - def calculate_duration - if started_at && finished_at - finished_at - started_at - elsif started_at - Time.current - started_at - end - end -end diff --git a/app/models/concerns/integration.rb b/app/models/concerns/integration.rb index 644a0ba1b5e..34ff5bb1195 100644 --- a/app/models/concerns/integration.rb +++ b/app/models/concerns/integration.rb @@ -15,5 +15,19 @@ module Integration Project.where(id: custom_integration_project_ids) end + + def ids_without_integration(integration, limit) + services = Service + .select('1') + .where('services.project_id = projects.id') + .where(type: integration.type) + + Project + .where('NOT EXISTS (?)', services) + .where(pending_delete: false) + .where(archived: false) + .limit(limit) + .pluck(:id) + end end end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 220af8ab7c7..715cbd15d93 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -411,8 +411,8 @@ module Issuable changes = previous_changes if old_associations - old_labels = old_associations.fetch(:labels, []) - old_assignees = old_associations.fetch(:assignees, []) + old_labels = old_associations.fetch(:labels, labels) + old_assignees = old_associations.fetch(:assignees, assignees) if old_labels != labels changes[:labels] = [old_labels.map(&:hook_attrs), labels.map(&:hook_attrs)] @@ -423,7 +423,7 @@ module Issuable end if self.respond_to?(:total_time_spent) - old_total_time_spent = old_associations.fetch(:total_time_spent, nil) + old_total_time_spent = old_associations.fetch(:total_time_spent, total_time_spent) if old_total_time_spent != total_time_spent changes[:total_time_spent] = [old_total_time_spent, total_time_spent] diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb index 183b902dd37..2dbe9360d42 100644 --- a/app/models/concerns/noteable.rb +++ b/app/models/concerns/noteable.rb @@ -67,6 +67,10 @@ module Noteable false end + def has_any_diff_note_positions? + notes.any? && DiffNotePosition.where(note: notes).exists? + end + def discussion_notes notes end diff --git a/app/models/concerns/partitioned_table.rb b/app/models/concerns/partitioned_table.rb new file mode 100644 index 00000000000..9f1cec5d520 --- /dev/null +++ b/app/models/concerns/partitioned_table.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module PartitionedTable + extend ActiveSupport::Concern + + class_methods do + attr_reader :partitioning_strategy + + PARTITIONING_STRATEGIES = { + monthly: Gitlab::Database::Partitioning::MonthlyStrategy + }.freeze + + def partitioned_by(partitioning_key, strategy:) + strategy_class = PARTITIONING_STRATEGIES[strategy.to_sym] || raise(ArgumentError, "Unknown partitioning strategy: #{strategy}") + + @partitioning_strategy = strategy_class.new(self, partitioning_key) + + Gitlab::Database::Partitioning::PartitionCreator.register(self) + end + end +end diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb index d294563139c..5f30fc0c36c 100644 --- a/app/models/concerns/reactive_caching.rb +++ b/app/models/concerns/reactive_caching.rb @@ -29,7 +29,7 @@ module ReactiveCaching self.reactive_cache_lease_timeout = 2.minutes self.reactive_cache_refresh_interval = 1.minute self.reactive_cache_lifetime = 10.minutes - self.reactive_cache_hard_limit = 1.megabyte + self.reactive_cache_hard_limit = nil # this value should be set in megabytes. E.g: 1.megabyte self.reactive_cache_work_type = :default self.reactive_cache_worker_finder = ->(id, *_args) do find_by(primary_key => id) @@ -159,8 +159,12 @@ module ReactiveCaching WORK_TYPE.fetch(self.class.reactive_cache_work_type.to_sym) end + def reactive_cache_limit_enabled? + !!self.reactive_cache_hard_limit + end + def check_exceeded_reactive_cache_limit!(data) - return unless Feature.enabled?(:reactive_cache_limit) + return unless reactive_cache_limit_enabled? data_deep_size = Gitlab::Utils::DeepSize.new(data, max_size: self.class.reactive_cache_hard_limit) diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index 129d0fbb2c0..c70ce9bebcc 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -17,11 +17,8 @@ module Routable after_validation :set_path_errors - before_validation do - if full_path_changed? || full_name_changed? - prepare_route - end - end + before_validation :prepare_route + before_save :prepare_route # in case validation is skipped end class_methods do @@ -118,6 +115,8 @@ module Routable end def prepare_route + return unless full_path_changed? || full_name_changed? + route || build_route(source: self) route.path = build_full_path route.name = build_full_name diff --git a/app/models/concerns/update_project_statistics.rb b/app/models/concerns/update_project_statistics.rb index 6cf012680d8..c0fa14d3369 100644 --- a/app/models/concerns/update_project_statistics.rb +++ b/app/models/concerns/update_project_statistics.rb @@ -35,8 +35,8 @@ module UpdateProjectStatistics @project_statistics_name = project_statistics_name @statistic_attribute = statistic_attribute - after_save(:update_project_statistics_after_save, if: :update_project_statistics_attribute_changed?) - after_destroy(:update_project_statistics_after_destroy, unless: :project_destroyed?) + after_save(:update_project_statistics_after_save, if: :update_project_statistics_after_save?) + after_destroy(:update_project_statistics_after_destroy, if: :update_project_statistics_after_destroy?) end private :update_project_statistics @@ -45,6 +45,14 @@ module UpdateProjectStatistics included do private + def update_project_statistics_after_save? + update_project_statistics_attribute_changed? + end + + def update_project_statistics_after_destroy? + !project_destroyed? + end + def update_project_statistics_after_save attr = self.class.statistic_attribute delta = read_attribute(attr).to_i - attribute_before_last_save(attr).to_i diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb new file mode 100644 index 00000000000..643b4060ad6 --- /dev/null +++ b/app/models/custom_emoji.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class CustomEmoji < ApplicationRecord + belongs_to :namespace, inverse_of: :custom_emoji + + validate :valid_emoji_name + + validates :namespace, presence: true + validates :name, + uniqueness: { scope: [:namespace_id, :name] }, + presence: true, + length: { maximum: 36 }, + format: { with: /\A\w+\z/ } + + private + + def valid_emoji_name + if Gitlab::Emoji.emoji_exists?(name) + errors.add(:name, _('%{name} is already being used for another emoji') % { name: self.name }) + end + end +end diff --git a/app/models/deploy_keys_project.rb b/app/models/deploy_keys_project.rb index 40c66d5bc4c..a9cc56a7246 100644 --- a/app/models/deploy_keys_project.rb +++ b/app/models/deploy_keys_project.rb @@ -6,6 +6,7 @@ class DeployKeysProject < ApplicationRecord scope :without_project_deleted, -> { joins(:project).where(projects: { pending_delete: false }) } scope :in_project, ->(project) { where(project: project) } scope :with_write_access, -> { where(can_push: true) } + scope :with_deploy_keys, -> { includes(:deploy_key) } accepts_nested_attributes_for :deploy_key diff --git a/app/models/diff_viewer/image.rb b/app/models/diff_viewer/image.rb index cfda0058d81..62a3446a7b6 100644 --- a/app/models/diff_viewer/image.rb +++ b/app/models/diff_viewer/image.rb @@ -8,7 +8,7 @@ module DiffViewer self.partial_name = 'image' self.extensions = UploaderHelper::SAFE_IMAGE_EXT self.binary = true - self.switcher_icon = 'picture-o' + self.switcher_icon = 'doc-image' self.switcher_title = _('image diff') end end diff --git a/app/models/environment.rb b/app/models/environment.rb index 8dae2d760f5..bddc84f10b5 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -21,6 +21,7 @@ class Environment < ApplicationRecord has_many :prometheus_alerts, inverse_of: :environment has_many :metrics_dashboard_annotations, class_name: 'Metrics::Dashboard::Annotation', inverse_of: :environment has_many :self_managed_prometheus_alert_events, inverse_of: :environment + has_many :alert_management_alerts, class_name: 'AlertManagement::Alert', inverse_of: :environment has_one :last_deployment, -> { success.order('deployments.id DESC') }, class_name: 'Deployment' has_one :last_deployable, through: :last_deployment, source: 'deployable', source_type: 'CommitStatus' @@ -147,7 +148,7 @@ class Environment < ApplicationRecord Ci::Build.joins(inner_join_stop_actions) .with(cte.to_arel) .where(ci_builds[:commit_id].in(pipeline_ids)) - .where(status: HasStatus::BLOCKED_STATUS) + .where(status: Ci::HasStatus::BLOCKED_STATUS) .preload_project_and_pipeline_project .preload(:user, :metadata, :deployment) end @@ -226,6 +227,21 @@ class Environment < ApplicationRecord available? && stop_action.present? end + def cancel_deployment_jobs! + jobs = active_deployments.with_deployable + jobs.each do |deployment| + # guard against data integrity issues, + # for example https://gitlab.com/gitlab-org/gitlab/-/issues/218659#note_348823660 + next unless deployment.deployable + + Gitlab::OptimisticLocking.retry_lock(deployment.deployable) do |deployable| + deployable.cancel! if deployable&.cancelable? + end + rescue => e + Gitlab::ErrorTracking.track_exception(e, environment_id: id, deployment_id: deployment.id) + end + end + def stop_with_action!(current_user) return unless available? @@ -362,6 +378,11 @@ class Environment < ApplicationRecord def generate_slug self.slug = Gitlab::Slug::Environment.new(name).generate end + + # Overrides ReactiveCaching default to activate limit checking behind a FF + def reactive_cache_limit_enabled? + Feature.enabled?(:reactive_caching_limit_environment, project) + end end Environment.prepend_if_ee('EE::Environment') diff --git a/app/models/epic.rb b/app/models/epic.rb index e09dc1080e6..93f286f97d3 100644 --- a/app/models/epic.rb +++ b/app/models/epic.rb @@ -5,8 +5,6 @@ class Epic < ApplicationRecord include IgnorableColumns - ignore_column :health_status, remove_with: '13.0', remove_after: '2019-05-22' - def self.link_reference_pattern nil end diff --git a/app/models/event.rb b/app/models/event.rb index 9c0fcbb354b..56d7742c51a 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -83,10 +83,6 @@ class Event < ApplicationRecord scope :for_wiki_page, -> { where(target_type: 'WikiPage::Meta') } scope :for_design, -> { where(target_type: 'DesignManagement::Design') } - # Needed to implement feature flag: can be removed when feature flag is removed - scope :not_wiki_page, -> { where('target_type IS NULL or target_type <> ?', 'WikiPage::Meta') } - scope :not_design, -> { where('target_type IS NULL or target_type <> ?', 'DesignManagement::Design') } - scope :with_associations, -> do # We're using preload for "push_event_payload" as otherwise the association # is not always available (depending on the query being built). diff --git a/app/models/event_collection.rb b/app/models/event_collection.rb index 4c178e27b75..4768506b8fa 100644 --- a/app/models/event_collection.rb +++ b/app/models/event_collection.rb @@ -33,23 +33,16 @@ class EventCollection project_events end - relation = apply_feature_flags(relation) relation = paginate_events(relation) relation.with_associations.to_a end def all_project_events - apply_feature_flags(Event.from_union([project_events]).recent) + Event.from_union([project_events]).recent end private - def apply_feature_flags(events) - return events if ::Feature.enabled?(:wiki_events) - - events.not_wiki_page - end - def project_events relation_with_join_lateral('project_id', projects) end diff --git a/app/models/group.rb b/app/models/group.rb index 71f58a5fd1a..c38ddbdf6fb 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -18,6 +18,8 @@ class Group < Namespace ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT = 10 + UpdateSharedRunnersError = Class.new(StandardError) + has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent alias_method :members, :group_members has_many :users, through: :group_members @@ -89,6 +91,8 @@ class Group < Namespace scope :with_users, -> { includes(:users) } + scope :by_id, ->(groups) { where(id: groups) } + class << self def sort_by_attribute(method) if method == 'storage_size_desc' @@ -504,6 +508,55 @@ class Group < Namespace preloader.preload(self, shared_with_group_links: [shared_with_group: :route]) end + def shared_runners_allowed? + shared_runners_enabled? || allow_descendants_override_disabled_shared_runners? + end + + def parent_allows_shared_runners? + return true unless has_parent? + + parent.shared_runners_allowed? + end + + def parent_enabled_shared_runners? + return true unless has_parent? + + parent.shared_runners_enabled? + end + + def enable_shared_runners! + raise UpdateSharedRunnersError, 'Shared Runners disabled for the parent group' unless parent_enabled_shared_runners? + + update_column(:shared_runners_enabled, true) + end + + def disable_shared_runners! + group_ids = self_and_descendants + return if group_ids.empty? + + Group.by_id(group_ids).update_all(shared_runners_enabled: false) + + all_projects.update_all(shared_runners_enabled: false) + end + + def allow_descendants_override_disabled_shared_runners! + raise UpdateSharedRunnersError, 'Shared Runners enabled' if shared_runners_enabled? + raise UpdateSharedRunnersError, 'Group level shared Runners not allowed' unless parent_allows_shared_runners? + + update_column(:allow_descendants_override_disabled_shared_runners, true) + end + + def disallow_descendants_override_disabled_shared_runners! + raise UpdateSharedRunnersError, 'Shared Runners enabled' if shared_runners_enabled? + + group_ids = self_and_descendants + return if group_ids.empty? + + Group.by_id(group_ids).update_all(allow_descendants_override_disabled_shared_runners: false) + + all_projects.update_all(shared_runners_enabled: false) + end + private def update_two_factor_requirement diff --git a/app/models/incident_management/project_incident_management_setting.rb b/app/models/incident_management/project_incident_management_setting.rb index bf57c5b883f..c79acdb685f 100644 --- a/app/models/incident_management/project_incident_management_setting.rb +++ b/app/models/incident_management/project_incident_management_setting.rb @@ -8,6 +8,15 @@ module IncidentManagement validate :issue_template_exists, if: :create_issue? + before_validation :ensure_pagerduty_token + + attr_encrypted :pagerduty_token, + mode: :per_attribute_iv, + key: ::Settings.attr_encrypted_db_key_base_truncated, + algorithm: 'aes-256-gcm', + encode: false, # No need to encode for binary column https://github.com/attr-encrypted/attr_encrypted#the-encode-encode_iv-encode_salt-and-default_encoding-options + encode_iv: false + def available_issue_templates Gitlab::Template::IssueTemplate.all(project) end @@ -30,5 +39,15 @@ module IncidentManagement Gitlab::Template::IssueTemplate.find(issue_template_key, project) rescue Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError end + + def ensure_pagerduty_token + return unless pagerduty_active + + self.pagerduty_token ||= generate_pagerduty_token + end + + def generate_pagerduty_token + SecureRandom.hex + end end end diff --git a/app/models/issue.rb b/app/models/issue.rb index 5c5190f88b1..619555f369d 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -98,6 +98,8 @@ class Issue < ApplicationRecord scope :counts_by_state, -> { reorder(nil).group(:state_id).count } + scope :service_desk, -> { where(author: ::User.support_bot) } + # 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 # `{project_id: x, iid: y}`. @@ -373,6 +375,10 @@ class Issue < ApplicationRecord ) end + def from_service_desk? + author.id == User.support_bot.id + end + private def ensure_metrics diff --git a/app/models/issue_assignee.rb b/app/models/issue_assignee.rb index 8128b8a538e..e57acbae546 100644 --- a/app/models/issue_assignee.rb +++ b/app/models/issue_assignee.rb @@ -2,9 +2,12 @@ class IssueAssignee < ApplicationRecord belongs_to :issue - belongs_to :assignee, class_name: "User", foreign_key: :user_id + belongs_to :assignee, class_name: "User", foreign_key: :user_id, inverse_of: :issue_assignees validates :assignee, uniqueness: { scope: :issue_id } + + scope :in_projects, ->(project_ids) { joins(:issue).where("issues.project_id in (?)", project_ids) } + scope :on_issues, ->(issue_ids) { where(issue_id: issue_ids) } end IssueAssignee.prepend_if_ee('EE::IssueAssignee') diff --git a/app/models/iteration.rb b/app/models/iteration.rb index 2bda0725471..0b59cf047f7 100644 --- a/app/models/iteration.rb +++ b/app/models/iteration.rb @@ -34,6 +34,9 @@ class Iteration < ApplicationRecord .where('due_date is NULL or due_date >= ?', start_date) end + scope :start_date_passed, -> { where('start_date <= ?', Date.current).where('due_date > ?', Date.current) } + scope :due_date_passed, -> { where('due_date <= ?', Date.current) } + state_machine :state_enum, initial: :upcoming do event :start do transition upcoming: :started @@ -93,7 +96,7 @@ class Iteration < ApplicationRecord # ensure dates do not overlap with other Iterations in the same group/project def dates_do_not_overlap - return unless resource_parent.iterations.within_timeframe(start_date, due_date).exists? + return unless resource_parent.iterations.where.not(id: self.id).within_timeframe(start_date, due_date).exists? errors.add(:base, s_("Iteration|Dates cannot overlap with other existing Iterations")) end diff --git a/app/models/label.rb b/app/models/label.rb index 910cc0d68cd..3c70eef9bd5 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -149,10 +149,6 @@ class Label < ApplicationRecord 1 end - def self.by_ids(ids) - where(id: ids) - end - def self.on_project_board?(project_id, label_id) return false if label_id.blank? diff --git a/app/models/lfs_objects_project.rb b/app/models/lfs_objects_project.rb index e1966eda277..674294f0916 100644 --- a/app/models/lfs_objects_project.rb +++ b/app/models/lfs_objects_project.rb @@ -15,7 +15,7 @@ class LfsObjectsProject < ApplicationRecord enum repository_type: { project: 0, wiki: 1, - design: 2 ## EE-specific + design: 2 } scope :project_id_in, ->(ids) { where(project_id: ids) } diff --git a/app/models/member.rb b/app/models/member.rb index f2926d32d47..36f9741ce01 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -38,6 +38,11 @@ class Member < ApplicationRecord scope: [:source_type, :source_id], allow_nil: true } + validates :user_id, + uniqueness: { + message: _('project bots cannot be added to other groups / projects') + }, + if: :project_bot? # This scope encapsulates (most of) the conditions a row in the member table # must satisfy if it is a valid permission. Of particular note: @@ -473,6 +478,10 @@ class Member < ApplicationRecord def update_highest_role_attribute user_id end + + def project_bot? + user&.project_bot? + end end Member.prepend_if_ee('EE::Member') diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index 9a916cd40ae..8c224dea88f 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -17,14 +17,7 @@ class GroupMember < Member scope :of_groups, ->(groups) { where(source_id: groups.select(:id)) } scope :of_ldap_type, -> { where(ldap: true) } - - scope :count_users_by_group_id, -> do - if Feature.enabled?(:optimized_count_users_by_group_id) - group(:source_id).count - else - joins(:user).group(:source_id).count - end - end + scope :count_users_by_group_id, -> { group(:source_id).count } after_create :update_two_factor_requirement, unless: :invite? after_destroy :update_two_factor_requirement, unless: :invite? diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index a7e0907eb5f..b7885771781 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -20,13 +20,15 @@ class MergeRequest < ApplicationRecord include IgnorableColumns include MilestoneEventable include StateEventable + include ApprovableBase + + extend ::Gitlab::Utils::Override sha_attribute :squash_commit_sha self.reactive_cache_key = ->(model) { [model.project.id, model.iid] } self.reactive_cache_refresh_interval = 10.minutes self.reactive_cache_lifetime = 10.minutes - self.reactive_cache_hard_limit = 20.megabytes SORTING_PREFERENCE_FIELD = :merge_requests_sort @@ -103,6 +105,7 @@ class MergeRequest < ApplicationRecord after_create :ensure_merge_request_diff after_update :clear_memoized_shas + after_update :clear_memoized_source_branch_exists after_update :reload_diff_if_branch_changed after_commit :ensure_metrics, on: [:create, :update], unless: :importing? after_commit :expire_etag_cache, unless: :importing? @@ -260,6 +263,7 @@ class MergeRequest < ApplicationRecord *PROJECT_ROUTE_AND_NAMESPACE_ROUTE, metrics: [:latest_closed_by, :merged_by]) } + scope :by_target_branch_wildcard, ->(wildcard_branch_name) do where("target_branch LIKE ?", ApplicationRecord.sanitize_sql_like(wildcard_branch_name).tr('*', '%')) end @@ -386,25 +390,27 @@ class MergeRequest < ApplicationRecord end end - WIP_REGEX = /\A*(\[WIP\]\s*|WIP:\s*|WIP\s+)+\s*/i.freeze + # WIP is deprecated in favor of Draft. Currently both options are supported + # https://gitlab.com/gitlab-org/gitlab/-/issues/227426 + DRAFT_REGEX = /\A*#{Regexp.union(Gitlab::Regex.merge_request_wip, Gitlab::Regex.merge_request_draft)}+\s*/i.freeze def self.work_in_progress?(title) - !!(title =~ WIP_REGEX) + !!(title =~ DRAFT_REGEX) end def self.wipless_title(title) - title.sub(WIP_REGEX, "") + title.sub(DRAFT_REGEX, "") end def self.wip_title(title) - work_in_progress?(title) ? title : "WIP: #{title}" + work_in_progress?(title) ? title : "Draft: #{title}" end def committers @committers ||= commits.committers end - # Verifies if title has changed not taking into account WIP prefix + # Verifies if title has changed not taking into account Draft prefix # for merge requests. def wipless_title_changed(old_title) self.class.wipless_title(old_title) != self.wipless_title @@ -858,6 +864,10 @@ class MergeRequest < ApplicationRecord clear_memoization(:target_branch_head) end + def clear_memoized_source_branch_exists + clear_memoization(:source_branch_exists) + end + def reload_diff_if_branch_changed if (saved_change_to_source_branch? || saved_change_to_target_branch?) && (source_branch_head && target_branch_head) @@ -946,7 +956,8 @@ class MergeRequest < ApplicationRecord end def can_remove_source_branch?(current_user) - !ProtectedBranch.protected?(source_project, source_branch) && + source_project && + !ProtectedBranch.protected?(source_project, source_branch) && !source_project.root_ref?(source_branch) && Ability.allowed?(current_user, :push_code, source_project) && diff_head_sha == source_branch_head.try(:sha) @@ -1017,6 +1028,10 @@ class MergeRequest < ApplicationRecord target_project != source_project end + def for_same_project? + target_project == source_project + end + # If the merge request closes any issues, save this information in the # `MergeRequestsClosingIssues` model. This is a performance optimization. # Calculating this information for a number of merge requests requires @@ -1104,9 +1119,11 @@ class MergeRequest < ApplicationRecord end def source_branch_exists? - return false unless self.source_project + strong_memoize(:source_branch_exists) do + next false unless self.source_project - self.source_project.repository.branch_exists?(self.source_branch) + self.source_project.repository.branch_exists?(self.source_branch) + end end def target_branch_exists? @@ -1142,6 +1159,13 @@ class MergeRequest < ApplicationRecord end end + def squash_on_merge? + return true if target_project.squash_always? + return false if target_project.squash_never? + + squash? + end + def has_ci? return false if has_no_commits? @@ -1273,7 +1297,7 @@ class MergeRequest < ApplicationRecord def all_pipelines strong_memoize(:all_pipelines) do - Ci::PipelinesForMergeRequestFinder.new(self).all + Ci::PipelinesForMergeRequestFinder.new(self, nil).all end end @@ -1374,9 +1398,9 @@ class MergeRequest < ApplicationRecord # TODO: consider renaming this as with exposed artifacts we generate reports, # not always compare # issue: https://gitlab.com/gitlab-org/gitlab/issues/34224 - def compare_reports(service_class, current_user = nil) - with_reactive_cache(service_class.name, current_user&.id) do |data| - unless service_class.new(project, current_user, id: id) + def compare_reports(service_class, current_user = nil, report_type = nil ) + with_reactive_cache(service_class.name, current_user&.id, report_type) do |data| + unless service_class.new(project, current_user, id: id, report_type: report_type) .latest?(base_pipeline, actual_head_pipeline, data) raise InvalidateReactiveCache end @@ -1385,7 +1409,7 @@ class MergeRequest < ApplicationRecord end || { status: :parsing } end - def calculate_reactive_cache(identifier, current_user_id = nil, *args) + def calculate_reactive_cache(identifier, current_user_id = nil, report_type = nil, *args) service_class = identifier.constantize # TODO: the type check should change to something that includes exposed artifacts service @@ -1393,7 +1417,7 @@ class MergeRequest < ApplicationRecord raise NameError, service_class unless service_class < Ci::CompareReportsBaseService current_user = User.find_by(id: current_user_id) - service_class.new(project, current_user, id: id).execute(base_pipeline, actual_head_pipeline) + service_class.new(project, current_user, id: id, report_type: report_type).execute(base_pipeline, actual_head_pipeline) end def all_commits @@ -1582,6 +1606,23 @@ class MergeRequest < ApplicationRecord super.merge(label_url_method: :project_merge_requests_url) end + override :ensure_metrics + def ensure_metrics + MergeRequest::Metrics.safe_find_or_create_by(merge_request_id: id).tap do |metrics_record| + # Make sure we refresh the loaded association object with the newly created/loaded item. + # This is needed in order to have the exact functionality than before. + # + # Example: + # + # merge_request.metrics.destroy + # merge_request.ensure_metrics + # merge_request.metrics # should return the metrics record and not nil + # merge_request.metrics.merge_request # should return the same MR record + metrics_record.association(:merge_request).target = self + association(:metrics).target = metrics_record + end + end + private def with_rebase_lock diff --git a/app/models/merge_request_assignee.rb b/app/models/merge_request_assignee.rb index fe642bee8e2..2ac1de4321a 100644 --- a/app/models/merge_request_assignee.rb +++ b/app/models/merge_request_assignee.rb @@ -2,7 +2,9 @@ class MergeRequestAssignee < ApplicationRecord belongs_to :merge_request - belongs_to :assignee, class_name: "User", foreign_key: :user_id + belongs_to :assignee, class_name: "User", foreign_key: :user_id, inverse_of: :merge_request_assignees validates :assignee, uniqueness: { scope: :merge_request_id } + + scope :in_projects, ->(project_ids) { joins(:merge_request).where("merge_requests.target_project_id in (?)", project_ids) } end diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 66b27aeac91..eb5250d5cf6 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -414,10 +414,16 @@ class MergeRequestDiff < ApplicationRecord return if stored_externally? || !use_external_diff? || merge_request_diff_files.count == 0 rows = build_merge_request_diff_files(merge_request_diff_files) + rows = build_external_merge_request_diff_files(rows) + + # Perform carrierwave activity before entering the database transaction. + # This is safe as until the `external_diff_store` column is changed, we will + # continue to consult the in-database content. + self.external_diff.store! transaction do MergeRequestDiffFile.where(merge_request_diff_id: id).delete_all - create_merge_request_diff_files(rows) + Gitlab::Database.bulk_insert('merge_request_diff_files', rows) # rubocop:disable Gitlab/BulkInsert save! end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 90b4be7a674..e529ba6b486 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -13,9 +13,6 @@ class Namespace < ApplicationRecord include Gitlab::Utils::StrongMemoize include IgnorableColumns - ignore_column :plan_id, remove_with: '13.1', remove_after: '2020-06-22' - ignore_column :trial_ends_on, remove_with: '13.2', remove_after: '2020-07-22' - # Prevent users from creating unreasonably deep level of nesting. # The number 20 was taken based on maximum nesting level of # Android repo (15) + some extra backup. @@ -25,6 +22,7 @@ class Namespace < ApplicationRecord has_many :projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :project_statistics + has_one :namespace_settings, inverse_of: :namespace, class_name: 'NamespaceSetting', autosave: true has_many :runner_namespaces, inverse_of: :namespace, class_name: 'Ci::RunnerNamespace' has_many :runners, through: :runner_namespaces, source: :runner, class_name: 'Ci::Runner' @@ -35,6 +33,7 @@ class Namespace < ApplicationRecord belongs_to :parent, class_name: "Namespace" has_many :children, class_name: "Namespace", foreign_key: :parent_id + has_many :custom_emoji, inverse_of: :namespace has_one :chat_team, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_one :root_storage_statistics, class_name: 'Namespace::RootStorageStatistics' has_one :aggregation_schedule, class_name: 'Namespace::AggregationSchedule' @@ -50,6 +49,13 @@ class Namespace < ApplicationRecord length: { maximum: 255 }, namespace_path: true + # Introduce minimal path length of 2 characters. + # Allow change of other attributes without forcing users to + # rename their user or group. At the same time prevent changing + # the path without complying with new 2 chars requirement. + # Issue https://gitlab.com/gitlab-org/gitlab/-/issues/225214 + validates :path, length: { minimum: 2 }, if: :path_changed? + validates :max_artifacts_size, numericality: { only_integer: true, greater_than: 0, allow_nil: true } validate :nesting_level_allowed @@ -82,6 +88,7 @@ class Namespace < ApplicationRecord 'COALESCE(SUM(ps.storage_size), 0) AS storage_size', 'COALESCE(SUM(ps.repository_size), 0) AS repository_size', 'COALESCE(SUM(ps.wiki_size), 0) AS wiki_size', + 'COALESCE(SUM(ps.snippets_size), 0) AS snippets_size', 'COALESCE(SUM(ps.lfs_objects_size), 0) AS lfs_objects_size', 'COALESCE(SUM(ps.build_artifacts_size), 0) AS build_artifacts_size', 'COALESCE(SUM(ps.packages_size), 0) AS packages_size' @@ -212,7 +219,7 @@ class Namespace < ApplicationRecord Gitlab.config.lfs.enabled end - def shared_runners_enabled? + def any_project_with_shared_runners_enabled? projects.with_shared_runners.any? end @@ -281,6 +288,8 @@ class Namespace < ApplicationRecord end def root_ancestor + return self if persisted? && parent_id.nil? + strong_memoize(:root_ancestor) do self_and_ancestors.reorder(nil).find_by(parent_id: nil) end diff --git a/app/models/namespace/root_storage_size.rb b/app/models/namespace/root_storage_size.rb deleted file mode 100644 index d61917e468e..00000000000 --- a/app/models/namespace/root_storage_size.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -class Namespace::RootStorageSize - def initialize(root_namespace) - @root_namespace = root_namespace - end - - def above_size_limit? - return false if limit == 0 - - usage_ratio > 1 - end - - def usage_ratio - return 0 if limit == 0 - - current_size.to_f / limit.to_f - end - - def current_size - @current_size ||= root_namespace.root_storage_statistics&.storage_size - end - - def limit - @limit ||= Gitlab::CurrentSettings.namespace_storage_size_limit.megabytes - end - - private - - attr_reader :root_namespace -end diff --git a/app/models/namespace/root_storage_statistics.rb b/app/models/namespace/root_storage_statistics.rb index ae9b2f14343..2ad6ea59588 100644 --- a/app/models/namespace/root_storage_statistics.rb +++ b/app/models/namespace/root_storage_statistics.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true class Namespace::RootStorageStatistics < ApplicationRecord - STATISTICS_ATTRIBUTES = %w(storage_size repository_size wiki_size lfs_objects_size build_artifacts_size packages_size).freeze + SNIPPETS_SIZE_STAT_NAME = 'snippets_size'.freeze + STATISTICS_ATTRIBUTES = %W(storage_size repository_size wiki_size lfs_objects_size build_artifacts_size packages_size #{SNIPPETS_SIZE_STAT_NAME}).freeze self.primary_key = :namespace_id @@ -13,11 +14,15 @@ class Namespace::RootStorageStatistics < ApplicationRecord delegate :all_projects, to: :namespace def recalculate! - update!(attributes_from_project_statistics) + update!(merged_attributes) end private + def merged_attributes + attributes_from_project_statistics.merge!(attributes_from_personal_snippets) { |key, v1, v2| v1 + v2 } + end + def attributes_from_project_statistics from_project_statistics .take @@ -34,7 +39,22 @@ class Namespace::RootStorageStatistics < ApplicationRecord 'COALESCE(SUM(ps.wiki_size), 0) AS wiki_size', 'COALESCE(SUM(ps.lfs_objects_size), 0) AS lfs_objects_size', 'COALESCE(SUM(ps.build_artifacts_size), 0) AS build_artifacts_size', - 'COALESCE(SUM(ps.packages_size), 0) AS packages_size' + 'COALESCE(SUM(ps.packages_size), 0) AS packages_size', + "COALESCE(SUM(ps.snippets_size), 0) AS #{SNIPPETS_SIZE_STAT_NAME}" ) end + + def attributes_from_personal_snippets + # Return if the type of namespace does not belong to a user + return {} unless namespace.type.nil? + + from_personal_snippets.take.slice(SNIPPETS_SIZE_STAT_NAME) + end + + def from_personal_snippets + PersonalSnippet + .joins('INNER JOIN snippet_statistics s ON s.snippet_id = snippets.id') + .where(author: namespace.owner_id) + .select("COALESCE(SUM(s.repository_size), 0) AS #{SNIPPETS_SIZE_STAT_NAME}") + end end diff --git a/app/models/namespace/traversal_hierarchy.rb b/app/models/namespace/traversal_hierarchy.rb new file mode 100644 index 00000000000..cfb6cfdde74 --- /dev/null +++ b/app/models/namespace/traversal_hierarchy.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true +# +# A Namespace::TraversalHierarchy is the collection of namespaces that descend +# from a root Namespace as defined by the Namespace#traversal_ids attributes. +# +# This class provides operations to be performed on the hierarchy itself, +# rather than individual namespaces. +# +# This includes methods for synchronizing traversal_ids attributes to a correct +# state. We use recursive methods to determine the correct state so we don't +# have to depend on the integrity of the traversal_ids attribute values +# themselves. +# +class Namespace + class TraversalHierarchy + attr_accessor :root + + def self.for_namespace(namespace) + new(recursive_root_ancestor(namespace)) + end + + def initialize(root) + raise StandardError.new('Must specify a root node') if root.parent_id + + @root = root + end + + # Update all traversal_ids in the current namespace hierarchy. + def sync_traversal_ids! + # An issue in Rails since 2013 prevents this kind of join based update in + # ActiveRecord. https://github.com/rails/rails/issues/13496 + # Ideally it would be: + # `incorrect_traversal_ids.update_all('traversal_ids = cte.traversal_ids')` + sql = """ + UPDATE namespaces + SET traversal_ids = cte.traversal_ids + FROM (#{recursive_traversal_ids}) as cte + WHERE namespaces.id = cte.id + AND namespaces.traversal_ids <> cte.traversal_ids + """ + Namespace.connection.exec_query(sql) + end + + # Identify all incorrect traversal_ids in the current namespace hierarchy. + def incorrect_traversal_ids + Namespace + .joins("INNER JOIN (#{recursive_traversal_ids}) as cte ON namespaces.id = cte.id") + .where('namespaces.traversal_ids <> cte.traversal_ids') + end + + private + + # Determine traversal_ids for the namespace hierarchy using recursive methods. + # Generate a collection of [id, traversal_ids] rows. + # + # Note that the traversal_ids represent a calculated traversal path for the + # namespace and not the value stored within the traversal_ids attribute. + def recursive_traversal_ids + root_id = Integer(@root.id) + + """ + WITH RECURSIVE cte(id, traversal_ids, cycle) AS ( + VALUES(#{root_id}, ARRAY[#{root_id}], false) + UNION ALL + SELECT n.id, cte.traversal_ids || n.id, n.id = ANY(cte.traversal_ids) + FROM namespaces n, cte + WHERE n.parent_id = cte.id AND NOT cycle + ) + SELECT id, traversal_ids FROM cte + """ + end + + # This is essentially Namespace#root_ancestor which will soon be rewritten + # to use traversal_ids. We replicate here as a reliable way to find the + # root using recursive methods. + def self.recursive_root_ancestor(namespace) + Gitlab::ObjectHierarchy + .new(Namespace.where(id: namespace)) + .base_and_ancestors + .reorder(nil) + .find_by(parent_id: nil) + end + end +end diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb new file mode 100644 index 00000000000..53bfa3d979e --- /dev/null +++ b/app/models/namespace_setting.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class NamespaceSetting < ApplicationRecord + belongs_to :namespace, inverse_of: :namespace_settings + + self.primary_key = :namespace_id +end + +NamespaceSetting.prepend_if_ee('EE::NamespaceSetting') diff --git a/app/models/note.rb b/app/models/note.rb index 6b6a7c50b00..2db7e4e406d 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -5,6 +5,7 @@ # A note of this type is never resolvable. class Note < ApplicationRecord extend ActiveModel::Naming + include Gitlab::Utils::StrongMemoize include Participable include Mentionable include Awardable @@ -122,6 +123,8 @@ class Note < ApplicationRecord scope :common, -> { where(noteable_type: ["", nil]) } scope :fresh, -> { order(created_at: :asc, id: :asc) } scope :updated_after, ->(time) { where('updated_at > ?', time) } + scope :with_updated_at, ->(time) { where(updated_at: time) } + scope :by_updated_at, -> { reorder(:updated_at, :id) } scope :inc_author_project, -> { includes(:project, :author) } scope :inc_author, -> { includes(:author) } scope :inc_relations_for_view, -> do @@ -446,8 +449,10 @@ class Note < ApplicationRecord # Consider using `#to_discussion` if we do not need to render the discussion # and all its notes and if we don't care about the discussion's resolvability status. def discussion - full_discussion = self.noteable.notes.find_discussion(self.discussion_id) if part_of_discussion? - full_discussion || to_discussion + strong_memoize(:discussion) do + full_discussion = self.noteable.notes.find_discussion(self.discussion_id) if part_of_discussion? + full_discussion || to_discussion + end end def start_of_discussion? diff --git a/app/models/packages.rb b/app/models/packages.rb new file mode 100644 index 00000000000..e14c9290093 --- /dev/null +++ b/app/models/packages.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +module Packages + def self.table_name_prefix + 'packages_' + end +end diff --git a/app/models/packages/build_info.rb b/app/models/packages/build_info.rb new file mode 100644 index 00000000000..df8cf68490e --- /dev/null +++ b/app/models/packages/build_info.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class Packages::BuildInfo < ApplicationRecord + belongs_to :package, inverse_of: :build_info + belongs_to :pipeline, class_name: 'Ci::Pipeline' +end diff --git a/app/models/packages/composer/metadatum.rb b/app/models/packages/composer/metadatum.rb new file mode 100644 index 00000000000..3026f5ea878 --- /dev/null +++ b/app/models/packages/composer/metadatum.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Packages + module Composer + class Metadatum < ApplicationRecord + self.table_name = 'packages_composer_metadata' + self.primary_key = :package_id + + belongs_to :package, -> { where(package_type: :composer) }, inverse_of: :composer_metadatum + + validates :package, :target_sha, :composer_json, presence: true + end + end +end diff --git a/app/models/packages/conan.rb b/app/models/packages/conan.rb new file mode 100644 index 00000000000..01007c3fa78 --- /dev/null +++ b/app/models/packages/conan.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +module Packages + module Conan + def self.table_name_prefix + 'packages_conan_' + end + end +end diff --git a/app/models/packages/conan/file_metadatum.rb b/app/models/packages/conan/file_metadatum.rb new file mode 100644 index 00000000000..e1ef62b3959 --- /dev/null +++ b/app/models/packages/conan/file_metadatum.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class Packages::Conan::FileMetadatum < ApplicationRecord + belongs_to :package_file, inverse_of: :conan_file_metadatum + + validates :package_file, presence: true + + validates :recipe_revision, + presence: true, + format: { with: Gitlab::Regex.conan_revision_regex } + + validates :package_revision, absence: true, if: :recipe_file? + validates :package_revision, format: { with: Gitlab::Regex.conan_revision_regex }, if: :package_file? + + validates :conan_package_reference, absence: true, if: :recipe_file? + validates :conan_package_reference, format: { with: Gitlab::Regex.conan_package_reference_regex }, if: :package_file? + validate :conan_package_type + + enum conan_file_type: { recipe_file: 1, package_file: 2 } + + RECIPE_FILES = ::Gitlab::Regex::Packages::CONAN_RECIPE_FILES + PACKAGE_FILES = ::Gitlab::Regex::Packages::CONAN_PACKAGE_FILES + PACKAGE_BINARY = 'conan_package.tgz' + + private + + def conan_package_type + unless package_file&.package&.conan? + errors.add(:base, _('Package type must be Conan')) + end + end +end diff --git a/app/models/packages/conan/metadatum.rb b/app/models/packages/conan/metadatum.rb new file mode 100644 index 00000000000..7ec2641177a --- /dev/null +++ b/app/models/packages/conan/metadatum.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class Packages::Conan::Metadatum < ApplicationRecord + belongs_to :package, -> { where(package_type: :conan) }, inverse_of: :conan_metadatum + + validates :package, presence: true + + validates :package_username, + presence: true, + format: { with: Gitlab::Regex.conan_recipe_component_regex } + + validates :package_channel, + presence: true, + format: { with: Gitlab::Regex.conan_recipe_component_regex } + + validate :conan_package_type + + def recipe + "#{package.name}/#{package.version}@#{package_username}/#{package_channel}" + end + + def recipe_path + recipe.tr('@', '/') + end + + def self.package_username_from(full_path:) + full_path.tr('/', '+') + end + + def self.full_path_from(package_username:) + package_username.tr('+', '/') + end + + private + + def conan_package_type + unless package&.conan? + errors.add(:base, _('Package type must be Conan')) + end + end +end diff --git a/app/models/packages/dependency.rb b/app/models/packages/dependency.rb new file mode 100644 index 00000000000..51b80934827 --- /dev/null +++ b/app/models/packages/dependency.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true +class Packages::Dependency < ApplicationRecord + has_many :dependency_links, class_name: 'Packages::DependencyLink' + + validates :name, :version_pattern, presence: true + + validates :name, uniqueness: { scope: :version_pattern } + + NAME_VERSION_PATTERN_TUPLE_MATCHING = '(name, version_pattern) = (?, ?)'.freeze + MAX_STRING_LENGTH = 255.freeze + MAX_CHUNKED_QUERIES_COUNT = 10.freeze + + def self.ids_for_package_names_and_version_patterns(names_and_version_patterns = {}, chunk_size = 50, max_rows_limit = 200) + names_and_version_patterns.reject! { |key, value| key.size > MAX_STRING_LENGTH || value.size > MAX_STRING_LENGTH } + raise ArgumentError, 'Too many names_and_version_patterns' if names_and_version_patterns.size > MAX_CHUNKED_QUERIES_COUNT * chunk_size + + matched_ids = [] + names_and_version_patterns.each_slice(chunk_size) do |tuples| + where_statement = Array.new(tuples.size, NAME_VERSION_PATTERN_TUPLE_MATCHING) + .join(' OR ') + ids = where(where_statement, *tuples.flatten) + .limit(max_rows_limit + 1) + .pluck(:id) + matched_ids.concat(ids) + + raise ArgumentError, 'Too many Dependencies selected' if matched_ids.size > max_rows_limit + end + + matched_ids + end + + def self.for_package_names_and_version_patterns(names_and_version_patterns = {}, chunk_size = 50, max_rows_limit = 200) + ids = ids_for_package_names_and_version_patterns(names_and_version_patterns, chunk_size, max_rows_limit) + + return none if ids.empty? + + id_in(ids) + end + + def self.pluck_ids_and_names + pluck(:id, :name) + end + + def orphaned? + self.dependency_links.empty? + end +end diff --git a/app/models/packages/dependency_link.rb b/app/models/packages/dependency_link.rb new file mode 100644 index 00000000000..51018602bdc --- /dev/null +++ b/app/models/packages/dependency_link.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true +class Packages::DependencyLink < ApplicationRecord + belongs_to :package, inverse_of: :dependency_links + belongs_to :dependency, inverse_of: :dependency_links, class_name: 'Packages::Dependency' + has_one :nuget_metadatum, inverse_of: :dependency_link, class_name: 'Packages::Nuget::DependencyLinkMetadatum' + + validates :package, :dependency, presence: true + + validates :dependency_type, + uniqueness: { scope: %i[package_id dependency_id] } + + enum dependency_type: { dependencies: 1, devDependencies: 2, bundleDependencies: 3, peerDependencies: 4 } + + scope :with_dependency_type, ->(dependency_type) { where(dependency_type: dependency_type) } + scope :includes_dependency, -> { includes(:dependency) } + scope :for_package, ->(package) { where(package_id: package.id) } + scope :preload_dependency, -> { preload(:dependency) } + scope :preload_nuget_metadatum, -> { preload(:nuget_metadatum) } +end diff --git a/app/models/packages/go/module.rb b/app/models/packages/go/module.rb new file mode 100644 index 00000000000..b38b691ed6c --- /dev/null +++ b/app/models/packages/go/module.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module Packages + module Go + class Module + include Gitlab::Utils::StrongMemoize + + attr_reader :project, :name, :path + + def initialize(project, name, path) + @project = project + @name = name + @path = path + end + + def versions + strong_memoize(:versions) { Packages::Go::VersionFinder.new(self).execute } + end + + def version_by(ref: nil, commit: nil) + raise ArgumentError.new 'no filter specified' unless ref || commit + raise ArgumentError.new 'ref and commit are mutually exclusive' if ref && commit + + if commit + return version_by_sha(commit) if commit.is_a? String + + return version_by_commit(commit) + end + + return version_by_name(ref) if ref.is_a? String + + version_by_ref(ref) + end + + def path_valid?(major) + m = /\/v(\d+)$/i.match(@name) + + case major + when 0, 1 + m.nil? + else + !m.nil? && m[1].to_i == major + end + end + + def gomod_valid?(gomod) + if Feature.enabled?(:go_proxy_disable_gomod_validation, @project) + return gomod&.start_with?("module ") + end + + gomod&.split("\n", 2)&.first == "module #{@name}" + end + + private + + def version_by_name(name) + # avoid a Gitaly call if possible + if strong_memoized?(:versions) + v = versions.find { |v| v.name == ref } + return v if v + end + + ref = @project.repository.find_tag(name) || @project.repository.find_branch(name) + return unless ref + + version_by_ref(ref) + end + + def version_by_ref(ref) + # reuse existing versions + if strong_memoized?(:versions) + v = versions.find { |v| v.ref == ref } + return v if v + end + + commit = ref.dereferenced_target + semver = Packages::SemVer.parse(ref.name, prefixed: true) + Packages::Go::ModuleVersion.new(self, :ref, commit, ref: ref, semver: semver) + end + + def version_by_sha(sha) + commit = @project.commit_by(oid: sha) + return unless ref + + version_by_commit(commit) + end + + def version_by_commit(commit) + Packages::Go::ModuleVersion.new(self, :commit, commit) + end + end + end +end diff --git a/app/models/packages/go/module_version.rb b/app/models/packages/go/module_version.rb new file mode 100644 index 00000000000..a50c78f8e69 --- /dev/null +++ b/app/models/packages/go/module_version.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +module Packages + module Go + class ModuleVersion + include Gitlab::Utils::StrongMemoize + + VALID_TYPES = %i[ref commit pseudo].freeze + + attr_reader :mod, :type, :ref, :commit + + delegate :major, to: :@semver, allow_nil: true + delegate :minor, to: :@semver, allow_nil: true + delegate :patch, to: :@semver, allow_nil: true + delegate :prerelease, to: :@semver, allow_nil: true + delegate :build, to: :@semver, allow_nil: true + + def initialize(mod, type, commit, name: nil, semver: nil, ref: nil) + raise ArgumentError.new("invalid type '#{type}'") unless VALID_TYPES.include? type + raise ArgumentError.new("mod is required") unless mod + raise ArgumentError.new("commit is required") unless commit + + if type == :ref + raise ArgumentError.new("ref is required") unless ref + elsif type == :pseudo + raise ArgumentError.new("name is required") unless name + raise ArgumentError.new("semver is required") unless semver + end + + @mod = mod + @type = type + @commit = commit + @name = name if name + @semver = semver if semver + @ref = ref if ref + end + + def name + @name || @ref&.name + end + + def full_name + "#{mod.name}@#{name || commit.sha}" + end + + def gomod + strong_memoize(:gomod) do + if strong_memoized?(:blobs) + blob_at(@mod.path + '/go.mod') + elsif @mod.path.empty? + @mod.project.repository.blob_at(@commit.sha, 'go.mod')&.data + else + @mod.project.repository.blob_at(@commit.sha, @mod.path + '/go.mod')&.data + end + end + end + + def archive + suffix_len = @mod.path == '' ? 0 : @mod.path.length + 1 + + Zip::OutputStream.write_buffer do |zip| + files.each do |file| + zip.put_next_entry "#{full_name}/#{file[suffix_len...]}" + zip.write blob_at(file) + end + end + end + + def files + strong_memoize(:files) do + ls_tree.filter { |e| !excluded.any? { |n| e.start_with? n } } + end + end + + def excluded + strong_memoize(:excluded) do + ls_tree + .filter { |f| f.end_with?('/go.mod') && f != @mod.path + '/go.mod' } + .map { |f| f[0..-7] } + end + end + + def valid? + @mod.path_valid?(major) && @mod.gomod_valid?(gomod) + end + + private + + def blob_at(path) + return if path.nil? || path.empty? + + path = path[1..] if path.start_with? '/' + + blobs.find { |x| x.path == path }&.data + end + + def blobs + strong_memoize(:blobs) { @mod.project.repository.batch_blobs(files.map { |x| [@commit.sha, x] }) } + end + + def ls_tree + strong_memoize(:ls_tree) do + path = + if @mod.path.empty? + '.' + else + @mod.path + end + + @mod.project.repository.gitaly_repository_client.search_files_by_name(@commit.sha, path) + end + end + end + end +end diff --git a/app/models/packages/maven.rb b/app/models/packages/maven.rb new file mode 100644 index 00000000000..5c1581ce0b7 --- /dev/null +++ b/app/models/packages/maven.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +module Packages + module Maven + def self.table_name_prefix + 'packages_maven_' + end + end +end diff --git a/app/models/packages/maven/metadatum.rb b/app/models/packages/maven/metadatum.rb new file mode 100644 index 00000000000..b7f27fb9e06 --- /dev/null +++ b/app/models/packages/maven/metadatum.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +class Packages::Maven::Metadatum < ApplicationRecord + belongs_to :package, -> { where(package_type: :maven) } + + validates :package, presence: true + + validates :path, + presence: true, + format: { with: Gitlab::Regex.maven_path_regex } + + validates :app_group, + presence: true, + format: { with: Gitlab::Regex.maven_app_group_regex } + + validates :app_name, + presence: true, + format: { with: Gitlab::Regex.maven_app_name_regex } + + validate :maven_package_type + + private + + def maven_package_type + unless package&.maven? + errors.add(:base, _('Package type must be Maven')) + end + end +end diff --git a/app/models/packages/nuget.rb b/app/models/packages/nuget.rb new file mode 100644 index 00000000000..42c167e9b7f --- /dev/null +++ b/app/models/packages/nuget.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +module Packages + module Nuget + def self.table_name_prefix + 'packages_nuget_' + end + end +end diff --git a/app/models/packages/nuget/dependency_link_metadatum.rb b/app/models/packages/nuget/dependency_link_metadatum.rb new file mode 100644 index 00000000000..b586b55d3f0 --- /dev/null +++ b/app/models/packages/nuget/dependency_link_metadatum.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class Packages::Nuget::DependencyLinkMetadatum < ApplicationRecord + self.primary_key = :dependency_link_id + + belongs_to :dependency_link, inverse_of: :nuget_metadatum + + validates :dependency_link, :target_framework, presence: true + + validate :ensure_nuget_package_type + + private + + def ensure_nuget_package_type + return if dependency_link&.package&.nuget? + + errors.add(:base, _('Package type must be NuGet')) + end +end diff --git a/app/models/packages/nuget/metadatum.rb b/app/models/packages/nuget/metadatum.rb new file mode 100644 index 00000000000..1db8c0eddbf --- /dev/null +++ b/app/models/packages/nuget/metadatum.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class Packages::Nuget::Metadatum < ApplicationRecord + belongs_to :package, -> { where(package_type: :nuget) }, inverse_of: :nuget_metadatum + + validates :package, presence: true + validates :license_url, public_url: { allow_blank: true } + validates :project_url, public_url: { allow_blank: true } + validates :icon_url, public_url: { allow_blank: true } + + validate :ensure_at_least_one_field_supplied + validate :ensure_nuget_package_type + + private + + def ensure_at_least_one_field_supplied + return if license_url? || project_url? || icon_url? + + errors.add(:base, _('Nuget metadatum must have at least license_url, project_url or icon_url set')) + end + + def ensure_nuget_package_type + return if package&.nuget? + + errors.add(:base, _('Package type must be NuGet')) + end +end diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb new file mode 100644 index 00000000000..d6633456de4 --- /dev/null +++ b/app/models/packages/package.rb @@ -0,0 +1,195 @@ +# frozen_string_literal: true +class Packages::Package < ApplicationRecord + include Sortable + include Gitlab::SQL::Pattern + include UsageStatistics + + belongs_to :project + # package_files must be destroyed by ruby code in order to properly remove carrierwave uploads and update project statistics + has_many :package_files, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :dependency_links, inverse_of: :package, class_name: 'Packages::DependencyLink' + has_many :tags, inverse_of: :package, class_name: 'Packages::Tag' + has_one :conan_metadatum, inverse_of: :package, class_name: 'Packages::Conan::Metadatum' + has_one :pypi_metadatum, inverse_of: :package, class_name: 'Packages::Pypi::Metadatum' + has_one :maven_metadatum, inverse_of: :package, class_name: 'Packages::Maven::Metadatum' + has_one :nuget_metadatum, inverse_of: :package, class_name: 'Packages::Nuget::Metadatum' + has_one :composer_metadatum, inverse_of: :package, class_name: 'Packages::Composer::Metadatum' + has_one :build_info, inverse_of: :package + + accepts_nested_attributes_for :conan_metadatum + accepts_nested_attributes_for :maven_metadatum + + delegate :recipe, :recipe_path, to: :conan_metadatum, prefix: :conan + + validates :project, presence: true + validates :name, presence: true + + validates :name, format: { with: Gitlab::Regex.package_name_regex }, unless: :conan? + + validates :name, + uniqueness: { scope: %i[project_id version package_type] }, unless: :conan? + + validate :valid_conan_package_recipe, if: :conan? + validate :valid_npm_package_name, if: :npm? + validate :valid_composer_global_name, if: :composer? + validate :package_already_taken, if: :npm? + validates :version, format: { with: Gitlab::Regex.semver_regex }, if: -> { npm? || nuget? } + validates :name, format: { with: Gitlab::Regex.conan_recipe_component_regex }, if: :conan? + validates :version, format: { with: Gitlab::Regex.conan_recipe_component_regex }, if: :conan? + validates :version, format: { with: Gitlab::Regex.maven_version_regex }, if: -> { version? && maven? } + + enum package_type: { maven: 1, npm: 2, conan: 3, nuget: 4, pypi: 5, composer: 6 } + + scope :with_name, ->(name) { where(name: name) } + scope :with_name_like, ->(name) { where(arel_table[:name].matches(name)) } + scope :search_by_name, ->(query) { fuzzy_search(query, [:name], use_minimum_char_limit: false) } + scope :with_version, ->(version) { where(version: version) } + scope :without_version_like, -> (version) { where.not(arel_table[:version].matches(version)) } + scope :with_package_type, ->(package_type) { where(package_type: package_type) } + + scope :with_conan_channel, ->(package_channel) do + joins(:conan_metadatum).where(packages_conan_metadata: { package_channel: package_channel }) + end + scope :with_conan_username, ->(package_username) do + joins(:conan_metadatum).where(packages_conan_metadata: { package_username: package_username }) + end + + scope :with_composer_target, -> (target) do + includes(:composer_metadatum) + .joins(:composer_metadatum) + .where(Packages::Composer::Metadatum.table_name => { target_sha: target }) + end + scope :preload_composer, -> { preload(:composer_metadatum) } + + scope :without_nuget_temporary_name, -> { where.not(name: Packages::Nuget::CreatePackageService::TEMPORARY_PACKAGE_NAME) } + + scope :has_version, -> { where.not(version: nil) } + scope :processed, -> do + where.not(package_type: :nuget).or( + where.not(name: Packages::Nuget::CreatePackageService::TEMPORARY_PACKAGE_NAME) + ) + end + scope :preload_files, -> { preload(:package_files) } + scope :last_of_each_version, -> { where(id: all.select('MAX(id) AS id').group(:version)) } + scope :limit_recent, ->(limit) { order_created_desc.limit(limit) } + scope :select_distinct_name, -> { select(:name).distinct } + + # Sorting + scope :order_created, -> { reorder('created_at ASC') } + scope :order_created_desc, -> { reorder('created_at DESC') } + scope :order_name, -> { reorder('name ASC') } + scope :order_name_desc, -> { reorder('name DESC') } + scope :order_version, -> { reorder('version ASC') } + scope :order_version_desc, -> { reorder('version DESC') } + scope :order_type, -> { reorder('package_type ASC') } + scope :order_type_desc, -> { reorder('package_type DESC') } + scope :order_project_name, -> { joins(:project).reorder('projects.name ASC') } + scope :order_project_name_desc, -> { joins(:project).reorder('projects.name DESC') } + scope :order_project_path, -> { joins(:project).reorder('projects.path ASC, id ASC') } + scope :order_project_path_desc, -> { joins(:project).reorder('projects.path DESC, id DESC') } + + def self.for_projects(projects) + return none unless projects.any? + + where(project_id: projects) + end + + def self.only_maven_packages_with_path(path) + joins(:maven_metadatum).where(packages_maven_metadata: { path: path }) + end + + def self.by_name_and_file_name(name, file_name) + with_name(name) + .joins(:package_files) + .where(packages_package_files: { file_name: file_name }).last! + end + + def self.by_file_name_and_sha256(file_name, sha256) + joins(:package_files) + .where(packages_package_files: { file_name: file_name, file_sha256: sha256 }).last! + end + + def self.pluck_names + pluck(:name) + end + + def self.pluck_versions + pluck(:version) + end + + def self.sort_by_attribute(method) + case method.to_s + when 'created_asc' then order_created + when 'created_at_asc' then order_created + when 'name_asc' then order_name + when 'name_desc' then order_name_desc + when 'version_asc' then order_version + when 'version_desc' then order_version_desc + when 'type_asc' then order_type + when 'type_desc' then order_type_desc + when 'project_name_asc' then order_project_name + when 'project_name_desc' then order_project_name_desc + when 'project_path_asc' then order_project_path + when 'project_path_desc' then order_project_path_desc + else + order_created_desc + end + end + + def versions + project.packages + .with_name(name) + .where.not(version: version) + .with_package_type(package_type) + .order(:version) + end + + def pipeline + build_info&.pipeline + end + + def tag_names + tags.pluck(:name) + end + + private + + def valid_conan_package_recipe + recipe_exists = project.packages + .conan + .includes(:conan_metadatum) + .with_name(name) + .with_version(version) + .with_conan_channel(conan_metadatum.package_channel) + .with_conan_username(conan_metadatum.package_username) + .id_not_in(id) + .exists? + + errors.add(:base, _('Package recipe already exists')) if recipe_exists + end + + def valid_composer_global_name + # .default_scoped is required here due to a bug in rails that leaks + # the scope and adds `self` to the query incorrectly + # See https://github.com/rails/rails/pull/35186 + if Packages::Package.default_scoped.composer.with_name(name).where.not(project_id: project_id).exists? + errors.add(:name, 'is already taken by another project') + end + end + + def valid_npm_package_name + return unless project&.root_namespace + + unless name =~ %r{\A@#{project.root_namespace.path}/[^/]+\z} + errors.add(:name, 'is not valid') + end + end + + def package_already_taken + return unless project + + if project.package_already_taken?(name) + errors.add(:base, _('Package already exists')) + end + end +end diff --git a/app/models/packages/package_file.rb b/app/models/packages/package_file.rb new file mode 100644 index 00000000000..567b5a14603 --- /dev/null +++ b/app/models/packages/package_file.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true +class Packages::PackageFile < ApplicationRecord + include UpdateProjectStatistics + + delegate :project, :project_id, to: :package + delegate :conan_file_type, to: :conan_file_metadatum + + belongs_to :package + + has_one :conan_file_metadatum, inverse_of: :package_file, class_name: 'Packages::Conan::FileMetadatum' + + accepts_nested_attributes_for :conan_file_metadatum + + validates :package, presence: true + validates :file, presence: true + validates :file_name, presence: true + + scope :recent, -> { order(id: :desc) } + scope :with_file_name, ->(file_name) { where(file_name: file_name) } + scope :with_file_name_like, ->(file_name) { where(arel_table[:file_name].matches(file_name)) } + scope :with_files_stored_locally, -> { where(file_store: ::Packages::PackageFileUploader::Store::LOCAL) } + scope :preload_conan_file_metadata, -> { preload(:conan_file_metadatum) } + + scope :with_conan_file_type, ->(file_type) do + joins(:conan_file_metadatum) + .where(packages_conan_file_metadata: { conan_file_type: ::Packages::Conan::FileMetadatum.conan_file_types[file_type] }) + end + + scope :with_conan_package_reference, ->(conan_package_reference) do + joins(:conan_file_metadatum) + .where(packages_conan_file_metadata: { conan_package_reference: conan_package_reference }) + end + + mount_uploader :file, Packages::PackageFileUploader + + after_save :update_file_metadata, if: :saved_change_to_file? + + update_project_statistics project_statistics_name: :packages_size + + def update_file_metadata + # The file.object_store is set during `uploader.store!` + # which happens after object is inserted/updated + self.update_column(:file_store, file.object_store) + self.update_column(:size, file.size) unless file.size == self.size + end + + def download_path + Gitlab::Routing.url_helpers.download_project_package_file_path(project, self) if ::Gitlab.ee? + end + + def local? + file_store == ::Packages::PackageFileUploader::Store::LOCAL + end +end + +Packages::PackageFile.prepend_if_ee('EE::Packages::PackageFileGeo') diff --git a/app/models/packages/pypi.rb b/app/models/packages/pypi.rb new file mode 100644 index 00000000000..fc8a55caa31 --- /dev/null +++ b/app/models/packages/pypi.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +module Packages + module Pypi + def self.table_name_prefix + 'packages_pypi_' + end + end +end diff --git a/app/models/packages/pypi/metadatum.rb b/app/models/packages/pypi/metadatum.rb new file mode 100644 index 00000000000..7e6456ad964 --- /dev/null +++ b/app/models/packages/pypi/metadatum.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class Packages::Pypi::Metadatum < ApplicationRecord + self.primary_key = :package_id + + belongs_to :package, -> { where(package_type: :pypi) }, inverse_of: :pypi_metadatum + + validates :package, presence: true + + validate :pypi_package_type + + private + + def pypi_package_type + unless package&.pypi? + errors.add(:base, _('Package type must be PyPi')) + end + end +end diff --git a/app/models/packages/sem_ver.rb b/app/models/packages/sem_ver.rb new file mode 100644 index 00000000000..b73d51b08b7 --- /dev/null +++ b/app/models/packages/sem_ver.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +class Packages::SemVer + attr_accessor :major, :minor, :patch, :prerelease, :build + + def initialize(major = 0, minor = 0, patch = 0, prerelease = nil, build = nil, prefixed: false) + @major = major + @minor = minor + @patch = patch + @prerelease = prerelease + @build = build + @prefixed = prefixed + end + + def prefixed? + @prefixed + end + + def ==(other) + self.class == other.class && + self.major == other.major && + self.minor == other.minor && + self.patch == other.patch && + self.prerelease == other.prerelease && + self.build == other.build + end + + def to_s + s = "#{prefixed? ? 'v' : ''}#{major || 0}.#{minor || 0}.#{patch || 0}" + s += "-#{prerelease}" if prerelease + s += "+#{build}" if build + + s + end + + def self.match(str, prefixed: false) + return unless str&.start_with?('v') == prefixed + + str = str[1..] if prefixed + + Gitlab::Regex.semver_regex.match(str) + end + + def self.match?(str, prefixed: false) + !match(str, prefixed: prefixed).nil? + end + + def self.parse(str, prefixed: false) + m = match str, prefixed: prefixed + return unless m + + new(m[1].to_i, m[2].to_i, m[3].to_i, m[4], m[5], prefixed: prefixed) + end +end diff --git a/app/models/packages/tag.rb b/app/models/packages/tag.rb new file mode 100644 index 00000000000..771d016daed --- /dev/null +++ b/app/models/packages/tag.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true +class Packages::Tag < ApplicationRecord + belongs_to :package, inverse_of: :tags + + validates :package, :name, presence: true + + FOR_PACKAGES_TAGS_LIMIT = 200.freeze + NUGET_TAGS_SEPARATOR = ' ' # https://docs.microsoft.com/en-us/nuget/reference/nuspec#tags + + scope :preload_package, -> { preload(:package) } + scope :with_name, -> (name) { where(name: name) } + + def self.for_packages(packages) + where(package_id: packages.select(:id)) + .order(updated_at: :desc) + .limit(FOR_PACKAGES_TAGS_LIMIT) + end +end diff --git a/app/models/performance_monitoring/prometheus_dashboard.rb b/app/models/performance_monitoring/prometheus_dashboard.rb index b04e7e689cd..bf87d2c3916 100644 --- a/app/models/performance_monitoring/prometheus_dashboard.rb +++ b/app/models/performance_monitoring/prometheus_dashboard.rb @@ -7,7 +7,7 @@ module PerformanceMonitoring attr_accessor :dashboard, :panel_groups, :path, :environment, :priority, :templating, :links validates :dashboard, presence: true - validates :panel_groups, presence: true + validates :panel_groups, array_members: { member_class: PerformanceMonitoring::PrometheusPanelGroup } class << self def from_json(json_content) @@ -35,9 +35,15 @@ module PerformanceMonitoring new( dashboard: attributes['dashboard'], - panel_groups: attributes['panel_groups']&.map { |group| PrometheusPanelGroup.from_json(group) } + panel_groups: initialize_children_collection(attributes['panel_groups']) ) end + + def initialize_children_collection(children) + return unless children.is_a?(Array) + + children.map { |group| PerformanceMonitoring::PrometheusPanelGroup.from_json(group) } + end end def to_yaml @@ -47,7 +53,7 @@ module PerformanceMonitoring # This method is planned to be refactored as a part of https://gitlab.com/gitlab-org/gitlab/-/issues/219398 # implementation. For new existing logic was reused to faster deliver MVC def schema_validation_warnings - self.class.from_json(self.as_json) + self.class.from_json(reload_schema) nil rescue ActiveModel::ValidationError => exception exception.model.errors.map { |attr, error| "#{attr}: #{error}" } @@ -55,6 +61,14 @@ module PerformanceMonitoring private + # dashboard finder methods are somehow limited, #find includes checking if + # user is authorised to view selected dashboard, but modifies schema, which in some cases may + # cause false positives returned from validation, and #find_raw does not authorise users + def reload_schema + project = environment&.project + project.nil? ? self.as_json : Gitlab::Metrics::Dashboard::Finder.find_raw(project, dashboard_path: path) + end + def yaml_valid_attributes %w(panel_groups panels metrics group priority type title y_label weight id unit label query query_range dashboard) end diff --git a/app/models/performance_monitoring/prometheus_panel.rb b/app/models/performance_monitoring/prometheus_panel.rb index a16a68ba832..b33c09001ae 100644 --- a/app/models/performance_monitoring/prometheus_panel.rb +++ b/app/models/performance_monitoring/prometheus_panel.rb @@ -7,7 +7,8 @@ module PerformanceMonitoring attr_accessor :type, :title, :y_label, :weight, :metrics, :y_axis, :max_value validates :title, presence: true - validates :metrics, presence: true + validates :metrics, array_members: { member_class: PerformanceMonitoring::PrometheusMetric } + class << self def from_json(json_content) build_from_hash(json_content).tap(&:validate!) @@ -23,9 +24,15 @@ module PerformanceMonitoring title: attributes['title'], y_label: attributes['y_label'], weight: attributes['weight'], - metrics: attributes['metrics']&.map { |metric| PrometheusMetric.from_json(metric) } + metrics: initialize_children_collection(attributes['metrics']) ) end + + def initialize_children_collection(children) + return unless children.is_a?(Array) + + children.map { |metrics| PerformanceMonitoring::PrometheusMetric.from_json(metrics) } + end end def id(group_title) diff --git a/app/models/performance_monitoring/prometheus_panel_group.rb b/app/models/performance_monitoring/prometheus_panel_group.rb index f88106f259b..7f3d2a1b8f4 100644 --- a/app/models/performance_monitoring/prometheus_panel_group.rb +++ b/app/models/performance_monitoring/prometheus_panel_group.rb @@ -7,7 +7,8 @@ module PerformanceMonitoring attr_accessor :group, :priority, :panels validates :group, presence: true - validates :panels, presence: true + validates :panels, array_members: { member_class: PerformanceMonitoring::PrometheusPanel } + class << self def from_json(json_content) build_from_hash(json_content).tap(&:validate!) @@ -21,9 +22,15 @@ module PerformanceMonitoring new( group: attributes['group'], priority: attributes['priority'], - panels: attributes['panels']&.map { |panel| PrometheusPanel.from_json(panel) } + panels: initialize_children_collection(attributes['panels']) ) end + + def initialize_children_collection(children) + return unless children.is_a?(Array) + + children.map { |panels| PerformanceMonitoring::PrometheusPanel.from_json(panels) } + end end end end diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index 7afee2a35cb..488ebd531a8 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -17,11 +17,13 @@ class PersonalAccessToken < ApplicationRecord before_save :ensure_token - scope :active, -> { where("revoked = false AND (expires_at >= NOW() OR expires_at IS NULL)") } - scope :expiring_and_not_notified, ->(date) { where(["revoked = false AND expire_notification_delivered = false AND expires_at >= NOW() AND expires_at <= ?", date]) } - scope :inactive, -> { where("revoked = true OR expires_at < NOW()") } + scope :active, -> { where("revoked = false AND (expires_at >= CURRENT_DATE OR expires_at IS NULL)") } + scope :expiring_and_not_notified, ->(date) { where(["revoked = false AND expire_notification_delivered = false AND expires_at >= CURRENT_DATE AND expires_at <= ?", date]) } + scope :inactive, -> { where("revoked = true OR expires_at < CURRENT_DATE") } scope :with_impersonation, -> { where(impersonation: true) } scope :without_impersonation, -> { where(impersonation: false) } + scope :revoked, -> { where(revoked: true) } + scope :not_revoked, -> { where(revoked: [false, nil]) } scope :for_user, -> (user) { where(user: user) } scope :preload_users, -> { preload(:user) } scope :order_expires_at_asc, -> { reorder(expires_at: :asc) } diff --git a/app/models/plan.rb b/app/models/plan.rb index acac5f9aeae..b4091e0a755 100644 --- a/app/models/plan.rb +++ b/app/models/plan.rb @@ -27,7 +27,7 @@ class Plan < ApplicationRecord end def actual_limits - self.limits || PlanLimits.new + self.limits || self.build_limits end def default? diff --git a/app/models/plan_limits.rb b/app/models/plan_limits.rb index 575105cfd79..f17078c0cab 100644 --- a/app/models/plan_limits.rb +++ b/app/models/plan_limits.rb @@ -1,23 +1,36 @@ # frozen_string_literal: true class PlanLimits < ApplicationRecord + LimitUndefinedError = Class.new(StandardError) + belongs_to :plan - def exceeded?(limit_name, object) - return false unless enabled?(limit_name) + def exceeded?(limit_name, subject, alternate_limit: 0) + limit = limit_for(limit_name, alternate_limit: alternate_limit) + return false unless limit - if object.is_a?(Integer) - object >= read_attribute(limit_name) - else - # object.count >= limit value is slower than checking + case subject + when Integer + subject >= limit + when ActiveRecord::Relation + # We intentionally not accept just plain ApplicationRecord classes to + # enforce the subject to be scoped down to a relation first. + # + # subject.count >= limit value is slower than checking # if a record exists at the limit value - 1 position. - object.offset(read_attribute(limit_name) - 1).exists? + subject.offset(limit - 1).exists? + else + raise ArgumentError, "#{subject.class} is not supported as a limit value" end end - private + def limit_for(limit_name, alternate_limit: 0) + limit = read_attribute(limit_name) + raise LimitUndefinedError, "The limit `#{limit_name}` is undefined" if limit.nil? + + alternate_limit = alternate_limit.call if alternate_limit.respond_to?(:call) - def enabled?(limit_name) - read_attribute(limit_name) > 0 + limits = [limit, alternate_limit] + limits.map(&:to_i).select(&:positive?).min end end diff --git a/app/models/product_analytics_event.rb b/app/models/product_analytics_event.rb new file mode 100644 index 00000000000..95a2e7a26c4 --- /dev/null +++ b/app/models/product_analytics_event.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class ProductAnalyticsEvent < ApplicationRecord + self.table_name = 'product_analytics_events_experimental' + + # Ignore that the partition key :project_id is part of the formal primary key + self.primary_key = :id + + belongs_to :project + + validates :event_id, :project_id, :v_collector, :v_etl, presence: true + + # There is no default Rails timestamps in the table. + # collector_tstamp is a timestamp when a collector recorded an event. + scope :order_by_time, -> { order(collector_tstamp: :desc) } + + # If we decide to change this scope to use date_trunc('day', collector_tstamp), + # we should remember that a btree index on collector_tstamp will be no longer effective. + scope :timerange, ->(duration, today = Time.zone.today) { + where('collector_tstamp BETWEEN ? AND ? ', today - duration + 1, today + 1) + } +end diff --git a/app/models/project.rb b/app/models/project.rb index 845e9e83e78..3aa0db56404 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -65,6 +65,7 @@ class Project < ApplicationRecord cache_markdown_field :description, pipeline: :description + default_value_for :packages_enabled, true default_value_for :archived, false default_value_for :resolve_outdated_diff_discussions, false default_value_for :container_registry_enabled, gitlab_config_features.container_registry @@ -168,6 +169,7 @@ class Project < ApplicationRecord has_one :custom_issue_tracker_service has_one :bugzilla_service has_one :gitlab_issue_tracker_service, inverse_of: :project + has_one :confluence_service has_one :external_wiki_service has_one :prometheus_service, inverse_of: :project has_one :mock_ci_service @@ -190,6 +192,10 @@ class Project < ApplicationRecord has_many :forks, through: :forked_to_members, source: :project, inverse_of: :forked_from_project has_many :fork_network_projects, through: :fork_network, source: :projects + # Packages + has_many :packages, class_name: 'Packages::Package' + has_many :package_files, through: :packages, class_name: 'Packages::PackageFile' + has_one :import_state, autosave: true, class_name: 'ProjectImportState', inverse_of: :project has_one :import_export_upload, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :export_jobs, class_name: 'ProjectExportJob' @@ -200,6 +206,7 @@ class Project < ApplicationRecord has_one :grafana_integration, inverse_of: :project has_one :project_setting, inverse_of: :project, autosave: true has_one :alerting_setting, inverse_of: :project, class_name: 'Alerting::ProjectAlertingSetting' + has_one :service_desk_setting, class_name: 'ServiceDeskSetting' # Merge Requests for target project should be removed with it has_many :merge_requests, foreign_key: 'target_project_id', inverse_of: :target_project @@ -363,6 +370,7 @@ class Project < ApplicationRecord to: :project_setting, allow_nil: true delegate :scheduled?, :started?, :in_progress?, :failed?, :finished?, prefix: :import, to: :import_state, allow_nil: true + delegate :squash_always?, :squash_never?, :squash_enabled_by_default?, :squash_readonly?, to: :project_setting delegate :no_import?, to: :import_state, allow_nil: true delegate :name, to: :owner, allow_nil: true, prefix: true delegate :members, to: :team, prefix: true @@ -376,7 +384,10 @@ class Project < ApplicationRecord delegate :default_git_depth, :default_git_depth=, to: :ci_cd_settings, prefix: :ci delegate :forward_deployment_enabled, :forward_deployment_enabled=, :forward_deployment_enabled?, to: :ci_cd_settings delegate :actual_limits, :actual_plan_name, to: :namespace, allow_nil: true - delegate :allow_merge_on_skipped_pipeline, :allow_merge_on_skipped_pipeline?, :allow_merge_on_skipped_pipeline=, to: :project_setting + delegate :allow_merge_on_skipped_pipeline, :allow_merge_on_skipped_pipeline?, + :allow_merge_on_skipped_pipeline=, :has_confluence?, + to: :project_setting + delegate :active?, to: :prometheus_service, allow_nil: true, prefix: true # Validations validates :creator, presence: true, on: :create @@ -439,6 +450,7 @@ class Project < ApplicationRecord # Sometimes queries (e.g. using CTEs) require explicit disambiguation with table name scope :projects_order_id_desc, -> { reorder(self.arel_table['id'].desc) } + scope :with_packages, -> { joins(:packages) } scope :in_namespace, ->(namespace_ids) { where(namespace_id: namespace_ids) } scope :personal, ->(user) { where(namespace_id: user.namespace_id) } scope :joined, ->(user) { where('namespace_id != ?', user.namespace_id) } @@ -454,6 +466,7 @@ class Project < ApplicationRecord scope :with_statistics, -> { includes(:statistics) } scope :with_namespace, -> { includes(:namespace) } scope :with_import_state, -> { includes(:import_state) } + scope :include_project_feature, -> { includes(:project_feature) } scope :with_service, ->(service) { joins(service).eager_load(service) } scope :with_shared_runners, -> { where(shared_runners_enabled: true) } scope :with_container_registry, -> { where(container_registry_enabled: true) } @@ -488,6 +501,7 @@ class Project < ApplicationRecord .where(repository_languages: { programming_language_id: lang_id_query }) end + scope :service_desk_enabled, -> { where(service_desk_enabled: true) } scope :with_builds_enabled, -> { with_feature_enabled(:builds) } scope :with_issues_enabled, -> { with_feature_enabled(:issues) } scope :with_issues_available_for_user, ->(current_user) { with_feature_available_for_user(:issues, current_user) } @@ -513,9 +527,8 @@ class Project < ApplicationRecord .where(project_pages_metadata: { project_id: nil }) end - scope :with_api_entity_associations, -> { - preload(:project_feature, :route, :tags, - group: :ip_restrictions, namespace: [:route, :owner]) + scope :with_api_commit_entity_associations, -> { + preload(:project_feature, :route, namespace: [:route, :owner]) } enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 } @@ -532,6 +545,10 @@ class Project < ApplicationRecord # Used by Projects::CleanupService to hold a map of rewritten object IDs mount_uploader :bfg_object_map, AttachmentUploader + def self.with_api_entity_associations + preload(:project_feature, :route, :tags, :group, namespace: [:route, :owner]) + end + def self.with_web_entity_associations preload(:project_feature, :route, :creator, :group, namespace: [:route, :owner]) end @@ -589,6 +606,14 @@ class Project < ApplicationRecord end end + def self.projects_user_can(projects, user, action) + projects = where(id: projects) + + DeclarativePolicy.user_scope do + projects.select { |project| Ability.allowed?(user, action, project) } + end + end + # This scope returns projects where user has access to both the project and the feature. def self.filter_by_feature_visibility(feature, user) with_feature_available_for_user(feature, user) @@ -675,10 +700,11 @@ class Project < ApplicationRecord # '>' or its escaped form ('>') are checked for because '>' is sometimes escaped # when the reference comes from an external source. def markdown_reference_pattern - %r{ - #{reference_pattern} - (#{reference_postfix}|#{reference_postfix_escaped}) - }x + @markdown_reference_pattern ||= + %r{ + #{reference_pattern} + (#{reference_postfix}|#{reference_postfix_escaped}) + }x end def trending @@ -706,6 +732,12 @@ class Project < ApplicationRecord from_union([with_issues_enabled, with_merge_requests_enabled]).select(:id) end + + def find_by_service_desk_project_key(key) + # project_key is not indexed for now + # see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/24063#note_282435524 for details + joins(:service_desk_setting).find_by('service_desk_settings.project_key' => key) + end end def initialize(attributes = nil) @@ -839,6 +871,15 @@ class Project < ApplicationRecord end end + # Because we use default_value_for we need to be sure + # packages_enabled= method does exist even if we rollback migration. + # Otherwise many tests from spec/migrations will fail. + def packages_enabled=(value) + if has_attribute?(:packages_enabled) + write_attribute(:packages_enabled, value) + end + end + def cleanup @repository = nil end @@ -1699,7 +1740,7 @@ class Project < ApplicationRecord url_path = full_path.partition('/').last # If the project path is the same as host, we serve it as group page - return url if url == "#{Settings.pages.protocol}://#{url_path}" + return url if url == "#{Settings.pages.protocol}://#{url_path}".downcase "#{url}/#{url_path}" end @@ -1795,6 +1836,7 @@ class Project < ApplicationRecord after_create_default_branch join_pool_repository refresh_markdown_cache! + write_repository_config end def update_project_counter_caches @@ -1922,6 +1964,7 @@ class Project < ApplicationRecord .append(key: 'CI_PROJECT_PATH', value: full_path) .append(key: 'CI_PROJECT_PATH_SLUG', value: full_path_slug) .append(key: 'CI_PROJECT_NAMESPACE', value: namespace.full_path) + .append(key: 'CI_PROJECT_ROOT_NAMESPACE', value: namespace.root_ancestor.path) .append(key: 'CI_PROJECT_URL', value: web_url) .append(key: 'CI_PROJECT_VISIBILITY', value: Gitlab::VisibilityLevel.string_level(visibility_level)) .append(key: 'CI_PROJECT_REPOSITORY_LANGUAGES', value: repository_languages.map(&:name).join(',').downcase) @@ -2131,7 +2174,13 @@ class Project < ApplicationRecord # rubocop: disable CodeReuse/ServiceClass def forks_count - Projects::ForksCountService.new(self).count + BatchLoader.for(self).batch do |projects, loader| + fork_count_per_project = ::Projects::BatchForksCountService.new(projects).refresh_cache_and_retrieve_data + + fork_count_per_project.each do |project, count| + loader.call(project, count) + end + end end # rubocop: enable CodeReuse/ServiceClass @@ -2410,6 +2459,37 @@ class Project < ApplicationRecord super || build_metrics_setting end + def service_desk_enabled + Gitlab::ServiceDesk.enabled?(project: self) + end + + alias_method :service_desk_enabled?, :service_desk_enabled + + def service_desk_address + return unless service_desk_enabled? + + config = Gitlab.config.incoming_email + wildcard = Gitlab::IncomingEmail::WILDCARD_PLACEHOLDER + + config.address&.gsub(wildcard, "#{full_path_slug}-#{id}-issue-") + end + + def root_namespace + if namespace.has_parent? + namespace.root_ancestor + else + namespace + end + end + + def package_already_taken?(package_name) + namespace.root_ancestor.all_projects + .joins(:packages) + .where.not(id: id) + .merge(Packages::Package.with_name(package_name)) + .exists? + end + private def find_service(services, name) diff --git a/app/models/project_services/alerts_service.rb b/app/models/project_services/alerts_service.rb index 58c47accfd1..28902114f3c 100644 --- a/app/models/project_services/alerts_service.rb +++ b/app/models/project_services/alerts_service.rb @@ -78,3 +78,5 @@ class AlertsService < Service Gitlab::Routing.url_helpers end end + +AlertsService.prepend_if_ee('EE::AlertsService') diff --git a/app/models/project_services/bugzilla_service.rb b/app/models/project_services/bugzilla_service.rb index 0a498fde95a..4332db3e961 100644 --- a/app/models/project_services/bugzilla_service.rb +++ b/app/models/project_services/bugzilla_service.rb @@ -3,11 +3,11 @@ class BugzillaService < IssueTrackerService validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? - def default_title + def title 'Bugzilla' end - def default_description + def description s_('IssueTracker|Bugzilla issue tracker') end diff --git a/app/models/project_services/confluence_service.rb b/app/models/project_services/confluence_service.rb new file mode 100644 index 00000000000..dd44a0d1d56 --- /dev/null +++ b/app/models/project_services/confluence_service.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +class ConfluenceService < Service + include ActionView::Helpers::UrlHelper + + VALID_SCHEME_MATCH = %r{\Ahttps?\Z}.freeze + VALID_HOST_MATCH = %r{\A.+\.atlassian\.net\Z}.freeze + VALID_PATH_MATCH = %r{\A/wiki(/|\Z)}.freeze + + prop_accessor :confluence_url + + validates :confluence_url, presence: true, if: :activated? + validate :validate_confluence_url_is_cloud, if: :activated? + + after_commit :cache_project_has_confluence + + def self.to_param + 'confluence' + end + + def self.supported_events + %w() + end + + def title + s_('ConfluenceService|Confluence Workspace') + end + + def description + s_('ConfluenceService|Connect a Confluence Cloud Workspace to your GitLab project') + end + + def detailed_description + return unless project.wiki_enabled? + + if activated? + wiki_url = project.wiki.web_url + + s_( + 'ConfluenceService|Your GitLab Wiki can be accessed here: %{wiki_link}. To re-enable your GitLab Wiki, disable this integration' % + { wiki_link: link_to(wiki_url, wiki_url) } + ).html_safe + else + s_('ConfluenceService|Enabling the Confluence Workspace will disable the default GitLab Wiki. Your GitLab Wiki data will be saved and you can always re-enable it later by turning off this integration').html_safe + end + end + + def fields + [ + { + type: 'text', + name: 'confluence_url', + title: 'Confluence Cloud Workspace URL', + placeholder: s_('ConfluenceService|The URL of the Confluence Workspace'), + required: true + } + ] + end + + def can_test? + false + end + + private + + def validate_confluence_url_is_cloud + unless confluence_uri_valid? + errors.add(:confluence_url, 'URL must be to a Confluence Cloud Workspace hosted on atlassian.net') + end + end + + def confluence_uri_valid? + return false unless confluence_url + + uri = URI.parse(confluence_url) + + (uri.scheme&.match(VALID_SCHEME_MATCH) && + uri.host&.match(VALID_HOST_MATCH) && + uri.path&.match(VALID_PATH_MATCH)).present? + + rescue URI::InvalidURIError + false + end + + def cache_project_has_confluence + return unless project && !project.destroyed? + + project.project_setting.save! unless project.project_setting.persisted? + project.project_setting.update_column(:has_confluence, active?) + end +end diff --git a/app/models/project_services/custom_issue_tracker_service.rb b/app/models/project_services/custom_issue_tracker_service.rb index dbc42b1b86d..fc58ba27c3d 100644 --- a/app/models/project_services/custom_issue_tracker_service.rb +++ b/app/models/project_services/custom_issue_tracker_service.rb @@ -3,11 +3,11 @@ class CustomIssueTrackerService < IssueTrackerService validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? - def default_title + def title 'Custom Issue Tracker' end - def default_description + def description s_('IssueTracker|Custom issue tracker') end @@ -17,8 +17,6 @@ class CustomIssueTrackerService < IssueTrackerService def fields [ - { type: 'text', name: 'title', placeholder: title }, - { type: 'text', name: 'description', placeholder: description }, { type: 'text', name: 'project_url', placeholder: 'Project url', required: true }, { type: 'text', name: 'issues_url', placeholder: 'Issue url', required: true }, { type: 'text', name: 'new_issue_url', placeholder: 'New Issue url', required: true } diff --git a/app/models/project_services/gitlab_issue_tracker_service.rb b/app/models/project_services/gitlab_issue_tracker_service.rb index ec28602b5e6..b3f44e040bc 100644 --- a/app/models/project_services/gitlab_issue_tracker_service.rb +++ b/app/models/project_services/gitlab_issue_tracker_service.rb @@ -7,11 +7,11 @@ class GitlabIssueTrackerService < IssueTrackerService default_value_for :default, true - def default_title + def title 'GitLab' end - def default_description + def description s_('IssueTracker|GitLab issue tracker') end diff --git a/app/models/project_services/issue_tracker_service.rb b/app/models/project_services/issue_tracker_service.rb index f5d6ae10469..694374e9548 100644 --- a/app/models/project_services/issue_tracker_service.rb +++ b/app/models/project_services/issue_tracker_service.rb @@ -25,28 +25,6 @@ class IssueTrackerService < Service end end - # this will be removed as part of https://gitlab.com/gitlab-org/gitlab/issues/29404 - def title - if title_attribute = read_attribute(:title) - title_attribute - elsif self.properties && self.properties['title'].present? - self.properties['title'] - else - default_title - end - end - - # this will be removed as part of https://gitlab.com/gitlab-org/gitlab/issues/29404 - def description - if description_attribute = read_attribute(:description) - description_attribute - elsif self.properties && self.properties['description'].present? - self.properties['description'] - else - default_description - end - end - def handle_properties # this has been moved from initialize_properties and should be improved # as part of https://gitlab.com/gitlab-org/gitlab/issues/29404 @@ -54,13 +32,6 @@ class IssueTrackerService < Service @legacy_properties_data = properties.dup data_values = properties.slice!('title', 'description') - properties.each do |key, _| - current_value = self.properties.delete(key) - value = attribute_changed?(key) ? attribute_change(key).last : current_value - - write_attribute(key, value) - end - data_values.reject! { |key| data_fields.changed.include?(key) } data_values.slice!(*data_fields.attributes.keys) data_fields.assign_attributes(data_values) if data_values.present? @@ -102,7 +73,6 @@ class IssueTrackerService < Service def fields [ - { type: 'text', name: 'description', placeholder: description }, { type: 'text', name: 'project_url', placeholder: 'Project url', required: true }, { type: 'text', name: 'issues_url', placeholder: 'Issue url', required: true }, { type: 'text', name: 'new_issue_url', placeholder: 'New Issue url', required: true } @@ -117,8 +87,6 @@ class IssueTrackerService < Service def set_default_data return unless issues_tracker.present? - self.title ||= issues_tracker['title'] - # we don't want to override if we have set something return if project_url || issues_url || new_issue_url diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index bb4d35cad22..4ea2ec10f11 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -23,7 +23,7 @@ class JiraService < IssueTrackerService # TODO: we can probably just delegate as part of # https://gitlab.com/gitlab-org/gitlab/issues/29404 - data_field :username, :password, :url, :api_url, :jira_issue_transition_id + data_field :username, :password, :url, :api_url, :jira_issue_transition_id, :project_key, :issues_enabled before_update :reset_password @@ -64,8 +64,6 @@ class JiraService < IssueTrackerService def set_default_data return unless issues_tracker.present? - self.title ||= issues_tracker['title'] - return if url data_fields.url ||= issues_tracker['url'] @@ -103,11 +101,11 @@ class JiraService < IssueTrackerService [Jira service documentation](#{help_page_url('user/project/integrations/jira')})." end - def default_title + def title 'Jira' end - def default_description + def description s_('JiraService|Jira issue tracker') end @@ -130,7 +128,7 @@ class JiraService < IssueTrackerService end def new_issue_url - "#{url}/secure/CreateIssue.jspa" + "#{url}/secure/CreateIssue!default.jspa" end alias_method :original_url, :url @@ -442,3 +440,5 @@ class JiraService < IssueTrackerService end end end + +JiraService.prepend_if_ee('EE::JiraService') diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb index 44a41969b1c..997c6eba91a 100644 --- a/app/models/project_services/prometheus_service.rb +++ b/app/models/project_services/prometheus_service.rb @@ -28,6 +28,9 @@ class PrometheusService < MonitoringService after_create_commit :create_default_alerts + scope :preload_project, -> { preload(:project) } + scope :with_clusters_with_cilium, -> { joins(project: [:clusters]).merge(Clusters::Cluster.with_available_cilium) } + def initialize_properties if properties.nil? self.properties = {} @@ -51,7 +54,7 @@ class PrometheusService < MonitoringService end def fields - result = [ + [ { type: 'checkbox', name: 'manual_configuration', @@ -64,30 +67,23 @@ class PrometheusService < MonitoringService title: 'API URL', placeholder: s_('PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/'), required: true + }, + { + type: 'text', + name: 'google_iap_audience_client_id', + title: 'Google IAP Audience Client ID', + placeholder: s_('PrometheusService|Client ID of the IAP secured resource (looks like IAP_CLIENT_ID.apps.googleusercontent.com)'), + autocomplete: 'off', + required: false + }, + { + type: 'textarea', + name: 'google_iap_service_account_json', + title: 'Google IAP Service Account JSON', + placeholder: s_('PrometheusService|Contents of the credentials.json file of your service account, like: { "type": "service_account", "project_id": ... }'), + required: false } ] - - if Feature.enabled?(:prometheus_service_iap_auth) - result += [ - { - type: 'text', - name: 'google_iap_audience_client_id', - title: 'Google IAP Audience Client ID', - placeholder: s_('PrometheusService|Client ID of the IAP secured resource (looks like IAP_CLIENT_ID.apps.googleusercontent.com)'), - autocomplete: 'off', - required: false - }, - { - type: 'textarea', - name: 'google_iap_service_account_json', - title: 'Google IAP Service Account JSON', - placeholder: s_('PrometheusService|Contents of the credentials.json file of your service account, like: { "type": "service_account", "project_id": ... }'), - required: false - } - ] - end - - result end # Check we can connect to the Prometheus API @@ -103,7 +99,7 @@ class PrometheusService < MonitoringService options = { allow_local_requests: allow_local_api_url? } - if Feature.enabled?(:prometheus_service_iap_auth) && behind_iap? + if behind_iap? # Adds the Authorization header options[:headers] = iap_client.apply({}) end diff --git a/app/models/project_services/redmine_service.rb b/app/models/project_services/redmine_service.rb index a4ca0d20669..df78520d65f 100644 --- a/app/models/project_services/redmine_service.rb +++ b/app/models/project_services/redmine_service.rb @@ -3,11 +3,11 @@ class RedmineService < IssueTrackerService validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? - def default_title + def title 'Redmine' end - def default_description + def description s_('IssueTracker|Redmine issue tracker') end diff --git a/app/models/project_services/youtrack_service.rb b/app/models/project_services/youtrack_service.rb index 40203ad692d..7fb3bde44a5 100644 --- a/app/models/project_services/youtrack_service.rb +++ b/app/models/project_services/youtrack_service.rb @@ -12,11 +12,11 @@ class YoutrackService < IssueTrackerService end end - def default_title + def title 'YouTrack' end - def default_description + def description s_('IssueTracker|YouTrack issue tracker') end @@ -26,7 +26,6 @@ class YoutrackService < IssueTrackerService def fields [ - { type: 'text', name: 'description', placeholder: description }, { type: 'text', name: 'project_url', title: 'Project URL', placeholder: 'Project URL', required: true }, { type: 'text', name: 'issues_url', title: 'Issue URL', placeholder: 'Issue URL', required: true } ] diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb index 9022d3e879d..aca7eec3382 100644 --- a/app/models/project_setting.rb +++ b/app/models/project_setting.rb @@ -3,7 +3,22 @@ class ProjectSetting < ApplicationRecord belongs_to :project, inverse_of: :project_setting + enum squash_option: { + never: 0, + always: 1, + default_on: 2, + default_off: 3 + }, _prefix: 'squash' + self.primary_key = :project_id + + def squash_enabled_by_default? + %w[always default_on].include?(squash_option) + end + + def squash_readonly? + %w[always never].include?(squash_option) + end end ProjectSetting.prepend_if_ee('EE::ProjectSetting') diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb index 6f04a36392d..f153bfe3f5b 100644 --- a/app/models/project_statistics.rb +++ b/app/models/project_statistics.rb @@ -7,16 +7,12 @@ class ProjectStatistics < ApplicationRecord belongs_to :namespace default_value_for :wiki_size, 0 - - # older migrations fail due to non-existent attribute without this - def wiki_size - has_attribute?(:wiki_size) ? super : 0 - end + default_value_for :snippets_size, 0 before_save :update_storage_size - COLUMNS_TO_REFRESH = [:repository_size, :wiki_size, :lfs_objects_size, :commit_count].freeze - INCREMENTABLE_COLUMNS = { build_artifacts_size: %i[storage_size], packages_size: %i[storage_size] }.freeze + COLUMNS_TO_REFRESH = [:repository_size, :wiki_size, :lfs_objects_size, :commit_count, :snippets_size].freeze + INCREMENTABLE_COLUMNS = { build_artifacts_size: %i[storage_size], packages_size: %i[storage_size], snippets_size: %i[storage_size] }.freeze NAMESPACE_RELATABLE_COLUMNS = [:repository_size, :wiki_size, :lfs_objects_size].freeze scope :for_project_ids, ->(project_ids) { where(project_id: project_ids) } @@ -54,17 +50,37 @@ class ProjectStatistics < ApplicationRecord self.wiki_size = project.wiki.repository.size * 1.megabyte end + def update_snippets_size + self.snippets_size = project.snippets.with_statistics.sum(:repository_size) + end + def update_lfs_objects_size self.lfs_objects_size = project.lfs_objects.sum(:size) end - # older migrations fail due to non-existent attribute without this - def packages_size - has_attribute?(:packages_size) ? super : 0 + # `wiki_size` and `snippets_size` have no default value in the database + # and the column can be nil. + # This means that, when the columns were added, all rows had nil + # values on them. + # Therefore, any call to any of those methods will return nil instead + # of 0, because `default_value_for` works with new records, not existing ones. + # + # These two methods provide consistency and avoid returning nil. + def wiki_size + super.to_i + end + + def snippets_size + super.to_i end def update_storage_size - self.storage_size = repository_size + wiki_size.to_i + lfs_objects_size + build_artifacts_size + packages_size + storage_size = repository_size + wiki_size + lfs_objects_size + build_artifacts_size + packages_size + # The `snippets_size` column was added on 20200622095419 but db/post_migrate/20190527194900_schedule_calculate_wiki_sizes.rb + # might try to update project statistics before the `snippets_size` column has been created. + storage_size += snippets_size if self.class.column_names.include?('snippets_size') + + self.storage_size = storage_size end # Since this incremental update method does not call update_storage_size above, diff --git a/app/models/prometheus_alert.rb b/app/models/prometheus_alert.rb index fbc0281296f..32f9809e538 100644 --- a/app/models/prometheus_alert.rb +++ b/app/models/prometheus_alert.rb @@ -16,6 +16,7 @@ class PrometheusAlert < ApplicationRecord has_many :prometheus_alert_events, inverse_of: :prometheus_alert has_many :related_issues, through: :prometheus_alert_events + has_many :alert_management_alerts, class_name: 'AlertManagement::Alert', inverse_of: :prometheus_alert after_save :clear_prometheus_adapter_cache! after_destroy :clear_prometheus_adapter_cache! diff --git a/app/models/prometheus_metric.rb b/app/models/prometheus_metric.rb index 571b586056b..bfd23d2a334 100644 --- a/app/models/prometheus_metric.rb +++ b/app/models/prometheus_metric.rb @@ -11,6 +11,7 @@ class PrometheusMetric < ApplicationRecord validates :group, presence: true validates :y_label, presence: true validates :unit, presence: true + validates :identifier, uniqueness: { scope: :project_id }, allow_nil: true validates :project, presence: true, unless: :common? validates :project, absence: true, if: :common? diff --git a/app/models/repository.rb b/app/models/repository.rb index 911cfc7db38..48e96d4c193 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -149,7 +149,8 @@ class Repository before: opts[:before], all: !!opts[:all], first_parent: !!opts[:first_parent], - order: opts[:order] + order: opts[:order], + literal_pathspec: opts.fetch(:literal_pathspec, true) } commits = Gitlab::Git::Commit.where(options) @@ -676,24 +677,24 @@ class Repository end end - def list_last_commits_for_tree(sha, path, offset: 0, limit: 25) - commits = raw_repository.list_last_commits_for_tree(sha, path, offset: offset, limit: limit) + def list_last_commits_for_tree(sha, path, offset: 0, limit: 25, literal_pathspec: false) + commits = raw_repository.list_last_commits_for_tree(sha, path, offset: offset, limit: limit, literal_pathspec: literal_pathspec) commits.each do |path, commit| commits[path] = ::Commit.new(commit, container) end end - def last_commit_for_path(sha, path) - commit = raw_repository.last_commit_for_path(sha, path) + def last_commit_for_path(sha, path, literal_pathspec: false) + commit = raw_repository.last_commit_for_path(sha, path, literal_pathspec: literal_pathspec) ::Commit.new(commit, container) if commit end - def last_commit_id_for_path(sha, path) + def last_commit_id_for_path(sha, path, literal_pathspec: false) key = path.blank? ? "last_commit_id_for_path:#{sha}" : "last_commit_id_for_path:#{sha}:#{Digest::SHA1.hexdigest(path)}" cache.fetch(key) do - last_commit_for_path(sha, path)&.id + last_commit_for_path(sha, path, literal_pathspec: literal_pathspec)&.id end end @@ -712,8 +713,8 @@ class Repository "#{name}-#{highest_branch_id + 1}" end - def branches_sorted_by(value) - raw_repository.local_branches(sort_by: value) + def branches_sorted_by(sort_by, pagination_params = nil) + raw_repository.local_branches(sort_by: sort_by, pagination_params: pagination_params) end def tags_sorted_by(value) @@ -1113,7 +1114,7 @@ class Repository def project if repo_type.snippet? container.project - else + elsif container.is_a?(Project) container end end diff --git a/app/models/resource_event.rb b/app/models/resource_event.rb index 86e11c2d568..26dcda2630a 100644 --- a/app/models/resource_event.rb +++ b/app/models/resource_event.rb @@ -11,6 +11,7 @@ class ResourceEvent < ApplicationRecord belongs_to :user scope :created_after, ->(time) { where('created_at > ?', time) } + scope :created_on_or_before, ->(time) { where('created_at <= ?', time) } def discussion_id strong_memoize(:discussion_id) do diff --git a/app/models/resource_state_event.rb b/app/models/resource_state_event.rb index 1d6573b180f..766b4d7a865 100644 --- a/app/models/resource_state_event.rb +++ b/app/models/resource_state_event.rb @@ -6,10 +6,16 @@ class ResourceStateEvent < ResourceEvent validate :exactly_one_issuable + belongs_to :source_merge_request, class_name: 'MergeRequest', foreign_key: :source_merge_request_id + # state is used for issue and merge request states. enum state: Issue.available_states.merge(MergeRequest.available_states).merge(reopened: 5) def self.issuable_attrs %i(issue merge_request).freeze end + + def issuable + issue || merge_request + end end diff --git a/app/models/service.rb b/app/models/service.rb index 2880526c9de..89bde61bfe1 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -7,9 +7,12 @@ class Service < ApplicationRecord include Importable include ProjectServicesLoggable include DataFields + include IgnorableColumns + + ignore_columns %i[title description], remove_with: '13.4', remove_after: '2020-09-22' SERVICE_NAMES = %w[ - alerts asana assembla bamboo bugzilla buildkite campfire custom_issue_tracker discord + alerts asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker discord drone_ci emails_on_push external_wiki flowdock hangouts_chat hipchat irker jira mattermost mattermost_slash_commands microsoft_teams packagist pipelines_email pivotaltracker prometheus pushover redmine slack slack_slash_commands teamcity unify_circuit webex_teams youtrack @@ -357,6 +360,14 @@ class Service < ApplicationRecord service end + def self.instance_exists_for?(type) + exists?(instance: true, type: type) + end + + def self.instance_for(type) + find_by(instance: true, type: type) + end + # override if needed def supports_data_fields? false @@ -381,30 +392,7 @@ class Service < ApplicationRecord end def self.event_description(event) - case event - when "push", "push_events" - "Event will be triggered by a push to the repository" - when "tag_push", "tag_push_events" - "Event will be triggered when a new tag is pushed to the repository" - when "note", "note_events" - "Event will be triggered when someone adds a comment" - when "issue", "issue_events" - "Event will be triggered when an issue is created/updated/closed" - when "confidential_issue", "confidential_issue_events" - "Event will be triggered when a confidential issue is created/updated/closed" - when "merge_request", "merge_request_events" - "Event will be triggered when a merge request is created/updated/merged" - when "pipeline", "pipeline_events" - "Event will be triggered when a pipeline status changes" - when "wiki_page", "wiki_page_events" - "Event will be triggered when a wiki page is created/updated" - when "commit", "commit_events" - "Event will be triggered when a commit is created/updated" - when "deployment" - "Event will be triggered when a deployment finishes" - when "alert" - "Event will be triggered when a new, unique alert is recorded" - end + ServicesHelper.service_event_description(event) end def valid_recipients? diff --git a/app/models/service_desk_setting.rb b/app/models/service_desk_setting.rb new file mode 100644 index 00000000000..bcc17d32272 --- /dev/null +++ b/app/models/service_desk_setting.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class ServiceDeskSetting < ApplicationRecord + include Gitlab::Utils::StrongMemoize + + belongs_to :project + validates :project_id, presence: true + validate :valid_issue_template + validates :outgoing_name, length: { maximum: 255 }, allow_blank: true + validates :project_key, length: { maximum: 255 }, allow_blank: true, format: { with: /\A[a-z0-9_]+\z/ } + + def issue_template_content + strong_memoize(:issue_template_content) do + next unless issue_template_key.present? + + Gitlab::Template::IssueTemplate.find(issue_template_key, project).content + rescue ::Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError + end + end + + def issue_template_missing? + issue_template_key.present? && !issue_template_content.present? + end + + def valid_issue_template + if issue_template_missing? + errors.add(:issue_template_key, 'is empty or does not exist') + end + end +end diff --git a/app/models/snippet.rb b/app/models/snippet.rb index b63ab003711..eb3960ff12b 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -45,6 +45,9 @@ class Snippet < ApplicationRecord has_many :user_mentions, class_name: "SnippetUserMention", dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_one :snippet_repository, inverse_of: :snippet + # We need to add the `dependent` in order to call the after_destroy callback + has_one :statistics, class_name: 'SnippetStatistics', dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + delegate :name, :email, to: :author, prefix: true, allow_nil: true validates :author, presence: true @@ -68,6 +71,7 @@ class Snippet < ApplicationRecord validates :visibility_level, inclusion: { in: Gitlab::VisibilityLevel.values } after_save :store_mentions!, if: :any_mentionable_attributes_changed? + after_create :create_statistics # Scopes scope :are_internal, -> { where(visibility_level: Snippet::INTERNAL) } @@ -77,6 +81,7 @@ class Snippet < ApplicationRecord scope :fresh, -> { order("created_at DESC") } scope :inc_author, -> { includes(:author) } scope :inc_relations_for_view, -> { includes(author: :status) } + scope :with_statistics, -> { joins(:statistics) } attr_mentionable :description @@ -331,7 +336,13 @@ class Snippet < ApplicationRecord def file_name_on_repo return if repository.empty? - repository.ls_files(repository.root_ref).first + list_files(repository.root_ref).first + end + + def list_files(ref = nil) + return [] if repository.empty? + + repository.ls_files(ref) end class << self diff --git a/app/models/snippet_input_action.rb b/app/models/snippet_input_action.rb index 7f4ab775ab0..cc6373264cc 100644 --- a/app/models/snippet_input_action.rb +++ b/app/models/snippet_input_action.rb @@ -15,9 +15,10 @@ class SnippetInputAction validates :action, inclusion: { in: ACTIONS, message: "%{value} is not a valid action" } validates :previous_path, presence: true, if: :move_action? - validates :file_path, presence: true + validates :file_path, presence: true, unless: :create_action? validates :content, presence: true, if: -> (action) { action.create_action? || action.update_action? } validate :ensure_same_file_path_and_previous_path, if: :update_action? + validate :ensure_different_file_path_and_previous_path, if: :move_action? validate :ensure_allowed_action def initialize(action: nil, previous_path: nil, file_path: nil, content: nil, allowed_actions: nil) @@ -52,6 +53,12 @@ class SnippetInputAction errors.add(:file_path, "can't be different from the previous_path attribute") end + def ensure_different_file_path_and_previous_path + return if previous_path != file_path + + errors.add(:file_path, 'must be different from the previous_path attribute') + end + def ensure_allowed_action return if @allowed_actions.empty? diff --git a/app/models/snippet_statistics.rb b/app/models/snippet_statistics.rb new file mode 100644 index 00000000000..7439f98d114 --- /dev/null +++ b/app/models/snippet_statistics.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +class SnippetStatistics < ApplicationRecord + include AfterCommitQueue + include UpdateProjectStatistics + + belongs_to :snippet + + validates :snippet, presence: true + + update_project_statistics project_statistics_name: :snippets_size, statistic_attribute: :repository_size + + delegate :repository, :project, :project_id, to: :snippet + + after_save :update_author_root_storage_statistics, if: :update_author_root_storage_statistics? + after_destroy :update_author_root_storage_statistics, unless: :project_snippet? + + def update_commit_count + self.commit_count = repository.commit_count + end + + def update_repository_size + self.repository_size = repository.size.megabytes + end + + def update_file_count + count = if snippet.repository_exists? + repository.ls_files(repository.root_ref).size + else + 0 + end + + self.file_count = count + end + + def refresh! + update_commit_count + update_repository_size + update_file_count + + save! + end + + private + + alias_method :original_update_project_statistics_after_save?, :update_project_statistics_after_save? + def update_project_statistics_after_save? + project_snippet? && original_update_project_statistics_after_save? + end + + alias_method :original_update_project_statistics_after_destroy?, :update_project_statistics_after_destroy? + def update_project_statistics_after_destroy? + project_snippet? && original_update_project_statistics_after_destroy? + end + + def update_author_root_storage_statistics? + !project_snippet? && saved_change_to_repository_size? + end + + def update_author_root_storage_statistics + run_after_commit do + Namespaces::ScheduleAggregationWorker.perform_async(snippet.author.namespace_id) + end + end + + def project_snippet? + snippet.is_a?(ProjectSnippet) + end +end diff --git a/app/models/state_note.rb b/app/models/state_note.rb index cbcb1c2b49d..5e35f15aac4 100644 --- a/app/models/state_note.rb +++ b/app/models/state_note.rb @@ -1,19 +1,47 @@ # frozen_string_literal: true class StateNote < SyntheticNote + include Gitlab::Utils::StrongMemoize + def self.from_event(event, resource: nil, resource_parent: nil) - attrs = note_attributes(event.state, event, resource, resource_parent) + attrs = note_attributes(action_by(event), event, resource, resource_parent) StateNote.new(attrs) end def note_html - @note_html ||= "<p dir=\"auto\">#{note_text(html: true)}</p>" + @note_html ||= Banzai::Renderer.cacheless_render_field(self, :note, { group: group, project: project }) end private def note_text(html: false) - event.state + if event.state == 'closed' + if event.close_after_error_tracking_resolve + return 'resolved the corresponding error and closed the issue.' + end + + if event.close_auto_resolve_prometheus_alert + return 'automatically closed this issue because the alert resolved.' + end + end + + body = event.state.dup + body << " via #{event_source.gfm_reference(project)}" if event_source + body + end + + def event_source + strong_memoize(:event_source) do + if event.source_commit + project&.commit(event.source_commit) + else + event.source_merge_request + end + end + end + + def self.action_by(event) + event.state == 'reopened' ? 'opened' : event.state end end diff --git a/app/models/suggestion.rb b/app/models/suggestion.rb index 96ffec90c00..94f3a140098 100644 --- a/app/models/suggestion.rb +++ b/app/models/suggestion.rb @@ -38,11 +38,18 @@ class Suggestion < ApplicationRecord end def appliable?(cached: true) - !applied? && - noteable.opened? && - !outdated?(cached: cached) && - different_content? && - note.active? + inapplicable_reason(cached: cached).nil? + end + + def inapplicable_reason(cached: true) + strong_memoize("inapplicable_reason_#{cached}") do + next :applied if applied? + next :merge_request_merged if noteable.merged? + next :merge_request_closed if noteable.closed? + next :source_branch_deleted unless noteable.source_branch_exists? + next :outdated if outdated?(cached: cached) || !note.active? + next :same_content unless different_content? + end end # Overwrites outdated column @@ -53,6 +60,10 @@ class Suggestion < ApplicationRecord from_content != fetch_from_content end + def single_line? + lines_above.zero? && lines_below.zero? + end + def target_line position.new_line end diff --git a/app/models/synthetic_note.rb b/app/models/synthetic_note.rb index 3017140f871..dea7165af9f 100644 --- a/app/models/synthetic_note.rb +++ b/app/models/synthetic_note.rb @@ -3,20 +3,18 @@ class SyntheticNote < Note attr_accessor :resource_parent, :event - self.abstract_class = true - def self.note_attributes(action, event, resource, resource_parent) resource ||= event.resource attrs = { - system: true, - author: event.user, - created_at: event.created_at, - discussion_id: event.discussion_id, - noteable: resource, - event: event, - system_note_metadata: ::SystemNoteMetadata.new(action: action), - resource_parent: resource_parent + system: true, + author: event.user, + created_at: event.created_at, + discussion_id: event.discussion_id, + noteable: resource, + event: event, + system_note_metadata: ::SystemNoteMetadata.new(action: action), + resource_parent: resource_parent } if resource_parent.is_a?(Project) diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb index 4e14bb4e92c..b6ba96c768e 100644 --- a/app/models/system_note_metadata.rb +++ b/app/models/system_note_metadata.rb @@ -18,7 +18,8 @@ class SystemNoteMetadata < ApplicationRecord designs_added designs_modified designs_removed designs_discussion_added title time_tracking branch milestone discussion task moved opened closed merged duplicate locked unlocked outdated - tag due_date pinned_embed cherry_pick health_status + tag due_date pinned_embed cherry_pick health_status approved unapproved + status alert_issue_added ].freeze validates :note, presence: true diff --git a/app/models/todo.rb b/app/models/todo.rb index 102f36a991e..f973c1ff1d4 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -7,15 +7,16 @@ class Todo < ApplicationRecord # Time to wait for todos being removed when not visible for user anymore. # Prevents TODOs being removed by mistake, for example, removing access from a user # and giving it back again. - WAIT_FOR_DELETE = 1.hour + WAIT_FOR_DELETE = 1.hour - ASSIGNED = 1 - MENTIONED = 2 - BUILD_FAILED = 3 - MARKED = 4 - APPROVAL_REQUIRED = 5 # This is an EE-only feature - UNMERGEABLE = 6 - DIRECTLY_ADDRESSED = 7 + ASSIGNED = 1 + MENTIONED = 2 + BUILD_FAILED = 3 + MARKED = 4 + APPROVAL_REQUIRED = 5 # This is an EE-only feature + UNMERGEABLE = 6 + DIRECTLY_ADDRESSED = 7 + MERGE_TRAIN_REMOVED = 8 # This is an EE-only feature ACTION_NAMES = { ASSIGNED => :assigned, @@ -24,7 +25,8 @@ class Todo < ApplicationRecord MARKED => :marked, APPROVAL_REQUIRED => :approval_required, UNMERGEABLE => :unmergeable, - DIRECTLY_ADDRESSED => :directly_addressed + DIRECTLY_ADDRESSED => :directly_addressed, + MERGE_TRAIN_REMOVED => :merge_train_removed }.freeze belongs_to :author, class_name: "User" @@ -165,6 +167,10 @@ class Todo < ApplicationRecord action == ASSIGNED end + def merge_train_removed? + action == MERGE_TRAIN_REMOVED + end + def done? state == 'done' end diff --git a/app/models/user.rb b/app/models/user.rb index 431a5b3a5b7..643b759e6f4 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -69,7 +69,7 @@ class User < ApplicationRecord MINIMUM_INACTIVE_DAYS = 180 - ignore_column :ghost, remove_with: '13.2', remove_after: '2020-06-22' + ignore_column :bio, remove_with: '13.4', remove_after: '2020-09-22' # Override Devise::Models::Trackable#update_tracked_fields! # to limit database writes to at most once every hour @@ -163,9 +163,10 @@ class User < ApplicationRecord has_many :award_emoji, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :owner_id # rubocop:disable Cop/ActiveRecordDependent - has_many :issue_assignees + has_many :issue_assignees, inverse_of: :assignee + has_many :merge_request_assignees, inverse_of: :assignee has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue - has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest" # rubocop:disable Cop/ActiveRecordDependent + has_many :assigned_merge_requests, class_name: "MergeRequest", through: :merge_request_assignees, source: :merge_request has_many :custom_attributes, class_name: 'UserCustomAttribute' has_many :callouts, class_name: 'UserCallout' @@ -194,7 +195,6 @@ class User < ApplicationRecord validates :notification_email, devise_email: true, if: ->(user) { user.notification_email != user.email } validates :public_email, presence: true, uniqueness: true, devise_email: true, allow_blank: true validates :commit_email, devise_email: true, allow_nil: true, if: ->(user) { user.commit_email != user.email } - validates :bio, length: { maximum: 255 }, allow_blank: true validates :projects_limit, presence: true, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: Gitlab::Database::MAX_INT_VALUE } @@ -229,7 +229,6 @@ class User < ApplicationRecord before_save :check_for_verified_email, if: ->(user) { user.email_changed? && !user.new_record? } before_validation :ensure_namespace_correct before_save :ensure_namespace_correct # in case validation is skipped - before_save :ensure_bio_is_assigned_to_user_details, if: :bio_changed? after_validation :set_username_errors after_update :username_changed_hook, if: :saved_change_to_username? after_destroy :post_destroy_hook @@ -272,6 +271,7 @@ class User < ApplicationRecord :time_display_relative, :time_display_relative=, :time_format_in_24h, :time_format_in_24h=, :show_whitespace_in_diffs, :show_whitespace_in_diffs=, + :view_diffs_file_by_file, :view_diffs_file_by_file=, :tab_width, :tab_width=, :sourcegraph_enabled, :sourcegraph_enabled=, :setup_for_company, :setup_for_company=, @@ -281,6 +281,7 @@ class User < ApplicationRecord delegate :path, to: :namespace, allow_nil: true, prefix: true delegate :job_title, :job_title=, to: :user_detail, allow_nil: true + delegate :bio, :bio=, :bio_html, to: :user_detail, allow_nil: true accepts_nested_attributes_for :user_preference, update_only: true accepts_nested_attributes_for :user_detail, update_only: true @@ -619,11 +620,12 @@ class User < ApplicationRecord # Pattern used to extract `@user` user references from text def reference_pattern - %r{ - (?<!\w) - #{Regexp.escape(reference_prefix)} - (?<user>#{Gitlab::PathRegex::FULL_NAMESPACE_FORMAT_REGEX}) - }x + @reference_pattern ||= + %r{ + (?<!\w) + #{Regexp.escape(reference_prefix)} + (?<user>#{Gitlab::PathRegex::FULL_NAMESPACE_FORMAT_REGEX}) + }x end # Return (create if necessary) the ghost user. The ghost user @@ -642,6 +644,7 @@ class User < ApplicationRecord unique_internal(where(user_type: :alert_bot), 'alert-bot', email_pattern) do |u| u.bio = 'The GitLab alert bot' u.name = 'GitLab Alert Bot' + u.avatar = bot_avatar(image: 'alert-bot.png') end end @@ -655,6 +658,16 @@ class User < ApplicationRecord end end + def support_bot + email_pattern = "support%s@#{Settings.gitlab.host}" + + unique_internal(where(user_type: :support_bot), 'support-bot', email_pattern) do |u| + u.bio = 'The GitLab support bot used for Service Desk' + u.name = 'GitLab Support Bot' + u.avatar = bot_avatar(image: 'support-bot.png') + end + end + # Return true if there is only single non-internal user in the deployment, # ghost user is ignored. def single_user? @@ -1257,17 +1270,11 @@ class User < ApplicationRecord namespace.path = username if username_changed? namespace.name = name if name_changed? else - build_namespace(path: username, name: name) + namespace = build_namespace(path: username, name: name) + namespace.build_namespace_settings end end - # Temporary, will be removed when bio is fully migrated - def ensure_bio_is_assigned_to_user_details - return if Feature.disabled?(:migrate_bio_to_user_details, default_enabled: true) - - user_detail.bio = bio.to_s[0...255] # bio can be NULL in users, but cannot be NULL in user_details - end - def set_username_errors namespace_path_errors = self.errors.delete(:"namespace.path") self.errors[:username].concat(namespace_path_errors) if namespace_path_errors @@ -1692,6 +1699,10 @@ class User < ApplicationRecord impersonator.present? end + def created_recently? + created_at > Devise.confirm_within.ago + end + protected # override, from Devise::Validatable diff --git a/app/models/user_callout_enums.rb b/app/models/user_callout_enums.rb index 0a3f597ae27..226c8cd9ab5 100644 --- a/app/models/user_callout_enums.rb +++ b/app/models/user_callout_enums.rb @@ -17,7 +17,8 @@ module UserCalloutEnums suggest_popover_dismissed: 9, tabs_position_highlight: 10, webhooks_moved: 13, - admin_integrations_moved: 15 + admin_integrations_moved: 15, + personal_access_token_expiry: 21 # EE-only } end end diff --git a/app/models/user_detail.rb b/app/models/user_detail.rb index 5dc74421705..9674f9a41da 100644 --- a/app/models/user_detail.rb +++ b/app/models/user_detail.rb @@ -1,7 +1,33 @@ # frozen_string_literal: true class UserDetail < ApplicationRecord + extend ::Gitlab::Utils::Override + include CacheMarkdownField + belongs_to :user validates :job_title, length: { maximum: 200 } + validates :bio, length: { maximum: 255 }, allow_blank: true + + before_save :prevent_nil_bio + + cache_markdown_field :bio + + def bio_html + read_attribute(:bio_html) || bio + end + + # For backward compatibility. + # Older migrations (and their tests) reference the `User.migration_bot` where the `bio` attribute is set. + # Here we disable writing the markdown cache when the `bio_html` column does not exists. + override :invalidated_markdown_cache? + def invalidated_markdown_cache? + self.class.column_names.include?('bio_html') && super + end + + private + + def prevent_nil_bio + self.bio = '' if bio_changed? && bio.nil? + end end diff --git a/app/models/webauthn_registration.rb b/app/models/webauthn_registration.rb new file mode 100644 index 00000000000..76f8faa11c7 --- /dev/null +++ b/app/models/webauthn_registration.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# Registration information for WebAuthn credentials + +class WebauthnRegistration < ApplicationRecord + belongs_to :user + + validates :credential_xid, :public_key, :name, :counter, presence: true + validates :counter, + numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 2**32 - 1 } +end diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index 9e4e2f68d38..3dc90edb331 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -301,6 +301,10 @@ class WikiPage version&.commit&.committed_date end + def diffs(diff_options = {}) + Gitlab::Diff::FileCollection::WikiPage.new(self, diff_options: diff_options) + end + private def serialize_front_matter(hash) diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb index 2c26ba565ab..13d732e4edd 100644 --- a/app/policies/base_policy.rb +++ b/app/policies/base_policy.rb @@ -21,6 +21,10 @@ class BasePolicy < DeclarativePolicy::Base with_options scope: :user, score: 0 condition(:deactivated) { @user&.deactivated? } + desc "User is support bot" + with_options scope: :user, score: 0 + condition(:support_bot) { @user&.support_bot? } + desc "User email is unconfirmed or user account is locked" with_options scope: :user, score: 0 condition(:inactive) do @@ -54,6 +58,8 @@ class BasePolicy < DeclarativePolicy::Base rule { admin }.enable :read_all_resources rule { default }.enable :read_cross_project + + condition(:is_gitlab_com) { ::Gitlab.dev_env_or_com? } end BasePolicy.prepend_if_ee('EE::BasePolicy') diff --git a/app/policies/concerns/find_group_projects.rb b/app/policies/concerns/find_group_projects.rb index e2cb90079c7..aad9081bd7d 100644 --- a/app/policies/concerns/find_group_projects.rb +++ b/app/policies/concerns/find_group_projects.rb @@ -3,11 +3,11 @@ module FindGroupProjects extend ActiveSupport::Concern - def group_projects_for(user:, group:) + def group_projects_for(user:, group:, only_owned: true) GroupProjectsFinder.new( group: group, current_user: user, - options: { include_subgroups: true, only_owned: true } + options: { include_subgroups: true, only_owned: only_owned } ).execute end end diff --git a/app/policies/concerns/policy_actor.rb b/app/policies/concerns/policy_actor.rb index f910e04d015..3073a2e5d10 100644 --- a/app/policies/concerns/policy_actor.rb +++ b/app/policies/concerns/policy_actor.rb @@ -45,6 +45,10 @@ module PolicyActor false end + def support_bot? + false + end + def deactivated? false end diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb index 03f5a863421..c66f0d199b0 100644 --- a/app/policies/global_policy.rb +++ b/app/policies/global_policy.rb @@ -105,6 +105,9 @@ class GlobalPolicy < BasePolicy enable :update_custom_attribute end + # We can't use `read_statistics` because the user may have different permissions for different projects + rule { admin }.enable :use_project_statistics_filters + rule { external_user }.prevent :create_snippet end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index b1b52d62b85..62f66093875 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -42,6 +42,14 @@ class GroupPolicy < BasePolicy @subject.subgroup_creation_level == ::Gitlab::Access::MAINTAINER_SUBGROUP_ACCESS end + condition(:design_management_enabled) do + group_projects_for(user: @user, group: @subject, only_owned: false).any? { |p| p.design_management_enabled? } + end + + rule { design_management_enabled }.policy do + enable :read_design_activity + end + rule { public_group }.policy do enable :read_group enable :read_package @@ -59,6 +67,10 @@ class GroupPolicy < BasePolicy enable :update_max_artifacts_size end + rule { can?(:read_all_resources) }.policy do + enable :read_confidential_issues + end + rule { has_projects }.policy do enable :read_group end @@ -70,6 +82,10 @@ class GroupPolicy < BasePolicy enable :read_board end + rule { ~can?(:read_group) }.policy do + prevent :read_design_activity + end + rule { has_access }.enable :read_namespace rule { developer }.policy do @@ -87,6 +103,7 @@ class GroupPolicy < BasePolicy enable :admin_list enable :admin_issue enable :read_metrics_dashboard_annotation + enable :read_prometheus end rule { maintainer }.policy do diff --git a/app/policies/merge_request_policy.rb b/app/policies/merge_request_policy.rb index e2aca2a37d5..e5ac228b0ee 100644 --- a/app/policies/merge_request_policy.rb +++ b/app/policies/merge_request_policy.rb @@ -10,6 +10,10 @@ class MergeRequestPolicy < IssuablePolicy # it would not be safe to prevent :create_note there, since # note permissions are shared, and this would apply too broadly. rule { ~can?(:read_merge_request) }.prevent :create_note + + rule { can?(:update_merge_request) }.policy do + enable :approve_merge_request + end end MergeRequestPolicy.prepend_if_ee('EE::MergeRequestPolicy') diff --git a/app/policies/packages/package_policy.rb b/app/policies/packages/package_policy.rb new file mode 100644 index 00000000000..8eef280c640 --- /dev/null +++ b/app/policies/packages/package_policy.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +module Packages + class PackagePolicy < BasePolicy + delegate { @subject.project } + end +end diff --git a/app/policies/project_member_policy.rb b/app/policies/project_member_policy.rb index f2f18406bd3..ca33b95e523 100644 --- a/app/policies/project_member_policy.rb +++ b/app/policies/project_member_policy.rb @@ -5,14 +5,17 @@ class ProjectMemberPolicy < BasePolicy condition(:target_is_owner, scope: :subject) { @subject.user == @subject.project.owner } condition(:target_is_self) { @user && @subject.user == @user } + condition(:project_bot) { @subject.user&.project_bot? } rule { anonymous }.prevent_all rule { target_is_owner }.prevent_all - rule { can?(:admin_project_member) }.policy do + rule { ~project_bot & can?(:admin_project_member) }.policy do enable :update_project_member enable :destroy_project_member end + rule { project_bot & can?(:admin_project_member) }.enable :destroy_project_bot_member + rule { target_is_self }.enable :destroy_project_member end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index f87c72007ec..39b39bd2fce 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -123,6 +123,9 @@ class ProjectPolicy < BasePolicy !@subject.design_management_enabled? end + with_scope :subject + condition(:service_desk_enabled) { @subject.service_desk_enabled? } + # We aren't checking `:read_issue` or `:read_merge_request` in this case # because it could be possible for a user to see an issuable-iid # (`:read_issue_iid` or `:read_merge_request_iid`) but then wouldn't be @@ -151,6 +154,9 @@ class ProjectPolicy < BasePolicy ::Feature.enabled?(:build_service_proxy, @subject) end + with_scope :subject + condition(:packages_disabled) { !@subject.packages_enabled } + features = %w[ merge_requests issues @@ -173,6 +179,7 @@ class ProjectPolicy < BasePolicy rule { guest | admin }.enable :read_project_for_iids rule { admin }.enable :update_max_artifacts_size + rule { can?(:read_all_resources) }.enable :read_confidential_issues rule { guest }.enable :guest_access rule { reporter }.enable :reporter_access @@ -254,6 +261,8 @@ class ProjectPolicy < BasePolicy enable :read_prometheus enable :read_metrics_dashboard_annotation enable :metrics_dashboard + enable :read_confidential_issues + enable :read_package end # We define `:public_user_access` separately because there are cases in gitlab-ee @@ -290,12 +299,17 @@ class ProjectPolicy < BasePolicy enable :read_metrics_user_starred_dashboard end + rule { packages_disabled | repository_disabled }.policy do + prevent(*create_read_update_admin_destroy(:package)) + end + rule { owner | admin | guest | group_member }.prevent :request_access rule { ~request_access_enabled }.prevent :request_access rule { can?(:developer_access) & can?(:create_issue) }.enable :import_issues rule { can?(:developer_access) }.policy do + enable :create_package enable :admin_board enable :admin_merge_request enable :admin_milestone @@ -327,6 +341,7 @@ class ProjectPolicy < BasePolicy enable :update_alert_management_alert enable :create_design enable :destroy_design + enable :read_terraform_state end rule { can?(:developer_access) & user_confirmed? }.policy do @@ -336,6 +351,7 @@ class ProjectPolicy < BasePolicy end rule { can?(:maintainer_access) }.policy do + enable :destroy_package enable :admin_board enable :push_to_delete_protected_branch enable :update_snippet @@ -470,6 +486,7 @@ class ProjectPolicy < BasePolicy end rule { can?(:public_access) }.policy do + enable :read_package enable :read_project enable :read_board enable :read_list @@ -545,11 +562,13 @@ class ProjectPolicy < BasePolicy rule { can?(:read_issue) }.policy do enable :read_design + enable :read_design_activity end # Design abilities could also be prevented in the issue policy. rule { design_management_disabled }.policy do prevent :read_design + prevent :read_design_activity prevent :create_design prevent :destroy_design end @@ -576,6 +595,12 @@ class ProjectPolicy < BasePolicy enable :read_build_report_results end + rule { support_bot }.enable :guest_access + rule { support_bot & ~service_desk_enabled }.policy do + prevent :create_note + prevent :read_project + end + private def team_member? @@ -624,6 +649,7 @@ class ProjectPolicy < BasePolicy def lookup_access_level! return ::Gitlab::Access::REPORTER if alert_bot? + return ::Gitlab::Access::REPORTER if support_bot? && service_desk_enabled? # NOTE: max_member_access has its own cache project.team.max_member_access(@user.id) @@ -636,7 +662,7 @@ class ProjectPolicy < BasePolicy when ProjectFeature::DISABLED false when ProjectFeature::PRIVATE - admin? || team_access_level >= ProjectFeature.required_minimum_access_level(feature) + can?(:read_all_resources) || team_access_level >= ProjectFeature.required_minimum_access_level(feature) else true end diff --git a/app/policies/releases/source_policy.rb b/app/policies/releases/source_policy.rb index 8b86b925589..3b11c661237 100644 --- a/app/policies/releases/source_policy.rb +++ b/app/policies/releases/source_policy.rb @@ -3,11 +3,5 @@ module Releases class SourcePolicy < BasePolicy delegate { @subject.project } - - rule { can?(:public_access) | can?(:reporter_access) }.policy do - enable :read_release_sources - end - - rule { ~can?(:read_release) }.prevent :read_release_sources end end diff --git a/app/presenters/alert_management/alert_presenter.rb b/app/presenters/alert_management/alert_presenter.rb new file mode 100644 index 00000000000..a515c70152d --- /dev/null +++ b/app/presenters/alert_management/alert_presenter.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +module AlertManagement + class AlertPresenter < Gitlab::View::Presenter::Delegated + include Gitlab::Utils::StrongMemoize + include IncidentManagement::Settings + + MARKDOWN_LINE_BREAK = " \n".freeze + + def initialize(alert, _attributes = {}) + super + + @alert = alert + @project = alert.project + end + + def issue_description + horizontal_line = "\n\n---\n\n" + + [ + issue_summary_markdown, + alert_markdown, + incident_management_setting.issue_template_content + ].compact.join(horizontal_line) + end + + def start_time + started_at&.strftime('%d %B %Y, %-l:%M%p (%Z)') + end + + def issue_summary_markdown + <<~MARKDOWN.chomp + #### Summary + + #{metadata_list} + #{alert_details}#{metric_embed_for_alert} + MARKDOWN + end + + def metrics_dashboard_url; end + + private + + attr_reader :alert, :project + + def alerting_alert + strong_memoize(:alerting_alert) do + Gitlab::Alerting::Alert.new(project: project, payload: alert.payload).present + end + end + + def alert_markdown; end + + def metadata_list + metadata = [] + + metadata << list_item('Start time', start_time) + metadata << list_item('Severity', severity) + metadata << list_item('full_query', backtick(full_query)) if full_query + metadata << list_item('Service', service) if service + metadata << list_item('Monitoring tool', monitoring_tool) if monitoring_tool + metadata << list_item('Hosts', host_links) if hosts.any? + metadata << list_item('Description', description) if description.present? + + metadata.join(MARKDOWN_LINE_BREAK) + end + + def alert_details + if details.present? + <<~MARKDOWN.chomp + + #### Alert Details + + #{details_list} + MARKDOWN + end + end + + def details_list + alert.details + .map { |label, value| list_item(label, value) } + .join(MARKDOWN_LINE_BREAK) + end + + def metric_embed_for_alert; end + + def full_query; end + + def list_item(key, value) + "**#{key}:** #{value}".strip + end + + def backtick(value) + "`#{value}`" + end + + def host_links + hosts.join(' ') + end + end +end diff --git a/app/presenters/alert_management/prometheus_alert_presenter.rb b/app/presenters/alert_management/prometheus_alert_presenter.rb new file mode 100644 index 00000000000..3bcc98e6784 --- /dev/null +++ b/app/presenters/alert_management/prometheus_alert_presenter.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module AlertManagement + class PrometheusAlertPresenter < AlertManagement::AlertPresenter + def metrics_dashboard_url + alerting_alert.metrics_dashboard_url + end + + private + + def alert_markdown + alerting_alert.alert_markdown + end + + def details_list + alerting_alert.annotation_list + end + + def metric_embed_for_alert + alerting_alert.metric_embed_for_alert + end + + def full_query + alerting_alert.full_query + end + end +end diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb index 395eaeea8de..da610f13899 100644 --- a/app/presenters/ci/pipeline_presenter.rb +++ b/app/presenters/ci/pipeline_presenter.rb @@ -110,6 +110,17 @@ module Ci merge_request_presenter&.target_branch_link end + def downloadable_path_for_report_type(file_type) + if (job_artifact = batch_lookup_report_artifact_for_file_type(file_type)) && + can?(current_user, :read_build, job_artifact.job) + download_project_job_artifacts_path( + job_artifact.project, + job_artifact.job, + file_type: file_type, + proxy: true) + end + end + private def plain_ref_name diff --git a/app/presenters/clusterable_presenter.rb b/app/presenters/clusterable_presenter.rb index 5e669ff2e50..efb3cf7f348 100644 --- a/app/presenters/clusterable_presenter.rb +++ b/app/presenters/clusterable_presenter.rb @@ -13,8 +13,7 @@ class ClusterablePresenter < Gitlab::View::Presenter::Delegated end def can_add_cluster? - can?(current_user, :add_cluster, clusterable) && - (has_no_clusters? || multiple_clusters_available?) + can?(current_user, :add_cluster, clusterable) end def can_create_cluster? @@ -65,7 +64,11 @@ class ClusterablePresenter < Gitlab::View::Presenter::Delegated raise NotImplementedError end - # Will be overidden in EE + def metrics_dashboard_path(cluster) + raise NotImplementedError + end + + # Will be overridden in EE def environments_cluster_path(cluster) nil end @@ -81,17 +84,6 @@ class ClusterablePresenter < Gitlab::View::Presenter::Delegated def learn_more_link raise NotImplementedError end - - private - - # Overridden on EE module - def multiple_clusters_available? - false - end - - def has_no_clusters? - clusterable.clusters.empty? - end end ClusterablePresenter.prepend_if_ee('EE::ClusterablePresenter') diff --git a/app/presenters/clusters/cluster_presenter.rb b/app/presenters/clusters/cluster_presenter.rb index c4e3393cac9..c0da5310ca4 100644 --- a/app/presenters/clusters/cluster_presenter.rb +++ b/app/presenters/clusters/cluster_presenter.rb @@ -2,6 +2,7 @@ module Clusters class ClusterPresenter < Gitlab::View::Presenter::Delegated + include ::Gitlab::Utils::StrongMemoize include ActionView::Helpers::SanitizeHelper include ActionView::Helpers::UrlHelper include IconsHelper @@ -60,12 +61,53 @@ module Clusters end end + def gitlab_managed_apps_logs_path + return unless logs_project && can_read_cluster? + + if cluster.application_elastic_stack&.available? + elasticsearch_project_logs_path(logs_project, cluster_id: cluster.id, format: :json) + else + k8s_project_logs_path(logs_project, cluster_id: cluster.id, format: :json) + end + end + def read_only_kubernetes_platform_fields? !cluster.provided_by_user? end + def health_data(clusterable) + { + 'clusters-path': clusterable.index_path, + 'dashboard-endpoint': clusterable.metrics_dashboard_path(cluster), + 'documentation-path': help_page_path('user/project/clusters/index', anchor: 'monitoring-your-kubernetes-cluster-ultimate'), + 'add-dashboard-documentation-path': help_page_path('user/project/integrations/prometheus.md', anchor: 'adding-a-new-dashboard-to-your-project'), + 'empty-getting-started-svg-path': image_path('illustrations/monitoring/getting_started.svg'), + 'empty-loading-svg-path': image_path('illustrations/monitoring/loading.svg'), + 'empty-no-data-svg-path': image_path('illustrations/monitoring/no_data.svg'), + 'empty-no-data-small-svg-path': image_path('illustrations/chart-empty-state-small.svg'), + 'empty-unable-to-connect-svg-path': image_path('illustrations/monitoring/unable_to_connect.svg'), + 'settings-path': '', + 'project-path': '', + 'tags-path': '' + } + end + private + def image_path(path) + ActionController::Base.helpers.image_path(path) + end + + # currently log explorer is only available in the scope of the project + # for group and instance level cluster selected project does not affects + # fetching logs from gitlab managed apps namespace, therefore any project + # available to user will be sufficient. + def logs_project + strong_memoize(:logs_project) do + cluster.all_projects.first + end + end + def clusterable if cluster.group_type? cluster.group diff --git a/app/presenters/group_clusterable_presenter.rb b/app/presenters/group_clusterable_presenter.rb index 21db2f6f96b..dfe8e315f94 100644 --- a/app/presenters/group_clusterable_presenter.rb +++ b/app/presenters/group_clusterable_presenter.rb @@ -43,6 +43,10 @@ class GroupClusterablePresenter < ClusterablePresenter def learn_more_link link_to(s_('ClusterIntegration|Learn more about group Kubernetes clusters'), help_page_path('user/group/clusters/index'), target: '_blank', rel: 'noopener noreferrer') end + + def metrics_dashboard_path(cluster) + metrics_dashboard_group_cluster_path(clusterable, cluster) + end end GroupClusterablePresenter.prepend_if_ee('EE::GroupClusterablePresenter') diff --git a/app/presenters/instance_clusterable_presenter.rb b/app/presenters/instance_clusterable_presenter.rb index 41071bc7bc7..7704e6b59c1 100644 --- a/app/presenters/instance_clusterable_presenter.rb +++ b/app/presenters/instance_clusterable_presenter.rb @@ -81,6 +81,10 @@ class InstanceClusterablePresenter < ClusterablePresenter def learn_more_link link_to(s_('ClusterIntegration|Learn more about instance Kubernetes clusters'), help_page_path('user/instance/clusters/index'), target: '_blank', rel: 'noopener noreferrer') end + + def metrics_dashboard_path(cluster) + metrics_dashboard_admin_cluster_path(cluster) + end end InstanceClusterablePresenter.prepend_if_ee('EE::InstanceClusterablePresenter') diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb index af98a6ee36a..bccf0340749 100644 --- a/app/presenters/merge_request_presenter.rb +++ b/app/presenters/merge_request_presenter.rb @@ -8,6 +8,8 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated include ChecksCollaboration include Gitlab::Utils::StrongMemoize + APPROVALS_WIDGET_BASE_TYPE = 'base' + presents :merge_request def ci_status @@ -224,6 +226,22 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated end end + def api_approvals_path + expose_path(api_v4_projects_merge_requests_approvals_path(id: project.id, merge_request_iid: merge_request.iid)) + end + + def api_approve_path + expose_path(api_v4_projects_merge_requests_approve_path(id: project.id, merge_request_iid: merge_request.iid)) + end + + def api_unapprove_path + expose_path(api_v4_projects_merge_requests_unapprove_path(id: project.id, merge_request_iid: merge_request.iid)) + end + + def approvals_widget_type + APPROVALS_WIDGET_BASE_TYPE + end + private def cached_can_be_reverted? diff --git a/app/presenters/packages/composer/packages_presenter.rb b/app/presenters/packages/composer/packages_presenter.rb new file mode 100644 index 00000000000..84f266989e9 --- /dev/null +++ b/app/presenters/packages/composer/packages_presenter.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Packages + module Composer + class PackagesPresenter + include API::Helpers::RelatedResourcesHelpers + + def initialize(group, packages) + @group = group + @packages = packages + end + + def root + path = api_v4_group___packages_composer_package_name_path({ id: @group.id, package_name: '%package%', format: '.json' }, true) + { 'packages' => [], 'provider-includes' => { 'p/%hash%.json' => { 'sha256' => provider_sha } }, 'providers-url' => path } + end + + def provider + { 'providers' => providers_map } + end + + def package_versions(packages = @packages) + { 'packages' => { packages.first.name => package_versions_map(packages) } } + end + + private + + def package_versions_map(packages) + packages.each_with_object({}) do |package, map| + map[package.version] = package_metadata(package) + end + end + + def package_metadata(package) + json = package.composer_metadatum.composer_json + + json.merge('dist' => package_dist(package), 'uid' => package.id, 'version' => package.version) + end + + def package_dist(package) + sha = package.composer_metadatum.target_sha + archive_api_path = api_v4_projects_packages_composer_archives_package_name_path({ id: package.project_id, package_name: package.name, format: '.zip' }, true) + + { + 'type' => 'zip', + 'url' => expose_url(archive_api_path) + "?sha=#{sha}", + 'reference' => sha, + 'shasum' => '' + } + end + + def providers_map + map = {} + + @packages.group_by(&:name).each_pair do |package_name, packages| + map[package_name] = { 'sha256' => package_versions_sha(packages) } + end + + map + end + + def package_versions_sha(packages) + Digest::SHA256.hexdigest(package_versions(packages).to_json) + end + + def provider_sha + Digest::SHA256.hexdigest(provider.to_json) + end + end + end +end diff --git a/app/presenters/packages/conan/package_presenter.rb b/app/presenters/packages/conan/package_presenter.rb new file mode 100644 index 00000000000..5141c450412 --- /dev/null +++ b/app/presenters/packages/conan/package_presenter.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +module Packages + module Conan + class PackagePresenter + include API::Helpers::RelatedResourcesHelpers + include Gitlab::Utils::StrongMemoize + + attr_reader :params + + def initialize(recipe, user, project, params = {}) + @recipe = recipe + @user = user + @project = project + @params = params + end + + def recipe_urls + map_package_files do |package_file| + build_recipe_file_url(package_file) if package_file.conan_file_metadatum.recipe_file? + end + end + + def recipe_snapshot + map_package_files do |package_file| + package_file.file_md5 if package_file.conan_file_metadatum.recipe_file? + end + end + + def package_urls + map_package_files do |package_file| + next unless package_file.conan_file_metadatum.package_file? && matching_reference?(package_file) + + build_package_file_url(package_file) + end + end + + def package_snapshot + map_package_files do |package_file| + next unless package_file.conan_file_metadatum.package_file? && matching_reference?(package_file) + + package_file.file_md5 + end + end + + private + + def build_recipe_file_url(package_file) + expose_url( + api_v4_packages_conan_v1_files_export_path( + package_name: package.name, + package_version: package.version, + package_username: package.conan_metadatum.package_username, + package_channel: package.conan_metadatum.package_channel, + recipe_revision: package_file.conan_file_metadatum.recipe_revision, + file_name: package_file.file_name + ) + ) + end + + def build_package_file_url(package_file) + expose_url( + api_v4_packages_conan_v1_files_package_path( + package_name: package.name, + package_version: package.version, + package_username: package.conan_metadatum.package_username, + package_channel: package.conan_metadatum.package_channel, + recipe_revision: package_file.conan_file_metadatum.recipe_revision, + conan_package_reference: package_file.conan_file_metadatum.conan_package_reference, + package_revision: package_file.conan_file_metadatum.package_revision, + file_name: package_file.file_name + ) + ) + end + + def map_package_files + package_files.to_a.map do |package_file| + key = package_file.file_name + value = yield(package_file) + next unless key && value + + [key, value] + end.compact.to_h + end + + def package_files + return unless package + + @package_files ||= package.package_files.preload_conan_file_metadata + end + + def package + strong_memoize(:package) do + name, version = @recipe.split('@')[0].split('/') + + @project.packages + .conan + .with_name(name) + .with_version(version) + .order_created + .last + end + end + + def matching_reference?(package_file) + package_file.conan_file_metadatum.conan_package_reference == conan_package_reference + end + + def conan_package_reference + params[:conan_package_reference] + end + end + end +end diff --git a/app/presenters/packages/detail/package_presenter.rb b/app/presenters/packages/detail/package_presenter.rb new file mode 100644 index 00000000000..f6e068302c1 --- /dev/null +++ b/app/presenters/packages/detail/package_presenter.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Packages + module Detail + class PackagePresenter + def initialize(package) + @package = package + end + + def detail_view + package_detail = { + id: @package.id, + created_at: @package.created_at, + name: @package.name, + package_files: @package.package_files.map { |pf| build_package_file_view(pf) }, + package_type: @package.package_type, + project_id: @package.project_id, + tags: @package.tags.as_json, + updated_at: @package.updated_at, + version: @package.version + } + + package_detail[:maven_metadatum] = @package.maven_metadatum if @package.maven_metadatum + package_detail[:nuget_metadatum] = @package.nuget_metadatum if @package.nuget_metadatum + package_detail[:dependency_links] = @package.dependency_links.map(&method(:build_dependency_links)) + package_detail[:pipeline] = build_pipeline_info(@package.build_info.pipeline) if @package.build_info + + package_detail + end + + private + + def build_package_file_view(package_file) + { + created_at: package_file.created_at, + download_path: package_file.download_path, + file_name: package_file.file_name, + size: package_file.size + } + end + + def build_pipeline_info(pipeline_info) + { + created_at: pipeline_info.created_at, + id: pipeline_info.id, + sha: pipeline_info.sha, + ref: pipeline_info.ref, + git_commit_message: pipeline_info.git_commit_message, + user: build_user_info(pipeline_info.user), + project: { + name: pipeline_info.project.name, + web_url: pipeline_info.project.web_url + } + } + end + + def build_user_info(user) + return unless user + + { + avatar_url: user.avatar_url, + name: user.name + } + end + + def build_dependency_links(link) + { + name: link.dependency.name, + version_pattern: link.dependency.version_pattern, + target_framework: link.nuget_metadatum&.target_framework + }.compact + end + end + end +end diff --git a/app/presenters/packages/go/module_version_presenter.rb b/app/presenters/packages/go/module_version_presenter.rb new file mode 100644 index 00000000000..4c86eae46cd --- /dev/null +++ b/app/presenters/packages/go/module_version_presenter.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Packages + module Go + class ModuleVersionPresenter + def initialize(version) + @version = version + end + + def name + @version.name + end + + def time + @version.commit.committed_date + end + end + end +end diff --git a/app/presenters/packages/npm/package_presenter.rb b/app/presenters/packages/npm/package_presenter.rb new file mode 100644 index 00000000000..a3ab10d3913 --- /dev/null +++ b/app/presenters/packages/npm/package_presenter.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module Packages + module Npm + class PackagePresenter + include API::Helpers::RelatedResourcesHelpers + + attr_reader :name, :packages + + NPM_VALID_DEPENDENCY_TYPES = %i[dependencies devDependencies bundleDependencies peerDependencies].freeze + + def initialize(name, packages) + @name = name + @packages = packages + end + + def versions + package_versions = {} + + packages.each do |package| + package_file = package.package_files.last + + next unless package_file + + package_versions[package.version] = build_package_version(package, package_file) + end + + package_versions + end + + def dist_tags + build_package_tags.tap { |t| t["latest"] ||= sorted_versions.last } + end + + private + + def build_package_tags + Hash[ + package_tags.map { |tag| [tag.name, tag.package.version] } + ] + end + + def build_package_version(package, package_file) + { + name: package.name, + version: package.version, + dist: { + shasum: package_file.file_sha1, + tarball: tarball_url(package, package_file) + } + }.tap do |package_version| + package_version.merge!(build_package_dependencies(package)) + end + end + + def tarball_url(package, package_file) + expose_url "#{api_v4_projects_path(id: package.project_id)}" \ + "/packages/npm/#{package.name}" \ + "/-/#{package_file.file_name}" + end + + def build_package_dependencies(package) + dependencies = Hash.new { |h, key| h[key] = {} } + dependency_links = package.dependency_links + .with_dependency_type(NPM_VALID_DEPENDENCY_TYPES) + .includes_dependency + + dependency_links.find_each do |dependency_link| + dependency = dependency_link.dependency + dependencies[dependency_link.dependency_type][dependency.name] = dependency.version_pattern + end + + dependencies + end + + def sorted_versions + versions = packages.map(&:version).compact + VersionSorter.sort(versions) + end + + def package_tags + Packages::Tag.for_packages(packages) + .preload_package + end + end + end +end diff --git a/app/presenters/packages/nuget/package_metadata_presenter.rb b/app/presenters/packages/nuget/package_metadata_presenter.rb new file mode 100644 index 00000000000..500fc982e11 --- /dev/null +++ b/app/presenters/packages/nuget/package_metadata_presenter.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Packages + module Nuget + class PackageMetadataPresenter + include Packages::Nuget::PresenterHelpers + + def initialize(package) + @package = package + end + + def json_url + json_url_for(@package) + end + + def archive_url + archive_url_for(@package) + end + + def catalog_entry + catalog_entry_for(@package) + end + end + end +end diff --git a/app/presenters/packages/nuget/packages_metadata_presenter.rb b/app/presenters/packages/nuget/packages_metadata_presenter.rb new file mode 100644 index 00000000000..5f22d5dd8a1 --- /dev/null +++ b/app/presenters/packages/nuget/packages_metadata_presenter.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Packages + module Nuget + class PackagesMetadataPresenter + include Packages::Nuget::PresenterHelpers + include Gitlab::Utils::StrongMemoize + + COUNT = 1.freeze + + def initialize(packages) + @packages = packages + end + + def count + COUNT + end + + def items + [summary] + end + + private + + def summary + { + json_url: json_url, + lower_version: lower_version, + upper_version: upper_version, + packages_count: @packages.count, + packages: @packages.map { |pkg| metadata_for(pkg) } + } + end + + def metadata_for(package) + { + json_url: json_url_for(package), + archive_url: archive_url_for(package), + catalog_entry: catalog_entry_for(package) + } + end + + def json_url + json_url_for(@packages.first) + end + + def lower_version + sorted_versions.first + end + + def upper_version + sorted_versions.last + end + + def sorted_versions + strong_memoize(:sorted_versions) do + versions = @packages.map(&:version).compact + VersionSorter.sort(versions) + end + end + end + end +end diff --git a/app/presenters/packages/nuget/packages_versions_presenter.rb b/app/presenters/packages/nuget/packages_versions_presenter.rb new file mode 100644 index 00000000000..7f4ce4dbb2f --- /dev/null +++ b/app/presenters/packages/nuget/packages_versions_presenter.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Packages + module Nuget + class PackagesVersionsPresenter + def initialize(packages) + @packages = packages + end + + def versions + @packages.pluck_versions.sort + end + end + end +end diff --git a/app/presenters/packages/nuget/presenter_helpers.rb b/app/presenters/packages/nuget/presenter_helpers.rb new file mode 100644 index 00000000000..cc7e8619220 --- /dev/null +++ b/app/presenters/packages/nuget/presenter_helpers.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +module Packages + module Nuget + module PresenterHelpers + include ::API::Helpers::RelatedResourcesHelpers + + BLANK_STRING = '' + PACKAGE_DEPENDENCY_GROUP = 'PackageDependencyGroup' + PACKAGE_DEPENDENCY = 'PackageDependency' + + private + + def json_url_for(package) + path = api_v4_projects_packages_nuget_metadata_package_name_package_version_path( + { + id: package.project_id, + package_name: package.name, + package_version: package.version, + format: '.json' + }, + true + ) + + expose_url(path) + end + + def archive_url_for(package) + path = api_v4_projects_packages_nuget_download_package_name_package_version_package_filename_path( + { + id: package.project_id, + package_name: package.name, + package_version: package.version, + package_filename: package.package_files.last&.file_name + }, + true + ) + + expose_url(path) + end + + def catalog_entry_for(package) + { + json_url: json_url_for(package), + authors: BLANK_STRING, + dependency_groups: dependency_groups_for(package), + package_name: package.name, + package_version: package.version, + archive_url: archive_url_for(package), + summary: BLANK_STRING, + tags: tags_for(package), + metadatum: metadatum_for(package) + } + end + + def dependency_groups_for(package) + base_nuget_id = "#{json_url_for(package)}#dependencyGroup" + + dependency_links_grouped_by_target_framework(package).map do |target_framework, dependency_links| + nuget_id = target_framework_nuget_id(base_nuget_id, target_framework) + { + id: nuget_id, + type: PACKAGE_DEPENDENCY_GROUP, + target_framework: target_framework, + dependencies: dependencies_for(nuget_id, dependency_links) + }.compact + end + end + + def dependency_links_grouped_by_target_framework(package) + package + .dependency_links + .includes_dependency + .preload_nuget_metadatum + .group_by { |dependency_link| dependency_link.nuget_metadatum&.target_framework } + end + + def dependencies_for(nuget_id, dependency_links) + return [] if dependency_links.empty? + + dependency_links.map do |dependency_link| + dependency = dependency_link.dependency + { + id: "#{nuget_id}/#{dependency.name.downcase}", + type: PACKAGE_DEPENDENCY, + name: dependency.name, + range: dependency.version_pattern + } + end + end + + def target_framework_nuget_id(base_nuget_id, target_framework) + target_framework.blank? ? base_nuget_id : "#{base_nuget_id}/#{target_framework.downcase}" + end + + def metadatum_for(package) + metadatum = package.nuget_metadatum + return {} unless metadatum + + metadatum.slice(:project_url, :license_url, :icon_url) + .compact + end + + def base_path_for(package) + api_v4_projects_packages_nuget_path(id: package.project_id) + end + + def tags_for(package) + package.tag_names.join(::Packages::Tag::NUGET_TAGS_SEPARATOR) + end + end + end +end diff --git a/app/presenters/packages/nuget/search_results_presenter.rb b/app/presenters/packages/nuget/search_results_presenter.rb new file mode 100644 index 00000000000..96c8fe7dd2a --- /dev/null +++ b/app/presenters/packages/nuget/search_results_presenter.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Packages + module Nuget + class SearchResultsPresenter + include Packages::Nuget::PresenterHelpers + include Gitlab::Utils::StrongMemoize + + delegate :total_count, to: :@search + + def initialize(search) + @search = search + @package_versions = {} + end + + def data + strong_memoize(:data) do + @search.results.group_by(&:name).map do |package_name, packages| + latest_version = latest_version(packages) + latest_package = packages.find { |pkg| pkg.version == latest_version } + + { + type: 'Package', + authors: '', + name: package_name, + version: latest_version, + versions: build_package_versions(packages), + summary: '', + total_downloads: 0, + verified: true, + tags: tags_for(latest_package), + metadatum: metadatum_for(latest_package) + } + end + end + end + + private + + def build_package_versions(packages) + packages.map do |pkg| + { + json_url: json_url_for(pkg), + downloads: 0, + version: pkg.version + } + end + end + + def latest_version(packages) + versions = packages.map(&:version).compact + VersionSorter.sort(versions).last # rubocop: disable Style/UnneededSort + end + end + end +end diff --git a/app/presenters/packages/nuget/service_index_presenter.rb b/app/presenters/packages/nuget/service_index_presenter.rb new file mode 100644 index 00000000000..ed00b36b362 --- /dev/null +++ b/app/presenters/packages/nuget/service_index_presenter.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module Packages + module Nuget + class ServiceIndexPresenter + include API::Helpers::RelatedResourcesHelpers + + SERVICE_VERSIONS = { + download: %w[PackageBaseAddress/3.0.0], + search: %w[SearchQueryService SearchQueryService/3.0.0-beta SearchQueryService/3.0.0-rc], + publish: %w[PackagePublish/2.0.0], + metadata: %w[RegistrationsBaseUrl RegistrationsBaseUrl/3.0.0-beta RegistrationsBaseUrl/3.0.0-rc] + }.freeze + + SERVICE_COMMENTS = { + download: 'Get package content (.nupkg).', + search: 'Filter and search for packages by keyword.', + publish: 'Push and delete (or unlist) packages.', + metadata: 'Get package metadata.' + }.freeze + + VERSION = '3.0.0'.freeze + + def initialize(project) + @project = project + end + + def version + VERSION + end + + def resources + [ + build_service(:download), + build_service(:search), + build_service(:publish), + build_service(:metadata) + ].flatten + end + + private + + def build_service(service_type) + url = build_service_url(service_type) + comment = SERVICE_COMMENTS[service_type] + + SERVICE_VERSIONS[service_type].map do |version| + { :@id => url, :@type => version, :comment => comment } + end + end + + def build_service_url(service_type) + base_path = api_v4_projects_packages_nuget_path(id: @project.id) + + full_path = case service_type + when :download + api_v4_projects_packages_nuget_download_package_name_package_version_package_filename_path( + { + id: @project.id, + package_name: nil, + package_version: nil, + package_filename: nil + }, + true + ) + when :search + "#{base_path}/query" + when :metadata + api_v4_projects_packages_nuget_metadata_package_name_package_version_path( + { + id: @project.id, + package_name: nil, + package_version: nil + }, + true + ) + when :publish + base_path + end + + expose_url(full_path) + end + end + end +end diff --git a/app/presenters/packages/pypi/package_presenter.rb b/app/presenters/packages/pypi/package_presenter.rb new file mode 100644 index 00000000000..4192e974645 --- /dev/null +++ b/app/presenters/packages/pypi/package_presenter.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +# Display package version data acording to PyPi +# Simple API: https://warehouse.pypa.io/api-reference/legacy/#simple-project-api +module Packages + module Pypi + class PackagePresenter + include API::Helpers::RelatedResourcesHelpers + + def initialize(packages, project) + @packages = packages + @project = project + end + + # Returns the HTML body for PyPi simple API. + # Basically a list of package download links for a specific + # package + def body + <<-HTML + <!DOCTYPE html> + <html> + <head> + <title>Links for #{escape(name)}</title> + </head> + <body> + <h1>Links for #{escape(name)}</h1> + #{links} + </body> + </html> + HTML + end + + private + + def links + refs = [] + + @packages.map do |package| + package.package_files.each do |file| + url = build_pypi_package_path(file) + + refs << package_link(url, package.pypi_metadatum.required_python, file.file_name) + end + end + + refs.join + end + + def package_link(url, required_python, filename) + "<a href=\"#{url}\" data-requires-python=\"#{escape(required_python)}\">#{filename}</a><br>" + end + + def build_pypi_package_path(file) + expose_url( + api_v4_projects_packages_pypi_files_file_identifier_path( + { + id: @project.id, + sha256: file.file_sha256, + file_identifier: file.file_name + }, + true + ) + ) + "#sha256=#{file.file_sha256}" + end + + def name + @packages.first.name + end + + def escape(str) + ERB::Util.html_escape(str) + end + end + end +end diff --git a/app/presenters/project_clusterable_presenter.rb b/app/presenters/project_clusterable_presenter.rb index 5c56d42ed27..718f653eab1 100644 --- a/app/presenters/project_clusterable_presenter.rb +++ b/app/presenters/project_clusterable_presenter.rb @@ -38,6 +38,10 @@ class ProjectClusterablePresenter < ClusterablePresenter def learn_more_link link_to(s_('ClusterIntegration|Learn more about Kubernetes'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer') end + + def metrics_dashboard_path(cluster) + metrics_dashboard_project_cluster_path(clusterable, cluster) + end end ProjectClusterablePresenter.prepend_if_ee('EE::ProjectClusterablePresenter') diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index a663bc555f6..4e8dae1d508 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -16,7 +16,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated MAX_TOPICS_TO_SHOW = 3 def statistic_icon(icon_name = 'plus-square-o') - sprite_icon(icon_name, size: 16, css_class: 'icon append-right-4') + sprite_icon(icon_name, size: 16, css_class: 'icon gl-mr-2') end def statistics_anchors(show_auto_devops_callout:) diff --git a/app/presenters/projects/prometheus/alert_presenter.rb b/app/presenters/projects/prometheus/alert_presenter.rb index 6009ee4c7be..1cf8b202810 100644 --- a/app/presenters/projects/prometheus/alert_presenter.rb +++ b/app/presenters/projects/prometheus/alert_presenter.rb @@ -6,7 +6,7 @@ module Projects RESERVED_ANNOTATIONS = %w(gitlab_incident_markdown gitlab_y_label title).freeze GENERIC_ALERT_SUMMARY_ANNOTATIONS = %w(monitoring_tool service hosts).freeze MARKDOWN_LINE_BREAK = " \n".freeze - INCIDENT_LABEL_NAME = IncidentManagement::CreateIssueService::INCIDENT_LABEL[:title].freeze + INCIDENT_LABEL_NAME = ::IncidentManagement::CreateIncidentLabelService::LABEL_PROPERTIES[:title].freeze METRIC_TIME_WINDOW = 30.minutes def full_title @@ -58,6 +58,25 @@ module Projects MARKDOWN end + def annotation_list + strong_memoize(:annotation_list) do + annotations + .reject { |annotation| annotation.label.in?(RESERVED_ANNOTATIONS | GENERIC_ALERT_SUMMARY_ANNOTATIONS) } + .map { |annotation| list_item(annotation.label, annotation.value) } + .join(MARKDOWN_LINE_BREAK) + end + end + + def metric_embed_for_alert + "\n[](#{metrics_dashboard_url})" if metrics_dashboard_url + end + + def metrics_dashboard_url + strong_memoize(:metrics_dashboard_url) do + embed_url_for_gitlab_alert || embed_url_for_self_managed_alert + end + end + private def alert_title @@ -93,15 +112,6 @@ module Projects end end - def annotation_list - strong_memoize(:annotation_list) do - annotations - .reject { |annotation| annotation.label.in?(RESERVED_ANNOTATIONS | GENERIC_ALERT_SUMMARY_ANNOTATIONS) } - .map { |annotation| list_item(annotation.label, annotation.value) } - .join(MARKDOWN_LINE_BREAK) - end - end - def list_item(key, value) "**#{key}:** #{value}".strip end @@ -120,12 +130,6 @@ module Projects Array(hosts.value).join(' ') end - def metric_embed_for_alert - url = embed_url_for_gitlab_alert || embed_url_for_self_managed_alert - - "\n[](#{url})" if url - end - def embed_url_for_gitlab_alert return unless gitlab_alert @@ -133,6 +137,7 @@ module Projects project, gitlab_alert.prometheus_metric_id, environment_id: environment.id, + embedded: true, **alert_embed_window_params(embed_time) ) end @@ -144,6 +149,7 @@ module Projects project, environment, embed_json: dashboard_for_self_managed_alert.to_json, + embedded: true, **alert_embed_window_params(embed_time) ) end diff --git a/app/presenters/release_presenter.rb b/app/presenters/release_presenter.rb index 7b0a3d1e7b9..4393ca05f48 100644 --- a/app/presenters/release_presenter.rb +++ b/app/presenters/release_presenter.rb @@ -5,7 +5,7 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated presents :release - delegate :project, :tag, :assets_count, to: :release + delegate :project, :tag, to: :release def commit_path return unless release.commit && can_download_code? @@ -43,6 +43,18 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated edit_project_release_url(project, release) end + def assets_count + if can_download_code? + release.assets_count + else + release.assets_count(except: [:sources]) + end + end + + def name + can_download_code? ? release.name : "Release-#{release.id}" + end + private def can_download_code? diff --git a/app/presenters/snippet_blob_presenter.rb b/app/presenters/snippet_blob_presenter.rb index ed9c28bbc2c..d27fe751ab7 100644 --- a/app/presenters/snippet_blob_presenter.rb +++ b/app/presenters/snippet_blob_presenter.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class SnippetBlobPresenter < BlobPresenter + include GitlabRoutingHelper + def rich_data return if blob.binary? return unless blob.rich_viewer @@ -15,15 +17,17 @@ class SnippetBlobPresenter < BlobPresenter end def raw_path - if snippet.is_a?(ProjectSnippet) - raw_project_snippet_path(snippet.project, snippet) - else - raw_snippet_path(snippet) - end + return gitlab_raw_snippet_blob_path(blob) if snippet_multiple_files? + + gitlab_raw_snippet_path(snippet) end private + def snippet_multiple_files? + blob.container.repository_exists? && Feature.enabled?(:snippet_multiple_files, current_user) + end + def snippet blob.container end diff --git a/app/serializers/build_trace_entity.rb b/app/serializers/build_trace_entity.rb index b5bac8a5d64..f4c3c7770b2 100644 --- a/app/serializers/build_trace_entity.rb +++ b/app/serializers/build_trace_entity.rb @@ -12,6 +12,5 @@ class BuildTraceEntity < Grape::Entity expose :size expose :total - expose :json_lines, as: :lines, if: ->(*) { object.json? } - expose :html_lines, as: :html, if: ->(*) { object.html? } + expose :lines end diff --git a/app/serializers/ci/group_variable_entity.rb b/app/serializers/ci/group_variable_entity.rb new file mode 100644 index 00000000000..e7d0a957082 --- /dev/null +++ b/app/serializers/ci/group_variable_entity.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Ci + class GroupVariableEntity < Ci::BasicVariableEntity + end +end diff --git a/app/serializers/ci/group_variable_serializer.rb b/app/serializers/ci/group_variable_serializer.rb new file mode 100644 index 00000000000..b100a931620 --- /dev/null +++ b/app/serializers/ci/group_variable_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Ci + class GroupVariableSerializer < BaseSerializer + entity ::Ci::GroupVariableEntity + end +end diff --git a/app/serializers/ci/variable_entity.rb b/app/serializers/ci/variable_entity.rb new file mode 100644 index 00000000000..715f829a0e1 --- /dev/null +++ b/app/serializers/ci/variable_entity.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Ci + class VariableEntity < Ci::BasicVariableEntity + expose :environment_scope + end +end diff --git a/app/serializers/ci/variable_serializer.rb b/app/serializers/ci/variable_serializer.rb new file mode 100644 index 00000000000..eb47d3b71b5 --- /dev/null +++ b/app/serializers/ci/variable_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Ci + class VariableSerializer < BaseSerializer + entity ::Ci::VariableEntity + end +end diff --git a/app/serializers/cluster_application_entity.rb b/app/serializers/cluster_application_entity.rb index 32b759b9628..6b9a3ce114b 100644 --- a/app/serializers/cluster_application_entity.rb +++ b/app/serializers/cluster_application_entity.rb @@ -4,7 +4,7 @@ class ClusterApplicationEntity < Grape::Entity expose :name expose :status_name, as: :status expose :status_reason - expose :version + expose :version, if: -> (e, _) { e.respond_to?(:version) } expose :external_ip, if: -> (e, _) { e.respond_to?(:external_ip) } expose :external_hostname, if: -> (e, _) { e.respond_to?(:external_hostname) } expose :hostname, if: -> (e, _) { e.respond_to?(:hostname) } diff --git a/app/serializers/cluster_entity.rb b/app/serializers/cluster_entity.rb index 8a1d41dbd96..a46f2889a96 100644 --- a/app/serializers/cluster_entity.rb +++ b/app/serializers/cluster_entity.rb @@ -16,4 +16,8 @@ class ClusterEntity < Grape::Entity expose :path do |cluster| Clusters::ClusterPresenter.new(cluster).show_path # rubocop: disable CodeReuse/Presenter end + + expose :gitlab_managed_apps_logs_path do |cluster| + Clusters::ClusterPresenter.new(cluster, current_user: request.current_user).gitlab_managed_apps_logs_path # rubocop: disable CodeReuse/Presenter + end end diff --git a/app/serializers/cluster_serializer.rb b/app/serializers/cluster_serializer.rb index 27156d3178f..92363a4942c 100644 --- a/app/serializers/cluster_serializer.rb +++ b/app/serializers/cluster_serializer.rb @@ -10,6 +10,7 @@ class ClusterSerializer < BaseSerializer :cluster_type, :enabled, :environment_scope, + :gitlab_managed_apps_logs_path, :name, :nodes, :path, diff --git a/app/serializers/deploy_key_entity.rb b/app/serializers/deploy_key_entity.rb index 653316ce4d2..486189b84ca 100644 --- a/app/serializers/deploy_key_entity.rb +++ b/app/serializers/deploy_key_entity.rb @@ -16,6 +16,7 @@ class DeployKeyEntity < Grape::Entity end end expose :can_edit + expose :user, as: :owner, using: ::API::Entities::UserBasic, if: -> (_, opts) { can_read_owner?(opts) } private @@ -24,6 +25,10 @@ class DeployKeyEntity < Grape::Entity Ability.allowed?(options[:user], :update_deploy_keys_project, object.deploy_keys_project_for(options[:project])) end + def can_read_owner?(opts) + opts[:with_owner] && Ability.allowed?(options[:user], :read_user, object.user) + end + def allowed_to_read_project?(project) if options[:readable_project_ids] options[:readable_project_ids].include?(project.id) diff --git a/app/serializers/diff_file_base_entity.rb b/app/serializers/diff_file_base_entity.rb index 33eb33d314b..2af14f1eb82 100644 --- a/app/serializers/diff_file_base_entity.rb +++ b/app/serializers/diff_file_base_entity.rb @@ -26,13 +26,9 @@ class DiffFileBaseEntity < Grape::Entity target_project, target_branch = edit_project_branch_options(merge_request) - if Feature.enabled?(:web_ide_default) - ide_edit_path(target_project, target_branch, diff_file.new_path) - else - options = merge_request.persisted? && merge_request.source_branch_exists? && !merge_request.merged? ? { from_merge_request_iid: merge_request.iid } : {} + options = merge_request.persisted? && merge_request.source_branch_exists? && !merge_request.merged? ? { from_merge_request_iid: merge_request.iid } : {} - project_edit_blob_path(target_project, tree_join(target_branch, diff_file.new_path), options) - end + project_edit_blob_path(target_project, tree_join(target_branch, diff_file.new_path), options) end expose :old_path_html do |diff_file| diff --git a/app/serializers/evidences/release_entity.rb b/app/serializers/evidences/release_entity.rb index 59e379a3c08..dfc4f52de07 100644 --- a/app/serializers/evidences/release_entity.rb +++ b/app/serializers/evidences/release_entity.rb @@ -11,3 +11,5 @@ module Evidences expose :milestones, using: Evidences::MilestoneEntity end end + +Evidences::ReleaseEntity.prepend_if_ee('EE::Evidences::ReleaseEntity') diff --git a/app/serializers/fork_namespace_entity.rb b/app/serializers/fork_namespace_entity.rb new file mode 100644 index 00000000000..068862e0951 --- /dev/null +++ b/app/serializers/fork_namespace_entity.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class ForkNamespaceEntity < Grape::Entity + include ActionView::Helpers::NumberHelper + include RequestAwareEntity + include MarkupHelper + + expose :id, :name, :description, :visibility, :full_name, + :created_at, :updated_at, :avatar_url + + expose :fork_path do |namespace, options| + project_forks_path(options[:project], namespace_key: namespace.id) + end + + expose :forked_project_path do |namespace, options| + if forked_project = namespace.find_fork_of(options[:project]) + project_path(forked_project) + end + end + + expose :permission do |namespace, options| + membership(options[:current_user], namespace)&.human_access + end + + expose :relative_path do |namespace| + polymorphic_path(namespace) + end + + expose :markdown_description do |namespace| + markdown_description(namespace) + end + + expose :can_create_project do |namespace, options| + options[:current_user].can?(:create_projects, namespace) + end + + private + + # rubocop: disable CodeReuse/ActiveRecord + def membership(user, object) + return unless user + + @membership ||= user.members.find_by(source: object) + end + # rubocop: enable CodeReuse/ActiveRecord + + def markdown_description(namespace) + markdown_field(namespace, :description) + end +end + +ForkNamespaceEntity.prepend_if_ee('EE::ForkNamespaceEntity') diff --git a/app/serializers/fork_namespace_serializer.rb b/app/serializers/fork_namespace_serializer.rb new file mode 100644 index 00000000000..1461938269e --- /dev/null +++ b/app/serializers/fork_namespace_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ForkNamespaceSerializer < BaseSerializer + entity ForkNamespaceEntity +end diff --git a/app/serializers/group_variable_entity.rb b/app/serializers/group_variable_entity.rb deleted file mode 100644 index 4f44723fefe..00000000000 --- a/app/serializers/group_variable_entity.rb +++ /dev/null @@ -1,4 +0,0 @@ -# frozen_string_literal: true - -class GroupVariableEntity < Ci::BasicVariableEntity -end diff --git a/app/serializers/group_variable_serializer.rb b/app/serializers/group_variable_serializer.rb deleted file mode 100644 index ed20b240cce..00000000000 --- a/app/serializers/group_variable_serializer.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -class GroupVariableSerializer < BaseSerializer - entity GroupVariableEntity -end diff --git a/app/serializers/merge_request_poll_cached_widget_entity.rb b/app/serializers/merge_request_poll_cached_widget_entity.rb index 72f629b3507..c51c08ab646 100644 --- a/app/serializers/merge_request_poll_cached_widget_entity.rb +++ b/app/serializers/merge_request_poll_cached_widget_entity.rb @@ -74,6 +74,30 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity diffs_project_merge_request_path(merge_request.project, merge_request) end + expose :squash_enabled_by_default do |merge_request| + presenter(merge_request).project.squash_enabled_by_default? + end + + expose :squash_readonly do |merge_request| + presenter(merge_request).project.squash_readonly? + end + + expose :squash_on_merge do |merge_request| + presenter(merge_request).squash_on_merge? + end + + expose :api_approvals_path do |merge_request| + presenter(merge_request).api_approvals_path + end + + expose :api_approve_path do |merge_request| + presenter(merge_request).api_approve_path + end + + expose :api_unapprove_path do |merge_request| + presenter(merge_request).api_unapprove_path + end + private delegate :current_user, to: :request diff --git a/app/serializers/merge_request_poll_widget_entity.rb b/app/serializers/merge_request_poll_widget_entity.rb index aad607f358a..a365ebc29c9 100644 --- a/app/serializers/merge_request_poll_widget_entity.rb +++ b/app/serializers/merge_request_poll_widget_entity.rb @@ -145,6 +145,22 @@ class MergeRequestPollWidgetEntity < Grape::Entity presenter(merge_request).revert_in_fork_path end + expose :squash_enabled_by_default do |merge_request| + presenter(merge_request).project.squash_enabled_by_default? + end + + expose :squash_readonly do |merge_request| + presenter(merge_request).project.squash_readonly? + end + + expose :squash_on_merge do |merge_request| + presenter(merge_request).squash_on_merge? + end + + expose :approvals_widget_type do |merge_request| + presenter(merge_request).approvals_widget_type + end + private delegate :current_user, to: :request diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index 74f29b36209..2a7afb57314 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -85,6 +85,26 @@ class MergeRequestWidgetEntity < Grape::Entity end end + expose :blob_path do + expose :head_path, if: -> (mr, _) { mr.source_branch_sha } do |merge_request| + project_blob_path(merge_request.project, merge_request.source_branch_sha) + end + + expose :base_path, if: -> (mr, _) { mr.diff_base_sha } do |merge_request| + project_blob_path(merge_request.project, merge_request.diff_base_sha) + end + end + + expose :codeclimate, if: -> (mr, _) { head_pipeline_downloadable_path_for_report_type(:codequality) } do + expose :head_path do |merge_request| + head_pipeline_downloadable_path_for_report_type(:codequality) + end + + expose :base_path do |merge_request| + base_pipeline_downloadable_path_for_report_type(:codequality) + end + end + private delegate :current_user, to: :request @@ -95,12 +115,24 @@ class MergeRequestWidgetEntity < Grape::Entity end def can_add_ci_config_path?(merge_request) - merge_request.source_project&.uses_default_ci_config? && + merge_request.open? && + merge_request.source_branch_exists? && + merge_request.source_project&.uses_default_ci_config? && !merge_request.source_project.has_ci? && merge_request.commits_count.positive? && can?(current_user, :read_build, merge_request.source_project) && can?(current_user, :create_pipeline, merge_request.source_project) end + + def head_pipeline_downloadable_path_for_report_type(file_type) + object.head_pipeline&.present(current_user: current_user) + &.downloadable_path_for_report_type(file_type) + end + + def base_pipeline_downloadable_path_for_report_type(file_type) + object.base_pipeline&.present(current_user: current_user) + &.downloadable_path_for_report_type(file_type) + end end MergeRequestWidgetEntity.prepend_if_ee('EE::MergeRequestWidgetEntity') diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb index c3ddbb88c9c..8333a0bb863 100644 --- a/app/serializers/pipeline_entity.rb +++ b/app/serializers/pipeline_entity.rb @@ -85,6 +85,10 @@ class PipelineEntity < Grape::Entity pipeline.failed_builds end + expose :tests_total_count, if: -> (pipeline, _) { Feature.enabled?(:build_report_summary, pipeline.project) } do |pipeline| + pipeline.test_report_summary.total_count + end + private alias_method :pipeline, :object diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb index 21d49c6c292..bfd6851647f 100644 --- a/app/serializers/pipeline_serializer.rb +++ b/app/serializers/pipeline_serializer.rb @@ -60,8 +60,8 @@ class PipelineSerializer < BaseSerializer }, pending_builds: :project, project: [:route, { namespace: :route }], - triggered_by_pipeline: [:project, :user], - triggered_pipelines: [:project, :user] + triggered_by_pipeline: [{ project: [:route, { namespace: :route }] }, :user], + triggered_pipelines: [{ project: [:route, { namespace: :route }] }, :user, :source_job] } ] end diff --git a/app/serializers/service_event_entity.rb b/app/serializers/service_event_entity.rb index fd655dd1ed3..eb4f9c665f2 100644 --- a/app/serializers/service_event_entity.rb +++ b/app/serializers/service_event_entity.rb @@ -14,7 +14,7 @@ class ServiceEventEntity < Grape::Entity end expose :description do |event| - service.class.event_description(event) + ServicesHelper.service_event_description(event) end expose :field, if: -> (_, _) { event_field } do diff --git a/app/serializers/service_field_entity.rb b/app/serializers/service_field_entity.rb index 9929d7e2e5a..08e08ae187f 100644 --- a/app/serializers/service_field_entity.rb +++ b/app/serializers/service_field_entity.rb @@ -11,6 +11,8 @@ class ServiceFieldEntity < Grape::Entity if field[:type] == 'password' && value.present? 'true' + elsif field[:type] == 'checkbox' + ActiveRecord::Type::Boolean.new.deserialize(value).to_s else value end diff --git a/app/serializers/stage_entity.rb b/app/serializers/stage_entity.rb index 0b0454c5282..0aadcd01a43 100644 --- a/app/serializers/stage_entity.rb +++ b/app/serializers/stage_entity.rb @@ -59,13 +59,13 @@ class StageEntity < Grape::Entity end def latest_statuses - HasStatus::ORDERED_STATUSES.flat_map do |ordered_status| + Ci::HasStatus::ORDERED_STATUSES.flat_map do |ordered_status| grouped_statuses.fetch(ordered_status, []) end end def retried_statuses - HasStatus::ORDERED_STATUSES.flat_map do |ordered_status| + Ci::HasStatus::ORDERED_STATUSES.flat_map do |ordered_status| grouped_retried_statuses.fetch(ordered_status, []) end end diff --git a/app/serializers/suggestion_entity.rb b/app/serializers/suggestion_entity.rb index 4fb19fbc074..c9fcbe14f2e 100644 --- a/app/serializers/suggestion_entity.rb +++ b/app/serializers/suggestion_entity.rb @@ -2,6 +2,7 @@ class SuggestionEntity < API::Entities::Suggestion include RequestAwareEntity + include Gitlab::Utils::StrongMemoize unexpose :from_line, :to_line, :from_content, :to_content expose :diff_lines, using: DiffLineEntity do |suggestion| @@ -9,7 +10,29 @@ class SuggestionEntity < API::Entities::Suggestion end expose :current_user do expose :can_apply do |suggestion| - Ability.allowed?(current_user, :apply_suggestion, suggestion) + can_apply?(suggestion) + end + end + + expose :inapplicable_reason do |suggestion| + next _("You don't have write access to the source branch.") unless can_apply?(suggestion) + next if suggestion.appliable? + + case suggestion.inapplicable_reason + when :merge_request_merged + _("This merge request was merged. To apply this suggestion, edit this file directly.") + when :merge_request_closed + _("This merge request is closed. To apply this suggestion, edit this file directly.") + when :source_branch_deleted + _("Can't apply as the source branch was deleted.") + when :outdated + phrase = suggestion.single_line? ? 'this line was' : 'these lines were' + + _("Can't apply as %{phrase} changed in a more recent version.") % { phrase: phrase } + when :same_content + _("This suggestion already matches its content.") + else + _("Can't apply this suggestion.") end end @@ -18,4 +41,10 @@ class SuggestionEntity < API::Entities::Suggestion def current_user request.current_user end + + def can_apply?(suggestion) + strong_memoize(:can_apply) do + Ability.allowed?(current_user, :apply_suggestion, suggestion) + end + end end diff --git a/app/serializers/test_report_summary_entity.rb b/app/serializers/test_report_summary_entity.rb new file mode 100644 index 00000000000..5995ca007d6 --- /dev/null +++ b/app/serializers/test_report_summary_entity.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class TestReportSummaryEntity < TestReportEntity + expose :test_suites, using: TestSuiteSummaryEntity do |summary| + summary.test_suites.values + end +end diff --git a/app/serializers/test_report_summary_serializer.rb b/app/serializers/test_report_summary_serializer.rb new file mode 100644 index 00000000000..6077a4e87bb --- /dev/null +++ b/app/serializers/test_report_summary_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class TestReportSummarySerializer < BaseSerializer + entity TestReportSummaryEntity +end diff --git a/app/serializers/test_suite_entity.rb b/app/serializers/test_suite_entity.rb index 53fa830718a..d04fd5f6a84 100644 --- a/app/serializers/test_suite_entity.rb +++ b/app/serializers/test_suite_entity.rb @@ -9,9 +9,11 @@ class TestSuiteEntity < Grape::Entity expose :failed_count expose :skipped_count expose :error_count - expose :suite_error - expose :test_cases, using: TestCaseEntity do |test_suite| - test_suite.suite_error ? [] : test_suite.test_cases.values.flat_map(&:values) + with_options if: -> (_, opts) { opts[:details] } do |test_suite| + expose :suite_error + expose :test_cases, using: TestCaseEntity do |test_suite| + test_suite.suite_error ? [] : test_suite.test_cases.values.flat_map(&:values) + end end end diff --git a/app/serializers/test_suite_serializer.rb b/app/serializers/test_suite_serializer.rb new file mode 100644 index 00000000000..f11d0fbe7e6 --- /dev/null +++ b/app/serializers/test_suite_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class TestSuiteSerializer < BaseSerializer + entity TestSuiteEntity +end diff --git a/app/serializers/test_suite_summary_entity.rb b/app/serializers/test_suite_summary_entity.rb new file mode 100644 index 00000000000..6718b31a7f5 --- /dev/null +++ b/app/serializers/test_suite_summary_entity.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class TestSuiteSummaryEntity < TestSuiteEntity + expose :build_ids do |summary| + summary.build_ids + end +end diff --git a/app/serializers/triggered_pipeline_entity.rb b/app/serializers/triggered_pipeline_entity.rb index fd7e4454abf..47f51a6d76a 100644 --- a/app/serializers/triggered_pipeline_entity.rb +++ b/app/serializers/triggered_pipeline_entity.rb @@ -11,6 +11,12 @@ class TriggeredPipelineEntity < Grape::Entity expose :coverage expose :source + expose :source_job do + expose :name do |pipeline| + pipeline.source_job&.name + end + end + expose :path do |pipeline| project_pipeline_path(pipeline.project, pipeline) end @@ -27,7 +33,7 @@ class TriggeredPipelineEntity < Grape::Entity as: :triggered_by, with: TriggeredPipelineEntity, if: -> (_, opts) { can_read_details? && expand_for_path?(opts) } - expose :triggered_pipelines, + expose :triggered_pipelines_with_preloads, as: :triggered, using: TriggeredPipelineEntity, if: -> (_, opts) { can_read_details? && expand_for_path?(opts) } diff --git a/app/serializers/variable_entity.rb b/app/serializers/variable_entity.rb deleted file mode 100644 index 9b0db371acb..00000000000 --- a/app/serializers/variable_entity.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -class VariableEntity < Ci::BasicVariableEntity - expose :environment_scope -end diff --git a/app/serializers/variable_serializer.rb b/app/serializers/variable_serializer.rb deleted file mode 100644 index 586666cad8e..00000000000 --- a/app/serializers/variable_serializer.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -class VariableSerializer < BaseSerializer - entity VariableEntity -end diff --git a/app/services/access_token_validation_service.rb b/app/services/access_token_validation_service.rb index 851d862c0cf..eb2e66a9285 100644 --- a/app/services/access_token_validation_service.rb +++ b/app/services/access_token_validation_service.rb @@ -17,21 +17,21 @@ class AccessTokenValidationService def validate(scopes: []) if token.expired? - return EXPIRED + EXPIRED elsif token.revoked? - return REVOKED + REVOKED elsif !self.include_any_scope?(scopes) - return INSUFFICIENT_SCOPE + INSUFFICIENT_SCOPE elsif token.respond_to?(:impersonation) && token.impersonation && !Gitlab.config.gitlab.impersonation_enabled - return IMPERSONATION_DISABLED + IMPERSONATION_DISABLED else - return VALID + VALID end end diff --git a/app/services/admin/propagate_integration_service.rb b/app/services/admin/propagate_integration_service.rb index 084b103ee3b..e21bb03ed68 100644 --- a/app/services/admin/propagate_integration_service.rb +++ b/app/services/admin/propagate_integration_service.rb @@ -64,7 +64,7 @@ module Admin def create_integration_for_projects_without_integration loop do - batch = Project.uncached { project_ids_without_integration } + batch = Project.uncached { Project.ids_without_integration(integration, BATCH_SIZE) } bulk_create_from_integration(batch) unless batch.empty? @@ -114,22 +114,6 @@ module Admin integration.type == 'ExternalWikiService' end - # rubocop: disable CodeReuse/ActiveRecord - def project_ids_without_integration - services = Service - .select('1') - .where('services.project_id = projects.id') - .where(type: integration.type) - - Project - .where('NOT EXISTS (?)', services) - .where(pending_delete: false) - .where(archived: false) - .limit(BATCH_SIZE) - .pluck(:id) - end - # rubocop: enable CodeReuse/ActiveRecord - def service_hash @service_hash ||= integration.to_service_hash .tap { |json| json['inherit_from_id'] = integration.id } diff --git a/app/services/alert_management/alerts/todo/create_service.rb b/app/services/alert_management/alerts/todo/create_service.rb new file mode 100644 index 00000000000..87af943fdc2 --- /dev/null +++ b/app/services/alert_management/alerts/todo/create_service.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module AlertManagement + module Alerts + module Todo + class CreateService + # @param alert [AlertManagement::Alert] + # @param current_user [User] + def initialize(alert, current_user) + @alert = alert + @current_user = current_user + end + + def execute + return error_no_permissions unless allowed? + + todos = TodoService.new.mark_todo(alert, current_user) + todo = todos&.first + + return error_existing_todo unless todo + + success(todo) + end + + private + + attr_reader :alert, :current_user + + def allowed? + current_user&.can?(:update_alert_management_alert, alert) + end + + def error(message) + ServiceResponse.error(payload: { alert: alert, todo: nil }, message: message) + end + + def success(todo) + ServiceResponse.success(payload: { alert: alert, todo: todo }) + end + + def error_no_permissions + error(_('You have insufficient permissions to create a Todo for this alert')) + end + + def error_existing_todo + error(_('You already have pending todo for this alert')) + end + end + end + end +end diff --git a/app/services/alert_management/alerts/update_service.rb b/app/services/alert_management/alerts/update_service.rb index ffabbb37289..0b7216cd9f8 100644 --- a/app/services/alert_management/alerts/update_service.rb +++ b/app/services/alert_management/alerts/update_service.rb @@ -12,17 +12,20 @@ module AlertManagement @alert = alert @current_user = current_user @params = params + @param_errors = [] end def execute return error_no_permissions unless allowed? - return error_no_updates if params.empty? - filter_assignees + filter_params + return error_invalid_params if param_errors.any? + + # Save old assignees for system notes old_assignees = alert.assignees.to_a if alert.update(params) - process_assignement(old_assignees) + handle_changes(old_assignees: old_assignees) success else @@ -32,16 +35,13 @@ module AlertManagement private - attr_reader :alert, :current_user, :params + attr_reader :alert, :current_user, :params, :param_errors + delegate :resolved?, to: :alert def allowed? current_user&.can?(:update_alert_management_alert, alert) end - def assignee_todo_allowed? - assignee&.can?(:read_alert_management_alert, alert) - end - def todo_service strong_memoize(:todo_service) do TodoService.new @@ -60,39 +60,122 @@ module AlertManagement error(_('You have no permissions')) end - def error_no_updates - error(_('Please provide attributes to update')) + def error_invalid_params + error(param_errors.to_sentence) + end + + def add_param_error(message) + param_errors << message + end + + def filter_params + param_errors << _('Please provide attributes to update') if params.empty? + + filter_status + filter_assignees + filter_duplicate + end + + def handle_changes(old_assignees:) + handle_assignement(old_assignees) if params[:assignees] + handle_status_change if params[:status_event] end # ----- Assignee-related behavior ------ def filter_assignees return if params[:assignees].nil? - params[:assignees] = Array(assignee) + # Always take first assignee while multiple are not currently supported + params[:assignees] = Array(params[:assignees].first) + + param_errors << _('Assignee has no permissions') if unauthorized_assignees? end - def assignee - strong_memoize(:assignee) do - # Take first assignee while multiple are not currently supported - params[:assignees]&.first - end + def unauthorized_assignees? + params[:assignees]&.any? { |user| !user.can?(:read_alert_management_alert, alert) } end - def process_assignement(old_assignees) + def handle_assignement(old_assignees) assign_todo add_assignee_system_note(old_assignees) end def assign_todo - # Remove check in follow-up issue https://gitlab.com/gitlab-org/gitlab/-/issues/222672 - return unless assignee_todo_allowed? - todo_service.assign_alert(alert, current_user) end def add_assignee_system_note(old_assignees) SystemNoteService.change_issuable_assignees(alert, alert.project, current_user, old_assignees) end + + # ------ Status-related behavior ------- + def filter_status + return unless params[:status] + + status_event = AlertManagement::Alert::STATUS_EVENTS[status_key] + + unless status_event + param_errors << _('Invalid status') + return + end + + params[:status_event] = status_event + end + + def status_key + strong_memoize(:status_key) do + status = params.delete(:status) + AlertManagement::Alert::STATUSES.key(status) + end + end + + def handle_status_change + add_status_change_system_note + resolve_todos if resolved? + end + + def add_status_change_system_note + SystemNoteService.change_alert_status(alert, current_user) + end + + def resolve_todos + todo_service.resolve_todos_for_target(alert, current_user) + end + + def filter_duplicate + # Only need to check if changing to an open status + return unless params[:status_event] && AlertManagement::Alert::OPEN_STATUSES.include?(status_key) + + param_errors << unresolved_alert_error if duplicate_alert? + end + + def duplicate_alert? + return if alert.fingerprint.blank? + + open_alerts.any? && open_alerts.exclude?(alert) + end + + def open_alerts + strong_memoize(:open_alerts) do + AlertManagement::Alert.for_fingerprint(alert.project, alert.fingerprint).open + end + end + + def unresolved_alert_error + _('An %{link_start}alert%{link_end} with the same fingerprint is already open. ' \ + 'To change the status of this alert, resolve the linked alert.' + ) % open_alert_url_params + end + + def open_alert_url_params + open_alert = open_alerts.first + alert_path = Gitlab::Routing.url_helpers.details_project_alert_management_path(alert.project, open_alert) + + { + link_start: '<a href="%{url}">'.html_safe % { url: alert_path }, + link_end: '</a>'.html_safe + } + end end end end diff --git a/app/services/alert_management/create_alert_issue_service.rb b/app/services/alert_management/create_alert_issue_service.rb index beacd240b08..6ea3fd867ef 100644 --- a/app/services/alert_management/create_alert_issue_service.rb +++ b/app/services/alert_management/create_alert_issue_service.rb @@ -2,6 +2,8 @@ module AlertManagement class CreateAlertIssueService + include Gitlab::Utils::StrongMemoize + # @param alert [AlertManagement::Alert] # @param user [User] def initialize(alert, user) @@ -13,18 +15,20 @@ module AlertManagement return error_no_permissions unless allowed? return error_issue_already_exists if alert.issue - result = create_issue(alert, user, alert_payload) - @issue = result[:issue] + result = create_issue + issue = result.payload[:issue] + + return error(result.message, issue) if result.error? + return error(object_errors(alert), issue) unless associate_alert_with_issue(issue) - return error(result[:message]) if result[:status] == :error - return error(alert.errors.full_messages.to_sentence) unless update_alert_issue_id + SystemNoteService.new_alert_issue(alert, issue, user) - success + result end private - attr_reader :alert, :user, :issue + attr_reader :alert, :user delegate :project, to: :alert @@ -32,29 +36,36 @@ module AlertManagement user.can?(:create_issue, project) end - def create_issue(alert, user, alert_payload) - ::IncidentManagement::CreateIssueService - .new(project, alert_payload, user) - .execute(skip_settings_check: true) - end + def create_issue + label_result = find_or_create_incident_label - def alert_payload - if alert.prometheus? - alert.payload - else - Gitlab::Alerting::NotificationPayloadParser.call(alert.payload.to_h) - end + # Create an unlabelled issue if we couldn't create the label + # due to a race condition. + # See https://gitlab.com/gitlab-org/gitlab-foss/issues/65042 + extra_params = label_result.success? ? { label_ids: [label_result.payload[:label].id] } : {} + + issue = Issues::CreateService.new( + project, + user, + title: alert_presenter.title, + description: alert_presenter.issue_description, + **extra_params + ).execute + + return error(object_errors(issue), issue) unless issue.valid? + + success(issue) end - def update_alert_issue_id + def associate_alert_with_issue(issue) alert.update(issue_id: issue.id) end - def success + def success(issue) ServiceResponse.success(payload: { issue: issue }) end - def error(message) + def error(message, issue = nil) ServiceResponse.error(payload: { issue: issue }, message: message) end @@ -65,5 +76,19 @@ module AlertManagement def error_no_permissions error(_('You have no permissions')) end + + def alert_presenter + strong_memoize(:alert_presenter) do + alert.present + end + end + + def find_or_create_incident_label + IncidentManagement::CreateIncidentLabelService.new(project, user).execute + end + + def object_errors(object) + object.errors.full_messages.to_sentence + end end end diff --git a/app/services/alert_management/process_prometheus_alert_service.rb b/app/services/alert_management/process_prometheus_alert_service.rb index 90fcbd95e4b..573d3914c05 100644 --- a/app/services/alert_management/process_prometheus_alert_service.rb +++ b/app/services/alert_management/process_prometheus_alert_service.rb @@ -66,7 +66,11 @@ module AlertManagement def process_resolved_alert_management_alert return if am_alert.blank? - return if am_alert.resolve(ends_at) + + if am_alert.resolve(ends_at) + close_issue(am_alert.issue) + return + end logger.warn( message: 'Unable to update AlertManagement::Alert status to resolved', @@ -75,12 +79,22 @@ module AlertManagement ) 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 logger @logger ||= Gitlab::AppLogger end def am_alert - @am_alert ||= AlertManagement::Alert.for_fingerprint(project, gitlab_fingerprint).first + @am_alert ||= AlertManagement::Alert.not_resolved.for_fingerprint(project, gitlab_fingerprint).first end def bad_request diff --git a/app/services/alert_management/update_alert_status_service.rb b/app/services/alert_management/update_alert_status_service.rb deleted file mode 100644 index a7ebddb82e0..00000000000 --- a/app/services/alert_management/update_alert_status_service.rb +++ /dev/null @@ -1,63 +0,0 @@ -# frozen_string_literal: true - -module AlertManagement - class UpdateAlertStatusService - include Gitlab::Utils::StrongMemoize - - # @param alert [AlertManagement::Alert] - # @param user [User] - # @param status [Integer] Must match a value from AlertManagement::Alert::STATUSES - def initialize(alert, user, status) - @alert = alert - @user = user - @status = status - end - - def execute - return error_no_permissions unless allowed? - return error_invalid_status unless status_key - - if alert.update(status_event: status_event) - success - else - error(alert.errors.full_messages.to_sentence) - end - end - - private - - attr_reader :alert, :user, :status - - delegate :project, to: :alert - - def allowed? - user.can?(:update_alert_management_alert, project) - end - - def status_key - strong_memoize(:status_key) do - AlertManagement::Alert::STATUSES.key(status) - end - end - - def status_event - AlertManagement::Alert::STATUS_EVENTS[status_key] - end - - def success - ServiceResponse.success(payload: { alert: alert }) - end - - def error_no_permissions - error(_('You have no permissions')) - end - - def error_invalid_status - error(_('Invalid status')) - end - - def error(message) - ServiceResponse.error(payload: { alert: alert }, message: message) - end - end -end diff --git a/app/services/audit_event_service.rb b/app/services/audit_event_service.rb index fb309aed649..fef733a7d09 100644 --- a/app/services/audit_event_service.rb +++ b/app/services/audit_event_service.rb @@ -16,6 +16,7 @@ class AuditEventService @author = build_author(author) @entity = entity @details = details + @ip_address = (@details[:ip_address].presence || @author.current_sign_in_ip) end # Builds the @details attribute for authentication @@ -49,6 +50,8 @@ class AuditEventService private + attr_reader :ip_address + def build_author(author) case author when User @@ -61,6 +64,7 @@ class AuditEventService def base_payload { author_id: @author.id, + author_name: @author.name, entity_id: @entity.id, entity_type: @entity.class.name } diff --git a/app/services/authorized_project_update/project_create_service.rb b/app/services/authorized_project_update/project_create_service.rb index c17c0a033fe..5809315a066 100644 --- a/app/services/authorized_project_update/project_create_service.rb +++ b/app/services/authorized_project_update/project_create_service.rb @@ -21,7 +21,7 @@ module AuthorizedProjectUpdate { user_id: member.user_id, project_id: project.id, access_level: member.access_level } end - ProjectAuthorization.insert_all(attributes) + ProjectAuthorization.insert_all(attributes) unless attributes.empty? end ServiceResponse.success diff --git a/app/services/authorized_project_update/project_group_link_create_service.rb b/app/services/authorized_project_update/project_group_link_create_service.rb new file mode 100644 index 00000000000..db2db091374 --- /dev/null +++ b/app/services/authorized_project_update/project_group_link_create_service.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module AuthorizedProjectUpdate + class ProjectGroupLinkCreateService < BaseService + include Gitlab::Utils::StrongMemoize + + BATCH_SIZE = 1000 + + def initialize(project, group) + @project = project + @group = group + end + + def execute + group.members_from_self_and_ancestors_with_effective_access_level + .each_batch(of: BATCH_SIZE, column: :user_id) do |members| + existing_authorizations = existing_project_authorizations(members) + authorizations_to_create = [] + user_ids_to_delete = [] + + members.each do |member| + existing_access_level = existing_authorizations[member.user_id] + + if existing_access_level + # User might already have access to the project unrelated to the + # current project share + next if existing_access_level >= member.access_level + + user_ids_to_delete << member.user_id + end + + authorizations_to_create << { user_id: member.user_id, + project_id: project.id, + access_level: member.access_level } + end + + update_authorizations(user_ids_to_delete, authorizations_to_create) + end + + ServiceResponse.success + end + + private + + attr_reader :project, :group + + def existing_project_authorizations(members) + user_ids = members.map(&:user_id) + + ProjectAuthorization.where(project_id: project.id, user_id: user_ids) # rubocop: disable CodeReuse/ActiveRecord + .select(:user_id, :access_level) + .each_with_object({}) do |authorization, hash| + hash[authorization.user_id] = authorization.access_level + end + end + + def update_authorizations(user_ids_to_delete, authorizations_to_create) + ProjectAuthorization.transaction do + if user_ids_to_delete.any? + ProjectAuthorization.where(project_id: project.id, user_id: user_ids_to_delete) # rubocop: disable CodeReuse/ActiveRecord + .delete_all + end + + if authorizations_to_create.any? + ProjectAuthorization.insert_all(authorizations_to_create) + end + end + end + end +end diff --git a/app/services/auto_merge/base_service.rb b/app/services/auto_merge/base_service.rb index c4109765a1c..5c63dc34cb1 100644 --- a/app/services/auto_merge/base_service.rb +++ b/app/services/auto_merge/base_service.rb @@ -11,7 +11,7 @@ module AutoMerge yield if block_given? end - # Notify the event that auto merge is enabled or merge param is updated + notify(merge_request) AutoMergeProcessWorker.perform_async(merge_request.id) strategy.to_sym @@ -62,6 +62,10 @@ module AutoMerge private + # Overridden in child classes + def notify(merge_request) + end + def strategy strong_memoize(:strategy) do self.class.name.demodulize.remove('Service').underscore diff --git a/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb b/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb index 9ae5bd1b5ec..7e0298432ac 100644 --- a/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb +++ b/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb @@ -34,5 +34,13 @@ module AutoMerge merge_request.actual_head_pipeline&.active? end end + + private + + def notify(merge_request) + return unless Feature.enabled?(:mwps_notification, project) + + notification_service.async.merge_when_pipeline_succeeds(merge_request, current_user) if merge_request.saved_change_to_auto_merge_enabled? + end end end diff --git a/app/services/branches/delete_service.rb b/app/services/branches/delete_service.rb index ca2b4556b58..9bd5b343448 100644 --- a/app/services/branches/delete_service.rb +++ b/app/services/branches/delete_service.rb @@ -19,6 +19,7 @@ module Branches end if repository.rm_branch(current_user, branch_name) + unlock_artifacts(branch_name) ServiceResponse.success(message: 'Branch was deleted') else ServiceResponse.error( @@ -28,5 +29,11 @@ module Branches rescue Gitlab::Git::PreReceiveError => ex ServiceResponse.error(message: ex.message, http_status: 400) end + + private + + def unlock_artifacts(branch_name) + Ci::RefDeleteUnlockArtifactsWorker.perform_async(project.id, current_user.id, "#{::Gitlab::Git::BRANCH_REF_PREFIX}#{branch_name}") + end end end diff --git a/app/services/ci/authorize_job_artifact_service.rb b/app/services/ci/authorize_job_artifact_service.rb deleted file mode 100644 index 893e92d427c..00000000000 --- a/app/services/ci/authorize_job_artifact_service.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -module Ci - class AuthorizeJobArtifactService - include Gitlab::Utils::StrongMemoize - - # Max size of the zipped LSIF artifact - LSIF_ARTIFACT_MAX_SIZE = 20.megabytes - LSIF_ARTIFACT_TYPE = 'lsif' - - def initialize(job, params, max_size:) - @job = job - @max_size = max_size - @size = params[:filesize] - @type = params[:artifact_type].to_s - end - - def forbidden? - lsif? && !code_navigation_enabled? - end - - def too_large? - size && max_size <= size.to_i - end - - def headers - default_headers = JobArtifactUploader.workhorse_authorize(has_length: false, maximum_size: max_size) - default_headers.tap do |h| - h[:ProcessLsif] = true if lsif? && code_navigation_enabled? - end - end - - private - - attr_reader :job, :size, :type - - def code_navigation_enabled? - strong_memoize(:code_navigation_enabled) do - Feature.enabled?(:code_navigation, job.project, default_enabled: true) - end - end - - def lsif? - strong_memoize(:lsif) do - type == LSIF_ARTIFACT_TYPE - end - end - - def max_size - lsif? ? LSIF_ARTIFACT_MAX_SIZE : @max_size.to_i - end - end -end diff --git a/app/services/ci/create_job_artifacts_service.rb b/app/services/ci/create_job_artifacts_service.rb index f0ffe67510b..9a6e103e5dd 100644 --- a/app/services/ci/create_job_artifacts_service.rb +++ b/app/services/ci/create_job_artifacts_service.rb @@ -3,42 +3,104 @@ module Ci class CreateJobArtifactsService < ::BaseService ArtifactsExistError = Class.new(StandardError) + + LSIF_ARTIFACT_TYPE = 'lsif' + OBJECT_STORAGE_ERRORS = [ Errno::EIO, Google::Apis::ServerError, Signet::RemoteServerError ].freeze - def execute(job, artifacts_file, params, metadata_file: nil) - return success if sha256_matches_existing_artifact?(job, params['artifact_type'], artifacts_file) + def initialize(job) + @job = job + @project = job.project + end + + def authorize(artifact_type:, filesize: nil) + result = validate_requirements(artifact_type: artifact_type, filesize: filesize) + return result unless result[:status] == :success + + headers = JobArtifactUploader.workhorse_authorize(has_length: false, maximum_size: max_size(artifact_type)) - artifact, artifact_metadata = build_artifact(job, artifacts_file, params, metadata_file) - result = parse_artifact(job, artifact) + if lsif?(artifact_type) + headers[:ProcessLsif] = true + headers[:ProcessLsifReferences] = Feature.enabled?(:code_navigation_references, project, default_enabled: false) + end + success(headers: headers) + end + + def execute(artifacts_file, params, metadata_file: nil) + result = validate_requirements(artifact_type: params[:artifact_type], filesize: artifacts_file.size) return result unless result[:status] == :success - persist_artifact(job, artifact, artifact_metadata) + return success if sha256_matches_existing_artifact?(params[:artifact_type], artifacts_file) + + artifact, artifact_metadata = build_artifact(artifacts_file, params, metadata_file) + result = parse_artifact(artifact) + + return result unless result[:status] == :success + + persist_artifact(artifact, artifact_metadata, params) end private - def build_artifact(job, artifacts_file, params, metadata_file) + attr_reader :job, :project + + def validate_requirements(artifact_type:, filesize:) + return forbidden_type_error(artifact_type) if forbidden_type?(artifact_type) + return too_large_error if too_large?(artifact_type, filesize) + + success + end + + def forbidden_type?(type) + lsif?(type) && !code_navigation_enabled? + end + + def too_large?(type, size) + size > max_size(type) if size + end + + def code_navigation_enabled? + Feature.enabled?(:code_navigation, project, default_enabled: true) + end + + def lsif?(type) + type == LSIF_ARTIFACT_TYPE + end + + def max_size(type) + Ci::JobArtifact.max_artifact_size(type: type, project: project) + end + + def forbidden_type_error(type) + error("#{type} artifacts are forbidden", :forbidden) + end + + def too_large_error + error('file size has reached maximum size limit', :payload_too_large) + end + + def build_artifact(artifacts_file, params, metadata_file) expire_in = params['expire_in'] || Gitlab::CurrentSettings.current_application_settings.default_artifacts_expire_in artifact = Ci::JobArtifact.new( job_id: job.id, - project: job.project, + project: project, file: artifacts_file, - file_type: params['artifact_type'], - file_format: params['artifact_format'], + file_type: params[:artifact_type], + file_format: params[:artifact_format], file_sha256: artifacts_file.sha256, expire_in: expire_in) artifact_metadata = if metadata_file Ci::JobArtifact.new( job_id: job.id, - project: job.project, + project: project, file: metadata_file, file_type: :metadata, file_format: :gzip, @@ -46,31 +108,25 @@ module Ci expire_in: expire_in) end - if Feature.enabled?(:keep_latest_artifact_for_ref, job.project) - artifact.locked = true - artifact_metadata&.locked = true - end - [artifact, artifact_metadata] end - def parse_artifact(job, artifact) - unless Feature.enabled?(:ci_synchronous_artifact_parsing, job.project, default_enabled: true) + def parse_artifact(artifact) + unless Feature.enabled?(:ci_synchronous_artifact_parsing, project, default_enabled: true) return success end case artifact.file_type - when 'dotenv' then parse_dotenv_artifact(job, artifact) - when 'cluster_applications' then parse_cluster_applications_artifact(job, artifact) + when 'dotenv' then parse_dotenv_artifact(artifact) + when 'cluster_applications' then parse_cluster_applications_artifact(artifact) else success end end - def persist_artifact(job, artifact, artifact_metadata) + def persist_artifact(artifact, artifact_metadata, params) Ci::JobArtifact.transaction do artifact.save! artifact_metadata&.save! - unlock_previous_artifacts!(artifact) # NOTE: The `artifacts_expire_at` column is already deprecated and to be removed in the near future. job.update_column(:artifacts_expire_at, artifact.expire_at) @@ -78,42 +134,36 @@ module Ci success rescue ActiveRecord::RecordNotUnique => error - track_exception(error, job, params) + track_exception(error, params) error('another artifact of the same type already exists', :bad_request) rescue *OBJECT_STORAGE_ERRORS => error - track_exception(error, job, params) + track_exception(error, params) error(error.message, :service_unavailable) rescue => error - track_exception(error, job, params) + track_exception(error, params) error(error.message, :bad_request) end - def unlock_previous_artifacts!(artifact) - return unless Feature.enabled?(:keep_latest_artifact_for_ref, artifact.job.project) - - Ci::JobArtifact.for_ref(artifact.job.ref, artifact.project_id).locked.update_all(locked: false) - end - - def sha256_matches_existing_artifact?(job, artifact_type, artifacts_file) + def sha256_matches_existing_artifact?(artifact_type, artifacts_file) existing_artifact = job.job_artifacts.find_by_file_type(artifact_type) return false unless existing_artifact existing_artifact.file_sha256 == artifacts_file.sha256 end - def track_exception(error, job, params) + def track_exception(error, params) Gitlab::ErrorTracking.track_exception(error, job_id: job.id, project_id: job.project_id, - uploading_type: params['artifact_type'] + uploading_type: params[:artifact_type] ) end - def parse_dotenv_artifact(job, artifact) - Ci::ParseDotenvArtifactService.new(job.project, current_user).execute(artifact) + def parse_dotenv_artifact(artifact) + Ci::ParseDotenvArtifactService.new(project, current_user).execute(artifact) end - def parse_cluster_applications_artifact(job, artifact) + def parse_cluster_applications_artifact(artifact) Clusters::ParseClusterApplicationsArtifactService.new(job, job.user).execute(artifact) end end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 922c3556362..2d7f5014aa9 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -23,6 +23,24 @@ module Ci Gitlab::Ci::Pipeline::Chain::Limit::Activity, Gitlab::Ci::Pipeline::Chain::Limit::JobActivity].freeze + # Create a new pipeline in the specified project. + # + # @param [Symbol] source What event (Ci::Pipeline.sources) triggers the pipeline + # creation. + # @param [Boolean] ignore_skip_ci Whether skipping a pipeline creation when `[skip ci]` comment + # is present in the commit body + # @param [Boolean] save_on_errors Whether persisting an invalid pipeline when it encounters an + # error during creation (e.g. invalid yaml) + # @param [Ci::TriggerRequest] trigger_request The pipeline trigger triggers the pipeline creation. + # @param [Ci::PipelineSchedule] schedule The pipeline schedule triggers the pipeline creation. + # @param [MergeRequest] merge_request The merge request triggers the pipeline creation. + # @param [ExternalPullRequest] external_pull_request The external pull request triggers the pipeline creation. + # @param [Ci::Bridge] bridge The bridge job that triggers the downstream pipeline creation. + # @param [String] content The content of .gitlab-ci.yml to override the default config + # contents (e.g. .gitlab-ci.yml in repostiry). Mainly used for + # generating a dangling pipeline. + # + # @return [Ci::Pipeline] The created Ci::Pipeline object. # rubocop: disable Metrics/ParameterLists def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, merge_request: nil, external_pull_request: nil, bridge: nil, **options, &block) @pipeline = Ci::Pipeline.new @@ -77,7 +95,7 @@ module Ci def execute!(*args, &block) execute(*args, &block).tap do |pipeline| unless pipeline.persisted? - raise CreateError, pipeline.error_messages + raise CreateError, pipeline.full_error_messages end end end @@ -122,13 +140,8 @@ module Ci end end - def extra_options(options = {}) - # In Ruby 2.4, even when options is empty, f(**options) doesn't work when f - # doesn't have any parameters. We reproduce the Ruby 2.5 behavior by - # checking explicitly that no arguments are given. - raise ArgumentError if options.any? - - {} # overridden in EE + def extra_options(content: nil) + { content: content } end end end diff --git a/app/services/ci/destroy_expired_job_artifacts_service.rb b/app/services/ci/destroy_expired_job_artifacts_service.rb index 5deb84812ac..1fa8926faa1 100644 --- a/app/services/ci/destroy_expired_job_artifacts_service.rb +++ b/app/services/ci/destroy_expired_job_artifacts_service.rb @@ -28,7 +28,7 @@ module Ci private def destroy_batch - artifact_batch = if Feature.enabled?(:keep_latest_artifact_for_ref) + artifact_batch = if Gitlab::Ci::Features.destroy_only_unlocked_expired_artifacts_enabled? Ci::JobArtifact.expired(BATCH_SIZE).unlocked else Ci::JobArtifact.expired(BATCH_SIZE) diff --git a/app/services/ci/pipeline_processing/atomic_processing_service.rb b/app/services/ci/pipeline_processing/atomic_processing_service.rb index b01a9d2e3b8..a23d5d8941a 100644 --- a/app/services/ci/pipeline_processing/atomic_processing_service.rb +++ b/app/services/ci/pipeline_processing/atomic_processing_service.rb @@ -77,7 +77,7 @@ module Ci def update_processable!(processable) status = processable_status(processable) - return unless HasStatus::COMPLETED_STATUSES.include?(status) + return unless Ci::HasStatus::COMPLETED_STATUSES.include?(status) # transition status if possible Gitlab::OptimisticLocking.retry_lock(processable) do |subject| diff --git a/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb b/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb index 2228328882d..d0aa8b04775 100644 --- a/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb +++ b/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb @@ -80,7 +80,7 @@ module Ci # TODO: This is hack to support # the same exact behaviour for Atomic and Legacy processing # that DAG is blocked from executing if dependent is not "complete" - if dag && statuses.any? { |status| HasStatus::COMPLETED_STATUSES.exclude?(status[:status]) } + if dag && statuses.any? { |status| Ci::HasStatus::COMPLETED_STATUSES.exclude?(status[:status]) } return 'pending' end diff --git a/app/services/ci/pipeline_processing/legacy_processing_service.rb b/app/services/ci/pipeline_processing/legacy_processing_service.rb index c471f7f0011..56fbc7271da 100644 --- a/app/services/ci/pipeline_processing/legacy_processing_service.rb +++ b/app/services/ci/pipeline_processing/legacy_processing_service.rb @@ -35,7 +35,7 @@ module Ci def process_stage_for_stage_scheduling(index) current_status = status_for_prior_stages(index) - return unless HasStatus::COMPLETED_STATUSES.include?(current_status) + return unless Ci::HasStatus::COMPLETED_STATUSES.include?(current_status) created_stage_scheduled_processables_in_stage(index).find_each.select do |build| process_build(build, current_status) @@ -73,7 +73,7 @@ module Ci def process_dag_build_with_needs(build) current_status = status_for_build_needs(build.needs.map(&:name)) - return unless HasStatus::COMPLETED_STATUSES.include?(current_status) + return unless Ci::HasStatus::COMPLETED_STATUSES.include?(current_status) process_build(build, current_status) end diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb index 80ebe5f5eb6..1f24dce0458 100644 --- a/app/services/ci/process_pipeline_service.rb +++ b/app/services/ci/process_pipeline_service.rb @@ -9,6 +9,8 @@ module Ci end def execute(trigger_build_ids = nil, initial_process: false) + increment_processing_counter + update_retried if ::Gitlab::Ci::Features.atomic_processing?(pipeline.project) @@ -22,6 +24,10 @@ module Ci end end + def metrics + @metrics ||= ::Gitlab::Ci::Pipeline::Metrics.new + end + private # This method is for compatibility and data consistency and should be removed with 9.3 version of GitLab @@ -43,5 +49,9 @@ module Ci .update_all(retried: true) if latest_statuses.any? end # rubocop: enable CodeReuse/ActiveRecord + + def increment_processing_counter + metrics.pipeline_processing_events_counter.increment + end end end diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index 17b9e56636b..3797ea1d96c 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -11,7 +11,7 @@ module Ci METRICS_SHARD_TAG_PREFIX = 'metrics_shard::'.freeze DEFAULT_METRICS_SHARD = 'default'.freeze - Result = Struct.new(:build, :valid?) + Result = Struct.new(:build, :build_json, :valid?) def initialize(runner) @runner = runner @@ -59,7 +59,7 @@ module Ci end register_failure - Result.new(nil, valid) + Result.new(nil, nil, valid) end # rubocop: enable CodeReuse/ActiveRecord @@ -71,7 +71,7 @@ module Ci # In case when 2 runners try to assign the same build, second runner will be declined # with StateMachines::InvalidTransition or StaleObjectError when doing run! or save method. if assign_runner!(build, params) - Result.new(build, true) + present_build!(build) end rescue StateMachines::InvalidTransition, ActiveRecord::StaleObjectError # We are looping to find another build that is not conflicting @@ -83,8 +83,10 @@ module Ci # In case we hit the concurrency-access lock, # we still have to return 409 in the end, # to make sure that this is properly handled by runner. - Result.new(nil, false) + Result.new(nil, nil, false) rescue => ex + # If an error (e.g. GRPC::DeadlineExceeded) occurred constructing + # the result, consider this as a failure to be retried. scheduler_failure!(build) track_exception_for_build(ex, build) @@ -92,6 +94,15 @@ module Ci nil end + # Force variables evaluation to occur now + def present_build!(build) + # We need to use the presenter here because Gitaly calls in the presenter + # may fail, and we need to ensure the response has been generated. + presented_build = ::Ci::BuildRunnerPresenter.new(build) # rubocop:disable CodeReuse/Presenter + build_json = ::API::Entities::JobRequest::Response.new(presented_build).to_json + Result.new(build, build_json, true) + end + def assign_runner!(build, params) build.runner_id = runner.id build.runner_session_attributes = params[:session] if params[:session].present? diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb index 23507a31c72..60b3d28b0c5 100644 --- a/app/services/ci/retry_build_service.rb +++ b/app/services/ci/retry_build_service.rb @@ -34,10 +34,6 @@ module Ci attributes[:user] = current_user - # TODO: we can probably remove this logic - # see: https://gitlab.com/gitlab-org/gitlab/-/issues/217930 - attributes[:scheduling_type] ||= build.find_legacy_scheduling_type - Ci::Build.transaction do # mark all other builds of that name as retried build.pipeline.builds.latest @@ -59,7 +55,9 @@ module Ci build = project.builds.new(attributes) build.assign_attributes(::Gitlab::Ci::Pipeline::Seed::Build.environment_attributes_for(build)) build.retried = false - build.save! + BulkInsertableAssociations.with_bulk_insert(enabled: ::Gitlab::Ci::Features.bulk_insert_on_create?(project)) do + build.save! + end build end end diff --git a/app/services/ci/unlock_artifacts_service.rb b/app/services/ci/unlock_artifacts_service.rb new file mode 100644 index 00000000000..07faf90dd6d --- /dev/null +++ b/app/services/ci/unlock_artifacts_service.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Ci + class UnlockArtifactsService < ::BaseService + BATCH_SIZE = 100 + + def execute(ci_ref, before_pipeline = nil) + query = <<~SQL.squish + UPDATE "ci_pipelines" + SET "locked" = #{::Ci::Pipeline.lockeds[:unlocked]} + WHERE "ci_pipelines"."id" in ( + #{collect_pipelines(ci_ref, before_pipeline).select(:id).to_sql} + LIMIT #{BATCH_SIZE} + FOR UPDATE SKIP LOCKED + ) + RETURNING "ci_pipelines"."id"; + SQL + + loop do + break if ActiveRecord::Base.connection.exec_query(query).empty? + end + end + + private + + def collect_pipelines(ci_ref, before_pipeline) + pipeline_scope = ci_ref.pipelines + pipeline_scope = pipeline_scope.before_pipeline(before_pipeline) if before_pipeline + + pipeline_scope.artifacts_locked + end + end +end diff --git a/app/services/clusters/create_service.rb b/app/services/clusters/create_service.rb index 7b5bf6b32c2..6693a58683f 100644 --- a/app/services/clusters/create_service.rb +++ b/app/services/clusters/create_service.rb @@ -19,10 +19,6 @@ module Clusters cluster = Clusters::Cluster.new(cluster_params) - unless can_create_cluster? - cluster.errors.add(:base, _('Instance does not support multiple Kubernetes clusters')) - end - validate_management_project_permissions(cluster) return cluster if cluster.errors.present? @@ -55,16 +51,9 @@ module Clusters end end - # EE would override this method - def can_create_cluster? - clusterable.clusters.empty? - end - def validate_management_project_permissions(cluster) Clusters::Management::ValidateManagementProjectPermissionsService.new(current_user) .execute(cluster, params[:management_project_id]) end end end - -Clusters::CreateService.prepend_if_ee('EE::Clusters::CreateService') diff --git a/app/services/clusters/parse_cluster_applications_artifact_service.rb b/app/services/clusters/parse_cluster_applications_artifact_service.rb index 35fba5f47c7..6a0ca0ef9d0 100644 --- a/app/services/clusters/parse_cluster_applications_artifact_service.rb +++ b/app/services/clusters/parse_cluster_applications_artifact_service.rb @@ -5,7 +5,7 @@ module Clusters include Gitlab::Utils::StrongMemoize MAX_ACCEPTABLE_ARTIFACT_SIZE = 5.kilobytes - RELEASE_NAMES = %w[prometheus].freeze + RELEASE_NAMES = %w[prometheus cilium].freeze def initialize(job, current_user) @job = job diff --git a/app/services/concerns/exclusive_lease_guard.rb b/app/services/concerns/exclusive_lease_guard.rb index 4678d051d29..a58e9aefcec 100644 --- a/app/services/concerns/exclusive_lease_guard.rb +++ b/app/services/concerns/exclusive_lease_guard.rb @@ -21,7 +21,7 @@ module ExclusiveLeaseGuard lease = exclusive_lease.try_obtain unless lease - log_error('Cannot obtain an exclusive lease. There must be another instance already in execution.') + log_error("Cannot obtain an exclusive lease for #{self.class.name}. There must be another instance already in execution.") return end diff --git a/app/services/concerns/incident_management/settings.rb b/app/services/concerns/incident_management/settings.rb index 5f56d6e7f53..491bd4fa6bf 100644 --- a/app/services/concerns/incident_management/settings.rb +++ b/app/services/concerns/incident_management/settings.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true module IncidentManagement module Settings + include Gitlab::Utils::StrongMemoize + def incident_management_setting strong_memoize(:incident_management_setting) do project.incident_management_setting || diff --git a/app/services/deploy_keys/collect_keys_service.rb b/app/services/deploy_keys/collect_keys_service.rb new file mode 100644 index 00000000000..2ef49bf0f30 --- /dev/null +++ b/app/services/deploy_keys/collect_keys_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module DeployKeys + class CollectKeysService + def initialize(project, current_user) + @project = project + @current_user = current_user + end + + def execute + return [] unless current_user && project && user_can_read_project + + project.deploy_keys_projects + .with_deploy_keys + .with_write_access + .map(&:deploy_key) + end + + private + + def user_can_read_project + Ability.allowed?(current_user, :read_project, project) + end + + attr_reader :project, :current_user + end +end diff --git a/app/services/event_create_service.rb b/app/services/event_create_service.rb index 89c3225dbcd..ad36fe70b3a 100644 --- a/app/services/event_create_service.rb +++ b/app/services/event_create_service.rb @@ -11,44 +11,30 @@ class EventCreateService IllegalActionError = Class.new(StandardError) def open_issue(issue, current_user) - create_resource_event(issue, current_user, :opened) - create_record_event(issue, current_user, :created) end def close_issue(issue, current_user) - create_resource_event(issue, current_user, :closed) - create_record_event(issue, current_user, :closed) end def reopen_issue(issue, current_user) - create_resource_event(issue, current_user, :reopened) - create_record_event(issue, current_user, :reopened) end def open_mr(merge_request, current_user) - create_resource_event(merge_request, current_user, :opened) - create_record_event(merge_request, current_user, :created) end def close_mr(merge_request, current_user) - create_resource_event(merge_request, current_user, :closed) - create_record_event(merge_request, current_user, :closed) end def reopen_mr(merge_request, current_user) - create_resource_event(merge_request, current_user, :reopened) - create_record_event(merge_request, current_user, :reopened) end def merge_mr(merge_request, current_user) - create_resource_event(merge_request, current_user, :merged) - create_record_event(merge_request, current_user, :merged) end @@ -97,23 +83,13 @@ class EventCreateService end def save_designs(current_user, create: [], update: []) - created = create.group_by(&:project).flat_map do |project, designs| - Feature.enabled?(:design_activity_events, project) ? designs : [] - end.to_set - updated = update.group_by(&:project).flat_map do |project, designs| - Feature.enabled?(:design_activity_events, project) ? designs : [] - end.to_set - return [] if created.empty? && updated.empty? - - records = created.zip([:created].cycle) + updated.zip([:updated].cycle) + records = create.zip([:created].cycle) + update.zip([:updated].cycle) + return [] if records.empty? create_record_events(records, current_user) end def destroy_designs(designs, current_user) - designs = designs.select do |design| - Feature.enabled?(:design_activity_events, design.project) - end return [] unless designs.present? create_record_events(designs.zip([:destroyed].cycle), current_user) @@ -127,8 +103,6 @@ class EventCreateService # # @return a tuple of event and either :found or :created def wiki_event(wiki_page_meta, author, action) - return unless Feature.enabled?(:wiki_events) - raise IllegalActionError, action unless Event::WIKI_ACTIONS.include?(action) if duplicate = existing_wiki_event(wiki_page_meta, action) @@ -142,9 +116,15 @@ class EventCreateService event.update_columns(updated_at: time_stamp, created_at: time_stamp) end + Gitlab::UsageDataCounters::TrackUniqueActions.track_action(event_action: action, event_target: wiki_page_meta.class, author_id: author.id) + event end + def approve_mr(merge_request, current_user) + create_record_event(merge_request, current_user, :approved) + end + private def existing_wiki_event(wiki_page_meta, action) @@ -182,7 +162,13 @@ class EventCreateService .merge(action: action, target_id: record.id, target_type: record.class.name) end - Event.insert_all(attribute_sets, returning: %w[id]) + result = Event.insert_all(attribute_sets, returning: %w[id]) + + pairs.each do |record, status| + Gitlab::UsageDataCounters::TrackUniqueActions.track_action(event_action: status, event_target: record.class, author_id: current_user.id) + end + + result end def create_push_event(service_class, project, current_user, push_data) @@ -197,6 +183,8 @@ class EventCreateService new_event end + Gitlab::UsageDataCounters::TrackUniqueActions.track_action(event_action: :pushed, event_target: Project, author_id: current_user.id) + Users::LastPushEventService.new(current_user) .cache_last_push_event(event) @@ -225,18 +213,6 @@ class EventCreateService { resource_parent_attr => resource_parent.id } end - - def create_resource_event(issuable, current_user, status) - return unless state_change_tracking_enabled?(issuable) - - ResourceEvents::ChangeStateService.new(resource: issuable, user: current_user) - .execute(status) - end - - def state_change_tracking_enabled?(issuable) - issuable&.respond_to?(:resource_state_events) && - ::Feature.enabled?(:track_resource_state_change_events, issuable&.project) - end end EventCreateService.prepend_if_ee('EE::EventCreateService') diff --git a/app/services/files/base_service.rb b/app/services/files/base_service.rb index 39e614d6569..d42f718a272 100644 --- a/app/services/files/base_service.rb +++ b/app/services/files/base_service.rb @@ -25,7 +25,7 @@ module Files return false unless commit_id last_commit = Gitlab::Git::Commit - .last_for_path(@start_project.repository, @start_branch, path) + .last_for_path(@start_project.repository, @start_branch, path, literal_pathspec: true) return false unless last_commit diff --git a/app/services/git/branch_push_service.rb b/app/services/git/branch_push_service.rb index 5c1ee981d0c..2ec6ac99ece 100644 --- a/app/services/git/branch_push_service.rb +++ b/app/services/git/branch_push_service.rb @@ -29,6 +29,7 @@ module Git perform_housekeeping stop_environments + unlock_artifacts true end @@ -60,6 +61,12 @@ module Git Ci::StopEnvironmentsService.new(project, current_user).execute(branch_name) end + def unlock_artifacts + return unless removing_branch? + + Ci::RefDeleteUnlockArtifactsWorker.perform_async(project.id, current_user.id, ref) + end + def execute_related_hooks BranchHooksService.new(project, current_user, params).execute end diff --git a/app/services/git/tag_push_service.rb b/app/services/git/tag_push_service.rb index 9a266f7d74c..120c4cde94b 100644 --- a/app/services/git/tag_push_service.rb +++ b/app/services/git/tag_push_service.rb @@ -10,7 +10,25 @@ module Git project.repository.before_push_tag TagHooksService.new(project, current_user, params).execute + unlock_artifacts + true end + + private + + def unlock_artifacts + return unless removing_tag? + + Ci::RefDeleteUnlockArtifactsWorker.perform_async(project.id, current_user.id, ref) + end + + def removing_tag? + Gitlab::Git.blank_ref?(newrev) + end + + def tag_name + Gitlab::Git.ref_name(ref) + end end end diff --git a/app/services/git/wiki_push_service.rb b/app/services/git/wiki_push_service.rb index 8bdbc28f3e8..b3937a10a70 100644 --- a/app/services/git/wiki_push_service.rb +++ b/app/services/git/wiki_push_service.rb @@ -23,7 +23,7 @@ module Git end def can_process_wiki_events? - Feature.enabled?(:wiki_events) && Feature.enabled?(:wiki_events_on_git_push, project) + Feature.enabled?(:wiki_events_on_git_push, project) end def push_changes diff --git a/app/services/gpg_keys/destroy_service.rb b/app/services/gpg_keys/destroy_service.rb new file mode 100644 index 00000000000..cecbfe26611 --- /dev/null +++ b/app/services/gpg_keys/destroy_service.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module GpgKeys + class DestroyService < Keys::BaseService + def execute(key) + key.destroy + end + end +end diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb index eb1b8d4fcc0..ce583095168 100644 --- a/app/services/groups/create_service.rb +++ b/app/services/groups/create_service.rb @@ -28,7 +28,11 @@ module Groups @group.build_chat_team(name: response['name'], team_id: response['id']) end - @group.add_owner(current_user) if @group.save + if @group.save + @group.add_owner(current_user) + add_settings_record + end + @group end @@ -79,6 +83,10 @@ module Groups params[:visibility_level] = Gitlab::CurrentSettings.current_application_settings.default_group_visibility end + + def add_settings_record + @group.create_namespace_settings + end end end diff --git a/app/services/groups/update_shared_runners_service.rb b/app/services/groups/update_shared_runners_service.rb new file mode 100644 index 00000000000..63f57104510 --- /dev/null +++ b/app/services/groups/update_shared_runners_service.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Groups + class UpdateSharedRunnersService < Groups::BaseService + def execute + return error('Operation not allowed', 403) unless can?(current_user, :admin_group, group) + + validate_params + + enable_or_disable_shared_runners! + allow_or_disallow_descendants_override_disabled_shared_runners! + + success + + rescue Group::UpdateSharedRunnersError => error + error(error.message) + end + + private + + def validate_params + if Gitlab::Utils.to_boolean(params[:shared_runners_enabled]) && !params[:allow_descendants_override_disabled_shared_runners].nil? + raise Group::UpdateSharedRunnersError, 'Cannot set shared_runners_enabled to true and allow_descendants_override_disabled_shared_runners' + end + end + + def enable_or_disable_shared_runners! + return if params[:shared_runners_enabled].nil? + + if Gitlab::Utils.to_boolean(params[:shared_runners_enabled]) + group.enable_shared_runners! + else + group.disable_shared_runners! + end + end + + def allow_or_disallow_descendants_override_disabled_shared_runners! + return if params[:allow_descendants_override_disabled_shared_runners].nil? + + # Needs to reset group because if both params are present could result in error + group.reset + + if Gitlab::Utils.to_boolean(params[:allow_descendants_override_disabled_shared_runners]) + group.allow_descendants_override_disabled_shared_runners! + else + group.disallow_descendants_override_disabled_shared_runners! + end + end + end +end diff --git a/app/services/import/bitbucket_server_service.rb b/app/services/import/bitbucket_server_service.rb new file mode 100644 index 00000000000..86e8215821e --- /dev/null +++ b/app/services/import/bitbucket_server_service.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +module Import + class BitbucketServerService < Import::BaseService + attr_reader :client, :params, :current_user + + def execute(credentials) + if blocked_url? + return log_and_return_error("Invalid URL: #{url}", :bad_request) + end + + unless authorized? + return log_and_return_error("You don't have permissions to create this project", :unauthorized) + end + + unless repo + return log_and_return_error("Project %{project_repo} could not be found" % { project_repo: "#{project_key}/#{repo_slug}" }, :unprocessable_entity) + end + + project = create_project(credentials) + + if project.persisted? + success(project) + else + log_and_return_error(project_save_error(project), :unprocessable_entity) + end + rescue BitbucketServer::Connection::ConnectionError => e + log_and_return_error("Import failed due to a BitBucket Server error: #{e}", :bad_request) + end + + private + + def create_project(credentials) + Gitlab::BitbucketServerImport::ProjectCreator.new( + project_key, + repo_slug, + repo, + project_name, + target_namespace, + current_user, + credentials + ).execute + end + + def repo + @repo ||= client.repo(project_key, repo_slug) + end + + def project_name + @project_name ||= params[:new_name].presence || repo.name + end + + def namespace_path + @namespace_path ||= params[:new_namespace].presence || current_user.namespace_path + end + + def target_namespace + @target_namespace ||= find_or_create_namespace(namespace_path, current_user.namespace_path) + end + + def repo_slug + @repo_slug ||= params[:bitbucket_server_repo] + end + + def project_key + @project_key ||= params[:bitbucket_server_project] + end + + def url + @url ||= params[:bitbucket_server_url] + end + + def authorized? + can?(current_user, :create_projects, target_namespace) + end + + def allow_local_requests? + Gitlab::CurrentSettings.allow_local_requests_from_web_hooks_and_services? + end + + def blocked_url? + Gitlab::UrlBlocker.blocked_url?( + url, + { + allow_localhost: allow_local_requests?, + allow_local_network: allow_local_requests?, + schemes: %w(http https) + } + ) + end + + def log_and_return_error(message, error_type) + log_error(message) + error(_(message), error_type) + end + + def log_error(message) + Gitlab::Import::Logger.error( + message: 'Import failed due to a BitBucket Server error', + error: message + ) + end + end +end diff --git a/app/services/incident_management/create_incident_label_service.rb b/app/services/incident_management/create_incident_label_service.rb new file mode 100644 index 00000000000..dbd0d78fa3c --- /dev/null +++ b/app/services/incident_management/create_incident_label_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module IncidentManagement + class CreateIncidentLabelService < BaseService + LABEL_PROPERTIES = { + title: 'incident', + color: '#CC0033', + description: <<~DESCRIPTION.chomp + Denotes a disruption to IT services and \ + the associated issues require immediate attention + DESCRIPTION + }.freeze + + def execute + label = Labels::FindOrCreateService + .new(current_user, project, **LABEL_PROPERTIES) + .execute + + if label.invalid? + log_invalid_label_info(label) + return ServiceResponse.error(payload: { label: label }, message: full_error_message(label)) + end + + ServiceResponse.success(payload: { label: label }) + end + + private + + def log_invalid_label_info(label) + log_info <<~TEXT.chomp + Cannot create incident label "#{label.title}" \ + for "#{label.project.full_name}": #{full_error_message(label)}. + TEXT + end + + def full_error_message(label) + label.errors.full_messages.to_sentence + end + end +end diff --git a/app/services/incident_management/create_issue_service.rb b/app/services/incident_management/create_issue_service.rb index 4b59dc64cec..5e1e0863115 100644 --- a/app/services/incident_management/create_issue_service.rb +++ b/app/services/incident_management/create_issue_service.rb @@ -4,21 +4,12 @@ module IncidentManagement class CreateIssueService < BaseService include Gitlab::Utils::StrongMemoize - INCIDENT_LABEL = { - title: 'incident', - color: '#CC0033', - description: <<~DESCRIPTION.chomp - Denotes a disruption to IT services and \ - the associated issues require immediate attention - DESCRIPTION - }.freeze - - def initialize(project, params, user = User.alert_bot) - super(project, user, params) + def initialize(project, params) + super(project, User.alert_bot, params) end - def execute(skip_settings_check: false) - return error_with('setting disabled') unless skip_settings_check || incident_management_setting.create_issue? + def execute + return error_with('setting disabled') unless incident_management_setting.create_issue? return error_with('invalid alert') unless alert.valid? issue = create_issue @@ -30,26 +21,19 @@ module IncidentManagement private def create_issue - issue = do_create_issue(label_ids: issue_label_ids) + label_result = find_or_create_incident_label - # Create an unlabelled issue if we couldn't create the issue - # due to labels errors. + # Create an unlabelled issue if we couldn't create the label + # due to a race condition. # See https://gitlab.com/gitlab-org/gitlab-foss/issues/65042 - if issue.errors.include?(:labels) - log_label_error(issue) - issue = do_create_issue - end - - issue - end + extra_params = label_result.success? ? { label_ids: [label_result.payload[:label].id] } : {} - def do_create_issue(**params) Issues::CreateService.new( project, current_user, title: issue_title, description: issue_description, - **params + **extra_params ).execute end @@ -67,16 +51,8 @@ module IncidentManagement ].compact.join(horizontal_line) end - def issue_label_ids - [ - find_or_create_label(**INCIDENT_LABEL) - ].compact.map(&:id) - end - - def find_or_create_label(**params) - Labels::FindOrCreateService - .new(current_user, project, **params) - .execute + def find_or_create_incident_label + IncidentManagement::CreateIncidentLabelService.new(project, current_user).execute end def alert_summary @@ -108,15 +84,6 @@ module IncidentManagement issue.errors.full_messages.to_sentence end - def log_label_error(issue) - log_info <<~TEXT.chomp - Cannot create incident issue with labels \ - #{issue.labels.map(&:title).inspect} \ - for "#{project.full_name}": #{issue.errors.full_messages.to_sentence}. - Retrying without labels. - TEXT - end - def error_with(message) log_error(%{Cannot create incident issue for "#{project.full_name}": #{message}}) diff --git a/app/services/incident_management/pager_duty/create_incident_issue_service.rb b/app/services/incident_management/pager_duty/create_incident_issue_service.rb new file mode 100644 index 00000000000..ee0feb49e0d --- /dev/null +++ b/app/services/incident_management/pager_duty/create_incident_issue_service.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module IncidentManagement + module PagerDuty + class CreateIncidentIssueService < BaseService + include IncidentManagement::Settings + + def initialize(project, incident_payload) + super(project, User.alert_bot, incident_payload) + end + + def execute + return forbidden unless webhook_available? + + issue = create_issue + return error(issue.errors.full_messages.to_sentence, issue) unless issue.valid? + + success(issue) + end + + private + + alias_method :incident_payload, :params + + def create_issue + label_result = find_or_create_incident_label + + # Create an unlabelled issue if we couldn't create the label + # due to a race condition. + # See https://gitlab.com/gitlab-org/gitlab-foss/issues/65042 + extra_params = label_result.success? ? { label_ids: [label_result.payload[:label].id] } : {} + + Issues::CreateService.new( + project, + current_user, + title: issue_title, + description: issue_description, + **extra_params + ).execute + end + + def webhook_available? + Feature.enabled?(:pagerduty_webhook, project) && + incident_management_setting.pagerduty_active? + end + + def forbidden + ServiceResponse.error(message: 'Forbidden', http_status: :forbidden) + end + + def find_or_create_incident_label + ::IncidentManagement::CreateIncidentLabelService.new(project, current_user).execute + end + + def issue_title + incident_payload['title'] + end + + def issue_description + Gitlab::IncidentManagement::PagerDuty::IncidentIssueDescription.new(incident_payload).to_s + end + + def success(issue) + ServiceResponse.success(payload: { issue: issue }) + end + + def error(message, issue = nil) + ServiceResponse.error(payload: { issue: issue }, message: message) + end + end + end +end diff --git a/app/services/incident_management/pager_duty/process_webhook_service.rb b/app/services/incident_management/pager_duty/process_webhook_service.rb new file mode 100644 index 00000000000..5dd3186694a --- /dev/null +++ b/app/services/incident_management/pager_duty/process_webhook_service.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module IncidentManagement + module PagerDuty + class ProcessWebhookService < BaseService + include Gitlab::Utils::StrongMemoize + include IncidentManagement::Settings + + # https://developer.pagerduty.com/docs/webhooks/webhook-behavior/#size-limit + PAGER_DUTY_PAYLOAD_SIZE_LIMIT = 55.kilobytes + + # https://developer.pagerduty.com/docs/webhooks/v2-overview/#webhook-types + PAGER_DUTY_PROCESSABLE_EVENT_TYPES = %w(incident.trigger).freeze + + def execute(token) + return forbidden unless webhook_setting_active? + return unauthorized unless valid_token?(token) + return bad_request unless valid_payload_size? + + process_incidents + + accepted + end + + private + + def process_incidents + pager_duty_processable_events.each do |event| + ::IncidentManagement::PagerDuty::ProcessIncidentWorker.perform_async(project.id, event['incident']) + end + end + + def pager_duty_processable_events + strong_memoize(:pager_duty_processable_events) do + ::PagerDuty::WebhookPayloadParser + .call(params.to_h) + .filter { |msg| msg['event'].in?(PAGER_DUTY_PROCESSABLE_EVENT_TYPES) } + end + end + + def webhook_setting_active? + Feature.enabled?(:pagerduty_webhook, project) && + incident_management_setting.pagerduty_active? + end + + def valid_token?(token) + token && incident_management_setting.pagerduty_token == token + end + + def valid_payload_size? + Gitlab::Utils::DeepSize.new(params, max_size: PAGER_DUTY_PAYLOAD_SIZE_LIMIT).valid? + end + + def accepted + ServiceResponse.success(http_status: :accepted) + end + + def forbidden + ServiceResponse.error(message: 'Forbidden', http_status: :forbidden) + end + + def unauthorized + ServiceResponse.error(message: 'Unauthorized', http_status: :unauthorized) + end + + def bad_request + ServiceResponse.error(message: 'Bad Request', http_status: :bad_request) + end + end + end +end diff --git a/app/services/issuable/bulk_update_service.rb b/app/services/issuable/bulk_update_service.rb index 2902385da4a..79be771b3fb 100644 --- a/app/services/issuable/bulk_update_service.rb +++ b/app/services/issuable/bulk_update_service.rb @@ -11,40 +11,29 @@ module Issuable end def execute(type) - model_class = type.classify.constantize - update_class = type.classify.pluralize.constantize::UpdateService - ids = params.delete(:issuable_ids).split(",") - items = find_issuables(parent, model_class, ids) + set_update_params(type) + items = update_issuables(type, ids) + response_success(payload: { count: items.count }) + rescue ArgumentError => e + response_error(e.message, 422) + end + + private + + def set_update_params(type) params.slice!(*permitted_attrs(type)) params.delete_if { |k, v| v.blank? } if params[:assignee_ids] == [IssuableFinder::Params::NONE.to_s] params[:assignee_ids] = [] end - - items.each do |issuable| - next unless can?(current_user, :"update_#{type}", issuable) - - update_class.new(issuable.issuing_parent, current_user, params).execute(issuable) - end - - { - count: items.count, - success: !items.count.zero? - } end - private - def permitted_attrs(type) attrs = %i(state_event milestone_id add_label_ids remove_label_ids subscription_event) - issuable_specific_attrs(type, attrs) - end - - def issuable_specific_attrs(type, attrs) if type == 'issue' || type == 'merge_request' attrs.push(:assignee_ids) else @@ -52,6 +41,20 @@ module Issuable end end + def update_issuables(type, ids) + model_class = type.classify.constantize + update_class = type.classify.pluralize.constantize::UpdateService + items = find_issuables(parent, model_class, ids) + + items.each do |issuable| + next unless can?(current_user, :"update_#{type}", issuable) + + update_class.new(issuable.issuing_parent, current_user, params).execute(issuable) + end + + items + end + def find_issuables(parent, model_class, ids) if parent.is_a?(Project) model_class.id_in(ids).of_projects(parent) @@ -59,6 +62,14 @@ module Issuable model_class.id_in(ids).of_projects(parent.all_projects) end end + + def response_success(message: nil, payload: nil) + ServiceResponse.success(message: message, payload: payload) + end + + def response_error(message, http_status) + ServiceResponse.error(message: message, http_status: http_status) + end end end diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 38b10996f44..65a73dadc2e 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -97,29 +97,6 @@ class IssuableBaseService < BaseService params.delete(label_key) if params[label_key].nil? end - def filter_labels_in_param(key) - return if params[key].to_a.empty? - - params[key] = available_labels.id_in(params[key]).pluck_primary_key - end - - def find_or_create_label_ids - labels = params.delete(:labels) - - return unless labels - - params[:label_ids] = labels.map do |label_name| - label = Labels::FindOrCreateService.new( - current_user, - parent, - title: label_name.strip, - available_labels: available_labels - ).execute - - label.try(:id) - end.compact - end - def labels_service @labels_service ||= ::Labels::AvailableLabelsService.new(current_user, parent, params) end @@ -138,7 +115,7 @@ class IssuableBaseService < BaseService new_label_ids.uniq end - def handle_quick_actions_on_create(issuable) + def handle_quick_actions(issuable) merge_quick_actions_into_params!(issuable) end @@ -146,17 +123,21 @@ class IssuableBaseService < BaseService original_description = params.fetch(:description, issuable.description) description, command_params = - QuickActions::InterpretService.new(project, current_user) + QuickActions::InterpretService.new(project, current_user, quick_action_options) .execute(original_description, issuable, only: only) # Avoid a description already set on an issuable to be overwritten by a nil - params[:description] = description if description + params[:description] = description if description && description != original_description params.merge!(command_params) end + def quick_action_options + {} + end + def create(issuable) - handle_quick_actions_on_create(issuable) + handle_quick_actions(issuable) filter_params(issuable) params.delete(:state_event) @@ -200,11 +181,13 @@ class IssuableBaseService < BaseService end def update(issuable) + handle_quick_actions(issuable) + filter_params(issuable) + change_state(issuable) change_subscription(issuable) change_todo(issuable) toggle_award(issuable) - filter_params(issuable) old_associations = associations_before_update(issuable) label_ids = process_label_ids(params, existing_label_ids: issuable.label_ids) diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index 2409396c1ac..ce1466307e1 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -19,11 +19,22 @@ module Issues notify_participants + # Updates old issue sent notifications allowing + # to receive service desk emails on the new moved issue. + update_service_desk_sent_notifications + new_entity end private + def update_service_desk_sent_notifications + return unless original_entity.from_service_desk? + + original_entity + .sent_notifications.update_all(project_id: new_entity.project_id, noteable_id: new_entity.id) + end + def update_old_entity super diff --git a/app/services/jira/requests/base.rb b/app/services/jira/requests/base.rb index 7521c7610cb..7c6db372257 100644 --- a/app/services/jira/requests/base.rb +++ b/app/services/jira/requests/base.rb @@ -5,28 +5,32 @@ module Jira class Base include ProjectServicesLoggable - PER_PAGE = 50 + JIRA_API_VERSION = 2 - attr_reader :jira_service, :project, :limit, :start_at, :query - - def initialize(jira_service, limit: PER_PAGE, start_at: 0, query: nil) + def initialize(jira_service, params = {}) @project = jira_service&.project @jira_service = jira_service - - @limit = limit - @start_at = start_at - @query = query end def execute return ServiceResponse.error(message: _('Jira service not configured.')) unless jira_service&.active? - return ServiceResponse.success(payload: empty_payload) if limit.to_i <= 0 request end + def base_api_url + "/rest/api/#{api_version}" + end + private + attr_reader :jira_service, :project + + # override this method in the specific request class implementation if a differnt API version is required + def api_version + JIRA_API_VERSION + end + def client @client ||= jira_service.client end diff --git a/app/services/jira/requests/projects.rb b/app/services/jira/requests/projects.rb deleted file mode 100644 index da464503211..00000000000 --- a/app/services/jira/requests/projects.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -module Jira - module Requests - class Projects < Base - extend ::Gitlab::Utils::Override - - private - - override :url - def url - '/rest/api/2/project/search?query=%{query}&maxResults=%{limit}&startAt=%{start_at}' % - { query: CGI.escape(query.to_s), limit: limit.to_i, start_at: start_at.to_i } - end - - override :build_service_response - def build_service_response(response) - return ServiceResponse.success(payload: empty_payload) unless response['values'].present? - - ServiceResponse.success(payload: { projects: map_projects(response), is_last: response['isLast'] }) - end - - def map_projects(response) - response['values'].map { |v| JIRA::Resource::Project.build(client, v) } - end - - def empty_payload - { projects: [], is_last: true } - end - end - end -end diff --git a/app/services/jira/requests/projects/list_service.rb b/app/services/jira/requests/projects/list_service.rb new file mode 100644 index 00000000000..8ecfd358ffb --- /dev/null +++ b/app/services/jira/requests/projects/list_service.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Jira + module Requests + module Projects + class ListService < Base + extend ::Gitlab::Utils::Override + + def initialize(jira_service, params: {}) + super(jira_service, params) + + @query = params[:query] + end + + private + + attr_reader :query + + override :url + def url + "#{base_api_url}/project" + end + + override :build_service_response + def build_service_response(response) + return ServiceResponse.success(payload: empty_payload) unless response.present? + + ServiceResponse.success(payload: { projects: map_projects(response), is_last: true }) + end + + def map_projects(response) + response.map { |v| JIRA::Resource::Project.build(client, v) }.select(&method(:match_query?)) + end + + def match_query?(jira_project) + query = query.to_s.downcase + + jira_project&.key&.downcase&.include?(query) || jira_project&.name&.downcase&.include?(query) + end + + def empty_payload + { projects: [], is_last: true } + end + end + end + end +end diff --git a/app/services/jira_import/start_import_service.rb b/app/services/jira_import/start_import_service.rb index a06cc6df719..f85f686c61a 100644 --- a/app/services/jira_import/start_import_service.rb +++ b/app/services/jira_import/start_import_service.rb @@ -2,23 +2,39 @@ module JiraImport class StartImportService - attr_reader :user, :project, :jira_project_key + attr_reader :user, :project, :jira_project_key, :users_mapping - def initialize(user, project, jira_project_key) + def initialize(user, project, jira_project_key, users_mapping) @user = user @project = project @jira_project_key = jira_project_key + @users_mapping = users_mapping end def execute validation_response = validate return validation_response if validation_response&.error? + store_users_mapping create_and_schedule_import end private + def store_users_mapping + return if users_mapping.blank? + + mapping = users_mapping.map do |map| + next if !map[:jira_account_id] || !map[:gitlab_id] + + [map[:jira_account_id], map[:gitlab_id]] + end.compact.to_h + + return if mapping.blank? + + Gitlab::JiraImport.cache_users_mapping(project.id, mapping) + end + def create_and_schedule_import jira_import = build_jira_import project.import_type = 'jira' diff --git a/app/services/jira_import/users_mapper.rb b/app/services/jira_import/users_mapper.rb index 31a3f721556..c3cbeb157bd 100644 --- a/app/services/jira_import/users_mapper.rb +++ b/app/services/jira_import/users_mapper.rb @@ -14,9 +14,8 @@ module JiraImport { jira_account_id: jira_user['accountId'], jira_display_name: jira_user['displayName'], - jira_email: jira_user['emailAddress'], - gitlab_id: match_user(jira_user) - } + jira_email: jira_user['emailAddress'] + }.merge(match_user(jira_user)) end end @@ -25,7 +24,7 @@ module JiraImport # TODO: Matching user by email and displayName will be done as the part # of follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/219023 def match_user(jira_user) - nil + { gitlab_id: nil, gitlab_username: nil, gitlab_name: nil } end end end diff --git a/app/services/labels/available_labels_service.rb b/app/services/labels/available_labels_service.rb index 979964e09fd..3b226f39d04 100644 --- a/app/services/labels/available_labels_service.rb +++ b/app/services/labels/available_labels_service.rb @@ -34,7 +34,7 @@ module Labels return [] if ids.empty? # rubocop:disable CodeReuse/ActiveRecord - existing_ids = available_labels.by_ids(ids).pluck(:id) + existing_ids = available_labels.id_in(ids).pluck(:id) # rubocop:enable CodeReuse/ActiveRecord ids.map(&:to_i) & existing_ids end diff --git a/app/services/labels/transfer_service.rb b/app/services/labels/transfer_service.rb index e6f9cf35fcb..a05090d6bfb 100644 --- a/app/services/labels/transfer_service.rb +++ b/app/services/labels/transfer_service.rb @@ -15,14 +15,18 @@ module Labels def execute return unless old_group.present? + # rubocop: disable CodeReuse/ActiveRecord + link_ids = group_labels_applied_to_issues.pluck("label_links.id") + + group_labels_applied_to_merge_requests.pluck("label_links.id") + # rubocop: disable CodeReuse/ActiveRecord + Label.transaction do labels_to_transfer.find_each do |label| new_label_id = find_or_create_label!(label) next if new_label_id == label.id - update_label_links(group_labels_applied_to_issues, old_label_id: label.id, new_label_id: new_label_id) - update_label_links(group_labels_applied_to_merge_requests, old_label_id: label.id, new_label_id: new_label_id) + update_label_links(link_ids, old_label_id: label.id, new_label_id: new_label_id) update_label_priorities(old_label_id: label.id, new_label_id: new_label_id) end end @@ -46,20 +50,20 @@ module Labels # rubocop: disable CodeReuse/ActiveRecord def group_labels_applied_to_issues - Label.joins(:issues) + @group_labels_applied_to_issues ||= Label.joins(:issues) .where( issues: { project_id: project.id }, - labels: { type: 'GroupLabel', group_id: old_group.self_and_ancestors } + labels: { group_id: old_group.self_and_ancestors } ) end # rubocop: enable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord def group_labels_applied_to_merge_requests - Label.joins(:merge_requests) + @group_labels_applied_to_merge_requests ||= Label.joins(:merge_requests) .where( merge_requests: { target_project_id: project.id }, - labels: { type: 'GroupLabel', group_id: old_group.self_and_ancestors } + labels: { group_id: old_group.self_and_ancestors } ) end # rubocop: enable CodeReuse/ActiveRecord @@ -72,14 +76,7 @@ module Labels end # rubocop: disable CodeReuse/ActiveRecord - def update_label_links(labels, old_label_id:, new_label_id:) - # use 'labels' relation to get label_link ids only of issues/MRs - # in the project being transferred. - # IDs are fetched in a separate query because MySQL doesn't - # allow referring of 'label_links' table in UPDATE query: - # https://gitlab.com/gitlab-org/gitlab-foss/-/jobs/62435068 - link_ids = labels.pluck('label_links.id') - + def update_label_links(link_ids, old_label_id:, new_label_id:) LabelLink.where(id: link_ids, label_id: old_label_id) .update_all(label_id: new_label_id) end diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb index 0b729981a93..610288c5e76 100644 --- a/app/services/members/create_service.rb +++ b/app/services/members/create_service.rb @@ -22,7 +22,7 @@ module Members errors = [] members.each do |member| - if member.errors.any? + if member.invalid? current_error = # Invited users may not have an associated user if member.user.present? diff --git a/app/services/members/destroy_service.rb b/app/services/members/destroy_service.rb index 20f64a99ad7..fdd43260521 100644 --- a/app/services/members/destroy_service.rb +++ b/app/services/members/destroy_service.rb @@ -2,8 +2,8 @@ module Members class DestroyService < Members::BaseService - def execute(member, skip_authorization: false, skip_subresources: false) - raise Gitlab::Access::AccessDeniedError unless skip_authorization || can_destroy_member?(member) + def execute(member, skip_authorization: false, skip_subresources: false, unassign_issuables: false, destroy_bot: false) + raise Gitlab::Access::AccessDeniedError unless skip_authorization || authorized?(member, destroy_bot) @skip_auth = skip_authorization @@ -19,6 +19,7 @@ module Members delete_subresources(member) unless skip_subresources enqueue_delete_todos(member) + enqueue_unassign_issuables(member) if unassign_issuables after_execute(member: member) @@ -27,6 +28,12 @@ module Members private + def authorized?(member, destroy_bot) + return can_destroy_bot_member?(member) if destroy_bot + + can_destroy_member?(member) + end + def delete_subresources(member) return unless member.is_a?(GroupMember) && member.user && member.group @@ -54,6 +61,10 @@ module Members can?(current_user, destroy_member_permission(member), member) end + def can_destroy_bot_member?(member) + can?(current_user, destroy_bot_member_permission(member), member) + end + def destroy_member_permission(member) case member when GroupMember @@ -64,6 +75,20 @@ module Members raise "Unknown member type: #{member}!" end end + + def destroy_bot_member_permission(member) + raise "Unsupported bot member type: #{member}" unless member.is_a?(ProjectMember) + + :destroy_project_bot_member + end + + def enqueue_unassign_issuables(member) + source_type = member.is_a?(GroupMember) ? 'Group' : 'Project' + + member.run_after_commit_or_now do + MembersDestroyer::UnassignIssuablesWorker.perform_async(member.user_id, member.source_id, source_type) + end + end end end diff --git a/app/services/members/unassign_issuables_service.rb b/app/services/members/unassign_issuables_service.rb new file mode 100644 index 00000000000..95e07deb761 --- /dev/null +++ b/app/services/members/unassign_issuables_service.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Members + class UnassignIssuablesService + attr_reader :user, :entity + + def initialize(user, entity) + @user = user + @entity = entity + end + + def execute + return unless entity && user + + project_ids = entity.is_a?(Group) ? entity.all_projects.select(:id) : [entity.id] + + user.issue_assignees.on_issues(Issue.in_projects(project_ids).select(:id)).delete_all + user.merge_request_assignees.in_projects(project_ids).delete_all + + user.invalidate_cache_counts + end + end +end diff --git a/app/services/merge_requests/approval_service.rb b/app/services/merge_requests/approval_service.rb new file mode 100644 index 00000000000..150ec85fca9 --- /dev/null +++ b/app/services/merge_requests/approval_service.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module MergeRequests + class ApprovalService < MergeRequests::BaseService + def execute(merge_request) + return unless can_be_approved?(merge_request) + + approval = merge_request.approvals.new(user: current_user) + + return success unless save_approval(approval) + + reset_approvals_cache(merge_request) + create_event(merge_request) + create_approval_note(merge_request) + mark_pending_todos_as_done(merge_request) + execute_approval_hooks(merge_request, current_user) + + success + end + + private + + def can_be_approved?(merge_request) + current_user.can?(:approve_merge_request, merge_request) + end + + def reset_approvals_cache(merge_request) + merge_request.approvals.reset + end + + def execute_approval_hooks(merge_request, current_user) + # Only one approval is required for a merge request to be approved + execute_hooks(merge_request, 'approved') + end + + def save_approval(approval) + Approval.safe_ensure_unique do + approval.save + end + end + + def create_approval_note(merge_request) + SystemNoteService.approve_mr(merge_request, current_user) + end + + def mark_pending_todos_as_done(merge_request) + todo_service.resolve_todos_for_target(merge_request, current_user) + end + + def create_event(merge_request) + event_service.approve_mr(merge_request, current_user) + end + end +end + +MergeRequests::ApprovalService.prepend_if_ee('EE::MergeRequests::ApprovalService') diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index 7f7bfa29af7..7e301f311e9 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -2,6 +2,7 @@ module MergeRequests class BaseService < ::IssuableBaseService + extend ::Gitlab::Utils::Override include MergeRequests::AssignsMergeParams def create_note(merge_request, state = merge_request.state) @@ -29,6 +30,11 @@ module MergeRequests .execute_for_merge_request(merge_request) end + def cancel_review_app_jobs!(merge_request) + environments = merge_request.environments.in_review_folder.available + environments.each { |environment| environment.cancel_deployment_jobs! } + end + def source_project @source_project ||= merge_request.source_project end @@ -58,6 +64,12 @@ module MergeRequests super end + override :handle_quick_actions + def handle_quick_actions(merge_request) + super + handle_wip_event(merge_request) + end + def handle_wip_event(merge_request) if wip_event = params.delete(:wip_event) # We update the title that is provided in the params or we use the mr title @@ -90,10 +102,6 @@ module MergeRequests MergeRequests::CreatePipelineService.new(project, user).execute(merge_request) end - def can_use_merge_request_ref?(merge_request) - !merge_request.for_fork? - end - def abort_auto_merge(merge_request, reason) AutoMergeService.new(project, current_user).abort(merge_request, reason) end diff --git a/app/services/merge_requests/create_pipeline_service.rb b/app/services/merge_requests/create_pipeline_service.rb index f802aa44487..f9352f10fea 100644 --- a/app/services/merge_requests/create_pipeline_service.rb +++ b/app/services/merge_requests/create_pipeline_service.rb @@ -9,7 +9,7 @@ module MergeRequests end def create_detached_merge_request_pipeline(merge_request) - Ci::CreatePipelineService.new(merge_request.source_project, + Ci::CreatePipelineService.new(pipeline_project(merge_request), current_user, ref: pipeline_ref_for_detached_merge_request_pipeline(merge_request)) .execute(:merge_request_event, merge_request: merge_request) @@ -31,13 +31,29 @@ module MergeRequests private + def pipeline_project(merge_request) + if can_create_pipeline_in_target_project?(merge_request) + merge_request.target_project + else + merge_request.source_project + end + end + def pipeline_ref_for_detached_merge_request_pipeline(merge_request) - if can_use_merge_request_ref?(merge_request) + if can_create_pipeline_in_target_project?(merge_request) merge_request.ref_path else merge_request.source_branch end end + + def can_create_pipeline_in_target_project?(merge_request) + if Gitlab::Ci::Features.allow_to_create_merge_request_pipelines_in_target_project?(merge_request.target_project) + can?(current_user, :create_pipeline, merge_request.target_project) + else + merge_request.for_same_project? + end + end end end diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb index 1cdfba41432..ac84a13f437 100644 --- a/app/services/merge_requests/create_service.rb +++ b/app/services/merge_requests/create_service.rb @@ -33,12 +33,6 @@ module MergeRequests super end - # Override from IssuableBaseService - def handle_quick_actions_on_create(merge_request) - super - handle_wip_event(merge_request) - end - private def set_projects! diff --git a/app/services/merge_requests/ff_merge_service.rb b/app/services/merge_requests/ff_merge_service.rb index 6f1fa607ef9..b3896d61a78 100644 --- a/app/services/merge_requests/ff_merge_service.rb +++ b/app/services/merge_requests/ff_merge_service.rb @@ -16,7 +16,7 @@ module MergeRequests merge_request.target_branch, merge_request: merge_request) - if merge_request.squash + if merge_request.squash_on_merge? merge_request.update_column(:squash_commit_sha, merge_request.in_progress_merge_commit_sha) end diff --git a/app/services/merge_requests/merge_base_service.rb b/app/services/merge_requests/merge_base_service.rb index 27b5e31faab..fe09c92aab9 100644 --- a/app/services/merge_requests/merge_base_service.rb +++ b/app/services/merge_requests/merge_base_service.rb @@ -20,7 +20,7 @@ module MergeRequests def source strong_memoize(:source) do - if merge_request.squash + if merge_request.squash_on_merge? squash_sha! else merge_request.diff_head_sha diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb index 8d57a76f7d0..961a7cb1ef6 100644 --- a/app/services/merge_requests/merge_service.rb +++ b/app/services/merge_requests/merge_service.rb @@ -27,6 +27,7 @@ module MergeRequests success end end + log_info("Merge process finished on JID #{merge_jid} with state #{state}") rescue MergeError => e handle_merge_error(log_message: e.message, save_message_on_model: true) @@ -56,6 +57,8 @@ module MergeRequests 'Only fast-forward merge is allowed for your project. Please update your source branch' elsif !@merge_request.mergeable? 'Merge request is not mergeable' + elsif !@merge_request.squash && project.squash_always? + 'This project requires squashing commits when merge requests are accepted.' end raise_error(error) if error diff --git a/app/services/merge_requests/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb index 0364c0dd479..fdf8f442297 100644 --- a/app/services/merge_requests/post_merge_service.rb +++ b/app/services/merge_requests/post_merge_service.rb @@ -18,6 +18,7 @@ module MergeRequests invalidate_cache_counts(merge_request, users: merge_request.assignees) merge_request.update_project_counter_caches delete_non_latest_diffs(merge_request) + cancel_review_app_jobs!(merge_request) cleanup_environments(merge_request) end diff --git a/app/services/merge_requests/remove_approval_service.rb b/app/services/merge_requests/remove_approval_service.rb new file mode 100644 index 00000000000..3164d0b4069 --- /dev/null +++ b/app/services/merge_requests/remove_approval_service.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module MergeRequests + class RemoveApprovalService < MergeRequests::BaseService + # rubocop: disable CodeReuse/ActiveRecord + def execute(merge_request) + return unless merge_request.approved_by?(current_user) + + # paranoid protection against running wrong deletes + return unless merge_request.id && current_user.id + + approval = merge_request.approvals.where(user: current_user) + + trigger_approval_hooks(merge_request) do + next unless approval.destroy_all # rubocop: disable Cop/DestroyAll + + reset_approvals_cache(merge_request) + create_note(merge_request) + end + + success + end + # rubocop: enable CodeReuse/ActiveRecord + + private + + def reset_approvals_cache(merge_request) + merge_request.approvals.reset + end + + def trigger_approval_hooks(merge_request) + yield + + execute_hooks(merge_request, 'unapproved') + end + + def create_note(merge_request) + SystemNoteService.unapprove_mr(merge_request, current_user) + end + end +end + +MergeRequests::RemoveApprovalService.prepend_if_ee('EE::MergeRequests::RemoveApprovalService') diff --git a/app/services/merge_requests/squash_service.rb b/app/services/merge_requests/squash_service.rb index 4b04d42b48e..faa2e921581 100644 --- a/app/services/merge_requests/squash_service.rb +++ b/app/services/merge_requests/squash_service.rb @@ -11,11 +11,14 @@ module MergeRequests return success(squash_sha: merge_request.diff_head_sha) end + return error(s_('MergeRequests|This project does not allow squashing commits when merge requests are accepted.')) if squash_forbidden? + if squash_in_progress? return error(s_('MergeRequests|Squash task canceled: another squash is already in progress.')) end squash! || error(s_('MergeRequests|Failed to squash. Should be done manually.')) + rescue SquashInProgressError error(s_('MergeRequests|An error occurred while checking whether another squash is in progress.')) end @@ -40,6 +43,10 @@ module MergeRequests raise SquashInProgressError, e.message end + def squash_forbidden? + target_project.squash_never? + end + def repository target_project.repository end diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 561695baeab..29e0c22b155 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -2,6 +2,8 @@ module MergeRequests class UpdateService < MergeRequests::BaseService + extend ::Gitlab::Utils::Override + def execute(merge_request) # We don't allow change of source/target projects and source branch # after merge request was created @@ -9,14 +11,11 @@ module MergeRequests params.delete(:target_project_id) params.delete(:source_branch) - merge_from_quick_action(merge_request) if params[:merge] - if merge_request.closed_without_fork? params.delete(:target_branch) params.delete(:force_remove_source_branch) end - handle_wip_event(merge_request) update_task_event(merge_request) || update(merge_request) end @@ -77,26 +76,6 @@ module MergeRequests todo_service.update_merge_request(merge_request, current_user) end - def merge_from_quick_action(merge_request) - last_diff_sha = params.delete(:merge) - - if Feature.enabled?(:merge_orchestration_service, merge_request.project, default_enabled: true) - MergeRequests::MergeOrchestrationService - .new(project, current_user, { sha: last_diff_sha }) - .execute(merge_request) - else - return unless merge_request.mergeable_with_quick_action?(current_user, last_diff_sha: last_diff_sha) - - merge_request.update(merge_error: nil) - - if merge_request.head_pipeline_active? - AutoMergeService.new(project, current_user, { sha: last_diff_sha }).execute(merge_request, AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS) - else - merge_request.merge_async(current_user.id, { sha: last_diff_sha }) - end - end - end - def reopen_service MergeRequests::ReopenService end @@ -134,6 +113,37 @@ module MergeRequests issuable, issuable.project, current_user, branch_type, old_branch, new_branch) end + + override :handle_quick_actions + def handle_quick_actions(merge_request) + super + merge_from_quick_action(merge_request) if params[:merge] + end + + def merge_from_quick_action(merge_request) + last_diff_sha = params.delete(:merge) + + if Feature.enabled?(:merge_orchestration_service, merge_request.project, default_enabled: true) + MergeRequests::MergeOrchestrationService + .new(project, current_user, { sha: last_diff_sha }) + .execute(merge_request) + else + return unless merge_request.mergeable_with_quick_action?(current_user, last_diff_sha: last_diff_sha) + + merge_request.update(merge_error: nil) + + if merge_request.head_pipeline_active? + AutoMergeService.new(project, current_user, { sha: last_diff_sha }).execute(merge_request, AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS) + else + merge_request.merge_async(current_user.id, { sha: last_diff_sha }) + end + end + end + + override :quick_action_options + def quick_action_options + { merge_request_diff_head_sha: params.delete(:merge_request_diff_head_sha) } + end end end diff --git a/app/services/metrics/dashboard/base_service.rb b/app/services/metrics/dashboard/base_service.rb index c2a0f22e73e..5fa127d64b2 100644 --- a/app/services/metrics/dashboard/base_service.rb +++ b/app/services/metrics/dashboard/base_service.rb @@ -10,7 +10,8 @@ module Metrics STAGES = ::Gitlab::Metrics::Dashboard::Stages SEQUENCE = [ STAGES::CommonMetricsInserter, - STAGES::EndpointInserter, + STAGES::MetricEndpointInserter, + STAGES::VariableEndpointInserter, STAGES::PanelIdsInserter, STAGES::Sorter, STAGES::AlertsInserter, @@ -36,6 +37,14 @@ module Metrics Gitlab::Metrics::Dashboard::Cache.fetch(cache_key) { get_raw_dashboard } end + # Should return true if this dashboard service is for an out-of-the-box + # dashboard. + # This method is overridden in app/services/metrics/dashboard/predefined_dashboard_service.rb. + # @return Boolean + def self.out_of_the_box_dashboard? + false + end + private # Determines whether users should be able to view @@ -83,6 +92,17 @@ module Metrics params[:dashboard_path] end + def load_yaml(data) + ::Gitlab::Config::Loader::Yaml.new(data).load_raw! + rescue Gitlab::Config::Loader::Yaml::NotHashError + # Raise more informative error in app/models/performance_monitoring/prometheus_dashboard.rb. + {} + rescue Gitlab::Config::Loader::Yaml::DataTooLargeError => exception + raise Gitlab::Metrics::Dashboard::Errors::LayoutError, exception.message + rescue Gitlab::Config::Loader::FormatError + raise Gitlab::Metrics::Dashboard::Errors::LayoutError, _('Invalid yaml') + end + # @return [Hash] an unmodified dashboard def get_raw_dashboard raise NotImplementedError diff --git a/app/services/metrics/dashboard/clone_dashboard_service.rb b/app/services/metrics/dashboard/clone_dashboard_service.rb index 3ca25b3bd9b..a6bece391f2 100644 --- a/app/services/metrics/dashboard/clone_dashboard_service.rb +++ b/app/services/metrics/dashboard/clone_dashboard_service.rb @@ -6,30 +6,33 @@ module Metrics module Dashboard class CloneDashboardService < ::BaseService include Stepable + include Gitlab::Utils::StrongMemoize ALLOWED_FILE_TYPE = '.yml' USER_DASHBOARDS_DIR = ::Metrics::Dashboard::CustomDashboardService::DASHBOARD_ROOT + SEQUENCES = { + ::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH => [ + ::Gitlab::Metrics::Dashboard::Stages::CommonMetricsInserter, + ::Gitlab::Metrics::Dashboard::Stages::CustomMetricsInserter, + ::Gitlab::Metrics::Dashboard::Stages::Sorter + ].freeze, + + ::Metrics::Dashboard::SelfMonitoringDashboardService::DASHBOARD_PATH => [ + ::Gitlab::Metrics::Dashboard::Stages::CustomMetricsInserter + ].freeze, + + ::Metrics::Dashboard::ClusterDashboardService::DASHBOARD_PATH => [ + ::Gitlab::Metrics::Dashboard::Stages::CommonMetricsInserter, + ::Gitlab::Metrics::Dashboard::Stages::Sorter + ].freeze + }.freeze steps :check_push_authorized, - :check_branch_name, - :check_file_type, - :check_dashboard_template, - :create_file, - :refresh_repository_method_caches - - class << self - def allowed_dashboard_templates - @allowed_dashboard_templates ||= Set[::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH].freeze - end - - def sequences - @sequences ||= { - ::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH => [::Gitlab::Metrics::Dashboard::Stages::CommonMetricsInserter, - ::Gitlab::Metrics::Dashboard::Stages::CustomMetricsInserter, - ::Gitlab::Metrics::Dashboard::Stages::Sorter].freeze - }.freeze - end - end + :check_branch_name, + :check_file_type, + :check_dashboard_template, + :create_file, + :refresh_repository_method_caches def execute execute_steps @@ -56,8 +59,12 @@ module Metrics success(result) end + # Only allow out of the box metrics dashboards to be cloned. This can be + # changed to allow cloning of any metrics dashboard, if desired. + # However, only metrics dashboards should be allowed. If any file is + # allowed to be cloned, this will become a security risk. def check_dashboard_template(result) - return error(_('Not found.'), :not_found) unless self.class.allowed_dashboard_templates.include?(params[:dashboard]) + return error(_('Not found.'), :not_found) unless dashboard_service&.out_of_the_box_dashboard? success(result) end @@ -78,6 +85,12 @@ module Metrics success(result.merge(http_status: :created, dashboard: dashboard_details)) end + def dashboard_service + strong_memoize(:dashboard_service) do + Gitlab::Metrics::Dashboard::ServiceSelector.call(dashboard_service_options) + end + end + def dashboard_attrs { commit_message: params[:commit_message], @@ -149,14 +162,19 @@ module Metrics end def raw_dashboard - YAML.safe_load(File.read(Rails.root.join(dashboard_template))) + dashboard_service.new(project, current_user, dashboard_service_options).raw_dashboard + end + + def dashboard_service_options + { + embedded: false, + dashboard_path: dashboard_template + } end def sequence - self.class.sequences[dashboard_template] + SEQUENCES[dashboard_template] || [] end end end end - -Metrics::Dashboard::CloneDashboardService.prepend_if_ee('EE::Metrics::Dashboard::CloneDashboardService') diff --git a/app/services/metrics/dashboard/cluster_dashboard_service.rb b/app/services/metrics/dashboard/cluster_dashboard_service.rb new file mode 100644 index 00000000000..bfd5abf1126 --- /dev/null +++ b/app/services/metrics/dashboard/cluster_dashboard_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# Fetches the system metrics dashboard and formats the output. +# Use Gitlab::Metrics::Dashboard::Finder to retrive dashboards. +module Metrics + module Dashboard + class ClusterDashboardService < ::Metrics::Dashboard::PredefinedDashboardService + DASHBOARD_PATH = 'config/prometheus/cluster_metrics.yml' + DASHBOARD_NAME = 'Cluster' + + # SHA256 hash of dashboard content + DASHBOARD_VERSION = '9349afc1d96329c08ab478ea0b77db94ee5cc2549b8c754fba67a7f424666b22' + + SEQUENCE = [ + STAGES::ClusterEndpointInserter, + STAGES::PanelIdsInserter, + STAGES::Sorter + ].freeze + + class << self + def valid_params?(params) + # support selecting this service by cluster id via .find + # Use super to support selecting this service by dashboard_path via .find_raw + (params[:cluster].present? && params[:embedded] != 'true') || super + end + end + + # Permissions are handled at the controller level + def allowed? + true + end + + private + + def dashboard_version + DASHBOARD_VERSION + end + end + end +end diff --git a/app/services/metrics/dashboard/cluster_metrics_embed_service.rb b/app/services/metrics/dashboard/cluster_metrics_embed_service.rb new file mode 100644 index 00000000000..6fb39ed3004 --- /dev/null +++ b/app/services/metrics/dashboard/cluster_metrics_embed_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true +# +module Metrics + module Dashboard + class ClusterMetricsEmbedService < Metrics::Dashboard::DynamicEmbedService + class << self + def valid_params?(params) + [ + params[:cluster], + embedded?(params[:embedded]), + params[:group].present?, + params[:title].present?, + params[:y_label].present? + ].all? + end + end + + private + + # Permissions are handled at the controller level + def allowed? + true + end + + def dashboard_path + ::Metrics::Dashboard::ClusterDashboardService::DASHBOARD_PATH + end + + def sequence + [ + STAGES::ClusterEndpointInserter, + STAGES::PanelIdsInserter + ] + end + end + end +end diff --git a/app/services/metrics/dashboard/custom_dashboard_service.rb b/app/services/metrics/dashboard/custom_dashboard_service.rb index 77173813a4f..741738cc3af 100644 --- a/app/services/metrics/dashboard/custom_dashboard_service.rb +++ b/app/services/metrics/dashboard/custom_dashboard_service.rb @@ -21,7 +21,8 @@ module Metrics path: filepath, display_name: name_for_path(filepath), default: false, - system_dashboard: false + system_dashboard: false, + out_of_the_box_dashboard: out_of_the_box_dashboard? } end end @@ -42,7 +43,7 @@ module Metrics def get_raw_dashboard yml = self.class.file_finder(project).read(dashboard_path) - YAML.safe_load(yml) + load_yaml(yml) end def cache_key diff --git a/app/services/metrics/dashboard/gitlab_alert_embed_service.rb b/app/services/metrics/dashboard/gitlab_alert_embed_service.rb index 38e89d392ad..08d65413e1d 100644 --- a/app/services/metrics/dashboard/gitlab_alert_embed_service.rb +++ b/app/services/metrics/dashboard/gitlab_alert_embed_service.rb @@ -11,7 +11,7 @@ module Metrics include Gitlab::Utils::StrongMemoize SEQUENCE = [ - STAGES::EndpointInserter, + STAGES::MetricEndpointInserter, STAGES::PanelIdsInserter ].freeze diff --git a/app/services/metrics/dashboard/grafana_metric_embed_service.rb b/app/services/metrics/dashboard/grafana_metric_embed_service.rb index d9ce2c5e905..8e72a185406 100644 --- a/app/services/metrics/dashboard/grafana_metric_embed_service.rb +++ b/app/services/metrics/dashboard/grafana_metric_embed_service.rb @@ -80,7 +80,7 @@ module Metrics def fetch_dashboard uid = GrafanaUidParser.new(grafana_url, project).parse - raise DashboardProcessingError.new('Dashboard uid not found') unless uid + raise DashboardProcessingError.new(_('Dashboard uid not found')) unless uid response = client.get_dashboard(uid: uid) @@ -89,7 +89,7 @@ module Metrics def fetch_datasource(dashboard) name = DatasourceNameParser.new(grafana_url, dashboard).parse - raise DashboardProcessingError.new('Datasource name not found') unless name + raise DashboardProcessingError.new(_('Datasource name not found')) unless name response = client.get_datasource(name: name) @@ -115,7 +115,7 @@ module Metrics def parse_json(json) Gitlab::Json.parse(json, symbolize_names: true) rescue JSON::ParserError - raise DashboardProcessingError.new('Grafana response contains invalid json') + raise DashboardProcessingError.new(_('Grafana response contains invalid json')) end end diff --git a/app/services/metrics/dashboard/pod_dashboard_service.rb b/app/services/metrics/dashboard/pod_dashboard_service.rb index 16b87d2d587..8699189deac 100644 --- a/app/services/metrics/dashboard/pod_dashboard_service.rb +++ b/app/services/metrics/dashboard/pod_dashboard_service.rb @@ -5,6 +5,15 @@ module Metrics class PodDashboardService < ::Metrics::Dashboard::PredefinedDashboardService DASHBOARD_PATH = 'config/prometheus/pod_metrics.yml' DASHBOARD_NAME = 'Pod Health' + + # SHA256 hash of dashboard content + DASHBOARD_VERSION = 'f12f641d2575d5dcb69e2c633ff5231dbd879ad35020567d8fc4e1090bfdb4b4' + + private + + def dashboard_version + DASHBOARD_VERSION + end end end end diff --git a/app/services/metrics/dashboard/predefined_dashboard_service.rb b/app/services/metrics/dashboard/predefined_dashboard_service.rb index f454df63773..c21083475f0 100644 --- a/app/services/metrics/dashboard/predefined_dashboard_service.rb +++ b/app/services/metrics/dashboard/predefined_dashboard_service.rb @@ -10,7 +10,8 @@ module Metrics DASHBOARD_NAME = nil SEQUENCE = [ - STAGES::EndpointInserter, + STAGES::MetricEndpointInserter, + STAGES::VariableEndpointInserter, STAGES::PanelIdsInserter, STAGES::Sorter ].freeze @@ -23,12 +24,20 @@ module Metrics def matching_dashboard?(filepath) filepath == self::DASHBOARD_PATH end + + def out_of_the_box_dashboard? + true + end end private + def dashboard_version + raise NotImplementedError + end + def cache_key - "metrics_dashboard_#{dashboard_path}" + "metrics_dashboard_#{dashboard_path}_#{dashboard_version}" end def dashboard_path @@ -39,7 +48,7 @@ module Metrics def get_raw_dashboard yml = File.read(Rails.root.join(dashboard_path)) - YAML.safe_load(yml) + load_yaml(yml) end def sequence diff --git a/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb b/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb index 8599c23c206..f1f5cd7d77e 100644 --- a/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb +++ b/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb @@ -8,9 +8,13 @@ module Metrics DASHBOARD_PATH = 'config/prometheus/self_monitoring_default.yml' DASHBOARD_NAME = N_('Default dashboard') + # SHA256 hash of dashboard content + DASHBOARD_VERSION = '1dff3e3cb76e73c8e368823c98b34c61aec0d141978450dea195a3b3dc2415d6' + SEQUENCE = [ STAGES::CustomMetricsInserter, - STAGES::EndpointInserter, + STAGES::MetricEndpointInserter, + STAGES::VariableEndpointInserter, STAGES::PanelIdsInserter, STAGES::Sorter ].freeze @@ -25,7 +29,8 @@ module Metrics path: DASHBOARD_PATH, display_name: _(DASHBOARD_NAME), default: true, - system_dashboard: false + system_dashboard: false, + out_of_the_box_dashboard: out_of_the_box_dashboard? }] end @@ -33,6 +38,12 @@ module Metrics params[:dashboard_path].nil? && params[:environment]&.project&.self_monitoring? end end + + private + + def dashboard_version + DASHBOARD_VERSION + end end end end diff --git a/app/services/metrics/dashboard/system_dashboard_service.rb b/app/services/metrics/dashboard/system_dashboard_service.rb index db5599b4def..5c3562b8ca0 100644 --- a/app/services/metrics/dashboard/system_dashboard_service.rb +++ b/app/services/metrics/dashboard/system_dashboard_service.rb @@ -8,11 +8,15 @@ module Metrics DASHBOARD_PATH = 'config/prometheus/common_metrics.yml' DASHBOARD_NAME = N_('Default dashboard') + # SHA256 hash of dashboard content + DASHBOARD_VERSION = '4685fe386c25b1a786b3be18f79bb2ee9828019003e003816284cdb634fa3e13' + SEQUENCE = [ STAGES::CommonMetricsInserter, STAGES::CustomMetricsInserter, STAGES::CustomMetricsDetailsInserter, - STAGES::EndpointInserter, + STAGES::MetricEndpointInserter, + STAGES::VariableEndpointInserter, STAGES::PanelIdsInserter, STAGES::Sorter, STAGES::AlertsInserter @@ -24,10 +28,17 @@ module Metrics path: DASHBOARD_PATH, display_name: _(DASHBOARD_NAME), default: true, - system_dashboard: true + system_dashboard: true, + out_of_the_box_dashboard: out_of_the_box_dashboard? }] end end + + private + + def dashboard_version + DASHBOARD_VERSION + end end end end diff --git a/app/services/metrics/dashboard/transient_embed_service.rb b/app/services/metrics/dashboard/transient_embed_service.rb index cb6ca215447..0a9c4bc7b86 100644 --- a/app/services/metrics/dashboard/transient_embed_service.rb +++ b/app/services/metrics/dashboard/transient_embed_service.rb @@ -30,7 +30,7 @@ module Metrics override :sequence def sequence - [STAGES::EndpointInserter] + [STAGES::MetricEndpointInserter] end override :identifiers @@ -39,7 +39,7 @@ module Metrics end def invalid_embed_json!(message) - raise DashboardProcessingError.new("Parsing error for param :embed_json. #{message}") + raise DashboardProcessingError.new(_("Parsing error for param :embed_json. %{message}") % { message: message }) end end end diff --git a/app/services/namespaces/check_storage_size_service.rb b/app/services/namespaces/check_storage_size_service.rb deleted file mode 100644 index 57d2645a0c8..00000000000 --- a/app/services/namespaces/check_storage_size_service.rb +++ /dev/null @@ -1,95 +0,0 @@ -# frozen_string_literal: true - -module Namespaces - class CheckStorageSizeService - include ActiveSupport::NumberHelper - include Gitlab::Allowable - include Gitlab::Utils::StrongMemoize - - def initialize(namespace, user) - @root_namespace = namespace.root_ancestor - @root_storage_size = Namespace::RootStorageSize.new(root_namespace) - @user = user - end - - def execute - return ServiceResponse.success unless Feature.enabled?(:namespace_storage_limit, root_namespace) - return ServiceResponse.success if alert_level == :none - - if root_storage_size.above_size_limit? - ServiceResponse.error(message: above_size_limit_message, payload: payload) - else - ServiceResponse.success(payload: payload) - end - end - - private - - attr_reader :root_namespace, :root_storage_size, :user - - USAGE_THRESHOLDS = { - none: 0.0, - info: 0.5, - warning: 0.75, - alert: 0.95, - error: 1.0 - }.freeze - - def payload - return {} unless can?(user, :admin_namespace, root_namespace) - - { - explanation_message: explanation_message, - usage_message: usage_message, - alert_level: alert_level, - root_namespace: root_namespace - } - end - - def explanation_message - root_storage_size.above_size_limit? ? above_size_limit_message : below_size_limit_message - end - - def usage_message - s_("You reached %{usage_in_percent} of %{namespace_name}'s storage capacity (%{used_storage} of %{storage_limit})" % current_usage_params) - end - - def alert_level - strong_memoize(:alert_level) do - usage_ratio = root_storage_size.usage_ratio - current_level = USAGE_THRESHOLDS.each_key.first - - USAGE_THRESHOLDS.each do |level, threshold| - current_level = level if usage_ratio >= threshold - end - - current_level - end - end - - def below_size_limit_message - s_("If you reach 100%% storage capacity, you will not be able to: %{base_message}" % { base_message: base_message } ) - end - - def above_size_limit_message - s_("%{namespace_name} is now read-only. You cannot: %{base_message}" % { namespace_name: root_namespace.name, base_message: base_message }) - end - - def base_message - s_("push to your repository, create pipelines, create issues or add comments. To reduce storage capacity, delete unused repositories, artifacts, wikis, issues, and pipelines.") - end - - def current_usage_params - { - usage_in_percent: number_to_percentage(root_storage_size.usage_ratio * 100, precision: 0), - namespace_name: root_namespace.name, - used_storage: formatted(root_storage_size.current_size), - storage_limit: formatted(root_storage_size.limit) - } - end - - def formatted(number) - number_to_human_size(number, delimiter: ',', precision: 2) - end - end -end diff --git a/app/services/notes/post_process_service.rb b/app/services/notes/post_process_service.rb index 0e455c641ce..4f3b2000e9a 100644 --- a/app/services/notes/post_process_service.rb +++ b/app/services/notes/post_process_service.rb @@ -10,13 +10,13 @@ module Notes def execute # Skip system notes, like status changes and cross-references and awards - unless @note.system? - EventCreateService.new.leave_note(@note, @note.author) + unless note.system? + EventCreateService.new.leave_note(note, note.author) - return if @note.for_personal_snippet? + return if note.for_personal_snippet? - @note.create_cross_references! - ::SystemNoteService.design_discussion_added(@note) if create_design_discussion_system_note? + note.create_cross_references! + ::SystemNoteService.design_discussion_added(note) if create_design_discussion_system_note? execute_note_hooks end @@ -25,21 +25,21 @@ module Notes private def create_design_discussion_system_note? - @note && @note.for_design? && @note.start_of_discussion? + note && note.for_design? && note.start_of_discussion? end def hook_data - Gitlab::DataBuilder::Note.build(@note, @note.author) + Gitlab::DataBuilder::Note.build(note, note.author) end def execute_note_hooks - return unless @note.project + return unless note.project note_data = hook_data - hooks_scope = @note.confidential?(include_noteable: true) ? :confidential_note_hooks : :note_hooks + hooks_scope = note.confidential?(include_noteable: true) ? :confidential_note_hooks : :note_hooks - @note.project.execute_hooks(note_data, hooks_scope) - @note.project.execute_services(note_data, hooks_scope) + note.project.execute_hooks(note_data, hooks_scope) + note.project.execute_services(note_data, hooks_scope) end end end diff --git a/app/services/notes/quick_actions_service.rb b/app/services/notes/quick_actions_service.rb index 7e6568b5b25..c670f01e502 100644 --- a/app/services/notes/quick_actions_service.rb +++ b/app/services/notes/quick_actions_service.rb @@ -41,7 +41,7 @@ module Notes @interpret_service = QuickActions::InterpretService.new(project, current_user, options) - @interpret_service.execute(note.note, note.noteable) + interpret_service.execute(note.note, note.noteable) end # Applies updates extracted to note#noteable diff --git a/app/services/notes/update_service.rb b/app/services/notes/update_service.rb index 444656348ed..047848fd1a3 100644 --- a/app/services/notes/update_service.rb +++ b/app/services/notes/update_service.rb @@ -10,6 +10,7 @@ module Notes note.assign_attributes(params.merge(updated_by: current_user)) note.with_transaction_returning_status do + update_confidentiality(note) note.save end @@ -79,6 +80,15 @@ module Notes TodoService.new.update_note(note, current_user, old_mentioned_users) end + + # This method updates confidentiality of all discussion notes at once + def update_confidentiality(note) + return unless params.key?(:confidential) + return unless note.is_a?(DiscussionNote) # we don't need to do bulk update for single notes + return unless note.start_of_discussion? # don't update all notes if a response is being updated + + Note.id_in(note.discussion.notes.map(&:id)).update_all(confidential: params[:confidential]) + end end end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 73e60ac8420..a4e935a8cf5 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -294,6 +294,7 @@ class NotificationService return true if note.system_note_with_references? send_new_note_notifications(note) + send_service_desk_notification(note) end def send_new_note_notifications(note) @@ -305,6 +306,21 @@ class NotificationService end end + def send_service_desk_notification(note) + return unless Gitlab::ServiceDesk.supported? + return unless note.noteable_type == 'Issue' + + issue = note.noteable + support_bot = User.support_bot + + return unless issue.service_desk_reply_to.present? + return unless issue.project.service_desk_enabled? + return if note.author == support_bot + return unless issue.subscribed?(support_bot, issue.project) + + mailer.service_desk_new_note_email(issue.id, note.id).deliver_later + end + # Notify users when a new release is created def send_new_release_notifications(release) recipients = NotificationRecipients::BuildService.build_new_release_recipients(release) @@ -566,6 +582,14 @@ class NotificationService end end + def merge_when_pipeline_succeeds(merge_request, current_user) + recipients = ::NotificationRecipients::BuildService.build_recipients(merge_request, current_user, action: 'merge_when_pipeline_succeeds') + + recipients.each do |recipient| + mailer.merge_when_pipeline_succeeds_email(recipient.user.id, merge_request.id, current_user.id).deliver_later + end + end + protected def new_resource_email(target, method) diff --git a/app/services/packages/composer/composer_json_service.rb b/app/services/packages/composer/composer_json_service.rb new file mode 100644 index 00000000000..6ffb5a77da3 --- /dev/null +++ b/app/services/packages/composer/composer_json_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Packages + module Composer + class ComposerJsonService + def initialize(project, target) + @project, @target = project, target + end + + def execute + composer_json + end + + private + + def composer_json + composer_file = @project.repository.blob_at(@target, 'composer.json') + + composer_file_not_found! unless composer_file + + Gitlab::Json.parse(composer_file.data) + rescue JSON::ParserError + raise 'Could not parse composer.json file. Invalid JSON.' + end + + def composer_file_not_found! + raise 'The file composer.json was not found.' + end + end + end +end diff --git a/app/services/packages/composer/create_package_service.rb b/app/services/packages/composer/create_package_service.rb new file mode 100644 index 00000000000..ad5d267698b --- /dev/null +++ b/app/services/packages/composer/create_package_service.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Packages + module Composer + class CreatePackageService < BaseService + include ::Gitlab::Utils::StrongMemoize + + def execute + # fetches json outside of transaction + composer_json + + ::Packages::Package.transaction do + ::Packages::Composer::Metadatum.upsert( + package_id: created_package.id, + target_sha: target, + composer_json: composer_json + ) + end + end + + private + + def created_package + project + .packages + .composer + .safe_find_or_create_by!(name: package_name, version: package_version) + end + + def composer_json + strong_memoize(:composer_json) do + ::Packages::Composer::ComposerJsonService.new(project, target).execute + end + end + + def package_name + composer_json['name'] + end + + def target + (branch || tag).target + end + + def branch + params[:branch] + end + + def tag + params[:tag] + end + + def package_version + ::Packages::Composer::VersionParserService.new(tag_name: tag&.name, branch_name: branch&.name).execute + end + end + end +end diff --git a/app/services/packages/composer/version_parser_service.rb b/app/services/packages/composer/version_parser_service.rb new file mode 100644 index 00000000000..76dfd7a14bd --- /dev/null +++ b/app/services/packages/composer/version_parser_service.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Packages + module Composer + class VersionParserService + def initialize(tag_name: nil, branch_name: nil) + @tag_name, @branch_name = tag_name, branch_name + end + + def execute + if @tag_name.present? + @tag_name.match(Gitlab::Regex.composer_package_version_regex).captures[0] + elsif @branch_name.present? + branch_sufix_or_prefix(@branch_name.match(Gitlab::Regex.composer_package_version_regex)) + end + end + + private + + def branch_sufix_or_prefix(match) + if match + if match.captures[1] == '.x' + match.captures[0] + '-dev' + else + match.captures[0] + '.x-dev' + end + else + "dev-#{@branch_name}" + end + end + end + end +end diff --git a/app/services/packages/conan/create_package_file_service.rb b/app/services/packages/conan/create_package_file_service.rb new file mode 100644 index 00000000000..2db5c4e507b --- /dev/null +++ b/app/services/packages/conan/create_package_file_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Packages + module Conan + class CreatePackageFileService + attr_reader :package, :file, :params + + def initialize(package, file, params) + @package = package + @file = file + @params = params + end + + def execute + package.package_files.create!( + file: file, + size: params['file.size'], + file_name: params[:file_name], + file_sha1: params['file.sha1'], + file_md5: params['file.md5'], + conan_file_metadatum_attributes: { + recipe_revision: params[:recipe_revision], + package_revision: params[:package_revision], + conan_package_reference: params[:conan_package_reference], + conan_file_type: params[:conan_file_type] + } + ) + end + end + end +end diff --git a/app/services/packages/conan/create_package_service.rb b/app/services/packages/conan/create_package_service.rb new file mode 100644 index 00000000000..22a0436c5fb --- /dev/null +++ b/app/services/packages/conan/create_package_service.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Packages + module Conan + class CreatePackageService < BaseService + def execute + project.packages.create!( + name: params[:package_name], + version: params[:package_version], + package_type: :conan, + conan_metadatum_attributes: { + package_username: params[:package_username], + package_channel: params[:package_channel] + } + ) + end + end + end +end diff --git a/app/services/packages/conan/search_service.rb b/app/services/packages/conan/search_service.rb new file mode 100644 index 00000000000..4513616bad2 --- /dev/null +++ b/app/services/packages/conan/search_service.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Packages + module Conan + class SearchService < BaseService + include ActiveRecord::Sanitization::ClassMethods + + WILDCARD = '*' + RECIPE_SEPARATOR = '@' + + def initialize(user, params) + super(nil, user, params) + end + + def execute + ServiceResponse.success(payload: { results: search_results }) + end + + private + + def search_results + return [] if wildcard_query? + + return search_for_single_package(sanitized_query) if params[:query].include?(RECIPE_SEPARATOR) + + search_packages(build_query) + end + + def wildcard_query? + params[:query] == WILDCARD + end + + def build_query + return "#{sanitized_query}%" if params[:query].end_with?(WILDCARD) + + sanitized_query + end + + def search_packages(query) + ::Packages::Conan::PackageFinder.new(current_user, query: query).execute.map(&:conan_recipe) + end + + def search_for_single_package(query) + name, version, username, _ = query.split(/[@\/]/) + full_path = Packages::Conan::Metadatum.full_path_from(package_username: username) + project = Project.find_by_full_path(full_path) + return unless current_user.can?(:read_package, project) + + result = project.packages.with_name(name).with_version(version).order_created.last + [result&.conan_recipe].compact + end + + def sanitized_query + @sanitized_query ||= sanitize_sql_like(params[:query].delete(WILDCARD)) + end + end + end +end diff --git a/app/services/packages/create_dependency_service.rb b/app/services/packages/create_dependency_service.rb new file mode 100644 index 00000000000..2999885d55d --- /dev/null +++ b/app/services/packages/create_dependency_service.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true +module Packages + class CreateDependencyService < BaseService + attr_reader :package, :dependencies + + def initialize(package, dependencies) + @package = package + @dependencies = dependencies + end + + def execute + Packages::DependencyLink.dependency_types.each_key do |type| + create_dependency(type) + end + end + + private + + def create_dependency(type) + return unless dependencies[type].is_a?(Hash) + + names_and_version_patterns = dependencies[type] + existing_ids, existing_names = find_existing_ids_and_names(names_and_version_patterns) + dependencies_to_insert = names_and_version_patterns + + if existing_names.any? + dependencies_to_insert = names_and_version_patterns.reject { |k, _| k.in?(existing_names) } + end + + ActiveRecord::Base.transaction do + inserted_ids = bulk_insert_package_dependencies(dependencies_to_insert) + bulk_insert_package_dependency_links(type, (existing_ids + inserted_ids)) + end + end + + def find_existing_ids_and_names(names_and_version_patterns) + ids_and_names = Packages::Dependency.for_package_names_and_version_patterns(names_and_version_patterns) + .pluck_ids_and_names + ids = ids_and_names.map(&:first) || [] + names = ids_and_names.map(&:second) || [] + [ids, names] + end + + def bulk_insert_package_dependencies(names_and_version_patterns) + return [] if names_and_version_patterns.empty? + + rows = names_and_version_patterns.map do |name, version_pattern| + { + name: name, + version_pattern: version_pattern + } + end + + ids = database.bulk_insert(Packages::Dependency.table_name, rows, return_ids: true, on_conflict: :do_nothing) + return ids if ids.size == names_and_version_patterns.size + + Packages::Dependency.uncached do + # The bulk_insert statement above do not dirty the query cache. To make + # sure that the results are fresh from the database and not from a stalled + # and potentially wrong cache, this query has to be done with the query + # chache disabled. + Packages::Dependency.ids_for_package_names_and_version_patterns(names_and_version_patterns) + end + end + + def bulk_insert_package_dependency_links(type, dependency_ids) + rows = dependency_ids.map do |dependency_id| + { + package_id: package.id, + dependency_id: dependency_id, + dependency_type: Packages::DependencyLink.dependency_types[type.to_s] + } + end + + database.bulk_insert(Packages::DependencyLink.table_name, rows) + end + + def database + ::Gitlab::Database + end + end +end diff --git a/app/services/packages/create_package_file_service.rb b/app/services/packages/create_package_file_service.rb new file mode 100644 index 00000000000..0ebceeee779 --- /dev/null +++ b/app/services/packages/create_package_file_service.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +module Packages + class CreatePackageFileService + attr_reader :package, :params + + def initialize(package, params) + @package = package + @params = params + end + + def execute + package.package_files.create!( + file: params[:file], + size: params[:size], + file_name: params[:file_name], + file_sha1: params[:file_sha1], + file_sha256: params[:file_sha256], + file_md5: params[:file_md5] + ) + end + end +end diff --git a/app/services/packages/maven/create_package_service.rb b/app/services/packages/maven/create_package_service.rb new file mode 100644 index 00000000000..aca5d28ca98 --- /dev/null +++ b/app/services/packages/maven/create_package_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +module Packages + module Maven + class CreatePackageService < BaseService + def execute + app_group, _, app_name = params[:name].rpartition('/') + app_group.tr!('/', '.') + + package = project.packages.create!( + name: params[:name], + version: params[:version], + package_type: :maven, + maven_metadatum_attributes: { + path: params[:path], + app_group: app_group, + app_name: app_name, + app_version: params[:version] + } + ) + + build = params[:build] + package.create_build_info!(pipeline: build.pipeline) if build.present? + + package + 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 new file mode 100644 index 00000000000..50a008843ad --- /dev/null +++ b/app/services/packages/maven/find_or_create_package_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true +module Packages + module Maven + class FindOrCreatePackageService < BaseService + MAVEN_METADATA_FILE = 'maven-metadata.xml'.freeze + + def execute + package = ::Packages::Maven::PackageFinder + .new(params[:path], current_user, project: project).execute + + unless package + if params[:file_name] == MAVEN_METADATA_FILE + # Maven uploads several files during `mvn deploy` in next order: + # - my-company/my-app/1.0-SNAPSHOT/my-app.jar + # - my-company/my-app/1.0-SNAPSHOT/my-app.pom + # - my-company/my-app/1.0-SNAPSHOT/maven-metadata.xml + # - my-company/my-app/maven-metadata.xml + # + # The last xml file does not have VERSION in URL because it contains + # information about all versions. + package_name, version = params[:path], nil + else + package_name, _, version = params[:path].rpartition('/') + end + + package_params = { + name: package_name, + path: params[:path], + version: version, + build: params[:build] + } + + package = ::Packages::Maven::CreatePackageService + .new(project, current_user, package_params).execute + end + + package + end + end + end +end diff --git a/app/services/packages/npm/create_package_service.rb b/app/services/packages/npm/create_package_service.rb new file mode 100644 index 00000000000..cf927683ce9 --- /dev/null +++ b/app/services/packages/npm/create_package_service.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true +module Packages + module Npm + class CreatePackageService < BaseService + include Gitlab::Utils::StrongMemoize + + def execute + return error('Version is empty.', 400) if version.blank? + return error('Package already exists.', 403) if current_package_exists? + + ActiveRecord::Base.transaction { create_package! } + end + + private + + def create_package! + package = project.packages.create!( + name: name, + version: version, + package_type: 'npm' + ) + + if build.present? + package.create_build_info!(pipeline: build.pipeline) + end + + ::Packages::CreatePackageFileService.new(package, file_params).execute + ::Packages::CreateDependencyService.new(package, package_dependencies).execute + ::Packages::Npm::CreateTagService.new(package, dist_tag).execute + + package + end + + def current_package_exists? + project.packages + .npm + .with_name(name) + .with_version(version) + .exists? + end + + def name + params[:name] + end + + def version + strong_memoize(:version) do + params[:versions].each_key.first + end + end + + def version_data + params[:versions][version] + end + + def build + params[:build] + end + + def dist_tag + params['dist-tags'].each_key.first + end + + def package_file_name + strong_memoize(:package_file_name) do + "#{name}-#{version}.tgz" + end + end + + def attachment + strong_memoize(:attachment) do + params['_attachments'][package_file_name] + end + end + + def file_params + { + file: CarrierWaveStringFile.new(Base64.decode64(attachment['data'])), + size: attachment['length'], + file_sha1: version_data[:dist][:shasum], + file_name: package_file_name + } + end + + def package_dependencies + _version, versions_data = params[:versions].first + versions_data + end + end + end +end diff --git a/app/services/packages/npm/create_tag_service.rb b/app/services/packages/npm/create_tag_service.rb new file mode 100644 index 00000000000..82974d0ca4b --- /dev/null +++ b/app/services/packages/npm/create_tag_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true +module Packages + module Npm + class CreateTagService + include Gitlab::Utils::StrongMemoize + + attr_reader :package, :tag_name + + def initialize(package, tag_name) + @package = package + @tag_name = tag_name + end + + def execute + if existing_tag.present? + existing_tag.update_column(:package_id, package.id) + existing_tag + else + package.tags.create!(name: tag_name) + end + end + + private + + def existing_tag + strong_memoize(:existing_tag) do + Packages::TagsFinder + .new(package.project, package.name, package_type: package.package_type) + .find_by_name(tag_name) + end + end + end + end +end diff --git a/app/services/packages/nuget/create_dependency_service.rb b/app/services/packages/nuget/create_dependency_service.rb new file mode 100644 index 00000000000..2be5db732f6 --- /dev/null +++ b/app/services/packages/nuget/create_dependency_service.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true +module Packages + module Nuget + class CreateDependencyService < BaseService + def initialize(package, dependencies = []) + @package = package + @dependencies = dependencies + end + + def execute + return if @dependencies.empty? + + @package.transaction do + create_dependency_links + create_dependency_link_metadata + end + end + + private + + def create_dependency_links + ::Packages::CreateDependencyService + .new(@package, dependencies_for_create_dependency_service) + .execute + end + + def create_dependency_link_metadata + inserted_links = ::Packages::DependencyLink.preload_dependency + .for_package(@package) + + return if inserted_links.empty? + + rows = inserted_links.map do |dependency_link| + raw_dependency = raw_dependency_for(dependency_link.dependency) + + next if raw_dependency[:target_framework].blank? + + { + dependency_link_id: dependency_link.id, + target_framework: raw_dependency[:target_framework] + } + end + + ::Gitlab::Database.bulk_insert(::Packages::Nuget::DependencyLinkMetadatum.table_name, rows.compact) + end + + def raw_dependency_for(dependency) + name = dependency.name + version = dependency.version_pattern.presence + + @dependencies.find do |raw_dependency| + raw_dependency[:name] == name && raw_dependency[:version] == version + end + end + + def dependencies_for_create_dependency_service + names_and_versions = @dependencies.map do |dependency| + [dependency[:name], version_or_empty_string(dependency[:version])] + end.to_h + + { 'dependencies' => names_and_versions } + end + + def version_or_empty_string(version) + return '' if version.blank? + + version + end + end + end +end diff --git a/app/services/packages/nuget/create_package_service.rb b/app/services/packages/nuget/create_package_service.rb new file mode 100644 index 00000000000..68ad7f028e4 --- /dev/null +++ b/app/services/packages/nuget/create_package_service.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Packages + module Nuget + class CreatePackageService < BaseService + TEMPORARY_PACKAGE_NAME = 'NuGet.Temporary.Package' + PACKAGE_VERSION = '0.0.0' + + def execute + project.packages.nuget.create!( + name: TEMPORARY_PACKAGE_NAME, + version: "#{PACKAGE_VERSION}-#{uuid}" + ) + end + + private + + def uuid + SecureRandom.uuid + end + end + end +end diff --git a/app/services/packages/nuget/metadata_extraction_service.rb b/app/services/packages/nuget/metadata_extraction_service.rb new file mode 100644 index 00000000000..6fec398fab0 --- /dev/null +++ b/app/services/packages/nuget/metadata_extraction_service.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module Packages + module Nuget + class MetadataExtractionService + include Gitlab::Utils::StrongMemoize + + ExtractionError = Class.new(StandardError) + + XPATHS = { + package_name: '//xmlns:package/xmlns:metadata/xmlns:id', + package_version: '//xmlns:package/xmlns:metadata/xmlns:version', + license_url: '//xmlns:package/xmlns:metadata/xmlns:licenseUrl', + project_url: '//xmlns:package/xmlns:metadata/xmlns:projectUrl', + icon_url: '//xmlns:package/xmlns:metadata/xmlns:iconUrl' + }.freeze + + XPATH_DEPENDENCIES = '//xmlns:package/xmlns:metadata/xmlns:dependencies/xmlns:dependency' + XPATH_DEPENDENCY_GROUPS = '//xmlns:package/xmlns:metadata/xmlns:dependencies/xmlns:group' + XPATH_TAGS = '//xmlns:package/xmlns:metadata/xmlns:tags' + + MAX_FILE_SIZE = 4.megabytes.freeze + + def initialize(package_file_id) + @package_file_id = package_file_id + end + + def execute + raise ExtractionError.new('invalid package file') unless valid_package_file? + + extract_metadata(nuspec_file) + end + + private + + def package_file + strong_memoize(:package_file) do + ::Packages::PackageFile.find_by_id(@package_file_id) + end + end + + def valid_package_file? + package_file && + package_file.package&.nuget? && + package_file.file.size.positive? + end + + def extract_metadata(file) + doc = Nokogiri::XML(file) + + XPATHS.transform_values { |query| doc.xpath(query).text.presence } + .compact + .tap do |metadata| + metadata[:package_dependencies] = extract_dependencies(doc) + metadata[:package_tags] = extract_tags(doc) + end + end + + def extract_dependencies(doc) + dependencies = [] + + doc.xpath(XPATH_DEPENDENCIES).each do |node| + dependencies << extract_dependency(node) + end + + doc.xpath(XPATH_DEPENDENCY_GROUPS).each do |group_node| + target_framework = group_node.attr("targetFramework") + + group_node.xpath("xmlns:dependency").each do |node| + dependencies << extract_dependency(node).merge(target_framework: target_framework) + end + end + + dependencies + end + + def extract_dependency(node) + { + name: node.attr('id'), + version: node.attr('version') + }.compact + end + + def extract_tags(doc) + tags = doc.xpath(XPATH_TAGS).text + + return [] if tags.blank? + + tags.split(::Packages::Tag::NUGET_TAGS_SEPARATOR) + end + + def nuspec_file + package_file.file.use_file do |file_path| + Zip::File.open(file_path) do |zip_file| + entry = zip_file.glob('*.nuspec').first + + raise ExtractionError.new('nuspec file not found') unless entry + raise ExtractionError.new('nuspec file too big') if entry.size > MAX_FILE_SIZE + + entry.get_input_stream.read + end + end + end + end + end +end diff --git a/app/services/packages/nuget/search_service.rb b/app/services/packages/nuget/search_service.rb new file mode 100644 index 00000000000..f7e09e11819 --- /dev/null +++ b/app/services/packages/nuget/search_service.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +module Packages + module Nuget + class SearchService < BaseService + include Gitlab::Utils::StrongMemoize + include ActiveRecord::ConnectionAdapters::Quoting + + MAX_PER_PAGE = 30 + MAX_VERSIONS_PER_PACKAGE = 10 + PRE_RELEASE_VERSION_MATCHING_TERM = '%-%' + + DEFAULT_OPTIONS = { + include_prerelease_versions: true, + per_page: Kaminari.config.default_per_page, + padding: 0 + }.freeze + + def initialize(project, search_term, options = {}) + @project = project + @search_term = search_term + @options = DEFAULT_OPTIONS.merge(options) + + raise ArgumentError, 'negative per_page' if per_page.negative? + raise ArgumentError, 'negative padding' if padding.negative? + end + + def execute + OpenStruct.new( + total_count: package_names.total_count, + results: search_packages + ) + end + + private + + def search_packages + # custom query to get package names and versions as expected from the nuget search api + # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/24182#technical-notes + # and https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource + subquery_name = :partition_subquery + arel_table = Arel::Table.new(:partition_subquery) + column_names = Packages::Package.column_names.map do |cn| + "#{subquery_name}.#{quote_column_name(cn)}" + end + + # rubocop: disable CodeReuse/ActiveRecord + pkgs = Packages::Package.select(column_names.join(',')) + .from(package_names_partition, subquery_name) + .where(arel_table[:row_number].lteq(MAX_VERSIONS_PER_PACKAGE)) + + return pkgs if include_prerelease_versions? + + # we can't use pkgs.without_version_like since we have a custom from + pkgs.where.not(arel_table[:version].matches(PRE_RELEASE_VERSION_MATCHING_TERM)) + end + + def package_names_partition + table_name = quote_table_name(Packages::Package.table_name) + name_column = "#{table_name}.#{quote_column_name('name')}" + created_at_column = "#{table_name}.#{quote_column_name('created_at')}" + select_sql = "ROW_NUMBER() OVER (PARTITION BY #{name_column} ORDER BY #{created_at_column} DESC) AS row_number, #{table_name}.*" + + @project.packages + .select(select_sql) + .nuget + .has_version + .without_nuget_temporary_name + .with_name(package_names) + end + + def package_names + strong_memoize(:package_names) do + pkgs = @project.packages + .nuget + .has_version + .without_nuget_temporary_name + .order_name + .select_distinct_name + pkgs = pkgs.without_version_like(PRE_RELEASE_VERSION_MATCHING_TERM) unless include_prerelease_versions? + pkgs = pkgs.search_by_name(@search_term) if @search_term.present? + pkgs.page(0) # we're using a padding + .per(per_page) + .padding(padding) + end + end + + def include_prerelease_versions? + @options[:include_prerelease_versions] + end + + def padding + @options[:padding] + end + + def per_page + [@options[:per_page], MAX_PER_PAGE].min + end + end + end +end diff --git a/app/services/packages/nuget/sync_metadatum_service.rb b/app/services/packages/nuget/sync_metadatum_service.rb new file mode 100644 index 00000000000..ca9cc4d5b78 --- /dev/null +++ b/app/services/packages/nuget/sync_metadatum_service.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Packages + module Nuget + class SyncMetadatumService + include Gitlab::Utils::StrongMemoize + + def initialize(package, metadata) + @package = package + @metadata = metadata + end + + def execute + if blank_metadata? + metadatum.destroy! if metadatum.persisted? + else + metadatum.update!( + license_url: license_url, + project_url: project_url, + icon_url: icon_url + ) + end + end + + private + + def metadatum + strong_memoize(:metadatum) do + @package.nuget_metadatum || @package.build_nuget_metadatum + end + end + + def blank_metadata? + project_url.blank? && license_url.blank? && icon_url.blank? + end + + def project_url + @metadata[:project_url] + end + + def license_url + @metadata[:license_url] + end + + def icon_url + @metadata[:icon_url] + end + end + end +end diff --git a/app/services/packages/nuget/update_package_from_metadata_service.rb b/app/services/packages/nuget/update_package_from_metadata_service.rb new file mode 100644 index 00000000000..f72b1386985 --- /dev/null +++ b/app/services/packages/nuget/update_package_from_metadata_service.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +module Packages + module Nuget + class UpdatePackageFromMetadataService + include Gitlab::Utils::StrongMemoize + include ExclusiveLeaseGuard + + # used by ExclusiveLeaseGuard + DEFAULT_LEASE_TIMEOUT = 1.hour.to_i.freeze + + InvalidMetadataError = Class.new(StandardError) + + def initialize(package_file) + @package_file = package_file + end + + def execute + raise InvalidMetadataError.new('package name and/or package version not found in metadata') unless valid_metadata? + + try_obtain_lease do + @package_file.transaction do + package = existing_package ? link_to_existing_package : update_linked_package + + update_package(package) + + # Updating file_name updates the path where the file is stored. + # We must pass the file again so that CarrierWave can handle the update + @package_file.update!( + file_name: package_filename, + file: @package_file.file + ) + end + end + end + + private + + def update_package(package) + ::Packages::Nuget::SyncMetadatumService + .new(package, metadata.slice(:project_url, :license_url, :icon_url)) + .execute + ::Packages::UpdateTagsService + .new(package, package_tags) + .execute + rescue => e + raise InvalidMetadataError, e.message + end + + def valid_metadata? + package_name.present? && package_version.present? + end + + def link_to_existing_package + package_to_destroy = @package_file.package + # Updating package_id updates the path where the file is stored. + # We must pass the file again so that CarrierWave can handle the update + @package_file.update!( + package_id: existing_package.id, + file: @package_file.file + ) + package_to_destroy.destroy! + existing_package + end + + def update_linked_package + @package_file.package.update!( + name: package_name, + version: package_version + ) + + ::Packages::Nuget::CreateDependencyService.new(@package_file.package, package_dependencies) + .execute + @package_file.package + end + + def existing_package + strong_memoize(:existing_package) do + @package_file.project.packages + .nuget + .with_name(package_name) + .with_version(package_version) + .first + end + end + + def package_name + metadata[:package_name] + end + + def package_version + metadata[:package_version] + end + + def package_dependencies + metadata.fetch(:package_dependencies, []) + end + + def package_tags + metadata.fetch(:package_tags, []) + end + + def metadata + strong_memoize(:metadata) do + ::Packages::Nuget::MetadataExtractionService.new(@package_file.id).execute + end + end + + def package_filename + "#{package_name.downcase}.#{package_version.downcase}.nupkg" + end + + # used by ExclusiveLeaseGuard + def lease_key + package_id = existing_package ? existing_package.id : @package_file.package_id + "packages:nuget:update_package_from_metadata_service:package:#{package_id}" + end + + # used by ExclusiveLeaseGuard + def lease_timeout + DEFAULT_LEASE_TIMEOUT + end + end + end +end diff --git a/app/services/packages/pypi/create_package_service.rb b/app/services/packages/pypi/create_package_service.rb new file mode 100644 index 00000000000..1313fc80e33 --- /dev/null +++ b/app/services/packages/pypi/create_package_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Packages + module Pypi + class CreatePackageService < BaseService + include ::Gitlab::Utils::StrongMemoize + + def execute + ::Packages::Package.transaction do + Packages::Pypi::Metadatum.upsert( + package_id: created_package.id, + required_python: params[:requires_python] + ) + + ::Packages::CreatePackageFileService.new(created_package, file_params).execute + end + end + + private + + def created_package + strong_memoize(:created_package) do + project + .packages + .pypi + .safe_find_or_create_by!(name: params[:name], version: params[:version]) + end + end + + def file_params + { + file: params[:content], + file_name: params[:content].original_filename, + file_md5: params[:md5_digest], + file_sha256: params[:sha256_digest] + } + end + end + end +end diff --git a/app/services/packages/remove_tag_service.rb b/app/services/packages/remove_tag_service.rb new file mode 100644 index 00000000000..465b85506a6 --- /dev/null +++ b/app/services/packages/remove_tag_service.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +module Packages + class RemoveTagService < BaseService + attr_reader :package_tag + + def initialize(package_tag) + raise ArgumentError, "Package tag must be set" if package_tag.blank? + + @package_tag = package_tag + end + + def execute + package_tag.delete + end + end +end diff --git a/app/services/packages/update_tags_service.rb b/app/services/packages/update_tags_service.rb new file mode 100644 index 00000000000..da50cd3479e --- /dev/null +++ b/app/services/packages/update_tags_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true +module Packages + class UpdateTagsService + include Gitlab::Utils::StrongMemoize + + def initialize(package, tags = []) + @package = package + @tags = tags + end + + def execute + return if @tags.empty? + + tags_to_destroy = existing_tags - @tags + tags_to_create = @tags - existing_tags + + @package.tags.with_name(tags_to_destroy).delete_all if tags_to_destroy.any? + ::Gitlab::Database.bulk_insert(Packages::Tag.table_name, rows(tags_to_create)) if tags_to_create.any? + end + + private + + def existing_tags + strong_memoize(:existing_tags) do + @package.tag_names + end + end + + def rows(tags) + now = Time.zone.now + tags.map do |tag| + { + package_id: @package.id, + name: tag, + created_at: now, + updated_at: now + } + end + end + end +end diff --git a/app/services/personal_access_tokens/last_used_service.rb b/app/services/personal_access_tokens/last_used_service.rb new file mode 100644 index 00000000000..9066fd1acdf --- /dev/null +++ b/app/services/personal_access_tokens/last_used_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module PersonalAccessTokens + class LastUsedService + def initialize(personal_access_token) + @personal_access_token = personal_access_token + end + + def execute + # Needed to avoid calling service on Oauth tokens + return unless @personal_access_token.has_attribute?(:last_used_at) + + # We _only_ want to update last_used_at and not also updated_at (which + # would be updated when using #touch). + @personal_access_token.update_column(:last_used_at, Time.zone.now) if update? + end + + private + + def update? + return false if ::Gitlab::Database.read_only? + + last_used = @personal_access_token.last_used_at + + last_used.nil? || (last_used <= 1.day.ago) + end + end +end diff --git a/app/services/post_receive_service.rb b/app/services/post_receive_service.rb index 65e6ebc17d2..69c9868c75c 100644 --- a/app/services/post_receive_service.rb +++ b/app/services/post_receive_service.rb @@ -29,8 +29,6 @@ class PostReceiveService response.add_alert_message(message) end - response.add_alert_message(storage_size_limit_alert) - broadcast_message = BroadcastMessage.current_banner_messages&.last&.message response.add_alert_message(broadcast_message) @@ -76,19 +74,6 @@ class PostReceiveService ::MergeRequests::GetUrlsService.new(project).execute(params[:changes]) end - - private - - def storage_size_limit_alert - return unless repository&.repo_type&.project? - - payload = Namespaces::CheckStorageSizeService.new(project.namespace, user).execute.payload - return unless payload.present? - - alert_level = "##### #{payload[:alert_level].to_s.upcase} #####" - - [alert_level, payload[:usage_message], payload[:explanation_message]].join("\n") - end end PostReceiveService.prepend_if_ee('EE::PostReceiveService') diff --git a/app/services/projects/after_import_service.rb b/app/services/projects/after_import_service.rb index fad2290a47b..b37ae56ba0f 100644 --- a/app/services/projects/after_import_service.rb +++ b/app/services/projects/after_import_service.rb @@ -26,7 +26,7 @@ module Projects message: 'Project housekeeping failed', project_full_path: @project.full_path, project_id: @project.id, - error: e.message + 'error.message' => e.message ) end diff --git a/app/services/projects/alerting/notify_service.rb b/app/services/projects/alerting/notify_service.rb index 86c408aeec8..e08bc8efb15 100644 --- a/app/services/projects/alerting/notify_service.rb +++ b/app/services/projects/alerting/notify_service.rb @@ -4,7 +4,7 @@ module Projects module Alerting class NotifyService < BaseService include Gitlab::Utils::StrongMemoize - include IncidentManagement::Settings + include ::IncidentManagement::Settings def execute(token) return forbidden unless alerts_service_activated? @@ -55,7 +55,7 @@ module Projects def find_alert_by_fingerprint(fingerprint) return unless fingerprint - AlertManagement::Alert.for_fingerprint(project, fingerprint).first + AlertManagement::Alert.not_resolved.for_fingerprint(project, fingerprint).first end def send_email? @@ -65,8 +65,7 @@ module Projects def process_incident_issues(alert) return if alert.issue - IncidentManagement::ProcessAlertWorker - .perform_async(project.id, parsed_payload, alert.id) + ::IncidentManagement::ProcessAlertWorker.perform_async(nil, nil, alert.id) end def send_alert_email @@ -76,7 +75,7 @@ module Projects end def parsed_payload - Gitlab::Alerting::NotificationPayloadParser.call(params.to_h) + Gitlab::Alerting::NotificationPayloadParser.call(params.to_h, project) end def valid_token?(token) diff --git a/app/services/projects/batch_forks_count_service.rb b/app/services/projects/batch_forks_count_service.rb index 6467744a435..d12772b40ff 100644 --- a/app/services/projects/batch_forks_count_service.rb +++ b/app/services/projects/batch_forks_count_service.rb @@ -5,6 +5,21 @@ # because the service use maps to retrieve the project ids module Projects class BatchForksCountService < Projects::BatchCountService + def refresh_cache_and_retrieve_data + count_services = @projects.map { |project| count_service.new(project) } + + values = Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do + Rails.cache.fetch_multi(*(count_services.map { |ser| ser.cache_key } )) { |key| nil } + end + + results_per_service = Hash[count_services.zip(values.values)] + projects_to_refresh = results_per_service.select { |_k, value| value.nil? } + projects_to_refresh = recreate_cache(projects_to_refresh) + + results_per_service.update(projects_to_refresh) + results_per_service.transform_keys { |k| k.project } + end + # rubocop: disable CodeReuse/ActiveRecord def global_count @global_count ||= begin @@ -18,5 +33,13 @@ module Projects def count_service ::Projects::ForksCountService end + + def recreate_cache(projects_to_refresh) + projects_to_refresh.each_with_object({}) do |(service, _v), hash| + count = global_count[service.project.id].to_i + service.refresh_cache { count } + hash[service] = count + end + end end end diff --git a/app/services/projects/container_repository/delete_tags_service.rb b/app/services/projects/container_repository/delete_tags_service.rb index 21081bd077f..5d4059710bb 100644 --- a/app/services/projects/container_repository/delete_tags_service.rb +++ b/app/services/projects/container_repository/delete_tags_service.rb @@ -3,6 +3,8 @@ module Projects module ContainerRepository class DeleteTagsService < BaseService + LOG_DATA_BASE = { service_class: self.to_s }.freeze + def execute(container_repository) return error('access denied') unless can?(current_user, :destroy_container_image, project) @@ -51,10 +53,27 @@ module Projects def smart_delete(container_repository, tag_names) fast_delete_enabled = Feature.enabled?(:container_registry_fast_tag_delete, default_enabled: true) - if fast_delete_enabled && container_repository.client.supports_tag_delete? - fast_delete(container_repository, tag_names) + response = if fast_delete_enabled && container_repository.client.supports_tag_delete? + fast_delete(container_repository, tag_names) + else + slow_delete(container_repository, tag_names) + end + + response.tap { |r| log_response(r, container_repository) } + end + + def log_response(response, container_repository) + log_data = LOG_DATA_BASE.merge( + container_repository_id: container_repository.id, + message: 'deleted tags' + ) + + if response[:status] == :success + log_data[:deleted_tags_count] = response[:deleted].size + log_info(log_data) else - slow_delete(container_repository, tag_names) + log_data[:message] = response[:message] + log_error(log_data) end end diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index bffd443c49f..6569277ad9d 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -84,8 +84,12 @@ module Projects def after_create_actions log_info("#{@project.owner.name} created a new project \"#{@project.full_name}\"") + # Skip writing the config for project imports/forks because it + # will always fail since the Git directory doesn't exist until + # a background job creates it (see Project#add_import_job). + @project.write_repository_config unless @project.import? + unless @project.gitlab_project_import? - @project.write_repository_config @project.create_wiki unless skip_wiki? end @@ -103,12 +107,13 @@ module Projects create_readme if @initialize_with_readme end - # Refresh the current user's authorizations inline (so they can access the - # project immediately after this request completes), and any other affected - # users in the background + # Add an authorization for the current user authorizations inline + # (so they can access the project immediately after this request + # completes), and any other affected users in the background def setup_authorizations if @project.group - current_user.refresh_authorized_projects + current_user.project_authorizations.create!(project: @project, + access_level: @project.group.max_member_access_for_user(current_user)) if Feature.enabled?(:specialized_project_authorization_workers) AuthorizedProjectUpdate::ProjectCreateWorker.perform_async(@project.id) @@ -131,7 +136,7 @@ module Projects def create_readme commit_attrs = { - branch_name: 'master', + branch_name: Gitlab::CurrentSettings.default_branch_name.presence || 'master', commit_message: 'Initial commit', file_path: 'README.md', file_content: "# #{@project.name}\n\n#{@project.description}" diff --git a/app/services/projects/forks_count_service.rb b/app/services/projects/forks_count_service.rb index ca85e2dc281..848d8d54104 100644 --- a/app/services/projects/forks_count_service.rb +++ b/app/services/projects/forks_count_service.rb @@ -3,6 +3,8 @@ module Projects # Service class for getting and caching the number of forks of a project. class ForksCountService < Projects::CountService + attr_reader :project + def cache_key_name 'forks_count' end diff --git a/app/services/projects/group_links/create_service.rb b/app/services/projects/group_links/create_service.rb index 2ba3cd6694f..3c3cab26fb5 100644 --- a/app/services/projects/group_links/create_service.rb +++ b/app/services/projects/group_links/create_service.rb @@ -13,12 +13,32 @@ module Projects ) if link.save - group.refresh_members_authorized_projects + setup_authorizations(group) success(link: link) else error(link.errors.full_messages.to_sentence, 409) end end + + private + + def setup_authorizations(group) + if Feature.enabled?(:specialized_project_authorization_project_share_worker) + AuthorizedProjectUpdate::ProjectGroupLinkCreateWorker.perform_async(project.id, group.id) + + # AuthorizedProjectsWorker uses an exclusive lease per user but + # specialized workers might have synchronization issues. Until we + # compare the inconsistency rates of both approaches, we still run + # AuthorizedProjectsWorker but with some delay and lower urgency as a + # safety net. + group.refresh_members_authorized_projects( + blocking: false, + priority: UserProjectAccessChangedService::LOW_PRIORITY + ) + else + group.refresh_members_authorized_projects(blocking: false) + end + end end end end diff --git a/app/services/projects/operations/update_service.rb b/app/services/projects/operations/update_service.rb index 7aa7ea73639..7af489c3751 100644 --- a/app/services/projects/operations/update_service.rb +++ b/app/services/projects/operations/update_service.rb @@ -108,7 +108,18 @@ module Projects end def incident_management_setting_params - params.slice(:incident_management_setting_attributes) + attrs = params[:incident_management_setting_attributes] + return {} unless attrs + + regenerate_token = attrs.delete(:regenerate_token) + + if regenerate_token + attrs[:pagerduty_token] = nil + else + attrs = attrs.except(:pagerduty_token) + end + + { incident_management_setting_attributes: attrs } end end end diff --git a/app/services/projects/prometheus/alerts/create_events_service.rb b/app/services/projects/prometheus/alerts/create_events_service.rb deleted file mode 100644 index 4fcf841314b..00000000000 --- a/app/services/projects/prometheus/alerts/create_events_service.rb +++ /dev/null @@ -1,71 +0,0 @@ -# frozen_string_literal: true - -module Projects - module Prometheus - module Alerts - # Persists a series of Prometheus alert events as list of PrometheusAlertEvent. - class CreateEventsService < BaseService - def execute - create_events_from(alerts) - end - - private - - def create_events_from(alerts) - Array.wrap(alerts).map { |alert| create_event(alert) }.compact - end - - def create_event(payload) - parsed_alert = Gitlab::Alerting::Alert.new(project: project, payload: payload) - - return unless parsed_alert.valid? - - if parsed_alert.gitlab_managed? - create_managed_prometheus_alert_event(parsed_alert) - else - create_self_managed_prometheus_alert_event(parsed_alert) - end - end - - def alerts - params['alerts'] - end - - def find_alert(metric) - Projects::Prometheus::AlertsFinder - .new(project: project, metric: metric) - .execute - .first - end - - def create_managed_prometheus_alert_event(parsed_alert) - alert = find_alert(parsed_alert.metric_id) - event = PrometheusAlertEvent.find_or_initialize_by_payload_key(parsed_alert.project, alert, parsed_alert.gitlab_fingerprint) - - set_status(parsed_alert, event) - end - - def create_self_managed_prometheus_alert_event(parsed_alert) - event = SelfManagedPrometheusAlertEvent.find_or_initialize_by_payload_key(parsed_alert.project, parsed_alert.gitlab_fingerprint) do |event| - event.environment = parsed_alert.environment - event.title = parsed_alert.title - event.query_expression = parsed_alert.full_query - end - - set_status(parsed_alert, event) - end - - def set_status(parsed_alert, event) - persisted = case parsed_alert.status - when 'firing' - event.fire(parsed_alert.starts_at) - when 'resolved' - event.resolve(parsed_alert.ends_at) - end - - event if persisted - end - end - end - end -end diff --git a/app/services/projects/prometheus/alerts/notify_service.rb b/app/services/projects/prometheus/alerts/notify_service.rb index 877a4f99a94..ea557ebe20f 100644 --- a/app/services/projects/prometheus/alerts/notify_service.rb +++ b/app/services/projects/prometheus/alerts/notify_service.rb @@ -5,7 +5,7 @@ module Projects module Alerts class NotifyService < BaseService include Gitlab::Utils::StrongMemoize - include IncidentManagement::Settings + include ::IncidentManagement::Settings # This set of keys identifies a payload as a valid Prometheus # payload and thus processable by this service. See also @@ -23,9 +23,7 @@ module Projects return unauthorized unless valid_alert_manager_token?(token) process_prometheus_alerts - persist_events send_alert_email if send_email? - process_incident_issues if process_issues? ServiceResponse.success end @@ -132,13 +130,6 @@ module Projects .prometheus_alerts_fired(project, firings) end - def process_incident_issues - alerts.each do |alert| - IncidentManagement::ProcessPrometheusAlertWorker - .perform_async(project.id, alert.to_h) - end - end - def process_prometheus_alerts alerts.each do |alert| AlertManagement::ProcessPrometheusAlertService @@ -147,10 +138,6 @@ module Projects end end - def persist_events - CreateEventsService.new(project, nil, params).execute - end - def bad_request ServiceResponse.error(message: 'Bad Request', http_status: :bad_request) end diff --git a/app/services/projects/propagate_service_template.rb b/app/services/projects/propagate_service_template.rb index 4adcda042d1..b6465810fde 100644 --- a/app/services/projects/propagate_service_template.rb +++ b/app/services/projects/propagate_service_template.rb @@ -26,7 +26,7 @@ module Projects def propagate_projects_with_template loop do - batch = Project.uncached { project_ids_without_integration } + batch = Project.uncached { Project.ids_without_integration(template, BATCH_SIZE) } bulk_create_from_template(batch) unless batch.empty? @@ -50,22 +50,6 @@ module Projects end end - # rubocop: disable CodeReuse/ActiveRecord - def project_ids_without_integration - services = Service - .select('1') - .where('services.project_id = projects.id') - .where(type: template.type) - - Project - .where('NOT EXISTS (?)', services) - .where(pending_delete: false) - .where(archived: false) - .limit(BATCH_SIZE) - .pluck(:id) - end - # rubocop: enable CodeReuse/ActiveRecord - def bulk_insert(klass, columns, values_array) items_to_insert = values_array.map { |array| Hash[columns.zip(array)] } diff --git a/app/services/projects/update_remote_mirror_service.rb b/app/services/projects/update_remote_mirror_service.rb index 5f8ef75a8d7..d6c0d647468 100644 --- a/app/services/projects/update_remote_mirror_service.rb +++ b/app/services/projects/update_remote_mirror_service.rb @@ -29,7 +29,7 @@ module Projects remote_mirror.ensure_remote! # https://gitlab.com/gitlab-org/gitaly/-/issues/2670 - if Feature.disabled?(:gitaly_ruby_remote_branches_ls_remote) + if Feature.disabled?(:gitaly_ruby_remote_branches_ls_remote, default_enabled: true) repository.fetch_remote(remote_mirror.remote_name, ssh_auth: remote_mirror, no_tags: true) end diff --git a/app/services/projects/update_repository_storage_service.rb b/app/services/projects/update_repository_storage_service.rb index fa8d4c5aa5f..7b346c09635 100644 --- a/app/services/projects/update_repository_storage_service.rb +++ b/app/services/projects/update_repository_storage_service.rb @@ -14,7 +14,11 @@ module Projects end def execute - repository_storage_move.start! + repository_storage_move.with_lock do + return ServiceResponse.success unless repository_storage_move.scheduled? # rubocop:disable Cop/AvoidReturnFromBlocks + + repository_storage_move.start! + end raise SameFilesystemError if same_filesystem?(repository.storage, destination_storage_name) @@ -79,8 +83,6 @@ module Projects full_path ) - new_repository.create_repository - new_repository.replicate(raw_repository) new_checksum = new_repository.checksum @@ -93,25 +95,25 @@ module Projects old_repository_storage = project.repository_storage new_project_path = moved_path(project.disk_path) - # Notice that the block passed to `run_after_commit` will run with `project` + # Notice that the block passed to `run_after_commit` will run with `repository_storage_move` # as its context - project.run_after_commit do + repository_storage_move.run_after_commit do GitlabShellWorker.perform_async(:mv_repository, old_repository_storage, - disk_path, + project.disk_path, new_project_path) - if wiki.repository_exists? + if project.wiki.repository_exists? GitlabShellWorker.perform_async(:mv_repository, old_repository_storage, - wiki.disk_path, + project.wiki.disk_path, "#{new_project_path}.wiki") end - if design_repository.exists? + if project.design_repository.exists? GitlabShellWorker.perform_async(:mv_repository, old_repository_storage, - design_repository.disk_path, + project.design_repository.disk_path, "#{new_project_path}.design") end end diff --git a/app/services/prometheus/proxy_service.rb b/app/services/prometheus/proxy_service.rb index e0bc5518d30..33635796771 100644 --- a/app/services/prometheus/proxy_service.rb +++ b/app/services/prometheus/proxy_service.rb @@ -22,16 +22,20 @@ module Prometheus attr_accessor :proxyable, :method, :path, :params + PROMETHEUS_QUERY_API = 'query' + PROMETHEUS_QUERY_RANGE_API = 'query_range' + PROMETHEUS_SERIES_API = 'series' + PROXY_SUPPORT = { - 'query' => { + PROMETHEUS_QUERY_API => { method: ['GET'], params: %w(query time timeout) }, - 'query_range' => { + PROMETHEUS_QUERY_RANGE_API => { method: ['GET'], params: %w(query start end step timeout) }, - 'series' => { + PROMETHEUS_SERIES_API => { method: %w(GET), params: %w(match start end) } diff --git a/app/services/prometheus/proxy_variable_substitution_service.rb b/app/services/prometheus/proxy_variable_substitution_service.rb index 10fb3a8c1b5..820b551c30a 100644 --- a/app/services/prometheus/proxy_variable_substitution_service.rb +++ b/app/services/prometheus/proxy_variable_substitution_service.rb @@ -19,10 +19,52 @@ module Prometheus :substitute_params, :substitute_variables + # @param environment [Environment] + # @param params [Hash<Symbol,Any>] + # @param params - query [String] The Prometheus query string. + # @param params - start [String] (optional) A time string in the rfc3339 format. + # @param params - start_time [String] (optional) A time string in the rfc3339 format. + # @param params - end [String] (optional) A time string in the rfc3339 format. + # @param params - end_time [String] (optional) A time string in the rfc3339 format. + # @param params - variables [ActionController::Parameters] (optional) Variables with their values. + # The keys in the Hash should be the name of the variable. The value should be the value of the + # variable. Ex: `ActionController::Parameters.new(variable1: 'value 1', variable2: 'value 2').permit!` + # @return [Prometheus::ProxyVariableSubstitutionService] + # + # Example: + # Prometheus::ProxyVariableSubstitutionService.new(environment, { + # params: { + # start_time: '2020-07-03T06:08:36Z', + # end_time: '2020-07-03T14:08:52Z', + # query: 'up{instance="{{instance}}"}', + # variables: { instance: 'srv1' } + # } + # }) def initialize(environment, params = {}) @environment, @params = environment, params.deep_dup end + # @return - params [Hash<Symbol,Any>] Returns a Hash containing a params key which is + # similar to the `params` that is passed to the initialize method with 2 differences: + # 1. Variables in the query string are substituted with their values. + # If a variable present in the query string has no known value (values + # are obtained from the `variables` Hash in `params` or from + # `Gitlab::Prometheus::QueryVariables.call`), it will not be substituted. + # 2. `start` and `end` keys are added, with their values copied from `start_time` + # and `end_time`. + # + # Example output: + # + # { + # params: { + # start_time: '2020-07-03T06:08:36Z', + # start: '2020-07-03T06:08:36Z', + # end_time: '2020-07-03T14:08:52Z', + # end: '2020-07-03T14:08:52Z', + # query: 'up{instance="srv1"}', + # variables: { instance: 'srv1' } + # } + # } def execute execute_steps end diff --git a/app/services/releases/create_evidence_service.rb b/app/services/releases/create_evidence_service.rb index ac13dce1729..9c370722d2c 100644 --- a/app/services/releases/create_evidence_service.rb +++ b/app/services/releases/create_evidence_service.rb @@ -10,7 +10,7 @@ module Releases def execute evidence = release.evidences.build - summary = Evidences::EvidenceSerializer.new.represent(evidence) # rubocop: disable CodeReuse/Serializer + summary = ::Evidences::EvidenceSerializer.new.represent(evidence, evidence_options) # rubocop: disable CodeReuse/Serializer evidence.summary = summary # TODO: fix the sha generating https://gitlab.com/gitlab-org/gitlab/-/issues/209000 evidence.summary_sha = Gitlab::CryptoHelper.sha256(summary) @@ -20,6 +20,12 @@ module Releases private - attr_reader :release + attr_reader :release, :pipeline + + def evidence_options + {} + end end end + +Releases::CreateEvidenceService.prepend_if_ee('EE::Releases::CreateEvidenceService') diff --git a/app/services/repositories/base_service.rb b/app/services/repositories/base_service.rb index a99a65b7edb..efb6f6de8db 100644 --- a/app/services/repositories/base_service.rb +++ b/app/services/repositories/base_service.rb @@ -8,20 +8,19 @@ class Repositories::BaseService < BaseService attr_reader :repository delegate :container, :disk_path, :full_path, to: :repository - delegate :repository_storage, to: :container def initialize(repository) @repository = repository end def repo_exists?(path) - gitlab_shell.repository_exists?(repository_storage, path + '.git') + gitlab_shell.repository_exists?(repository.shard, path + '.git') end def mv_repository(from_path, to_path) return true unless repo_exists?(from_path) - gitlab_shell.mv_repository(repository_storage, from_path, to_path) + gitlab_shell.mv_repository(repository.shard, from_path, to_path) end # Build a path for removing repositories diff --git a/app/services/repositories/destroy_service.rb b/app/services/repositories/destroy_service.rb index b12d0744387..1e34dfbe398 100644 --- a/app/services/repositories/destroy_service.rb +++ b/app/services/repositories/destroy_service.rb @@ -14,8 +14,17 @@ class Repositories::DestroyService < Repositories::BaseService log_info(%Q{Repository "#{disk_path}" moved to "#{removal_path}" for repository "#{full_path}"}) current_repository = repository - container.run_after_commit do + + # Because GitlabShellWorker is inside a run_after_commit callback it will + # never be triggered on a read-only instance. + # + # Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/223272 + if Gitlab::Database.read_only? Repositories::ShellDestroyService.new(current_repository).execute + else + container.run_after_commit do + Repositories::ShellDestroyService.new(current_repository).execute + end end log_info("Repository \"#{full_path}\" was removed") diff --git a/app/services/repositories/shell_destroy_service.rb b/app/services/repositories/shell_destroy_service.rb index 2f5af10e24c..d25cb28c6d7 100644 --- a/app/services/repositories/shell_destroy_service.rb +++ b/app/services/repositories/shell_destroy_service.rb @@ -9,7 +9,7 @@ class Repositories::ShellDestroyService < Repositories::BaseService GitlabShellWorker.perform_in(delay, :remove_repository, - repository_storage, + repository.shard, removal_path) end end diff --git a/app/services/resource_access_tokens/create_service.rb b/app/services/resource_access_tokens/create_service.rb index c8e86e68383..2d0a78feb8e 100644 --- a/app/services/resource_access_tokens/create_service.rb +++ b/app/services/resource_access_tokens/create_service.rb @@ -13,8 +13,6 @@ module ResourceAccessTokens return unless feature_enabled? return error("User does not have permission to create #{resource_type} Access Token") unless has_permission_to_create? - # We skip authorization by default, since the user creating the bot is not an admin - # and project/group bot users are not created via sign-up user = create_user return error(user.errors.full_messages.to_sentence) unless user.persisted? @@ -49,6 +47,11 @@ module ResourceAccessTokens end def create_user + # Even project maintainers can create project access tokens, which in turn + # creates a bot user, and so it becomes necessary to have `skip_authorization: true` + # since someone like a project maintainer does not inherently have the ability + # to create a new user in the system. + Users::CreateService.new(current_user, default_user_params).execute(skip_authorization: true) end @@ -57,7 +60,8 @@ module ResourceAccessTokens name: params[:name] || "#{resource.name.to_s.humanize} bot", email: generate_email, username: generate_username, - user_type: "#{resource_type}_bot".to_sym + user_type: "#{resource_type}_bot".to_sym, + skip_confirmation: true # Bot users should always have their emails confirmed. } end diff --git a/app/services/resource_access_tokens/revoke_service.rb b/app/services/resource_access_tokens/revoke_service.rb index eea6bff572b..efeb0bfb8d5 100644 --- a/app/services/resource_access_tokens/revoke_service.rb +++ b/app/services/resource_access_tokens/revoke_service.rb @@ -35,7 +35,7 @@ module ResourceAccessTokens attr_reader :current_user, :access_token, :bot_user, :resource def remove_member - ::Members::DestroyService.new(current_user).execute(find_member) + ::Members::DestroyService.new(current_user).execute(find_member, destroy_bot: true) end def migrate_to_ghost_user diff --git a/app/services/resource_events/base_synthetic_notes_builder_service.rb b/app/services/resource_events/base_synthetic_notes_builder_service.rb index db8bf6e4b74..a2d78ec67c3 100644 --- a/app/services/resource_events/base_synthetic_notes_builder_service.rb +++ b/app/services/resource_events/base_synthetic_notes_builder_service.rb @@ -23,11 +23,25 @@ module ResourceEvents private - def since_fetch_at(events) + def apply_common_filters(events) + events = apply_last_fetched_at(events) + events = apply_fetch_until(events) + + events + end + + def apply_last_fetched_at(events) return events unless params[:last_fetched_at].present? - last_fetched_at = Time.zone.at(params.fetch(:last_fetched_at).to_i) - events.created_after(last_fetched_at - NotesFinder::FETCH_OVERLAP) + last_fetched_at = params[:last_fetched_at] - NotesFinder::FETCH_OVERLAP + + events.created_after(last_fetched_at) + end + + def apply_fetch_until(events) + return events unless params[:fetch_until].present? + + events.created_on_or_before(params[:fetch_until]) end def resource_parent diff --git a/app/services/resource_events/change_state_service.rb b/app/services/resource_events/change_state_service.rb index 8beb76d8aee..202972c1efd 100644 --- a/app/services/resource_events/change_state_service.rb +++ b/app/services/resource_events/change_state_service.rb @@ -8,12 +8,18 @@ module ResourceEvents @user, @resource = user, resource end - def execute(state) + def execute(params) + @params = params + ResourceStateEvent.create( user: user, issue: issue, merge_request: merge_request, + source_commit: commit_id_of(mentionable_source), + source_merge_request_id: merge_request_id_of(mentionable_source), state: ResourceStateEvent.states[state], + close_after_error_tracking_resolve: close_after_error_tracking_resolve, + close_auto_resolve_prometheus_alert: close_auto_resolve_prometheus_alert, created_at: Time.zone.now) resource.expire_note_etag_cache @@ -21,6 +27,36 @@ module ResourceEvents private + attr_reader :params + + def close_auto_resolve_prometheus_alert + params[:close_auto_resolve_prometheus_alert] || false + end + + def close_after_error_tracking_resolve + params[:close_after_error_tracking_resolve] || false + end + + def state + params[:status] + end + + def mentionable_source + params[:mentionable_source] + end + + def commit_id_of(mentionable_source) + return unless mentionable_source.is_a?(Commit) + + mentionable_source.id[0...40] + end + + def merge_request_id_of(mentionable_source) + return unless mentionable_source.is_a?(MergeRequest) + + mentionable_source.id + end + def issue return unless resource.is_a?(Issue) diff --git a/app/services/resource_events/synthetic_label_notes_builder_service.rb b/app/services/resource_events/synthetic_label_notes_builder_service.rb index fd128101b49..5915ea938cf 100644 --- a/app/services/resource_events/synthetic_label_notes_builder_service.rb +++ b/app/services/resource_events/synthetic_label_notes_builder_service.rb @@ -19,7 +19,7 @@ module ResourceEvents return [] unless resource.respond_to?(:resource_label_events) events = resource.resource_label_events.includes(:label, user: :status) # rubocop: disable CodeReuse/ActiveRecord - events = since_fetch_at(events) + events = apply_common_filters(events) events.group_by { |event| event.discussion_id } end diff --git a/app/services/resource_events/synthetic_milestone_notes_builder_service.rb b/app/services/resource_events/synthetic_milestone_notes_builder_service.rb index cc6383d7083..10acf94e22b 100644 --- a/app/services/resource_events/synthetic_milestone_notes_builder_service.rb +++ b/app/services/resource_events/synthetic_milestone_notes_builder_service.rb @@ -19,7 +19,7 @@ module ResourceEvents return [] unless resource.respond_to?(:resource_milestone_events) events = resource.resource_milestone_events.includes(user: :status) # rubocop: disable CodeReuse/ActiveRecord - since_fetch_at(events) + apply_common_filters(events) end end end diff --git a/app/services/resource_events/synthetic_state_notes_builder_service.rb b/app/services/resource_events/synthetic_state_notes_builder_service.rb index 763134d98d8..71d40200365 100644 --- a/app/services/resource_events/synthetic_state_notes_builder_service.rb +++ b/app/services/resource_events/synthetic_state_notes_builder_service.rb @@ -14,7 +14,7 @@ module ResourceEvents return [] unless resource.respond_to?(:resource_state_events) events = resource.resource_state_events.includes(user: :status) # rubocop: disable CodeReuse/ActiveRecord - since_fetch_at(events) + apply_common_filters(events) end end end diff --git a/app/services/service_desk_settings/update_service.rb b/app/services/service_desk_settings/update_service.rb new file mode 100644 index 00000000000..08106b04d18 --- /dev/null +++ b/app/services/service_desk_settings/update_service.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module ServiceDeskSettings + class UpdateService < BaseService + def execute + settings = ServiceDeskSetting.safe_find_or_create_by!(project_id: project.id) + + unless ::Feature.enabled?(:service_desk_custom_address, project) + params.delete(:project_key) + end + + if settings.update(params) + success + else + error(settings.errors.full_messages.to_sentence) + end + end + end +end diff --git a/app/services/snippets/base_service.rb b/app/services/snippets/base_service.rb index 5d1fe815d83..d9e8326f159 100644 --- a/app/services/snippets/base_service.rb +++ b/app/services/snippets/base_service.rb @@ -6,13 +6,15 @@ module Snippets CreateRepositoryError = Class.new(StandardError) - attr_reader :uploaded_assets, :snippet_files + attr_reader :uploaded_assets, :snippet_actions def initialize(project, user = nil, params = {}) super @uploaded_assets = Array(@params.delete(:files).presence) - @snippet_files = SnippetInputActionCollection.new(Array(@params.delete(:snippet_files).presence)) + + input_actions = Array(@params.delete(:snippet_actions).presence) + @snippet_actions = SnippetInputActionCollection.new(input_actions, allowed_actions: restricted_files_actions) filter_spam_check_params end @@ -30,18 +32,18 @@ module Snippets end def valid_params? - return true if snippet_files.empty? + return true if snippet_actions.empty? - (params.keys & [:content, :file_name]).none? && snippet_files.valid? + (params.keys & [:content, :file_name]).none? && snippet_actions.valid? end def invalid_params_error(snippet) - if snippet_files.valid? + if snippet_actions.valid? [:content, :file_name].each do |key| snippet.errors.add(key, 'and snippet files cannot be used together') if params.key?(key) end else - snippet.errors.add(:snippet_files, 'have invalid data') + snippet.errors.add(:snippet_actions, 'have invalid data') end snippet_error_response(snippet, 403) @@ -73,11 +75,15 @@ module Snippets end def files_to_commit(snippet) - snippet_files.to_commit_actions.presence || build_actions_from_params(snippet) + snippet_actions.to_commit_actions.presence || build_actions_from_params(snippet) end def build_actions_from_params(snippet) raise NotImplementedError end + + def restricted_files_actions + nil + end end end diff --git a/app/services/snippets/create_service.rb b/app/services/snippets/create_service.rb index 7b477621da3..dab47de8a36 100644 --- a/app/services/snippets/create_service.rb +++ b/app/services/snippets/create_service.rb @@ -37,13 +37,13 @@ module Snippets end end - # If the snippet_files param is present + # If the snippet_actions param is present # we need to fill content and file_name from # the model def create_params - return params if snippet_files.empty? + return params if snippet_actions.empty? - params.merge(content: snippet_files[0].content, file_name: snippet_files[0].file_path) + params.merge(content: snippet_actions[0].content, file_name: snippet_actions[0].file_path) end def save_and_commit @@ -100,5 +100,9 @@ module Snippets def build_actions_from_params(_snippet) [{ file_path: params[:file_name], content: params[:content] }] end + + def restricted_files_actions + :create + end end end diff --git a/app/services/snippets/update_service.rb b/app/services/snippets/update_service.rb index 6cdc2c374da..00146389e22 100644 --- a/app/services/snippets/update_service.rb +++ b/app/services/snippets/update_service.rb @@ -37,8 +37,9 @@ module Snippets # is implemented. # Once we can perform different operations through this service # we won't need to keep track of the `content` and `file_name` fields - if snippet_files.any? - params.merge!(content: snippet_files[0].content, file_name: snippet_files[0].file_path) + if snippet_actions.any? + params[:content] = snippet_actions[0].content if snippet_actions[0].content + params[:file_name] = snippet_actions[0].file_path end snippet.assign_attributes(params) @@ -108,7 +109,7 @@ module Snippets end def committable_attributes? - (params.stringify_keys.keys & COMMITTABLE_ATTRIBUTES).present? || snippet_files.any? + (params.stringify_keys.keys & COMMITTABLE_ATTRIBUTES).present? || snippet_actions.any? end def build_actions_from_params(snippet) diff --git a/app/services/snippets/update_statistics_service.rb b/app/services/snippets/update_statistics_service.rb new file mode 100644 index 00000000000..295cb963ccc --- /dev/null +++ b/app/services/snippets/update_statistics_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Snippets + class UpdateStatisticsService + attr_reader :snippet + + def initialize(snippet) + @snippet = snippet + end + + def execute + unless snippet.repository_exists? + return ServiceResponse.error(message: 'Invalid snippet repository', http_status: 400) + end + + snippet.repository.expire_statistics_caches + statistics.refresh! + + ServiceResponse.success(message: 'Snippet statistics successfully updated.') + end + + private + + def statistics + @statistics ||= snippet.statistics || snippet.build_statistics + end + end +end diff --git a/app/services/spam/spam_verdict_service.rb b/app/services/spam/spam_verdict_service.rb index 68f1135ae28..7de3bad607a 100644 --- a/app/services/spam/spam_verdict_service.rb +++ b/app/services/spam/spam_verdict_service.rb @@ -14,7 +14,7 @@ module Spam end def execute - external_spam_check_result = spam_verdict + external_spam_check_result = external_verdict akismet_result = akismet_verdict # filter out anything we don't recognise, including nils. @@ -38,7 +38,7 @@ module Spam end end - def spam_verdict + def external_verdict return unless Gitlab::CurrentSettings.spam_check_endpoint_enabled return if endpoint_url.blank? @@ -50,17 +50,14 @@ module Spam # @TODO metrics/logging # Expecting: # error: (string or nil) - # result: (string or nil) - verdict = json_result[:verdict] - return unless SUPPORTED_VERDICTS.include?(verdict) - + # verdict: (string or nil) # @TODO log if json_result[:error] json_result[:verdict] rescue *Gitlab::HTTP::HTTP_ERRORS => e # @TODO: log error via try_post https://gitlab.com/gitlab-org/gitlab/-/issues/219223 Gitlab::ErrorTracking.log_exception(e) - return + nil rescue # @TODO log ALLOW diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 6bf04c55415..db5693960b2 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -273,6 +273,38 @@ module SystemNoteService ::SystemNotes::DesignManagementService.new(noteable: design.issue, project: design.project, author: discussion_note.author).design_discussion_added(discussion_note) end + + # Called when the merge request is approved by user + # + # noteable - Noteable object + # user - User performing approve + # + # Example Note text: + # + # "approved this merge request" + # + # Returns the created Note object + def approve_mr(noteable, user) + merge_requests_service(noteable, noteable.project, user).approve_mr + end + + def unapprove_mr(noteable, user) + merge_requests_service(noteable, noteable.project, user).unapprove_mr + end + + def change_alert_status(alert, author) + ::SystemNotes::AlertManagementService.new(noteable: alert, project: alert.project, author: author).change_alert_status(alert) + end + + def new_alert_issue(alert, issue, author) + ::SystemNotes::AlertManagementService.new(noteable: alert, project: alert.project, author: author).new_alert_issue(alert, issue) + end + + private + + def merge_requests_service(noteable, project, author) + ::SystemNotes::MergeRequestsService.new(noteable: noteable, project: project, author: author) + end end SystemNoteService.prepend_if_ee('EE::SystemNoteService') diff --git a/app/services/system_notes/alert_management_service.rb b/app/services/system_notes/alert_management_service.rb new file mode 100644 index 00000000000..55a6a17bbca --- /dev/null +++ b/app/services/system_notes/alert_management_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module SystemNotes + class AlertManagementService < ::SystemNotes::BaseService + # Called when the status of an AlertManagement::Alert has changed + # + # alert - AlertManagement::Alert object. + # + # Example Note text: + # + # "changed the status to Acknowledged" + # + # Returns the created Note object + def change_alert_status(alert) + status = AlertManagement::Alert::STATUSES.key(alert.status).to_s.titleize + body = "changed the status to **#{status}**" + + create_note(NoteSummary.new(noteable, project, author, body, action: 'status')) + end + + # Called when an issue is created based on an AlertManagement::Alert + # + # alert - AlertManagement::Alert object. + # issue - Issue object. + # + # Example Note text: + # + # "created issue #17 for this alert" + # + # Returns the created Note object + def new_alert_issue(alert, issue) + body = "created issue #{issue.to_reference(project)} for this alert" + + create_note(NoteSummary.new(noteable, project, author, body, action: 'alert_issue_added')) + end + end +end diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb index 7d7ee8d829e..76261aa716e 100644 --- a/app/services/system_notes/issuables_service.rb +++ b/app/services/system_notes/issuables_service.rb @@ -228,7 +228,9 @@ module SystemNotes # A state event which results in a synthetic note will be # created by EventCreateService if change event tracking # is enabled. - unless state_change_tracking_enabled? + if state_change_tracking_enabled? + create_resource_state_event(status: status, mentionable_source: source) + else create_note(NoteSummary.new(noteable, project, author, body, action: action)) end end @@ -288,15 +290,23 @@ module SystemNotes end def close_after_error_tracking_resolve - body = _('resolved the corresponding error and closed the issue.') + if state_change_tracking_enabled? + create_resource_state_event(status: 'closed', close_after_error_tracking_resolve: true) + else + body = 'resolved the corresponding error and closed the issue.' - create_note(NoteSummary.new(noteable, project, author, body, action: 'closed')) + create_note(NoteSummary.new(noteable, project, author, body, action: 'closed')) + end end def auto_resolve_prometheus_alert - body = 'automatically closed this issue because the alert resolved.' + if state_change_tracking_enabled? + create_resource_state_event(status: 'closed', close_auto_resolve_prometheus_alert: true) + else + body = 'automatically closed this issue because the alert resolved.' - create_note(NoteSummary.new(noteable, project, author, body, action: 'closed')) + create_note(NoteSummary.new(noteable, project, author, body, action: 'closed')) + end end private @@ -324,6 +334,11 @@ module SystemNotes note_text =~ /\A#{cross_reference_note_prefix}/i end + def create_resource_state_event(params) + ResourceEvents::ChangeStateService.new(resource: noteable, user: author) + .execute(params) + end + def state_change_tracking_enabled? noteable.respond_to?(:resource_state_events) && ::Feature.enabled?(:track_resource_state_change_events, noteable.project) diff --git a/app/services/system_notes/merge_requests_service.rb b/app/services/system_notes/merge_requests_service.rb index baf26245eb9..9b5c9ba20b2 100644 --- a/app/services/system_notes/merge_requests_service.rb +++ b/app/services/system_notes/merge_requests_service.rb @@ -150,7 +150,24 @@ module SystemNotes create_note(summary) end + + # Called when the merge request is approved by user + # + # Example Note text: + # + # "approved this merge request" + # + # Returns the created Note object + def approve_mr + body = "approved this merge request" + + create_note(NoteSummary.new(noteable, project, author, body, action: 'approved')) + end + + def unapprove_mr + body = "unapproved this merge request" + + create_note(NoteSummary.new(noteable, project, author, body, action: 'unapproved')) + end end end - -SystemNotes::MergeRequestsService.prepend_if_ee('::EE::SystemNotes::MergeRequestsService') diff --git a/app/services/tags/destroy_service.rb b/app/services/tags/destroy_service.rb index 3a01192487d..4d1f4043b01 100644 --- a/app/services/tags/destroy_service.rb +++ b/app/services/tags/destroy_service.rb @@ -18,6 +18,8 @@ module Tags .new(project, current_user, tag: tag_name) .execute + unlock_artifacts(tag_name) + success('Tag was removed') else error('Failed to remove tag') @@ -33,5 +35,11 @@ module Tags def success(message) super().merge(message: message) end + + private + + def unlock_artifacts(tag_name) + Ci::RefDeleteUnlockArtifactsWorker.perform_async(project.id, current_user.id, "#{::Gitlab::Git::TAG_REF_PREFIX}#{tag_name}") + end end end diff --git a/app/services/terraform/remote_state_handler.rb b/app/services/terraform/remote_state_handler.rb index d180a3a2432..d2c44d4a265 100644 --- a/app/services/terraform/remote_state_handler.rb +++ b/app/services/terraform/remote_state_handler.rb @@ -5,26 +5,17 @@ module Terraform include Gitlab::OptimisticLocking StateLockedError = Class.new(StandardError) + UnauthorizedError = Class.new(StandardError) - # rubocop: disable CodeReuse/ActiveRecord def find_with_lock - raise ArgumentError unless params[:name].present? - - state = Terraform::State.find_by(project: project, name: params[:name]) - raise ActiveRecord::RecordNotFound.new("Couldn't find state") unless state - - retry_optimistic_lock(state) { |state| yield state } if state && block_given? - state - end - # rubocop: enable CodeReuse/ActiveRecord - - def create_or_find! - raise ArgumentError unless params[:name].present? - - Terraform::State.create_or_find_by(project: project, name: params[:name]) + retrieve_with_lock(find_only: true) do |state| + yield state if block_given? + end end def handle_with_lock + raise UnauthorizedError unless can_modify_state? + retrieve_with_lock do |state| raise StateLockedError unless lock_matches?(state) @@ -36,6 +27,7 @@ module Terraform def lock! raise ArgumentError if params[:lock_id].blank? + raise UnauthorizedError unless can_modify_state? retrieve_with_lock do |state| raise StateLockedError if state.locked? @@ -49,6 +41,8 @@ module Terraform end def unlock! + raise UnauthorizedError unless can_modify_state? + retrieve_with_lock do |state| # force-unlock does not pass ID, so we ignore it if it is missing raise StateLockedError unless params[:lock_id].nil? || lock_matches?(state) @@ -63,8 +57,21 @@ module Terraform private - def retrieve_with_lock - create_or_find!.tap { |state| retry_optimistic_lock(state) { |state| yield state } } + def retrieve_with_lock(find_only: false) + create_or_find!(find_only: find_only).tap { |state| retry_optimistic_lock(state) { |state| yield state } } + end + + def create_or_find!(find_only:) + raise ArgumentError unless params[:name].present? + + 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 end def lock_matches?(state) @@ -73,5 +80,9 @@ module Terraform ActiveSupport::SecurityUtils .secure_compare(state.lock_xid.to_s, params[:lock_id].to_s) end + + def can_modify_state? + current_user.can?(:admin_terraform_state, project) + end end end diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index e6fb0d3c72e..ec15bdde8d7 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -162,9 +162,9 @@ class TodoService create_assignment_todo(alert, current_user, []) end - # When user marks an issue as todo - def mark_todo(issuable, current_user) - attributes = attributes_for_todo(issuable.project, issuable, current_user, Todo::MARKED) + # When user marks a target as todo + def mark_todo(target, current_user) + attributes = attributes_for_todo(target.project, target, current_user, Todo::MARKED) create_todos(current_user, attributes) end diff --git a/app/services/update_container_registry_info_service.rb b/app/services/update_container_registry_info_service.rb new file mode 100644 index 00000000000..531335839a9 --- /dev/null +++ b/app/services/update_container_registry_info_service.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class UpdateContainerRegistryInfoService + def execute + registry_config = Gitlab.config.registry + return unless registry_config.enabled && registry_config.api_url.presence + + # registry_info will query the /v2 route of the registry API. This route + # requires authentication, but not authorization (the response has no body, + # only headers that show the version of the registry). There might be no + # associated user when running this (e.g. from a rake task or a cron job), + # so we need to generate a valid JWT token with no access permissions to + # authenticate as a trusted client. + token = Auth::ContainerRegistryAuthenticationService.access_token([], []) + client = ContainerRegistry::Client.new(registry_config.api_url, token: token) + info = client.registry_info + + Gitlab::CurrentSettings.update!( + container_registry_vendor: info[:vendor] || '', + container_registry_version: info[:version] || '', + container_registry_features: info[:features] || [] + ) + end +end diff --git a/app/services/users/block_service.rb b/app/services/users/block_service.rb index 9c393832d8f..041db731875 100644 --- a/app/services/users/block_service.rb +++ b/app/services/users/block_service.rb @@ -19,7 +19,7 @@ module Users private def after_block_hook(user) - # overriden by EE module + # overridden by EE module end end end diff --git a/app/services/wiki_pages/base_service.rb b/app/services/wiki_pages/base_service.rb index a0256ea5e69..2967684f7bc 100644 --- a/app/services/wiki_pages/base_service.rb +++ b/app/services/wiki_pages/base_service.rb @@ -44,8 +44,6 @@ module WikiPages end def create_wiki_event(page) - return unless ::Feature.enabled?(:wiki_events) - response = WikiPages::EventCreateService.new(current_user).execute(slug_for_page(page), page, event_action) log_error(response.message) if response.error? diff --git a/app/services/wiki_pages/event_create_service.rb b/app/services/wiki_pages/event_create_service.rb index 18a45d057a9..0453c90d693 100644 --- a/app/services/wiki_pages/event_create_service.rb +++ b/app/services/wiki_pages/event_create_service.rb @@ -10,8 +10,6 @@ module WikiPages end def execute(slug, page, action) - return ServiceResponse.success(message: 'No event created as `wiki_events` feature is disabled') unless ::Feature.enabled?(:wiki_events) - event = Event.transaction do wiki_page_meta = WikiPage::Meta.find_or_create(slug, page) diff --git a/app/uploaders/object_storage.rb b/app/uploaders/object_storage.rb index 5297112eef8..63b6197a04d 100644 --- a/app/uploaders/object_storage.rb +++ b/app/uploaders/object_storage.rb @@ -169,6 +169,10 @@ module ObjectStorage object_store_options.connection.to_hash.deep_symbolize_keys end + def consolidated_settings? + object_store_options.fetch('consolidated_settings', false) + end + def remote_store_path object_store_options.remote_directory end @@ -196,7 +200,7 @@ module ObjectStorage id = [CarrierWave.generate_cache_id, SecureRandom.hex].join('-') upload_path = File.join(TMP_UPLOAD_PATH, id) direct_upload = ObjectStorage::DirectUpload.new(self.object_store_credentials, remote_store_path, upload_path, - has_length: has_length, maximum_size: maximum_size) + has_length: has_length, maximum_size: maximum_size, consolidated_settings: consolidated_settings?) direct_upload.to_hash.merge(ID: id) end diff --git a/app/uploaders/packages/package_file_uploader.rb b/app/uploaders/packages/package_file_uploader.rb new file mode 100644 index 00000000000..20fcf0a7a32 --- /dev/null +++ b/app/uploaders/packages/package_file_uploader.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true +class Packages::PackageFileUploader < GitlabUploader + extend Workhorse::UploadPath + include ObjectStorage::Concern + + storage_options Gitlab.config.packages + + after :store, :schedule_background_upload + + alias_method :upload, :model + + def filename + model.file_name + end + + def store_dir + dynamic_segment + end + + private + + def dynamic_segment + File.join(disk_hash[0..1], disk_hash[2..3], disk_hash, + 'packages', model.package.id.to_s, 'files', model.id.to_s) + end + + def disk_hash + @disk_hash ||= Digest::SHA2.hexdigest(model.package.project_id.to_s) + end +end diff --git a/app/validators/addressable_url_validator.rb b/app/validators/addressable_url_validator.rb index 99f503c3f06..9fa99903e36 100644 --- a/app/validators/addressable_url_validator.rb +++ b/app/validators/addressable_url_validator.rb @@ -95,9 +95,9 @@ class AddressableUrlValidator < ActiveModel::EachValidator end def current_options - options.map do |option, value| - [option, value.is_a?(Proc) ? value.call(record) : value] - end.to_h + options.transform_values do |value| + value.is_a?(Proc) ? value.call(record) : value + end end def blocker_args diff --git a/app/validators/array_members_validator.rb b/app/validators/array_members_validator.rb new file mode 100644 index 00000000000..c5d3d25b4d9 --- /dev/null +++ b/app/validators/array_members_validator.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# ArrayMembersValidator +# +# Custom validator that checks if validated +# attribute contains non empty array, which every +# element is an instances of :member_class +# +# Example: +# +# class Config::Root < ActiveRecord::Base +# validates :nodes, member_class: Config::Node +# end +# +class ArrayMembersValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + if !value.is_a?(Array) || value.empty? || value.any? { |child| !child.instance_of?(options[:member_class]) } + record.errors.add(attribute, _("should be an array of %{object_name} objects") % { object_name: options.fetch(:object_name, attribute) }) + end + end +end diff --git a/app/validators/json_schemas/build_metadata_secrets.json b/app/validators/json_schemas/build_metadata_secrets.json new file mode 100644 index 00000000000..e745a266777 --- /dev/null +++ b/app/validators/json_schemas/build_metadata_secrets.json @@ -0,0 +1,30 @@ +{ + "description": "CI builds metadata secrets", + "type": "object", + "patternProperties": { + ".*": { + "type": "object", + "patternProperties": { + "^vault$": { + "type": "object", + "required": ["path", "field", "engine"], + "properties": { + "path": { "type": "string" }, + "field": { "type": "string" }, + "engine": { + "type": "object", + "required": ["name", "path"], + "properties": { + "path": { "type": "string" }, + "name": { "type": "string" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + } +} diff --git a/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json b/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json new file mode 100644 index 00000000000..1154a4c45b8 --- /dev/null +++ b/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json @@ -0,0 +1,153 @@ +{ + "global": [ + { + "field" : "SECURE_ANALYZERS_PREFIX", + "label" : "Image prefix", + "type": "string", + "default_value": "registry.gitlab.com/gitlab-org/security-products/analyzers", + "value": "" + }, + { + "field" : "SAST_EXCLUDED_PATHS", + "label" : "Excluded Paths", + "type": "string", + "default_value": "spec, test, tests, tmp", + "value": "" + }, + { + "field" : "SECURE_ANALYZER_IMAGE_TAG", + "label" : "Image tag", + "type": "string", + "options": [], + "default_value": "2", + "value": "" + }, + { + "field" : "SAST_DISABLED", + "label" : "Disable SAST", + "type": "options", + "options": [ + { + "value" :"true", + "label" : "true (disables SAST)" + }, + { + "value":"false", + "label":"false (enables SAST)" + } + ], + "default_value": "false", + "value": "" + } + ], + "pipeline": [ + { + "field" : "stage", + "label" : "Stage", + "type": "dropdown", + "options": [ + { + "value" :"test", + "label" : "test" + }, + { + "value":"build", + "label":"build" + } + ], + "default_value": "test", + "value": "" + }, + { + "field" : "allow_failure", + "label" : "Allow Failure", + "type": "options", + "options": [ + { + "value" :"true", + "label" : "Allows pipeline failure" + }, + { + "value": "false", + "label": "Does not allow pipeline failure" + } + ], + "default_value": "true", + "value": "" + }, + { + "field" : "rules", + "label" : "Rules", + "type": "multiline", + "default_value": "", + "value": "" + } + ], + "analyzers": [ + { + "name": "brakeman", + "label": "Brakeman", + "enabled" : true + }, + { + "name": "bandit", + "label": "Bandit", + "enabled" : true + }, + { + "name": "eslint", + "label": "ESLint", + "enabled" : true + }, + { + "name": "flawfinder", + "label": "Flawfinder", + "enabled" : true + }, + { + "name": "kubesec", + "label": "kubesec", + "enabled" : true + }, + { + "name": "nodejsscan", + "label": "Node.js Scan", + "enabled" : true + }, + { + "name": "gosec", + "label": "Golang Security Checker", + "enabled" : true + }, + { + "name": "phpcs-security-audit", + "label": "PHP Security Audit", + "enabled" : true + }, + { + "name": "pmd-apex", + "label": "PMD APEX", + "enabled" : true + }, + { + "name": "security-code-scan", + "label": "Security Code Scan", + "enabled" : true + }, + { + "name": "sobelow", + "label": "Sobelow", + "enabled" : true + }, + { + "name": "spotbugs", + "label": "Spotbugs", + "enabled" : true + }, + { + "name": "secrets", + "label": "Secrets", + "enabled" : true + } + ] +} diff --git a/app/views/admin/appearances/_form.html.haml b/app/views/admin/appearances/_form.html.haml index aa47daf4a57..fcb1c1a6f3e 100644 --- a/app/views/admin/appearances/_form.html.haml +++ b/app/views/admin/appearances/_form.html.haml @@ -1,6 +1,6 @@ - parsed_with_gfm = "Content parsed with #{link_to('GitLab Flavored Markdown', help_page_path('user/markdown'), target: '_blank')}.".html_safe -= form_for @appearance, url: admin_appearances_path, html: { class: 'prepend-top-default' } do |f| += form_for @appearance, url: admin_appearances_path, html: { class: 'gl-mt-3' } do |f| = form_errors(@appearance) @@ -100,7 +100,7 @@ .hint = parsed_with_gfm - .prepend-top-default.append-bottom-default + .gl-mt-3.gl-mb-3 = f.submit 'Update appearance settings', class: 'btn btn-success' - if @appearance.persisted? || @appearance.updated_at .mt-4 diff --git a/app/views/admin/appearances/show.html.haml b/app/views/admin/appearances/show.html.haml index ccf6f960cf2..77a08913666 100644 --- a/app/views/admin/appearances/show.html.haml +++ b/app/views/admin/appearances/show.html.haml @@ -1,4 +1,4 @@ -- page_title "Appearance" +- page_title _("Appearance") - @content_class = "limit-container-width" unless fluid_layout = render 'form' 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 ceec8901951..65a2f1d42e1 100644 --- a/app/views/admin/application_settings/_account_and_limit.html.haml +++ b/app/views/admin/application_settings/_account_and_limit.html.haml @@ -54,10 +54,10 @@ = _('Newly registered users will by default be external') .prepend-top-10 = _('Internal users') - = f.text_field :user_default_internal_regex, placeholder: _('Regex pattern'), class: 'form-control prepend-top-5' + = f.text_field :user_default_internal_regex, placeholder: _('Regex pattern'), class: 'form-control 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: 'external-users-permissions'), + = link_to _('More information'), help_page_path('user/permissions', anchor: 'setting-new-users-to-external'), target: '_blank' .form-group = f.label :user_show_add_ssh_key_message, _('Prompt users to upload SSH keys'), class: 'label-bold' diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml index c7918881bdf..410820dfb85 100644 --- a/app/views/admin/application_settings/_ci_cd.html.haml +++ b/app/views/admin/application_settings/_ci_cd.html.haml @@ -40,7 +40,7 @@ = f.text_field :default_artifacts_expire_in, class: 'form-control' .form-text.text-muted = _("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>4 mins 2 sec</code>, <code>2h42min</code>.").html_safe - = link_to icon('question-circle'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'default-artifacts-expiration') + = link_to icon('question-circle'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'default-artifacts-expiration-core-only') .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' diff --git a/app/views/admin/application_settings/_email.html.haml b/app/views/admin/application_settings/_email.html.haml index 3dd72909805..49747f2bfd4 100644 --- a/app/views/admin/application_settings/_email.html.haml +++ b/app/views/admin/application_settings/_email.html.haml @@ -20,7 +20,7 @@ = f.label :commit_email_hostname, _('Custom hostname (for private commit emails)'), class: 'label-bold' = f.text_field :commit_email_hostname, class: 'form-control' .form-text.text-muted - - commit_email_hostname_docs_link = link_to _('Learn more'), help_page_path('user/admin_area/settings/email', anchor: 'custom-private-commit-email-hostname'), target: '_blank' + - 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 } = render_if_exists 'admin/application_settings/email_additional_text_setting', form: f diff --git a/app/views/admin/application_settings/_import_export_limits.html.haml b/app/views/admin/application_settings/_import_export_limits.html.haml new file mode 100644 index 00000000000..d26c3376391 --- /dev/null +++ b/app/views/admin/application_settings/_import_export_limits.html.haml @@ -0,0 +1,34 @@ += form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-import-export-limits-settings'), html: { class: 'fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .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' + + %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' + + %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' + + %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' + + %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' + + %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.submit 'Save changes', class: "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 new file mode 100644 index 00000000000..e76374e88a8 --- /dev/null +++ b/app/views/admin/application_settings/_initial_branch_name.html.haml @@ -0,0 +1,12 @@ += form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-default-branch-name'), html: { class: 'fieldset-form' } do |f| + = form_errors(@application_setting) + + - fallback_branch_name = '<code>master</code>' + + %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' + %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 + = f.submit _('Save changes'), class: 'gl-button btn-success' diff --git a/app/views/admin/application_settings/_registry.html.haml b/app/views/admin/application_settings/_registry.html.haml index 0631c024eb8..fea3ff4c3ba 100644 --- a/app/views/admin/application_settings/_registry.html.haml +++ b/app/views/admin/application_settings/_registry.html.haml @@ -10,7 +10,7 @@ = f.check_box :container_expiration_policies_enable_historic_entries, class: 'form-check-input' = f.label :container_expiration_policies_enable_historic_entries, class: 'form-check-label' do = _("Enable container expiration and retention policies for projects created earlier than GitLab 12.7.") - = link_to icon('question-circle'), help_page_path('user/packages/container_registry/index', anchor: 'expiration-policy') + = link_to icon('question-circle'), help_page_path('user/packages/container_registry/index', anchor: 'cleanup-policy') .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 icon('question-circle'), help_page_path('user/packages/container_registry/index', anchor: 'use-with-external-container-registries') diff --git a/app/views/admin/application_settings/_repository_storage.html.haml b/app/views/admin/application_settings/_repository_storage.html.haml index ed276da08f2..ecae720cd49 100644 --- a/app/views/admin/application_settings/_repository_storage.html.haml +++ b/app/views/admin/application_settings/_repository_storage.html.haml @@ -15,7 +15,7 @@ .form-group .form-text %p.text-secondary - = _('Select a weight for the storage new repositories will be placed on.') + = _('Enter weights for storages for new repositories.') = link_to icon('question-circle'), help_page_path('administration/repository_storage_paths') .form-check - storage_weights.each do |attribute| diff --git a/app/views/admin/application_settings/_signin.html.haml b/app/views/admin/application_settings/_signin.html.haml index 007cd343339..0972e10e12c 100644 --- a/app/views/admin/application_settings/_signin.html.haml +++ b/app/views/admin/application_settings/_signin.html.haml @@ -33,6 +33,15 @@ = f.label :require_two_factor_authentication, class: 'form-check-label' do Require all users to set up Two-factor authentication .form-group + = f.label :unknown_sign_in, _('Email notification for unknown sign-ins'), class: 'label-bold' + .form-check + = f.check_box :notify_on_unknown_sign_in, class: 'form-check-input' + = f.label :notify_on_unknown_sign_in, class: 'form-check-label' do + = _('Notify users by email when sign-in location is not recognized') + = link_to icon('question-circle'), + 'https://docs.gitlab.com/ee/user/profile/unknown_sign_in_notification.html', + 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' .form-text.text-muted Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication diff --git a/app/views/admin/application_settings/_usage.html.haml b/app/views/admin/application_settings/_usage.html.haml index 9421585b70c..d8a4c601b77 100644 --- a/app/views/admin/application_settings/_usage.html.haml +++ b/app/views/admin/application_settings/_usage.html.haml @@ -31,7 +31,7 @@ %pre.usage-data.js-usage-ping-payload.js-syntax-highlight.code.highlight.mt-2.d-none{ data: { endpoint: usage_data_admin_application_settings_path(format: :html) } } - else = _('The usage ping is disabled, and cannot be configured through this form.') - - deactivating_usage_ping_path = help_page_path('user/admin_area/settings/usage_statistics', anchor: 'deactivate-the-usage-ping') + - deactivating_usage_ping_path = help_page_path('development/telemetry/usage_ping', anchor: 'disable-usage-ping') - deactivating_usage_ping_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: deactivating_usage_ping_path } = s_('For more information, see the documentation on %{deactivating_usage_ping_link_start}deactivating the usage ping%{deactivating_usage_ping_link_end}.').html_safe % { deactivating_usage_ping_link_start: deactivating_usage_ping_link_start, deactivating_usage_ping_link_end: '</a>'.html_safe } .form-group.mt-3 diff --git a/app/views/admin/application_settings/ci/_header.html.haml b/app/views/admin/application_settings/ci/_header.html.haml index 9f03936f64a..fe86284ba2f 100644 --- a/app/views/admin/application_settings/ci/_header.html.haml +++ b/app/views/admin/application_settings/ci/_header.html.haml @@ -2,7 +2,7 @@ %h4 = _('Variables') - = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'variables'), target: '_blank', rel: 'noopener noreferrer' + = link_to icon('question-circle'), 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/admin/application_settings/ci_cd.html.haml b/app/views/admin/application_settings/ci_cd.html.haml index 2452ab794fc..cdb69d33b12 100644 --- a/app/views/admin/application_settings/ci_cd.html.haml +++ b/app/views/admin/application_settings/ci_cd.html.haml @@ -9,7 +9,7 @@ .settings-content - 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: 'protected-variables') } + - 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 } #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} } diff --git a/app/views/admin/application_settings/integrations.html.haml b/app/views/admin/application_settings/integrations.html.haml index a8eff26b94c..cca0240462f 100644 --- a/app/views/admin/application_settings/integrations.html.haml +++ b/app/views/admin/application_settings/integrations.html.haml @@ -12,7 +12,7 @@ %h4.gl-alert-title= s_('AdminSettings|Some settings have moved') = s_('AdminSettings|Elasticsearch, PlantUML, Slack application, Third party offers, Snowplow, Amazon EKS have moved to Settings > General.') .gl-alert-actions - = link_to s_('AdminSettings|Go to General Settings'), 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 new-gl-button' %h4= s_('AdminSettings|Apply integration settings to all Projects') %p diff --git a/app/views/admin/application_settings/network.html.haml b/app/views/admin/application_settings/network.html.haml index db4611964b4..15149e46f9c 100644 --- a/app/views/admin/application_settings/network.html.haml +++ b/app/views/admin/application_settings/network.html.haml @@ -57,4 +57,15 @@ .settings-content = render 'issue_limits' +%section.settings.as-import-export-limits.no-animate#js-import-export-limits-settings{ class: ('expanded' if expanded_by_default?) } + .settings-header + %h4 + = _('Import/Export Rate Limits') + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded_by_default? ? _('Collapse') : _('Expand') + %p + = _('Configure limits for Project/Group Import/Export.') + .settings-content + = render 'import_export_limits' + = render_if_exists 'admin/application_settings/ee_network_settings' diff --git a/app/views/admin/application_settings/repository.html.haml b/app/views/admin/application_settings/repository.html.haml index b0934a9d9fb..33a6715d424 100644 --- a/app/views/admin/application_settings/repository.html.haml +++ b/app/views/admin/application_settings/repository.html.haml @@ -2,6 +2,18 @@ - page_title _("Repository") - @content_class = "limit-container-width" unless fluid_layout +- if Feature.enabled?(:global_default_branch_name, default_enabled: true) + %section.settings.as-default-branch-name.no-animate#js-default-branch-name{ class: ('expanded' if expanded_by_default?) } + .settings-header + %h4 + = _('Default initial branch name') + %button.gl-button.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.') + .settings-content + = render 'initial_branch_name' + %section.settings.as-mirror.no-animate#js-mirror-settings{ class: ('expanded' if expanded_by_default?) } .settings-header %h4 diff --git a/app/views/admin/applications/edit.html.haml b/app/views/admin/applications/edit.html.haml index 13c408914bb..4f737a14e12 100644 --- a/app/views/admin/applications/edit.html.haml +++ b/app/views/admin/applications/edit.html.haml @@ -1,6 +1,6 @@ -- add_to_breadcrumbs "Applications", admin_applications_path +- add_to_breadcrumbs _("Applications"), admin_applications_path - breadcrumb_title @application.name -- page_title "Edit", @application.name, "Applications" +- page_title _("Edit"), @application.name, _("Applications") %h3.page-title Edit application - @url = admin_application_path(@application) diff --git a/app/views/admin/applications/index.html.haml b/app/views/admin/applications/index.html.haml index c3861f335b8..0119cabf1ad 100644 --- a/app/views/admin/applications/index.html.haml +++ b/app/views/admin/applications/index.html.haml @@ -1,4 +1,4 @@ -- page_title "Applications" +- page_title _("Applications") %h3.page-title System OAuth applications %p.light diff --git a/app/views/admin/applications/new.html.haml b/app/views/admin/applications/new.html.haml index 346c58877d9..4d4b6b0c994 100644 --- a/app/views/admin/applications/new.html.haml +++ b/app/views/admin/applications/new.html.haml @@ -1,5 +1,5 @@ -- breadcrumb_title "Applications" -- page_title "New Application" +- breadcrumb_title _("Applications") +- page_title _("New Application") %h3.page-title New application - @url = admin_applications_path diff --git a/app/views/admin/applications/show.html.haml b/app/views/admin/applications/show.html.haml index 146674a2fac..5259dd56df5 100644 --- a/app/views/admin/applications/show.html.haml +++ b/app/views/admin/applications/show.html.haml @@ -1,4 +1,4 @@ -- page_title @application.name, "Applications" +- page_title @application.name, _("Applications") %h3.page-title Application: #{@application.name} @@ -46,4 +46,4 @@ .form-actions = link_to 'Edit', edit_admin_application_path(@application), class: 'btn btn-primary wide float-left' - = render 'delete_form', application: @application, submit_btn_css: 'btn btn-danger prepend-left-10' + = render 'delete_form', application: @application, submit_btn_css: 'btn btn-danger gl-ml-3' diff --git a/app/views/admin/background_jobs/show.html.haml b/app/views/admin/background_jobs/show.html.haml index 1001a69b787..bbb47e29bb9 100644 --- a/app/views/admin/background_jobs/show.html.haml +++ b/app/views/admin/background_jobs/show.html.haml @@ -1,4 +1,4 @@ -- page_title "Background Jobs" +- page_title _("Background Jobs") %h3.page-title Background Jobs %p.light GitLab uses #{link_to "sidekiq", "http://sidekiq.org/"} library for async job processing diff --git a/app/views/admin/broadcast_messages/edit.html.haml b/app/views/admin/broadcast_messages/edit.html.haml index 8cbc4597e32..569aaa29cc4 100644 --- a/app/views/admin/broadcast_messages/edit.html.haml +++ b/app/views/admin/broadcast_messages/edit.html.haml @@ -1,4 +1,4 @@ -- breadcrumb_title "Messages" -- page_title "Broadcast Messages" +- breadcrumb_title _("Messages") +- page_title _("Broadcast Messages") = render 'form' diff --git a/app/views/admin/broadcast_messages/index.html.haml b/app/views/admin/broadcast_messages/index.html.haml index e7a7ee96508..bca74f71c5c 100644 --- a/app/views/admin/broadcast_messages/index.html.haml +++ b/app/views/admin/broadcast_messages/index.html.haml @@ -1,5 +1,5 @@ -- breadcrumb_title "Messages" -- page_title "Broadcast Messages" +- breadcrumb_title _("Messages") +- page_title _("Broadcast Messages") %h3.page-title Broadcast Messages diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 951e5364ad8..7c6c21bc509 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -1,4 +1,5 @@ -- breadcrumb_title "Dashboard" +- breadcrumb_title _("Dashboard") +- page_title _("Dashboard") - if show_license_breakdown? = render_if_exists 'admin/licenses/breakdown', license: @license @@ -9,7 +10,7 @@ dismissible: true.to_s } } = notice[:message].html_safe -.admin-dashboard.prepend-top-default +.admin-dashboard.gl-mt-3 .row .col-sm-4 .info-well.dark-well diff --git a/app/views/admin/deploy_keys/new.html.haml b/app/views/admin/deploy_keys/new.html.haml index 9a563a5bc78..f43c1447f09 100644 --- a/app/views/admin/deploy_keys/new.html.haml +++ b/app/views/admin/deploy_keys/new.html.haml @@ -1,4 +1,4 @@ -- page_title 'New Deploy Key' +- page_title _('New Deploy Key') %h3.page-title New public deploy key %hr diff --git a/app/views/admin/gitaly_servers/index.html.haml b/app/views/admin/gitaly_servers/index.html.haml index 9b24f411a75..0b06f145687 100644 --- a/app/views/admin/gitaly_servers/index.html.haml +++ b/app/views/admin/gitaly_servers/index.html.haml @@ -1,4 +1,5 @@ - breadcrumb_title _("Gitaly Servers") +- page_title _("Gitaly Servers") %h3.page-title= _("Gitaly Servers") %hr diff --git a/app/views/admin/groups/index.html.haml b/app/views/admin/groups/index.html.haml index f295e5a06cb..da2b2c60b15 100644 --- a/app/views/admin/groups/index.html.haml +++ b/app/views/admin/groups/index.html.haml @@ -1,7 +1,7 @@ - page_title _("Groups") .top-area - .prepend-top-default.append-bottom-default + .gl-mt-3.gl-mb-3 = form_tag admin_groups_path, method: :get, class: 'js-search-form' do |f| = hidden_field_tag :sort, @sort .search-holder diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml index e105091e773..4b0e0b9c697 100644 --- a/app/views/admin/groups/show.html.haml +++ b/app/views/admin/groups/show.html.haml @@ -1,6 +1,8 @@ - add_to_breadcrumbs _("Groups"), admin_groups_path - breadcrumb_title @group.name - page_title @group.name, _("Groups") + +.js-remove-member-modal %h3.page-title = _('Group: %{group_name}') % { group_name: @group.full_name } diff --git a/app/views/admin/hook_logs/_index.html.haml b/app/views/admin/hook_logs/_index.html.haml index 841640efad2..5e70e80cff7 100644 --- a/app/views/admin/hook_logs/_index.html.haml +++ b/app/views/admin/hook_logs/_index.html.haml @@ -1,4 +1,4 @@ -.row.prepend-top-default.append-bottom-default +.row.gl-mt-3.gl-mb-3 .col-lg-3 %h4.gl-mt-0 Recent Deliveries diff --git a/app/views/admin/hook_logs/show.html.haml b/app/views/admin/hook_logs/show.html.haml index 86729dbe7bc..4d534c59c40 100644 --- a/app/views/admin/hook_logs/show.html.haml +++ b/app/views/admin/hook_logs/show.html.haml @@ -1,9 +1,9 @@ -- page_title 'Request details' +- page_title _('Request details') %h3.page-title Request details %hr -= link_to 'Resend Request', retry_admin_hook_hook_log_path(@hook, @hook_log), method: :post, class: "btn btn-default float-right prepend-left-10" += link_to 'Resend Request', retry_admin_hook_hook_log_path(@hook, @hook_log), method: :post, class: "btn btn-default float-right gl-ml-3" = render partial: 'shared/hook_logs/content', locals: { hook_log: @hook_log } diff --git a/app/views/admin/hooks/_form.html.haml b/app/views/admin/hooks/_form.html.haml index 072f80b56b9..17bb054b869 100644 --- a/app/views/admin/hooks/_form.html.haml +++ b/app/views/admin/hooks/_form.html.haml @@ -16,7 +16,7 @@ System hook will be triggered on set of events like creating project or adding ssh key. But you can also enable extra triggers like Push events. - .prepend-top-default + .gl-mt-3 = form.check_box :repository_update_events, class: 'float-left' .prepend-left-20 = form.label :repository_update_events, class: 'list-label' do diff --git a/app/views/admin/hooks/edit.html.haml b/app/views/admin/hooks/edit.html.haml index 636dd6bdfc1..f9faf5b11fa 100644 --- a/app/views/admin/hooks/edit.html.haml +++ b/app/views/admin/hooks/edit.html.haml @@ -1,11 +1,11 @@ - add_to_breadcrumbs @hook.pluralized_name, admin_hooks_path - page_title _('Edit System Hook') -.row.prepend-top-default +.row.gl-mt-3 .col-lg-3 = render 'shared/web_hooks/title_and_docs', hook: @hook - .col-lg-9.append-bottom-default + .col-lg-9.gl-mb-3 = form_for @hook, as: :hook, url: admin_hook_path do |f| = render partial: 'form', locals: { form: f, hook: @hook } .form-actions diff --git a/app/views/admin/hooks/index.html.haml b/app/views/admin/hooks/index.html.haml index 1c14291b58e..d70baa592ea 100644 --- a/app/views/admin/hooks/index.html.haml +++ b/app/views/admin/hooks/index.html.haml @@ -1,10 +1,10 @@ - page_title @hook.pluralized_name -.row.prepend-top-default +.row.gl-mt-3 .col-lg-4 = render 'shared/web_hooks/title_and_docs', hook: @hook - .col-lg-8.append-bottom-default + .col-lg-8.gl-mb-3 = form_for @hook, as: :hook, url: admin_hooks_path do |f| = render partial: 'form', locals: { form: f, hook: @hook } = f.submit _('Add system hook'), class: 'btn btn-success' diff --git a/app/views/admin/impersonation_tokens/index.html.haml b/app/views/admin/impersonation_tokens/index.html.haml index 8342507d8a6..ec393fdd794 100644 --- a/app/views/admin/impersonation_tokens/index.html.haml +++ b/app/views/admin/impersonation_tokens/index.html.haml @@ -6,7 +6,7 @@ = render 'admin/users/head' -.row.prepend-top-default +.row.gl-mt-3 .col-lg-12 - if @new_impersonation_token = render 'shared/access_tokens/created_container', diff --git a/app/views/admin/jobs/index.html.haml b/app/views/admin/jobs/index.html.haml index f1bdd52b399..32c0a801a1d 100644 --- a/app/views/admin/jobs/index.html.haml +++ b/app/views/admin/jobs/index.html.haml @@ -1,4 +1,5 @@ -- breadcrumb_title "Jobs" +- breadcrumb_title _("Jobs") +- page_title _("Jobs") .top-area.scrolling-tabs-container.inner-page-scroll-tabs - build_path_proc = ->(scope) { admin_jobs_path(scope: scope) } diff --git a/app/views/admin/keys/show.html.haml b/app/views/admin/keys/show.html.haml index 9ee77c77398..03cc0ae15be 100644 --- a/app/views/admin/keys/show.html.haml +++ b/app/views/admin/keys/show.html.haml @@ -1,2 +1,2 @@ -- page_title @key.title, "Keys" +- page_title @key.title, _("Keys") = render "profiles/keys/key_details", admin: true diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index f9d42d3f53b..96337d357eb 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -1,13 +1,14 @@ -- add_to_breadcrumbs "Projects", admin_projects_path +- add_to_breadcrumbs _("Projects"), admin_projects_path - breadcrumb_title @project.full_name -- page_title @project.full_name, "Projects" +- page_title @project.full_name, _("Projects") - @content_class = "admin-projects" +.js-remove-member-modal %h3.page-title - Project: #{@project.full_name} + = _('Project: %{name}') % { name: @project.full_name } = link_to edit_project_path(@project), class: "btn btn-nr float-right" do %i.fa.fa-pencil-square-o - Edit + = _('Edit') %hr - if @project.last_repository_check_failed? .row @@ -21,57 +22,67 @@ .col-md-6 .card .card-header - Project info: + = _('Project info:') %ul.content-list %li - %span.light Name: + %span.light + = _('Name:') %strong = link_to @project.name, project_path(@project) %li - %span.light Namespace: + %span.light + = _('Namespace:') %strong - if @project.namespace = link_to @project.namespace.human_name, [:admin, @project.group || @project.owner] - else - Global + = s_('ProjectSettings|Global') %li - %span.light Owned by: + %span.light + = _('Owned by:') %strong - if @project.owner = link_to @project.owner_name, [:admin, @project.owner] - else - (deleted) + = _('(deleted)') %li - %span.light Created by: + %span.light + = _('Created by:') %strong - = @project.creator.try(:name) || '(deleted)' + = @project.creator.try(:name) || _('(deleted)') %li - %span.light Created on: + %span.light + = _('Created on:') %strong = @project.created_at.to_s(:medium) %li - %span.light ID: + %span.light + = _('ID:') %strong = @project.id %li - %span.light http: + %span.light + = _('http:') %strong = link_to @project.http_url_to_repo, project_path(@project) %li - %span.light ssh: + %span.light + = _('ssh:') %strong = link_to @project.ssh_url_to_repo, project_path(@project) - if @project.repository.exists? %li - %span.light Gitaly storage name: + %span.light + = _('Gitaly storage name:') %strong = @project.repository.storage %li - %span.light Gitaly relative path: + %span.light + = _('Gitaly relative path:') %strong = @project.repository.relative_path @@ -79,30 +90,36 @@ = render 'shared/storage_counter_statistics', storage_size: @project.statistics&.storage_size, storage_details: @project.statistics %li - %span.light last commit: + %span.light + = _('last commit:') %strong = last_commit(@project) %li - %span.light Git LFS status: + %span.light + = _('Git LFS status:') %strong = project_lfs_status(@project) = link_to icon('question-circle'), help_page_path('topics/git/lfs/index') - else %li - %span.light repository: + %span.light + = _('repository:') %strong.cred - does not exist + = _('does not exist') - if @project.archived? %li - %span.light archived: - %strong project is read-only + %span.light + = _('archived:') + %strong + = _('project is read-only') = render_if_exists "shared_runner_status", project: @project %li - %span.light access: + %span.light + = _('access:') %strong %span{ class: visibility_level_color(@project.visibility_level) } = visibility_level_icon(@project.visibility_level) @@ -114,24 +131,24 @@ .card .card-header - Transfer project + = s_('ProjectSettings|Transfer project') .card-body = form_for @project, url: transfer_admin_project_path(@project), method: :put do |f| .form-group.row .col-sm-3.col-form-label - = f.label :new_namespace_id, "Namespace" + = f.label :new_namespace_id, _("Namespace") .col-sm-9 .dropdown - = dropdown_toggle('Search for Namespace', { toggle: 'dropdown', field_name: 'new_namespace_id' }, { toggle_class: 'js-namespace-select large' }) + = dropdown_toggle(_('Search for Namespace'), { toggle: 'dropdown', field_name: 'new_namespace_id' }, { toggle_class: 'js-namespace-select large' }) .dropdown-menu.dropdown-select - = dropdown_title('Namespaces') - = dropdown_filter("Search for Namespace") + = dropdown_title(_('Namespaces')) + = dropdown_filter(_('Search for Namespace')) = dropdown_content = dropdown_loading .form-group.row .offset-sm-3.col-sm-9 - = f.submit 'Transfer', class: 'btn btn-primary' + = f.submit _('Transfer'), class: 'btn btn-primary' .card.repository-check .card-header @@ -151,18 +168,18 @@ = link_to icon('question-circle'), help_page_path('administration/repository_checks') .form-group - = f.submit 'Trigger repository check', class: 'btn btn-primary' + = f.submit _('Trigger repository check'), class: 'btn btn-primary' .col-md-6 - if @group .card .card-header %strong= @group.name - group members + = _('group members') %span.badge.badge-pill= @group_members.size .float-right = link_to admin_group_path(@group), class: 'btn btn-sm' do - = icon('pencil-square-o', text: 'Manage access') + = icon('pencil-square-o', text: _('Manage access')) %ul.content-list.members-list = render partial: 'shared/members/member', collection: @group_members, as: :member, locals: { show_controls: false } .card-footer @@ -173,10 +190,10 @@ .card .card-header %strong= @project.name - project members + = _('project members') %span.badge.badge-pill= @project.users.size .float-right - = link_to icon('pencil-square-o', text: 'Manage access'), project_project_members_path(@project), class: "btn btn-sm" + = link_to icon('pencil-square-o', text: _('Manage access')), project_project_members_path(@project), class: "btn btn-sm" %ul.content-list.project_members.members-list = render partial: 'shared/members/member', collection: @project_members, as: :member, locals: { show_controls: false } .card-footer diff --git a/app/views/admin/requests_profiles/index.html.haml b/app/views/admin/requests_profiles/index.html.haml index efc16bb4d3b..6e1ac452d52 100644 --- a/app/views/admin/requests_profiles/index.html.haml +++ b/app/views/admin/requests_profiles/index.html.haml @@ -1,4 +1,4 @@ -- page_title 'Requests Profiles' +- page_title _('Requests Profiles') %h3.page-title = page_title @@ -9,7 +9,7 @@ to profile the request - if @profiles.present? - .prepend-top-default + .gl-mt-3 - @profiles.each do |path, profiles| .card.card-small .card-header diff --git a/app/views/admin/runners/_runner.html.haml b/app/views/admin/runners/_runner.html.haml index 423472324fe..5c834c2125f 100644 --- a/app/views/admin/runners/_runner.html.haml +++ b/app/views/admin/runners/_runner.html.haml @@ -72,8 +72,8 @@ = link_to [:pause, :admin, runner], method: :get, class: 'btn btn-default has-tooltip', title: _('Pause'), ref: 'tooltip', aria: { label: _('Pause') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do = icon('pause') - else - = link_to [:resume, :admin, runner], method: :get, class: 'btn btn-default has-tooltip', title: _('Resume'), ref: 'tooltip', aria: { label: _('Resume') }, data: { placement: 'top', container: 'body'} do - = icon('play') + = link_to [:resume, :admin, runner], method: :get, class: 'btn btn-default has-tooltip gl-px-3', title: _('Resume'), ref: 'tooltip', aria: { label: _('Resume') }, data: { placement: 'top', container: 'body'} do + = sprite_icon('play') .btn-group = link_to [:admin, runner], method: :delete, class: 'btn btn-danger has-tooltip', title: _('Remove'), ref: 'tooltip', aria: { label: _('Remove') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do = icon('remove') diff --git a/app/views/admin/runners/_sort_dropdown.html.haml b/app/views/admin/runners/_sort_dropdown.html.haml index 4f4f0a543e0..3b3de042511 100644 --- a/app/views/admin/runners/_sort_dropdown.html.haml +++ b/app/views/admin/runners/_sort_dropdown.html.haml @@ -1,6 +1,6 @@ - sorted_by = sort_options_hash[@sort] -.dropdown.inline.prepend-left-10 +.dropdown.inline.gl-ml-3 %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' } } = sorted_by = icon('chevron-down') diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml index 59e28a3b244..08d65819476 100644 --- a/app/views/admin/runners/index.html.haml +++ b/app/views/admin/runners/index.html.haml @@ -1,4 +1,5 @@ - breadcrumb_title _('Runners') +- page_title _('Runners') .row .col-sm-6 diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml index 0120d4038b9..0c2b9bab357 100644 --- a/app/views/admin/runners/show.html.haml +++ b/app/views/admin/runners/show.html.haml @@ -9,6 +9,7 @@ %span.runner-state.runner-state-specific Specific +- page_title _("Runners") - add_to_breadcrumbs _("Runners"), admin_runners_path - breadcrumb_title "##{@runner.id}" diff --git a/app/views/admin/services/_form.html.haml b/app/views/admin/services/_form.html.haml index d18e91c0b14..f2153e503af 100644 --- a/app/views/admin/services/_form.html.haml +++ b/app/views/admin/services/_form.html.haml @@ -4,7 +4,7 @@ %p #{@service.description} template. = form_for :service, url: admin_application_settings_service_path, method: :put, html: { class: 'fieldset-form' } do |form| - = render 'shared/service_settings', form: form, service: @service + = render 'shared/service_settings', form: form, integration: @service .footer-block.row-content-block = form.submit 'Save', class: 'btn btn-success' diff --git a/app/views/admin/services/edit.html.haml b/app/views/admin/services/edit.html.haml index 00ed5464a44..d13b5a34dac 100644 --- a/app/views/admin/services/edit.html.haml +++ b/app/views/admin/services/edit.html.haml @@ -1,5 +1,6 @@ -- add_to_breadcrumbs "Service Templates", admin_application_settings_services_path +- add_to_breadcrumbs _("Service Templates"), admin_application_settings_services_path +- page_title @service.title, _("Service Templates") - breadcrumb_title @service.title -- page_title @service.title, "Service Templates" +- @content_class = 'limit-container-width' unless fluid_layout = render 'form' diff --git a/app/views/admin/services/index.html.haml b/app/views/admin/services/index.html.haml index e0a1a3549a5..ec343c38470 100644 --- a/app/views/admin/services/index.html.haml +++ b/app/views/admin/services/index.html.haml @@ -1,4 +1,4 @@ -- page_title "Service Templates" +- page_title _("Service Templates") %h3.page-title Service templates %p.light= s_('AdminSettings|Service template allows you to set default values for integrations') @@ -11,13 +11,24 @@ %th Description %th Last edit - @services.each do |service| - %tr - %td - = boolean_to_icon service.activated? - %td - = link_to edit_admin_application_settings_service_path(service.id) do - %strong= service.title - %td - = service.description - %td.light - = time_ago_with_tooltip service.updated_at + - if service.type.in?(@existing_instance_types) + %tr + %td + %td + = link_to edit_admin_application_settings_integration_path(service.to_param), class: 'gl-text-blue-300!' do + %strong.has-tooltip{ title: s_('AdminSettings|Moved to integrations'), data: { container: 'body' } } + = service.title + %td.gl-cursor-default.gl-text-gray-600 + = service.description + %td + - else + %tr + %td + = boolean_to_icon service.activated? + %td + = link_to edit_admin_application_settings_service_path(service.id) do + %strong= service.title + %td + = service.description + %td.light + = time_ago_with_tooltip service.updated_at diff --git a/app/views/admin/sessions/new.html.haml b/app/views/admin/sessions/new.html.haml index 4ce1629bb53..67c607270a5 100644 --- a/app/views/admin/sessions/new.html.haml +++ b/app/views/admin/sessions/new.html.haml @@ -15,7 +15,7 @@ -# Show a message if none of the mechanisms above are enabled - if !allow_admin_mode_password_authentication_for_web? && !ldap_sign_in_enabled? && !omniauth_enabled? - .prepend-top-default.center + .gl-mt-3.center = _('No authentication methods configured.') - if omniauth_enabled? && button_based_providers_enabled? diff --git a/app/views/admin/spam_logs/index.html.haml b/app/views/admin/spam_logs/index.html.haml index b45d3e4823b..40fbc559d72 100644 --- a/app/views/admin/spam_logs/index.html.haml +++ b/app/views/admin/spam_logs/index.html.haml @@ -1,4 +1,4 @@ -- page_title "Spam Logs" +- page_title _("Spam Logs") %h3.page-title Spam Logs %hr - if @spam_logs.present? diff --git a/app/views/admin/system_info/show.html.haml b/app/views/admin/system_info/show.html.haml index b7648979edd..312ca62cfdf 100644 --- a/app/views/admin/system_info/show.html.haml +++ b/app/views/admin/system_info/show.html.haml @@ -1,6 +1,6 @@ - page_title _('System Info') -.prepend-top-default +.gl-mt-3 .row .col-sm .bg-light.light-well @@ -11,7 +11,7 @@ - else = icon('warning', class: 'text-warning') = _('Unable to collect CPU info') - .bg-light.light-well.prepend-top-default + .bg-light.light-well.gl-mt-3 %h4= _('Memory Usage') .data - if @memory @@ -19,7 +19,7 @@ - else = icon('warning', class: 'text-warning') = _('Unable to collect memory info') - .bg-light.light-well.prepend-top-default + .bg-light.light-well.gl-mt-3 %h4= _('Uptime') .data %h2= distance_of_time_in_words_to_now(Rails.application.config.booted_at) diff --git a/app/views/admin/users/_access_levels.html.haml b/app/views/admin/users/_access_levels.html.haml index e3ab2e4f9bd..3ba01e8a350 100644 --- a/app/views/admin/users/_access_levels.html.haml +++ b/app/views/admin/users/_access_levels.html.haml @@ -1,5 +1,6 @@ %fieldset - %legend Access + %legend + = s_('AdminUsers|Access') .form-group.row .col-sm-2.col-form-label = f.label :projects_limit @@ -7,43 +8,43 @@ = f.number_field :projects_limit, min: 0, max: Gitlab::Database::MAX_INT_VALUE, class: 'form-control' .form-group.row - .col-sm-2.col-form-label + .col-sm-2.col-form-label.gl-pt-0 = f.label :can_create_group .col-sm-10 = f.check_box :can_create_group .form-group.row - .col-sm-2.col-form-label + .col-sm-2.col-form-label.gl-pt-0 = f.label :access_level .col-sm-10 - editing_current_user = (current_user == @user) = f.radio_button :access_level, :regular, disabled: editing_current_user = f.label :access_level_regular, class: 'font-weight-bold' do - Regular + = s_('AdminUsers|Regular') %p.light - Regular users have access to their groups and projects + = s_('AdminUsers|Regular users have access to their groups and projects') = render_if_exists 'admin/users/auditor_access_level_radio', f: f, disabled: editing_current_user = f.radio_button :access_level, :admin, disabled: editing_current_user = f.label :access_level_admin, class: 'font-weight-bold' do - Admin + = s_('AdminUsers|Admin') %p.light - Administrators have access to all groups, projects and users and can manage all features in this installation + = s_('AdminUsers|Administrators have access to all groups, projects and users and can manage all features in this installation') - if editing_current_user %p.light - You cannot remove your own admin rights. + = s_('AdminUsers|You cannot remove your own admin rights.') .form-group.row - .col-sm-2.col-form-label + .col-sm-2.col-form-label.gl-pt-0 = f.label :external .hidden{ data: user_internal_regex_data } - .col-sm-10 + .col-sm-10.gl-display-flex.gl-align-items-baseline = f.check_box :external do - External - %p.light - External users cannot see internal or private projects unless access is explicitly granted. Also, external users cannot create projects, groups, or personal snippets. + = s_('AdminUsers|External') + %p.light.gl-pl-2 + = s_('AdminUsers|External users cannot see internal or private projects unless access is explicitly granted. Also, external users cannot create projects, groups, or personal snippets.') %row.hidden#warning_external_automatically_set.hidden .badge.badge-warning.text-white - = _('Automatically marked as default internal user') + = s_('AdminUsers|Automatically marked as default internal user') diff --git a/app/views/admin/users/_head.html.haml b/app/views/admin/users/_head.html.haml index a218885a00e..3403e9e5abf 100644 --- a/app/views/admin/users/_head.html.haml +++ b/app/views/admin/users/_head.html.haml @@ -28,4 +28,4 @@ = link_to "Identities", admin_user_identities_path(@user) = nav_link(controller: :impersonation_tokens) do = link_to "Impersonation Tokens", admin_user_impersonation_tokens_path(@user) -.append-bottom-default +.gl-mb-3 diff --git a/app/views/admin/users/_user_listing_note.html.haml b/app/views/admin/users/_user_listing_note.html.haml index df4af009c5c..b6c9bc43339 100644 --- a/app/views/admin/users/_user_listing_note.html.haml +++ b/app/views/admin/users/_user_listing_note.html.haml @@ -1,3 +1,3 @@ - if user.note.present? %span.has-tooltip.user-note{ title: user.note } - = icon("sticky-note-o cgrey") + = sprite_icon('document', size: 16, css_class: 'gl-vertical-align-middle') diff --git a/app/views/admin/users/edit.html.haml b/app/views/admin/users/edit.html.haml index 3b6fd71500d..7d10e839cd6 100644 --- a/app/views/admin/users/edit.html.haml +++ b/app/views/admin/users/edit.html.haml @@ -1,4 +1,4 @@ -- page_title "Edit", @user.name, "Users" +- page_title _("Edit"), @user.name, _("Users") %h3.page-title Edit user: #{@user.name} %hr diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml index ecbabab3e7f..05988c17412 100644 --- a/app/views/admin/users/index.html.haml +++ b/app/views/admin/users/index.html.haml @@ -1,10 +1,10 @@ -- page_title "Users" +- page_title _("Users") .top-area.scrolling-tabs-container.inner-page-scroll-tabs .fade-left - = icon('angle-left') + = sprite_icon('chevron-lg-left', size: 12) .fade-right - = icon('angle-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 diff --git a/app/views/admin/users/keys.html.haml b/app/views/admin/users/keys.html.haml index 103bbb3b063..5f9d11af7c1 100644 --- a/app/views/admin/users/keys.html.haml +++ b/app/views/admin/users/keys.html.haml @@ -1,5 +1,5 @@ -- add_to_breadcrumbs "Users", admin_users_path +- add_to_breadcrumbs _("Users"), admin_users_path - breadcrumb_title @user.name -- page_title "SSH Keys", @user.name, "Users" +- page_title _("SSH Keys"), @user.name, _("Users") = render 'admin/users/head' = render 'profiles/keys/key_table', admin: true diff --git a/app/views/admin/users/new.html.haml b/app/views/admin/users/new.html.haml index bfc36ed7373..e5e6790b789 100644 --- a/app/views/admin/users/new.html.haml +++ b/app/views/admin/users/new.html.haml @@ -1,4 +1,4 @@ -- page_title "New User" +- page_title _("New User") %h3.page-title New user %hr diff --git a/app/views/admin/users/projects.html.haml b/app/views/admin/users/projects.html.haml index e6da81831ab..f66d9b76afc 100644 --- a/app/views/admin/users/projects.html.haml +++ b/app/views/admin/users/projects.html.haml @@ -1,6 +1,6 @@ -- add_to_breadcrumbs "Users", admin_users_path +- add_to_breadcrumbs _("Users"), admin_users_path - breadcrumb_title @user.name -- page_title "Groups and projects", @user.name, "Users" +- page_title _("Groups and projects"), @user.name, _("Users") = render 'admin/users/head' - if @user.groups.any? @@ -16,7 +16,7 @@ .float-right %span.light.vertical-align-middle= group_member.human_access - unless group_member.owner? - = link_to group_group_member_path(group, group_member), data: { confirm: remove_member_message(group_member) }, method: :delete, remote: true, class: "btn-sm btn btn-remove prepend-left-10", title: 'Remove user from group' do + = link_to group_group_member_path(group, group_member), data: { confirm: remove_member_message(group_member) }, method: :delete, remote: true, class: "btn-sm btn btn-remove gl-ml-3", title: 'Remove user from group' do %i.fa.fa-times.fa-inverse .row @@ -46,5 +46,5 @@ %span.light.vertical-align-middle= member.human_access - if member.respond_to? :project - = link_to project_project_member_path(project, member), data: { confirm: remove_member_message(member) }, remote: true, method: :delete, class: "btn-sm btn btn-remove prepend-left-10", title: 'Remove user from project' do + = link_to project_project_member_path(project, member), data: { confirm: remove_member_message(member) }, remote: true, method: :delete, class: "btn-sm btn btn-remove gl-ml-3", title: 'Remove user from project' do %i.fa.fa-times diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index e76f1f6444c..2bc39a23b2d 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -1,6 +1,6 @@ -- add_to_breadcrumbs "Users", admin_users_path +- add_to_breadcrumbs _("Users"), admin_users_path - breadcrumb_title @user.name -- page_title @user.name, "Users" +- page_title @user.name, _("Users") = render 'admin/users/head' .row @@ -86,34 +86,22 @@ %li %span.light Current sign-in IP: %strong - - if @user.current_sign_in_ip # rubocop:disable Style/RedundantCondition - = @user.current_sign_in_ip - - else - never + = @user.current_sign_in_ip || _('never') %li %span.light Current sign-in at: %strong - - if @user.current_sign_in_at - = @user.current_sign_in_at.to_s(:medium) - - else - never + = @user.current_sign_in_at&.to_s(:medium) || _('never') %li %span.light Last sign-in IP: %strong - - if @user.last_sign_in_ip # rubocop:disable Style/RedundantCondition - = @user.last_sign_in_ip - - else - never + = @user.last_sign_in_ip || _('never') %li %span.light Last sign-in at: %strong - - if @user.last_sign_in_at - = @user.last_sign_in_at.to_s(:medium) - - else - never + = @user.last_sign_in_at&.to_s(:medium) || _('never') %li %span.light Sign-in count: diff --git a/app/views/ci/group_variables/_index.html.haml b/app/views/ci/group_variables/_index.html.haml index c350ba5caf7..84bcd42e07c 100644 --- a/app/views/ci/group_variables/_index.html.haml +++ b/app/views/ci/group_variables/_index.html.haml @@ -6,8 +6,8 @@ = 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.append-right-10.key + .table-section.section-40.gl-mr-3.key = variable.key - .table-section.section-40.append-right-10 + .table-section.section-40.gl-mr-3 %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 1a3168cf781..a8d533da0e0 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.append-right-10 + .bold.table-section.section-40.gl-mr-3 = s_('Key') - .bold.table-section.section-40.append-right-10 + .bold.table-section.section-40.gl-mr-3 = s_('Origin') diff --git a/app/views/ci/variables/_content.html.haml b/app/views/ci/variables/_content.html.haml index 0b5c1a806b2..144d13565b2 100644 --- a/app/views/ci/variables/_content.html.haml +++ b/app/views/ci/variables/_content.html.haml @@ -1,3 +1,3 @@ = _('Environment variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. Additionally, they can be masked so they are hidden in job logs, though they must match certain regexp requirements to do so. You can use environment variables for passwords, secret keys, or whatever you want.') = _('You may also add variables that are made available to the running application by prepending the variable key with <code>K8S_SECRET_</code>.').html_safe -= link_to _('More information'), help_page_path('ci/variables/README', anchor: 'variables') += link_to _('More information'), help_page_path('ci/variables/README', anchor: 'custom-environment-variables') diff --git a/app/views/ci/variables/_environment_scope_header.html.haml b/app/views/ci/variables/_environment_scope_header.html.haml index 4ba4ceec16c..fc3b7f925fc 100644 --- a/app/views/ci/variables/_environment_scope_header.html.haml +++ b/app/views/ci/variables/_environment_scope_header.html.haml @@ -1,2 +1,2 @@ -.bold.table-section.section-15.append-right-10 +.bold.table-section.section-15.gl-mr-3 = s_('CiVariables|Scope') diff --git a/app/views/ci/variables/_header.html.haml b/app/views/ci/variables/_header.html.haml index ce4dd5a4877..d0148e455de 100644 --- a/app/views/ci/variables/_header.html.haml +++ b/app/views/ci/variables/_header.html.haml @@ -2,7 +2,7 @@ %h4 = _('Variables') - = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'variables'), target: '_blank', rel: 'noopener noreferrer' + = link_to icon('question-circle'), 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 fa5f2c514ae..8d379774719 100644 --- a/app/views/ci/variables/_index.html.haml +++ b/app/views/ci/variables/_index.html.haml @@ -2,7 +2,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: 'protected-variables') } + - 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 } - if Feature.enabled?(:new_variables_ui, @project || @group, default_enabled: true) @@ -36,7 +36,7 @@ %span.hide.js-ci-variables-save-loading-icon .spinner.spinner-light.mr-1 = _('Save variables') - %button.btn.btn-info.btn-inverted.prepend-left-10.js-secret-value-reveal-button{ type: 'button', data: { secret_reveal_status: "#{@variables.size == 0}" } } + %button.btn.btn-info.btn-inverted.gl-ml-3.js-secret-value-reveal-button{ type: 'button', data: { secret_reveal_status: "#{@variables.size == 0}" } } - if @variables.size == 0 = n_('Hide value', 'Hide values', @variables.size) - else diff --git a/app/views/ci/variables/_variable_header.html.haml b/app/views/ci/variables/_variable_header.html.haml index d3b7a5ae883..65cea00a0c4 100644 --- a/app/views/ci/variables/_variable_header.html.haml +++ b/app/views/ci/variables/_variable_header.html.haml @@ -2,11 +2,11 @@ %li.ci-variable-row.m-0.d-none.d-sm-block .d-flex.w-100.align-items-center.pb-2 - .bold.table-section.section-15.append-right-10 + .bold.table-section.section-15.gl-mr-3 = s_('CiVariables|Type') - .bold.table-section.section-15.append-right-10 + .bold.table-section.section-15.gl-mr-3 = s_('CiVariables|Key') - .bold.table-section.section-15.append-right-10 + .bold.table-section.section-15.gl-mr-3 = s_('CiVariables|Value') - unless only_key_value .bold.table-section.section-20 diff --git a/app/views/ci/variables/_variable_row.html.haml b/app/views/ci/variables/_variable_row.html.haml index 4244556a24a..c69a3adb0e9 100644 --- a/app/views/ci/variables/_variable_row.html.haml +++ b/app/views/ci/variables/_variable_row.html.haml @@ -39,10 +39,10 @@ = value %p.masking-validation-error.gl-field-error.hide = s_("CiVariables|Cannot use Masked Variable with current value") - = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'masked-variables'), target: '_blank', rel: 'noopener noreferrer' + = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'mask-a-custom-variable'), target: '_blank', rel: 'noopener noreferrer' - unless only_key_value .ci-variable-body-item.ci-variable-protected-item.table-section.section-20.mr-0.border-top-0 - .append-right-default + .gl-mr-3 = s_("CiVariable|Protected") = render "shared/buttons/project_feature_toggle", is_checked: is_protected, label: s_("CiVariable|Toggle protected") do %input{ type: "hidden", @@ -51,7 +51,7 @@ value: is_protected, data: { default: is_protected_default.to_s } } .ci-variable-body-item.ci-variable-masked-item.table-section.section-20.mr-0.border-top-0 - .append-right-default + .gl-mr-3 = s_("CiVariable|Masked") = render "shared/buttons/project_feature_toggle", is_checked: is_masked, label: s_("CiVariable|Toggle masked"), class_list: "js-project-feature-toggle project-feature-toggle qa-variable-masked" do %input{ type: "hidden", diff --git a/app/views/clusters/clusters/_advanced_settings.html.haml b/app/views/clusters/clusters/_advanced_settings.html.haml index d823cd0412b..d1681409a93 100644 --- a/app/views/clusters/clusters/_advanced_settings.html.haml +++ b/app/views/clusters/clusters/_advanced_settings.html.haml @@ -40,4 +40,6 @@ %p = s_("ClusterIntegration|Remove this Kubernetes cluster's configuration from this project. This will not delete your actual Kubernetes cluster.") - #js-cluster-remove-actions{ data: { cluster_path: clusterable.cluster_path(@cluster), cluster_name: @cluster.name } } + #js-cluster-remove-actions{ data: { cluster_path: clusterable.cluster_path(@cluster), + cluster_name: @cluster.name, + has_management_project: @cluster.management_project_id? } } diff --git a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml index 486625c790b..3869ca6591c 100644 --- a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml +++ b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml @@ -1,5 +1,5 @@ - link = link_to(s_('ClusterIntegration|sign up'), 'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', target: '_blank', rel: 'noopener noreferrer') -.bs-callout.gcp-signup-offer.alert.alert-block.alert-dismissable.prepend-top-default.append-bottom-default{ role: 'alert', data: { feature_id: UserCalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: user_callouts_path } } +.bs-callout.gcp-signup-offer.alert.alert-block.alert-dismissable.gl-mt-3.gl-mb-3{ role: 'alert', data: { feature_id: UserCalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: user_callouts_path } } %button.close.js-close{ type: "button" } × .gcp-signup-offer--content .gcp-signup-offer--icon.gl-mr-3 diff --git a/app/views/clusters/clusters/_gitlab_integration_form.html.haml b/app/views/clusters/clusters/_gitlab_integration_form.html.haml index c5b54997407..160964b532a 100644 --- a/app/views/clusters/clusters/_gitlab_integration_form.html.haml +++ b/app/views/clusters/clusters/_gitlab_integration_form.html.haml @@ -10,17 +10,10 @@ .form-group %h5= s_('ClusterIntegration|Environment scope') - - if has_multiple_clusters? - = field.text_field :environment_scope, class: 'col-md-6 form-control js-select-on-focus', placeholder: s_('ClusterIntegration|Environment scope') - .form-text.text-muted= s_("ClusterIntegration|Choose which of your environments will use this cluster.") - - else - = text_field_tag :environment_scope, '*', class: 'col-md-6 form-control disabled', placeholder: s_('ClusterIntegration|Environment scope'), disabled: true - - environment_scope_url = help_page_path('user/project/clusters/index', anchor: 'base-domain') - - environment_scope_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: environment_scope_url } - .form-text.text-muted - %code - = _('*') - = s_("ClusterIntegration| is the default environment scope for this cluster. This means that all jobs, regardless of their environment, will use this cluster. %{environment_scope_start}More information%{environment_scope_end}").html_safe % { environment_scope_start: environment_scope_start, environment_scope_end: '</a>'.html_safe } + = field.text_field :environment_scope, class: 'col-md-6 form-control js-select-on-focus', placeholder: s_('ClusterIntegration|Environment scope') + - environment_scope_url = help_page_path('user/project/clusters/index', anchor: 'base-domain') + - environment_scope_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: environment_scope_url } + .form-text.text-muted= s_("ClusterIntegration|Choose which of your environments will use this cluster. %{environment_scope_start}More information%{environment_scope_end}").html_safe % { environment_scope_start: environment_scope_start, environment_scope_end: '</a>'.html_safe } .form-group %h5= s_('ClusterIntegration|Base domain') diff --git a/app/views/clusters/clusters/_health.html.haml b/app/views/clusters/clusters/_health.html.haml new file mode 100644 index 00000000000..5400bd7f201 --- /dev/null +++ b/app/views/clusters/clusters/_health.html.haml @@ -0,0 +1,6 @@ +%section.settings.no-animate.expanded.cluster-health-graphs.qa-cluster-health-section#cluster-health + - if @cluster&.application_prometheus_available? + #prometheus-graphs{ data: @cluster.health_data(clusterable) } + + - else + %p.settings-message.text-center= s_("ClusterIntegration|In order to view the health of your cluster, you must first install Prometheus in the Applications tab.") diff --git a/app/views/clusters/clusters/_health_tab.html.haml b/app/views/clusters/clusters/_health_tab.html.haml new file mode 100644 index 00000000000..fda392693f6 --- /dev/null +++ b/app/views/clusters/clusters/_health_tab.html.haml @@ -0,0 +1,5 @@ +- active = params[:tab] == 'health' + +%li.nav-item{ role: 'presentation' } + %a#cluster-health-tab.nav-link.qa-health{ class: active_when(active), href: clusterable.cluster_path(@cluster.id, params: {tab: 'health'}) } + %span= _('Health') diff --git a/app/views/clusters/clusters/_multiple_clusters_message.html.haml b/app/views/clusters/clusters/_multiple_clusters_message.html.haml new file mode 100644 index 00000000000..da3e128ba32 --- /dev/null +++ b/app/views/clusters/clusters/_multiple_clusters_message.html.haml @@ -0,0 +1,6 @@ +- autodevops_help_url = help_page_path('topics/autodevops/index.md', anchor: 'using-multiple-kubernetes-clusters') +- help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe +- help_link_end = '</a>'.html_safe + +%p + = s_('ClusterIntegration|If you are setting up multiple clusters and are using Auto DevOps, %{help_link_start}read this first%{help_link_end}.').html_safe % { help_link_start: help_link_start % { url: autodevops_help_url }, help_link_end: help_link_end } diff --git a/app/views/clusters/clusters/_sidebar.html.haml b/app/views/clusters/clusters/_sidebar.html.haml index 24a74c59b97..31add011bfa 100644 --- a/app/views/clusters/clusters/_sidebar.html.haml +++ b/app/views/clusters/clusters/_sidebar.html.haml @@ -5,4 +5,4 @@ %p = clusterable.learn_more_link -= render_if_exists 'clusters/multiple_clusters_message' += render 'clusters/clusters/multiple_clusters_message' diff --git a/app/views/clusters/clusters/aws/_new.html.haml b/app/views/clusters/clusters/aws/_new.html.haml index 5bbdadf83f3..ec604ca83e5 100644 --- a/app/views/clusters/clusters/aws/_new.html.haml +++ b/app/views/clusters/clusters/aws/_new.html.haml @@ -1,6 +1,6 @@ - if !Gitlab::CurrentSettings.eks_integration_enabled? - - documentation_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/clusters/add_remove_clusters.md', - anchor: 'additional-requirements-for-self-managed-instances') } + - documentation_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/clusters/add_eks_clusters.md', + anchor: 'additional-requirements-for-self-managed-instances-core-only') } = s_('Amazon authentication is not %{link_start}correctly configured%{link_end}. Ask your GitLab administrator if you want to use this service.').html_safe % { link_start: documentation_link_start, link_end: '<a/>'.html_safe } - else .js-create-eks-cluster-form-container{ data: { 'gitlab-managed-cluster-help-path' => help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'), diff --git a/app/views/clusters/clusters/gcp/_form.html.haml b/app/views/clusters/clusters/gcp/_form.html.haml index e83bf61ab9b..434c02a5c41 100644 --- a/app/views/clusters/clusters/gcp/_form.html.haml +++ b/app/views/clusters/clusters/gcp/_form.html.haml @@ -16,12 +16,11 @@ data: { token: token_in_session } }, url: clusterable.create_gcp_clusters_path, as: :cluster do |field| = field.text_field :name, required: true, title: s_('ClusterIntegration|Cluster name is required.'), label: s_('ClusterIntegration|Kubernetes cluster name'), label_class: 'label-bold' - - if has_multiple_clusters? - = field.form_group :environment_scope, label: { text: s_('ClusterIntegration|Environment scope'), - class: 'label-bold' } do - = field.text_field :environment_scope, required: true, class: 'form-control', - title: 'Environment scope is required.', wrapper: false - .form-text.text-muted= s_("ClusterIntegration|Choose which of your environments will use this cluster.") + = field.form_group :environment_scope, label: { text: s_('ClusterIntegration|Environment scope'), + class: 'label-bold' } do + = field.text_field :environment_scope, required: true, class: 'form-control', + title: 'Environment scope is required.', wrapper: false + .form-text.text-muted= s_("ClusterIntegration|Choose which of your environments will use this cluster.") = field.fields_for :provider_gcp, @gcp_cluster.provider_gcp do |provider_gcp_field| .form-group @@ -70,7 +69,7 @@ label_class: 'label-bold' } .form-text.text-muted = s_('ClusterIntegration|Uses the Cloud Run, Istio, and HTTP Load Balancing addons for this cluster.') - = link_to _('More information'), help_page_path('user/project/clusters/add_remove_clusters.md', anchor: 'cloud-run-for-anthos'), target: '_blank' + = link_to _('More information'), help_page_path('user/project/clusters/add_gke_clusters.md', anchor: 'cloud-run-for-anthos'), target: '_blank' .form-group = field.check_box :managed, { label: s_('ClusterIntegration|GitLab-managed cluster'), diff --git a/app/views/clusters/clusters/index.html.haml b/app/views/clusters/clusters/index.html.haml index a654a8741a4..557ad1bf280 100644 --- a/app/views/clusters/clusters/index.html.haml +++ b/app/views/clusters/clusters/index.html.haml @@ -12,15 +12,14 @@ = s_('ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project') = render 'clusters/clusters/buttons' - - if @has_ancestor_clusters - .bs-callout.bs-callout-info - = s_('ClusterIntegration|Clusters are utilized by selecting the nearest ancestor with a matching environment scope. For example, project clusters will override group clusters.') - %strong - = link_to _('More information'), help_page_path('user/group/clusters/index', anchor: 'cluster-precedence') - - if Feature.enabled?(:clusters_list_redesign) #js-clusters-list-app{ data: js_clusters_list_data(clusterable.index_path(format: :json)) } - else + - if @has_ancestor_clusters + .bs-callout.bs-callout-info + = s_('ClusterIntegration|Clusters are utilized by selecting the nearest ancestor with a matching environment scope. For example, project clusters will override group clusters.') + %strong + = link_to _('More information'), help_page_path('user/group/clusters/index', anchor: 'cluster-precedence') .clusters-table.js-clusters-list .gl-responsive-table-row.table-row-header{ role: "row" } .table-section.section-60{ role: "rowheader" } diff --git a/app/views/clusters/clusters/new.html.haml b/app/views/clusters/clusters/new.html.haml index fae78fbb7f4..0a51d4b2e93 100644 --- a/app/views/clusters/clusters/new.html.haml +++ b/app/views/clusters/clusters/new.html.haml @@ -6,7 +6,7 @@ = render_gcp_signup_offer -.row.prepend-top-default +.row.gl-mt-3 .col-md-3 = render 'sidebar' .col-md-9.js-toggle-container diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml index 83b8092fb48..ffa99f06593 100644 --- a/app/views/clusters/clusters/show.html.haml +++ b/app/views/clusters/clusters/show.html.haml @@ -32,7 +32,7 @@ ingress_mod_security_help_path: help_page_path('user/clusters/applications.md', anchor: 'web-application-firewall-modsecurity'), environments_help_path: help_page_path('ci/environments/index.md', anchor: 'defining-environments'), clusters_help_path: help_page_path('user/project/clusters/index.md', anchor: 'deploying-to-a-kubernetes-cluster'), - deploy_boards_help_path: help_page_path('user/project/deploy_boards.html', anchor: 'enabling-deploy-boards'), + deploy_boards_help_path: help_page_path('user/project/deploy_boards.md', anchor: 'enabling-deploy-boards'), cloud_run_help_path: help_page_path('user/project/clusters/add_remove_clusters.md', anchor: 'cloud-run-for-anthos'), manage_prometheus_path: manage_prometheus_path, cluster_id: @cluster.id } } @@ -55,7 +55,7 @@ %ul.nav-links.mobile-separator.nav.nav-tabs{ role: 'tablist' } = render 'details_tab' = render_if_exists 'clusters/clusters/environments_tab' - = render_if_exists 'clusters/clusters/health_tab' + = render 'clusters/clusters/health_tab' = render 'applications_tab' = render 'advanced_settings_tab' diff --git a/app/views/clusters/clusters/user/_form.html.haml b/app/views/clusters/clusters/user/_form.html.haml index ce226d29113..11772107135 100644 --- a/app/views/clusters/clusters/user/_form.html.haml +++ b/app/views/clusters/clusters/user/_form.html.haml @@ -13,10 +13,10 @@ url: clusterable.create_user_clusters_path, as: :cluster do |field| = field.text_field :name, required: true, title: s_('ClusterIntegration|Cluster name is required.'), label: s_('ClusterIntegration|Kubernetes cluster name'), label_class: 'label-bold' - - if has_multiple_clusters? - = field.text_field :environment_scope, required: true, title: 'Environment scope is required.', - label: s_('ClusterIntegration|Environment scope'), label_class: 'label-bold', - help: s_("ClusterIntegration|Choose which of your environments will use this cluster.") + + = field.text_field :environment_scope, required: true, title: s_('ClusterIntegration|Environment scope is required.'), + label: s_('ClusterIntegration|Environment scope'), label_class: 'label-bold', + help: s_('ClusterIntegration|Choose which of your environments will use this cluster.') = field.fields_for :platform_kubernetes, @user_cluster.platform_kubernetes do |platform_kubernetes_field| = platform_kubernetes_field.url_field :api_url, required: true, diff --git a/app/views/dashboard/_projects_head.html.haml b/app/views/dashboard/_projects_head.html.haml index 97a446dbeec..5e78749fee2 100644 --- a/app/views/dashboard/_projects_head.html.haml +++ b/app/views/dashboard/_projects_head.html.haml @@ -12,8 +12,8 @@ = link_to _("New project"), new_project_path, class: "btn btn-success" .top-area.scrolling-tabs-container.inner-page-scroll-tabs - .fade-left= icon('angle-left') - .fade-right= icon('angle-right') + .fade-left= sprite_icon('chevron-lg-left', size: 12) + .fade-right= sprite_icon('chevron-lg-right', size: 12) %ul.nav-links.scrolling-tabs.mobile-separator.nav.nav-tabs{ class: ('border-0' if feature_project_list_filter_bar) } = nav_link(page: [dashboard_projects_path, root_path]) do = link_to dashboard_projects_path, class: 'shortcuts-activity', data: {placement: 'right'} do diff --git a/app/views/dashboard/activity.html.haml b/app/views/dashboard/activity.html.haml index d7306f5932d..1e93613e978 100644 --- a/app/views/dashboard/activity.html.haml +++ b/app/views/dashboard/activity.html.haml @@ -5,8 +5,8 @@ = render_dashboard_gold_trial(current_user) -- page_title "Activity" -- header_title "Activity", activity_dashboard_path +- page_title _("Activity") +- header_title _("Activity"), activity_dashboard_path = render "projects/last_push" = render 'dashboard/activity_head' diff --git a/app/views/dashboard/groups/index.html.haml b/app/views/dashboard/groups/index.html.haml index d1d8d970b59..9536ff940f5 100644 --- a/app/views/dashboard/groups/index.html.haml +++ b/app/views/dashboard/groups/index.html.haml @@ -1,6 +1,6 @@ - @hide_top_links = true -- page_title "Groups" -- header_title "Groups", dashboard_groups_path +- page_title _("Groups") +- header_title _("Groups"), dashboard_groups_path = render_dashboard_gold_trial(current_user) = render 'dashboard/groups_head' diff --git a/app/views/dashboard/milestones/index.html.haml b/app/views/dashboard/milestones/index.html.haml index b9be6028b72..a0c1c314a85 100644 --- a/app/views/dashboard/milestones/index.html.haml +++ b/app/views/dashboard/milestones/index.html.haml @@ -1,6 +1,6 @@ - @hide_top_links = true -- page_title 'Milestones' -- header_title 'Milestones', dashboard_milestones_path +- page_title _('Milestones') +- header_title _('Milestones'), dashboard_milestones_path .page-title-holder.d-flex.align-items-center %h1.page-title= _('Milestones') diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml index d2aa07bab22..2e7eab87af3 100644 --- a/app/views/dashboard/projects/index.html.haml +++ b/app/views/dashboard/projects/index.html.haml @@ -5,8 +5,8 @@ = render_dashboard_gold_trial(current_user) -- page_title "Projects" -- header_title "Projects", dashboard_projects_path +- page_title _("Projects") +- header_title _("Projects"), dashboard_projects_path = render "projects/last_push" - if show_projects?(@projects, params) diff --git a/app/views/dashboard/snippets/index.html.haml b/app/views/dashboard/snippets/index.html.haml index 2f0cc76f2e0..68457ab33f7 100644 --- a/app/views/dashboard/snippets/index.html.haml +++ b/app/views/dashboard/snippets/index.html.haml @@ -1,6 +1,6 @@ - @hide_top_links = true -- page_title "Snippets" -- header_title "Snippets", dashboard_snippets_path +- page_title _("Snippets") +- header_title _("Snippets"), dashboard_snippets_path - button_path = new_snippet_path if can?(current_user, :create_snippet) = render 'dashboard/snippets_head' diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml index f5ffe8f2e36..82abb9b3b8a 100644 --- a/app/views/dashboard/todos/_todo.html.haml +++ b/app/views/dashboard/todos/_todo.html.haml @@ -4,7 +4,7 @@ .todo-item.todo-block.align-self-center .todo-title - - unless todo.build_failed? || todo.unmergeable? + - if todo_author_display?(todo) = todo_target_state_pill(todo) %span.title-item.author-name.bold diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml index cfc637592d3..9b6150c4be2 100644 --- a/app/views/dashboard/todos/index.html.haml +++ b/app/views/dashboard/todos/index.html.haml @@ -1,6 +1,6 @@ - @hide_top_links = true -- page_title "To-Do List" -- header_title "To-Do List", dashboard_todos_path +- page_title _("To-Do List") +- header_title _("To-Do List"), dashboard_todos_path = render_dashboard_gold_trial(current_user) @@ -25,7 +25,7 @@ .nav-controls - if @todos.any?(&:pending?) - .append-right-default + .gl-mr-3 = link_to destroy_all_dashboard_todos_path(todos_filter_params), class: 'btn btn-loading d-flex align-items-center js-todos-mark-all', method: :delete, data: { href: destroy_all_dashboard_todos_path(todos_filter_params) } do Mark all as done %span.spinner.ml-1 diff --git a/app/views/devise/mailer/_confirmation_instructions_account.html.haml b/app/views/devise/mailer/_confirmation_instructions_account.html.haml index 65565b7b8a8..27ef586d90f 100644 --- a/app/views/devise/mailer/_confirmation_instructions_account.html.haml +++ b/app/views/devise/mailer/_confirmation_instructions_account.html.haml @@ -1,7 +1,7 @@ - confirmation_link = confirmation_url(@resource, confirmation_token: @token) -- if @resource.unconfirmed_email.present? +- if @resource.unconfirmed_email.present? || !@resource.created_recently? #content - = email_default_heading(@resource.unconfirmed_email) + = email_default_heading(@resource.unconfirmed_email || @resource.email) %p Click the link below to confirm your email address. #cta = link_to 'Confirm your email address', confirmation_link diff --git a/app/views/devise/mailer/_confirmation_instructions_account.text.erb b/app/views/devise/mailer/_confirmation_instructions_account.text.erb index 01f09aa763d..5bccb68bbe2 100644 --- a/app/views/devise/mailer/_confirmation_instructions_account.text.erb +++ b/app/views/devise/mailer/_confirmation_instructions_account.text.erb @@ -1,6 +1,5 @@ -<% if @resource.unconfirmed_email.present? %> -<%= @resource.unconfirmed_email %>, - +<% if @resource.unconfirmed_email.present? || !@resource.created_recently? %> +<%= @resource.unconfirmed_email || @resource.email %>, Use the link below to confirm your email address. <% else %> <% if Gitlab.com? %> diff --git a/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml b/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml index ccc3e734276..f14d50eaf71 100644 --- a/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml +++ b/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml @@ -1,5 +1,5 @@ #content - = email_default_heading("#{sanitize_name(@resource.user.name)}, you've added an additional email!") + = email_default_heading("#{sanitize_name(@resource.user.name)}, confirm your email address now!") %p Click the link below to confirm your email address (#{@resource.email}) #cta = link_to 'Confirm your email address', confirmation_url(@resource, confirmation_token: @token) diff --git a/app/views/devise/mailer/_confirmation_instructions_secondary.text.erb b/app/views/devise/mailer/_confirmation_instructions_secondary.text.erb index a3b28cb0b84..b91498ccfae 100644 --- a/app/views/devise/mailer/_confirmation_instructions_secondary.text.erb +++ b/app/views/devise/mailer/_confirmation_instructions_secondary.text.erb @@ -1,4 +1,4 @@ -<%= @resource.user.name %>, you've added an additional email! +<%= @resource.user.name %>, confirm your email address now! Use the link below to confirm your email address (<%= @resource.email %>) diff --git a/app/views/devise/registrations/new.html.haml b/app/views/devise/registrations/new.html.haml index 9fb5e27b692..fb00e1b4384 100644 --- a/app/views/devise/registrations/new.html.haml +++ b/app/views/devise/registrations/new.html.haml @@ -1,4 +1,4 @@ -- page_title "Sign up" +- page_title _("Sign up") - if experiment_enabled?(:signup_flow) .row .col-lg-7 diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml index fd6d8f3f769..c466d2ce936 100644 --- a/app/views/devise/sessions/new.html.haml +++ b/app/views/devise/sessions/new.html.haml @@ -1,4 +1,4 @@ -- page_title "Sign in" +- page_title _("Sign in") #signin-container - if any_form_based_providers_enabled? diff --git a/app/views/devise/sessions/two_factor.html.haml b/app/views/devise/sessions/two_factor.html.haml index 126d8450568..115ebc94238 100644 --- a/app/views/devise/sessions/two_factor.html.haml +++ b/app/views/devise/sessions/two_factor.html.haml @@ -8,10 +8,10 @@ = 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.' + = 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' } %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: "btn btn-success" + = f.submit "Verify code", class: "btn btn-success", data: { qa_selector: 'verify_code_button' } - if @user.two_factor_u2f_enabled? = render "u2f/authenticate", params: params, resource: resource, resource_name: resource_name, render_remember_me: true, target_path: new_user_session_path diff --git a/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml b/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml index 7bc3042c94d..61271f4525c 100644 --- a/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml +++ b/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml @@ -1,5 +1,6 @@ - max_first_name_length = max_last_name_length = 127 - max_username_length = 255 +- min_username_length = 2 .signup-box.p-3.mb-2 .signup-body = form_for(resource, as: "new_#{resource_name}", url: registration_path(resource_name), html: { class: "new_new_user gl-show-field-errors", "aria-live" => "assertive" }) do |f| @@ -16,7 +17,7 @@ = 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 => _("Last Name is too long (maximum is %{max_length} characters).") % { max_length: max_last_name_length }, :qa_selector => 'new_user_lastname_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 => { :max_length => max_username_length, :max_length_message => _("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 middle js-block-emoji js-validate-length js-validate-username", :data => { :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 => _("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-field-error-ignore.field-validation.mt-1.hide.cred= _('Username is already taken.') %p.validation-success.gl-field-error-ignore.field-validation.mt-1.hide.cgreen= _('Username is available.') %p.validation-pending.gl-field-error-ignore.field-validation.mt-1.hide= _('Checking username availability...') diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml index 7c5b85c903c..0735702ae5f 100644 --- a/app/views/devise/shared/_signup_box.html.haml +++ b/app/views/devise/shared/_signup_box.html.haml @@ -1,5 +1,6 @@ - max_name_length = 255 - max_username_length = 255 +- min_username_length = 2 #register-pane.tab-pane.login-box{ role: 'tabpanel' } .login-body = form_for(resource, as: "new_#{resource_name}", url: registration_path(resource_name), html: { class: "new_new_user gl-show-field-errors", "aria-live" => "assertive" }) do |f| @@ -12,7 +13,7 @@ = f.text_field :name, class: "form-control top js-block-emoji js-validate-length", :data => { :max_length => max_name_length, :max_length_message => s_("SignUp|Name is too long (maximum is %{max_length} characters).") % { max_length: max_name_length }, :qa_selector => 'new_user_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 => { :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 middle js-block-emoji js-validate-length js-validate-username", :data => { :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-field-error-ignore.field-validation.hide= _('Username is already taken.') %p.validation-success.gl-field-error-ignore.field-validation.hide= _('Username is available.') %p.validation-pending.gl-field-error-ignore.field-validation.hide= _('Checking username availability...') diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml index 9659d416a38..4a27284cbae 100644 --- a/app/views/discussions/_discussion.html.haml +++ b/app/views/discussions/_discussion.html.haml @@ -37,7 +37,7 @@ an outdated change in commit - %span.commit-sha= Commit.truncate_sha(discussion.commit_id) + %span.commit-sha= truncate_sha(discussion.commit_id) - else - unless discussion.active? an old version of diff --git a/app/views/doorkeeper/applications/_form.html.haml b/app/views/doorkeeper/applications/_form.html.haml index 79abe31a056..d74cba984e8 100644 --- a/app/views/doorkeeper/applications/_form.html.haml +++ b/app/views/doorkeeper/applications/_form.html.haml @@ -25,5 +25,5 @@ = f.label :scopes, class: 'label-bold' = render 'shared/tokens/scopes_form', prefix: 'doorkeeper_application', token: application, scopes: @scopes - .prepend-top-default + .gl-mt-3 = f.submit _('Save application'), class: "btn btn-success" diff --git a/app/views/doorkeeper/applications/index.html.haml b/app/views/doorkeeper/applications/index.html.haml index 9aab1556373..051799ca13f 100644 --- a/app/views/doorkeeper/applications/index.html.haml +++ b/app/views/doorkeeper/applications/index.html.haml @@ -1,7 +1,7 @@ - page_title _("Applications") - @content_class = "limit-container-width" unless fluid_layout -.row.prepend-top-default +.row.gl-mt-3 .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 = page_title @@ -41,7 +41,7 @@ %div= uri %td= application.access_tokens.count %td - = link_to edit_oauth_application_path(application), class: "btn btn-transparent append-right-5" do + = link_to edit_oauth_application_path(application), class: "btn btn-transparent gl-mr-2" do %span.sr-only = _('Edit') = icon('pencil') @@ -49,7 +49,7 @@ - else .settings-message.text-center = _("You don't have any applications") - .oauth-authorized-applications.prepend-top-20.append-bottom-default + .oauth-authorized-applications.prepend-top-20.gl-mb-3 - if user_oauth_applications? %h5 = _("Authorized applications (%{size})") % { size: @authorized_apps.size + @authorized_anonymous_tokens.size } diff --git a/app/views/doorkeeper/applications/show.html.haml b/app/views/doorkeeper/applications/show.html.haml index 7b29269dbb1..280b5d90793 100644 --- a/app/views/doorkeeper/applications/show.html.haml +++ b/app/views/doorkeeper/applications/show.html.haml @@ -44,4 +44,4 @@ .form-actions = link_to _('Edit'), edit_oauth_application_path(@application), class: 'btn btn-primary wide float-left' - = render 'delete_form', application: @application, submit_btn_css: 'btn btn-danger prepend-left-10' + = render 'delete_form', application: @application, submit_btn_css: 'btn btn-danger gl-ml-3' diff --git a/app/views/doorkeeper/authorizations/new.html.haml b/app/views/doorkeeper/authorizations/new.html.haml index 5d57337a568..70abc1a267a 100644 --- a/app/views/doorkeeper/authorizations/new.html.haml +++ b/app/views/doorkeeper/authorizations/new.html.haml @@ -46,4 +46,4 @@ = hidden_field_tag :response_type, @pre_auth.response_type = hidden_field_tag :scope, @pre_auth.scope = hidden_field_tag :nonce, @pre_auth.nonce - = submit_tag _("Authorize"), class: "btn btn-success prepend-left-10", data: { qa_selector: 'authorization_button' } + = submit_tag _("Authorize"), class: "btn btn-success gl-ml-3", data: { qa_selector: 'authorization_button' } diff --git a/app/views/events/_event.html.haml b/app/views/events/_event.html.haml index c042cd2c3e3..83f7d743755 100644 --- a/app/views/events/_event.html.haml +++ b/app/views/events/_event.html.haml @@ -7,6 +7,8 @@ - if event.wiki_page? = render "events/event/wiki", event: event + - elsif event.design? + = render 'events/event/design', event: event - elsif event.created_project_action? = render "events/event/created_project", event: event - elsif event.push_action? diff --git a/app/views/events/event/_common.html.haml b/app/views/events/event/_common.html.haml index 50c5885c648..dc16c46476e 100644 --- a/app/views/events/event/_common.html.haml +++ b/app/views/events/event/_common.html.haml @@ -5,16 +5,16 @@ .event-title.d-flex.flex-wrap = inline_event_icon(event) - if event.target - %span.event-type.d-inline-block.append-right-4{ class: event.action_name } + %span.event-type.d-inline-block.gl-mr-2{ class: event.action_name } = event.action_name - %span.event-target-type.append-right-4= event.target_type.titleize.downcase - = link_to event.target_link_options, class: 'has-tooltip event-target-link append-right-4', title: event.target_title do + %span.event-target-type.gl-mr-2= event.target_type.titleize.downcase + = link_to event.target_link_options, class: 'has-tooltip event-target-link gl-mr-2', title: event.target_title do = event.target.reference_link_text - unless event.milestone? - %span.event-target-title.append-right-4{ dir: "auto" } + %span.event-target-title.gl-mr-2{ dir: "auto" } = """.html_safe + event.target.title + """.html_safe - else - %span.event-type.d-inline-block.append-right-4{ class: event.action_name } + %span.event-type.d-inline-block.gl-mr-2{ class: event.action_name } = event_action_name(event) = render "events/event_scope", event: event if event.resource_parent.present? diff --git a/app/views/events/event/_created_project.html.haml b/app/views/events/event/_created_project.html.haml index 606b0febb57..f0bb07d062c 100644 --- a/app/views/events/event/_created_project.html.haml +++ b/app/views/events/event/_created_project.html.haml @@ -4,7 +4,7 @@ .event-title.d-flex.flex-wrap = inline_event_icon(event) - %span.event-type.d-inline-block.append-right-4{ class: event.action_name } + %span.event-type.d-inline-block.gl-mr-2{ class: event.action_name } = event_action_name(event) - if event.project diff --git a/app/views/events/event/_design.html.haml b/app/views/events/event/_design.html.haml new file mode 100644 index 00000000000..c1fa1aaca50 --- /dev/null +++ b/app/views/events/event/_design.html.haml @@ -0,0 +1,11 @@ += icon_for_profile_event(event) + += event_user_info(event) + +.event-title.d-flex.flex-wrap + = inline_event_icon(event) + %span.event-type.d-inline-block.gl-mr-2{ class: event.action_name } + = event.action_name + = event_design_title_html(event) + = render "events/event_scope", event: event + diff --git a/app/views/events/event/_note.html.haml b/app/views/events/event/_note.html.haml index 21e8b1401ca..a81b999acba 100644 --- a/app/views/events/event/_note.html.haml +++ b/app/views/events/event/_note.html.haml @@ -4,12 +4,12 @@ .event-title.d-flex.flex-wrap = inline_event_icon(event) - %span.event-type.d-inline-block.append-right-4{ class: event.action_name } + %span.event-type.d-inline-block.gl-mr-2{ class: event.action_name } = event.action_name = event_note_title_html(event) - title = note_target_title(event.target) - if title.present? - %span.event-target-title.append-right-4{ dir: "auto" } + %span.event-target-title.gl-mr-2{ dir: "auto" } = """.html_safe + title + """.html_safe = render "events/event_scope", event: event diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml index b9e88f3fc47..4c1ee5fd3b7 100644 --- a/app/views/events/event/_push.html.haml +++ b/app/views/events/event/_push.html.haml @@ -7,9 +7,9 @@ .event-title.d-flex.flex-wrap = inline_event_icon(event) - many_refs = event.ref_count.to_i > 1 - %span.event-type.d-inline-block.append-right-4.pushed= many_refs ? "#{event.action_name} #{event.ref_count} #{event.ref_type.pluralize}" : "#{event.action_name} #{event.ref_type}" + %span.event-type.d-inline-block.gl-mr-2.pushed= many_refs ? "#{event.action_name} #{event.ref_count} #{event.ref_type.pluralize}" : "#{event.action_name} #{event.ref_type}" - unless many_refs - %span.append-right-4 + %span.gl-mr-2 - commits_link = project_commits_path(project, event.ref_name) - should_link = event.tag? ? project.repository.tag_exists?(event.ref_name) : project.repository.branch_exists?(event.ref_name) = link_to_if should_link, event.ref_name, commits_link, class: 'ref-name' diff --git a/app/views/events/event/_wiki.html.haml b/app/views/events/event/_wiki.html.haml index 7ca98294521..cbd5ebcae12 100644 --- a/app/views/events/event/_wiki.html.haml +++ b/app/views/events/event/_wiki.html.haml @@ -4,7 +4,7 @@ .event-title.d-flex.flex-wrap = inline_event_icon(event) - %span.event-type.d-inline-block.append-right-4{ class: event.action_name } + %span.event-type.d-inline-block.gl-mr-2{ class: event.action_name } = event.action_name = event_wiki_title_html(event) = render "events/event_scope", event: event diff --git a/app/views/explore/snippets/index.html.haml b/app/views/explore/snippets/index.html.haml index d23c8301b10..bf861e30b3a 100644 --- a/app/views/explore/snippets/index.html.haml +++ b/app/views/explore/snippets/index.html.haml @@ -1,6 +1,6 @@ - @hide_top_links = true -- page_title "Snippets" -- header_title "Snippets", snippets_path +- page_title _("Snippets") +- header_title _("Snippets"), snippets_path - if current_user = render 'dashboard/snippets_head' diff --git a/app/views/groups/_flash_messages.html.haml b/app/views/groups/_flash_messages.html.haml index ca951f28fcf..d1fea0e60c6 100644 --- a/app/views/groups/_flash_messages.html.haml +++ b/app/views/groups/_flash_messages.html.haml @@ -1,3 +1,3 @@ = content_for :flash_message do = render_if_exists 'shared/shared_runners_minutes_limit', namespace: @group, classes: [container_class, ("limit-container-width" unless fluid_layout)] - = render 'shared/namespace_storage_limit_alert', namespace: @group, classes: [container_class, ("limit-container-width" unless fluid_layout)] + = render_if_exists 'shared/namespace_storage_limit_alert', namespace: @group, classes: [container_class, ("limit-container-width" unless fluid_layout)] diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml index 9bf7ad228d9..2cf94695482 100644 --- a/app/views/groups/_home_panel.html.haml +++ b/app/views/groups/_home_panel.html.haml @@ -5,11 +5,11 @@ .group-home-panel .row.mb-3 .home-panel-title-row.col-md-12.col-lg-6.d-flex - .avatar-container.rect-avatar.s64.home-panel-avatar.append-right-default.float-none + .avatar-container.rect-avatar.s64.home-panel-avatar.gl-mr-3.float-none = group_icon(@group, class: 'avatar avatar-tile s64', width: 64, height: 64) .d-flex.flex-column.flex-wrap.align-items-baseline .d-inline-flex.align-items-baseline - %h1.home-panel-title.gl-mt-3.append-bottom-5 + %h1.home-panel-title.gl-mt-3.gl-mb-2 = @group.name %span.visibility-icon.text-secondary.gl-ml-2.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@group) } = visibility_level_icon(@group.visibility_level, fw: false, options: {class: 'icon'}) @@ -27,7 +27,7 @@ - new_project_label = _("New project") - new_subgroup_label = _("New subgroup") - if can_create_projects and can_create_subgroups - .btn-group.new-project-subgroup.droplab-dropdown.home-panel-action-button.prepend-top-default.js-new-project-subgroup.qa-new-project-or-subgroup-dropdown{ data: { project_path: new_project_path(namespace_id: @group.id), subgroup_path: new_group_path(parent_id: @group.id) } } + .btn-group.new-project-subgroup.droplab-dropdown.home-panel-action-button.gl-mt-3.js-new-project-subgroup.qa-new-project-or-subgroup-dropdown{ data: { project_path: new_project_path(namespace_id: @group.id), subgroup_path: new_group_path(parent_id: @group.id) } } %input.btn.btn-success.dropdown-primary.js-new-group-child.qa-new-in-group-button{ type: "button", value: new_project_label, data: { action: "new-project" } } %button.btn.btn-success.dropdown-toggle.js-dropdown-toggle.qa-new-project-or-subgroup-dropdown-toggle{ type: "button", data: { "dropdown-trigger" => "#new-project-or-subgroup-dropdown", 'display' => 'static' } } = sprite_icon("chevron-down", css_class: "icon dropdown-btn-icon") @@ -48,9 +48,9 @@ %strong= new_subgroup_label %span= s_("GroupsTree|Create a subgroup in this group.") - elsif can_create_projects - = link_to new_project_label, new_project_path(namespace_id: @group.id), class: "btn btn-success prepend-top-default" + = link_to new_project_label, new_project_path(namespace_id: @group.id), class: "btn btn-success gl-mt-3" - elsif can_create_subgroups - = link_to new_subgroup_label, new_group_path(parent_id: @group.id), class: "btn btn-success prepend-top-default" + = link_to new_subgroup_label, new_group_path(parent_id: @group.id), class: "btn btn-success gl-mt-3" - if @group.description.present? .group-home-desc.mt-1 diff --git a/app/views/groups/activity.html.haml b/app/views/groups/activity.html.haml index cb7dab26332..bc75fada937 100644 --- a/app/views/groups/activity.html.haml +++ b/app/views/groups/activity.html.haml @@ -1,7 +1,7 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, group_url(@group, rss_url_options), title: "#{@group.name} activity") -- page_title "Activity" +- page_title _("Activity") %section.activities = render 'activities' diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml index 2e58517fdc7..1e04b2761f6 100644 --- a/app/views/groups/edit.html.haml +++ b/app/views/groups/edit.html.haml @@ -1,4 +1,5 @@ - breadcrumb_title _("General Settings") +- page_title _("General Settings") - @content_class = "limit-container-width" unless fluid_layout - expanded = expanded_by_default? diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index 1f2fb747c7d..b9ea8316bbc 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -4,7 +4,8 @@ - pending_active = params[:search_invited].present? - total_count = @members.count + @group.shared_with_group_links.count -.project-members-page.prepend-top-default +.js-remove-member-modal +.project-members-page.gl-mt-3 %h4 = _("Group members") %hr diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml index 1cb1cc45bdb..59432e5f015 100644 --- a/app/views/groups/issues.html.haml +++ b/app/views/groups/issues.html.haml @@ -1,6 +1,6 @@ - @can_bulk_update = can?(current_user, :admin_issue, @group) && @group.feature_available?(:group_bulk_edit) -- page_title "Issues" +- page_title _("Issues") = content_for :meta_tags do = auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@group.name} issues") diff --git a/app/views/groups/labels/edit.html.haml b/app/views/groups/labels/edit.html.haml index 586b0f6ebfa..fbab4f8a250 100644 --- a/app/views/groups/labels/edit.html.haml +++ b/app/views/groups/labels/edit.html.haml @@ -1,6 +1,6 @@ - add_to_breadcrumbs _("Labels"), group_labels_path(@group) - breadcrumb_title _("Edit") -- page_title "Edit", @label.name, _("Labels") +- page_title _("Edit"), @label.name, _("Labels") %h3.page-title Edit Label diff --git a/app/views/groups/labels/index.html.haml b/app/views/groups/labels/index.html.haml index 41c1d3e84b7..3299d127222 100644 --- a/app/views/groups/labels/index.html.haml +++ b/app/views/groups/labels/index.html.haml @@ -1,4 +1,4 @@ -- page_title 'Labels' +- page_title _('Labels') - can_admin_label = can?(current_user, :admin_label, @group) - search = params[:search] - subscribed = params[:subscribed] @@ -8,7 +8,7 @@ #promote-label-modal = render 'shared/labels/nav', labels_or_filters: labels_or_filters, can_admin_label: can_admin_label - .labels-container.prepend-top-5 + .labels-container.gl-mt-2 - if @labels.any? .text-muted = _('Labels can be applied to %{features}. Group labels are available for any project within the group.') % { features: issuable_types.to_sentence } @@ -27,5 +27,5 @@ = render 'shared/empty_states/labels' %template#js-badge-item-template - %li.label-link-item.js-priority-badge.inline.prepend-left-10 + %li.label-link-item.js-priority-badge.inline.gl-ml-3 .label-badge.label-badge-blue= _('Prioritized label') diff --git a/app/views/groups/merge_requests.html.haml b/app/views/groups/merge_requests.html.haml index 0780fab513b..1828f850d35 100644 --- a/app/views/groups/merge_requests.html.haml +++ b/app/views/groups/merge_requests.html.haml @@ -1,6 +1,6 @@ - @can_bulk_update = can?(current_user, :admin_merge_request, @group) && @group.feature_available?(:group_bulk_edit) -- page_title "Merge Requests" +- page_title _("Merge Requests") - if group_merge_requests_count(state: 'all').zero? = render 'shared/empty_states/merge_requests', project_select_button: true diff --git a/app/views/groups/milestones/_form.html.haml b/app/views/groups/milestones/_form.html.haml index 7a35bc12eee..df82b264f9a 100644 --- a/app/views/groups/milestones/_form.html.haml +++ b/app/views/groups/milestones/_form.html.haml @@ -6,20 +6,20 @@ .col-form-label.col-sm-2 = f.label :title, "Title" .col-sm-10 - = f.text_field :title, maxlength: 255, class: "form-control", required: true, autofocus: true + = f.text_field :title, maxlength: 255, class: "form-control", data: { qa_selector: "milestone_title_field" }, required: true, autofocus: true .form-group.row.milestone-description .col-form-label.col-sm-2 = f.label :description, "Description" .col-sm-10 = render layout: 'shared/md_preview', locals: { url: group_preview_markdown_path } do - = render 'shared/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: 'Write milestone description...', supports_autocomplete: false + = render 'shared/zen', f: f, attr: :description, classes: 'note-textarea', qa_selector: 'milestone_description_field', placeholder: 'Write milestone description...', supports_autocomplete: false .clearfix .error-alert = render "shared/milestones/form_dates", f: f .form-actions - if @milestone.new_record? - = f.submit 'Create milestone', class: "btn-success btn" + = f.submit 'Create milestone', class: "btn-success btn", data: { qa_selector: "create_milestone_button" } = link_to "Cancel", group_milestones_path(@group), class: "btn btn-cancel" - else = f.submit 'Update milestone', class: "btn-success btn" diff --git a/app/views/groups/milestones/index.html.haml b/app/views/groups/milestones/index.html.haml index 03407adb57d..1685707d457 100644 --- a/app/views/groups/milestones/index.html.haml +++ b/app/views/groups/milestones/index.html.haml @@ -1,4 +1,4 @@ -- page_title "Milestones" +- page_title _("Milestones") .top-area = render 'shared/milestones_filter', counts: @milestone_states @@ -7,7 +7,7 @@ = 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 btn-success" + = link_to "New milestone", new_group_milestone_path(@group), class: "btn btn-success", data: { qa_selector: "new_group_milestone_link" } .milestones %ul.content-list diff --git a/app/views/groups/new.html.haml b/app/views/groups/new.html.haml index ed016206310..a231702012c 100644 --- a/app/views/groups/new.html.haml +++ b/app/views/groups/new.html.haml @@ -4,7 +4,7 @@ - header_title _("Groups"), dashboard_groups_path - active_tab = local_assigns.fetch(:active_tab, 'create') -.group-edit-container.prepend-top-default +.group-edit-container.gl-mt-3 .row .col-lg-3.group-settings-sidebar %h4.prepend-top-0 diff --git a/app/views/groups/projects.html.haml b/app/views/groups/projects.html.haml index 8b01e54474a..bf9d89da24a 100644 --- a/app/views/groups/projects.html.haml +++ b/app/views/groups/projects.html.haml @@ -1,6 +1,7 @@ -- breadcrumb_title "Projects" +- breadcrumb_title _("Projects") +- page_title _("Projects") -.card.prepend-top-default +.card.gl-mt-3 .card-header %strong= @group.name projects: diff --git a/app/views/groups/runners/_group_runners.html.haml b/app/views/groups/runners/_group_runners.html.haml index f752bc0a702..554240b7aef 100644 --- a/app/views/groups/runners/_group_runners.html.haml +++ b/app/views/groups/runners/_group_runners.html.haml @@ -18,13 +18,3 @@ locals: { registration_token: @group.runners_token, type: 'group', reset_token_url: reset_registration_token_group_settings_ci_cd_path } - -- if @group.runners.empty? - %h4.underlined-title - = _('This group does not provide any group Runners yet.') - -- else - %h4.underlined-title - = _('Available group Runners: %{runners}').html_safe % { runners: @group.runners.count } - %ul.bordered-list - = render partial: 'groups/runners/runner', collection: @group.runners, as: :runner diff --git a/app/views/groups/runners/_index.html.haml b/app/views/groups/runners/_index.html.haml index 0cf9011b471..51375f50659 100644 --- a/app/views/groups/runners/_index.html.haml +++ b/app/views/groups/runners/_index.html.haml @@ -7,3 +7,97 @@ .row .col-sm-6 = render 'groups/runners/group_runners' + +%h4.underlined-title + = _('Available Runners: %{runners}').html_safe % { runners: limited_counter_with_delimiter(@all_group_runners) } + +-# haml-lint:disable NoPlainNodes +.row + .col-sm-9 + = form_tag group_settings_ci_cd_path, id: 'runners-search', method: :get, class: 'filter-form js-filter-form' do + .filtered-search-wrapper.d-flex + .filtered-search-box + = dropdown_tag(_('Recent searches'), + options: { wrapper_class: 'filtered-search-history-dropdown-wrapper', + 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: group_settings_ci_cd_path } } + .filtered-search-box-input-container.droplab-dropdown + .scroll-container + %ul.tokens-container.list-unstyled + %li.input-token + %input.form-control.filtered-search{ search_filter_input_options('runners') } + #js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } } + = button_tag class: 'btn btn-link' do + -# Encapsulate static class name `{{icon}}` inside #{} to bypass + -# haml lint's ClassAttributeWithStaticValue + %svg + %use{ 'xlink:href': "#{'{{icon}}'}" } + %span.js-filter-hint + {{formattedKey}} + #js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu + %ul.filter-dropdown{ data: { dropdown: true, dynamic: true } } + %li.filter-dropdown-item{ data: { value: "{{ title }}" } } + = button_tag class: 'btn btn-link' do + {{ title }} + %span.btn-helptext + {{ help }} + #js-dropdown-admin-runner-status.filtered-search-input-dropdown-menu.dropdown-menu + %ul{ data: { dropdown: true } } + - Ci::Runner::AVAILABLE_STATUSES.each do |status| + %li.filter-dropdown-item{ data: { value: status } } + = button_tag class: 'btn btn-link' do + = status.titleize + + #js-dropdown-admin-runner-type.filtered-search-input-dropdown-menu.dropdown-menu + %ul{ data: { dropdown: true } } + - Ci::Runner::AVAILABLE_TYPES.each do |runner_type| + - next if runner_type == 'instance_type' + %li.filter-dropdown-item{ data: { value: runner_type } } + = button_tag class: 'btn btn-link' do + = runner_type.titleize + + #js-dropdown-runner-tag.filtered-search-input-dropdown-menu.dropdown-menu + %ul{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { value: 'none' } } + = button_tag class: 'btn btn-link' do + = _('No Tag') + %li.divider.droplab-item-ignore + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + = button_tag class: 'btn btn-link js-data-value' do + %span.dropdown-light-content + {{name}} + + = button_tag class: 'clear-search hidden' do + = icon('times') + .filter-dropdown-container + = render 'admin/runners/sort_dropdown' + + .col-sm-3.text-right-lg + = _('Runners currently online: %{active_runners_count}') % { active_runners_count: limited_counter_with_delimiter(@all_group_runners.online) } + + +- if @group_runners.any? + .runners-content.content-list + .table-holder + .gl-responsive-table-row.table-row-header{ role: 'row' } + .table-section.section-10{ role: 'rowheader' }= _('Type/State') + .table-section.section-10{ role: 'rowheader' }= _('Runner token') + .table-section.section-20{ role: 'rowheader' }= _('Description') + .table-section.section-10{ role: 'rowheader' }= _('Version') + .table-section.section-10{ role: 'rowheader' }= _('IP Address') + .table-section.section-5{ role: 'rowheader' }= _('Projects') + .table-section.section-5{ role: 'rowheader' }= _('Jobs') + .table-section.section-10{ role: 'rowheader' }= _('Tags') + .table-section.section-10{ role: 'rowheader' }= _('Last contact') + .table-section.section-10{ role: 'rowheader' } + + - @group_runners.each do |runner| + = render 'groups/runners/runner', runner: runner + = paginate @group_runners, theme: 'gitlab', :params => { :anchor => 'runners-settings' } +- else + .nothing-here-block= _('No runners found') diff --git a/app/views/groups/runners/_runner.html.haml b/app/views/groups/runners/_runner.html.haml index 3f89b04a5fc..df615eb189a 100644 --- a/app/views/groups/runners/_runner.html.haml +++ b/app/views/groups/runners/_runner.html.haml @@ -1,27 +1,86 @@ -%li.runner{ id: dom_id(runner) } - %h4 - = runner_status_icon(runner) +.gl-responsive-table-row{ id: dom_id(runner) } + .table-section.section-10.section-wrap + .table-mobile-header{ role: 'rowheader' }= _('Type') + .table-mobile-content + - if runner.group_type? + %span.badge.badge-success + = _('group') + - else + %span.badge.badge-info + = _('specific') + - if runner.locked? + %span.badge.badge-warning + = _('locked') + - unless runner.active? + %span.badge.badge-danger + = _('paused') + + .table-section.section-10 + .table-mobile-header{ role: 'rowheader' }= _('Runner token') + .table-mobile-content + = link_to runner.short_sha, group_runner_path(@group, runner) + + .table-section.section-20 + .table-mobile-header{ role: 'rowheader' }= _('Description') + .table-mobile-content.str-truncated.has-tooltip{ title: runner.description } + = runner.description - = link_to runner.short_sha, group_runner_path(@group, runner), class: 'commit-sha' + .table-section.section-10 + .table-mobile-header{ role: 'rowheader' }= _('Version') + .table-mobile-content.str-truncated.has-tooltip{ title: runner.version } + = runner.version - %small.edit-runner - = link_to edit_group_runner_path(@group, runner) do - = icon('edit') + .table-section.section-10 + .table-mobile-header{ role: 'rowheader' }= _('IP Address') + .table-mobile-content.str-truncated.has-tooltip{ title: runner.ip_address } + = runner.ip_address - .float-right - - if runner.active? - = link_to _('Pause'), pause_group_runner_path(@group, runner), method: :post, class: 'btn btn-sm btn-danger', data: { confirm: _("Are you sure?") } + .table-section.section-5 + .table-mobile-header{ role: 'rowheader' }= _('Projects') + .table-mobile-content + - if runner.group_type? + = _('n/a') - else - = link_to _('Resume'), resume_group_runner_path(@group, runner), method: :post, class: 'btn btn-success btn-sm' - = link_to _('Remove Runner'), group_runner_path(@group, runner), data: { confirm: _("Are you sure?") }, method: :delete, class: 'btn btn-danger btn-sm' - .float-right - %small.light - \##{runner.id} - - if runner.description.present? - %p.runner-description - = runner.description - - if runner.tag_list.present? - %p - - runner.tag_list.sort.each do |tag| - %span.label.label-primary + = runner.projects.count(:all) + + .table-section.section-5 + .table-mobile-header{ role: 'rowheader' }= _('Jobs') + .table-mobile-content + = limited_counter_with_delimiter(runner.builds) + + .table-section.section-10.section-wrap + .table-mobile-header{ role: 'rowheader' }= _('Tags') + .table-mobile-content + - runner.tags.map(&:name).sort.each do |tag| + %span.badge.badge-primary.str-truncated.has-tooltip{ title: tag } = tag + + .table-section.section-10 + .table-mobile-header{ role: 'rowheader' }= _('Last contact') + .table-mobile-content + - contacted_at = runner_contacted_at(runner) + - if contacted_at + = time_ago_with_tooltip contacted_at + - else + = _('Never') + + .table-section.table-button-footer.section-10 + .btn-group.table-action-buttons + .btn-group + = link_to edit_group_runner_path(@group, runner), class: 'btn btn-default has-tooltip', title: _('Edit'), ref: 'tooltip', aria: { label: _('Edit') }, data: { placement: 'top', container: 'body'} do + = icon('pencil') + .btn-group + - if runner.active? + = link_to pause_group_runner_path(@group, runner), method: :post, class: 'btn btn-default has-tooltip', title: _('Pause'), ref: 'tooltip', aria: { label: _('Pause') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do + = icon('pause') + - else + = 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 + = 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' } + = icon('remove') + - else + .btn-group + = link_to group_runner_path(@group, runner), method: :delete, class: 'btn btn-danger has-tooltip', title: _('Remove'), ref: 'tooltip', aria: { label: _('Remove') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do + = icon('remove') diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml index 742bf50fb89..0094104e07d 100644 --- a/app/views/groups/settings/_general.html.haml +++ b/app/views/groups/settings/_general.html.haml @@ -19,7 +19,7 @@ = render_if_exists 'shared/repository_size_limit_setting', form: f, type: :group - .form-group.prepend-top-default.append-bottom-20 + .form-group.gl-mt-3.append-bottom-20 .avatar-container.rect-avatar.s90 = group_icon(@group, alt: '', class: 'avatar group-avatar s90') = f.label :avatar, _('Group avatar'), class: 'label-bold d-block' diff --git a/app/views/groups/settings/_lfs.html.haml b/app/views/groups/settings/_lfs.html.haml index 7970c3c73f6..77c84862316 100644 --- a/app/views/groups/settings/_lfs.html.haml +++ b/app/views/groups/settings/_lfs.html.haml @@ -5,7 +5,7 @@ %p= s_('Check the %{docs_link_start}documentation%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe } -.form-group.append-bottom-default +.form-group.gl-mb-3 .form-check = f.check_box :lfs_enabled, checked: @group.lfs_enabled?, class: 'form-check-input', data: { qa_selector: 'lfs_checkbox' } = f.label :lfs_enabled, class: 'form-check-label' do diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml index e886c99a656..507246d573e 100644 --- a/app/views/groups/settings/_permissions.html.haml +++ b/app/views/groups/settings/_permissions.html.haml @@ -7,7 +7,7 @@ .form-group = render 'shared/allow_request_access', form: f - .form-group.append-bottom-default + .form-group.gl-mb-3 .form-check = f.check_box :share_with_group_lock, disabled: !can_change_share_with_group_lock?(@group), class: 'form-check-input' = f.label :share_with_group_lock, class: 'form-check-label' do @@ -16,20 +16,21 @@ = s_('GroupSettings|Prevent sharing a project within %{group} with other groups').html_safe % { group: group_link } %span.js-descr.text-muted= share_with_group_lock_help_text(@group) - .form-group.append-bottom-default + .form-group.gl-mb-3 .form-check = f.check_box :emails_disabled, checked: @group.emails_disabled?, disabled: !can_disable_group_emails?(@group), class: 'form-check-input' = f.label :emails_disabled, class: 'form-check-label' do %span.d-block= s_('GroupSettings|Disable email notifications') %span.text-muted= s_('GroupSettings|This setting will override user notification preferences for all members of the group, subgroups, and projects.') - .form-group.append-bottom-default + .form-group.gl-mb-3 .form-check = f.check_box :mentions_disabled, checked: @group.mentions_disabled?, class: 'form-check-input' = f.label :mentions_disabled, class: 'form-check-label' do %span.d-block= s_('GroupSettings|Disable group mentions') %span.text-muted= s_('GroupSettings|This setting will prevent group members from being notified if the group is mentioned.') + = render_if_exists 'groups/settings/delayed_project_removal', f: f, group: @group = render_if_exists 'groups/settings/ip_restriction', f: f, group: @group = render_if_exists 'groups/settings/allowed_email_domain', f: f, group: @group = render 'groups/settings/lfs', f: f @@ -40,4 +41,4 @@ = render_if_exists 'groups/personal_access_token_expiration_policy', f: f, group: @group = render_if_exists 'groups/member_lock_setting', f: f, group: @group - = f.submit _('Save changes'), class: 'btn btn-success prepend-top-default js-dirty-submit', data: { qa_selector: 'save_permissions_changes_button' } + = f.submit _('Save changes'), class: 'btn btn-success gl-mt-3 js-dirty-submit', data: { qa_selector: 'save_permissions_changes_button' } diff --git a/app/views/groups/settings/ci_cd/_form.html.haml b/app/views/groups/settings/ci_cd/_form.html.haml index 54e88d11827..139c710fac0 100644 --- a/app/views/groups/settings/ci_cd/_form.html.haml +++ b/app/views/groups/settings/ci_cd/_form.html.haml @@ -1,4 +1,4 @@ -.row.prepend-top-default +.row.gl-mt-3 .col-lg-12 = form_for group, url: group_settings_ci_cd_path(group, anchor: 'js-general-pipeline-settings') do |f| = form_errors(group) diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml index 8c9b859e127..366d7dd5afe 100644 --- a/app/views/groups/settings/ci_cd/show.html.haml +++ b/app/views/groups/settings/ci_cd/show.html.haml @@ -1,5 +1,5 @@ -- breadcrumb_title "CI / CD Settings" -- page_title "CI / CD" +- breadcrumb_title _("CI / CD Settings") +- page_title _("CI / CD") - expanded = expanded_by_default? - general_expanded = @group.errors.empty? ? expanded : true diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 7e5bf6ddde1..6ad864121d7 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -1,4 +1,5 @@ - breadcrumb_title _("Details") +- page_title _("Groups") - @content_class = "limit-container-width" unless fluid_layout = content_for :meta_tags do @@ -18,8 +19,8 @@ .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 .scrolling-tabs-container.inner-page-scroll-tabs - .fade-left= icon('angle-left') - .fade-right= icon('angle-right') + .fade-left= sprite_icon('chevron-lg-left', size: 12) + .fade-right= sprite_icon('chevron-lg-right', size: 12) %ul.nav-links.scrolling-tabs.mobile-separator.nav.nav-tabs %li.js-subgroups_and_projects-tab = link_to group_path, data: { target: 'div#subgroups_and_projects', action: 'subgroups_and_projects', toggle: 'tab'} do diff --git a/app/views/help/_shortcuts.html.haml b/app/views/help/_shortcuts.html.haml index bd5424c30c6..80df8581a9b 100644 --- a/app/views/help/_shortcuts.html.haml +++ b/app/views/help/_shortcuts.html.haml @@ -2,10 +2,6 @@ .modal-dialog.modal-lg.modal-1040 .modal-content .modal-header - %h4.modal-title - = _('Keyboard Shortcuts') - %small - = link_to _('(Show all)'), '#', class: 'js-more-help-button' .js-toggle-shortcuts %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') } %span{ "aria-hidden": true } × @@ -313,6 +309,10 @@ %td.shortcut %kbd p %td= _('Previous unresolved discussion') + %tr + %td.shortcut + %kbd b + %td= _('Copy source branch name') %tbody %tr %th diff --git a/app/views/help/index.html.haml b/app/views/help/index.html.haml index ed904c48ddb..03f8539293b 100644 --- a/app/views/help/index.html.haml +++ b/app/views/help/index.html.haml @@ -1,6 +1,6 @@ %div - if Gitlab::CurrentSettings.help_page_text.present? - .prepend-top-default.md + .gl-mt-3.md = markdown_field(Gitlab::CurrentSettings.current_application_settings, :help_page_text) %hr @@ -28,7 +28,7 @@ %p= link_to 'Check the current instance configuration ', help_instance_configuration_url %hr -.row.prepend-top-default +.row.gl-mt-3 .col-md-8 .documentation-index.md = markdown(@help_index) diff --git a/app/views/help/instance_configuration.html.haml b/app/views/help/instance_configuration.html.haml index 99576d45f76..260566b1441 100644 --- a/app/views/help/instance_configuration.html.haml +++ b/app/views/help/instance_configuration.html.haml @@ -1,4 +1,4 @@ -- page_title 'Instance Configuration' +- page_title _('Instance Configuration') .documentation.md %h1 Instance Configuration diff --git a/app/views/help/show.html.haml b/app/views/help/show.html.haml index dace8a77736..c41f6ea3ed4 100644 --- a/app/views/help/show.html.haml +++ b/app/views/help/show.html.haml @@ -1,5 +1,5 @@ - page_title @path.split("/").reverse.map(&:humanize) - @content_class = "limit-container-width" unless fluid_layout -.documentation.md.prepend-top-default +.documentation.md.gl-mt-3 = markdown @markdown diff --git a/app/views/help/ui.html.haml b/app/views/help/ui.html.haml index d71650ae50c..5c216ee1ec0 100644 --- a/app/views/help/ui.html.haml +++ b/app/views/help/ui.html.haml @@ -1,4 +1,4 @@ -- page_title "UI Development Kit", "Help" +- page_title _("UI Development Kit"), _("Help") - lorem = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed fermentum nisi sapien, non consequat lectus aliquam ultrices. Suspendisse sodales est euismod nunc condimentum, a consectetur diam ornare." - link_classes = "flex-grow-1 mx-1 " diff --git a/app/views/ide/_show.html.haml b/app/views/ide/_show.html.haml index b871f0363f3..d0384fd50bc 100644 --- a/app/views/ide/_show.html.haml +++ b/app/views/ide/_show.html.haml @@ -1,5 +1,5 @@ - @body_class = 'ide-layout' -- page_title 'IDE' +- page_title _('IDE') - content_for :page_specific_javascripts do = stylesheet_link_tag 'page_bundles/ide' diff --git a/app/views/import/bitbucket/status.html.haml b/app/views/import/bitbucket/status.html.haml index d405acef75c..9b54cbe577a 100644 --- a/app/views/import/bitbucket/status.html.haml +++ b/app/views/import/bitbucket/status.html.haml @@ -5,93 +5,4 @@ %i.fa.fa-bitbucket = _('Import projects from Bitbucket') -- if Feature.enabled?(:new_import_ui) - = render 'import/githubish_status', provider: 'bitbucket' -- else - - if @repos.any? - %p.light - = _('Select projects you want to import.') - %p - - if @incompatible_repos.any? - = button_tag class: 'btn btn-import btn-success js-import-all' do - = _('Import all compatible projects') - = icon('spinner spin', class: 'loading-icon') - - else - = button_tag class: 'btn btn-import btn-success js-import-all' do - = _('Import all projects') - = icon('spinner spin', class: 'loading-icon') - - .position-relative.ms-no-clear.d-flex.flex-fill.float-right.append-bottom-10 - = form_tag status_import_bitbucket_path, method: 'get' do - = text_field_tag :filter, @filter, class: 'form-control pr-5', placeholder: _('Filter projects'), size: 40, autofocus: true, 'aria-label': _('Search') - .position-absolute.position-top-0.d-flex.align-items-center.text-muted.position-right-0.h-100 - .border-left - %button{ class: 'btn btn-transparent btn-secondary', 'aria-label': _('Search Button'), type: 'submit' } - %i{ class: 'fa fa-search', 'aria-hidden': true } - - .table-responsive - %table.table.import-jobs - %colgroup.import-jobs-from-col - %colgroup.import-jobs-to-col - %colgroup.import-jobs-status-col - %thead - %tr - %th= _('From Bitbucket') - %th= _('To GitLab') - %th= _('Status') - %tbody - - @already_added_projects.each do |project| - %tr{ id: "project_#{project.id}", class: "#{project_status_css_class(project.import_status)}" } - %td - = link_to project.import_source, "https://bitbucket.org/#{project.import_source}", target: '_blank', rel: 'noopener noreferrer' - %td - = link_to project.full_path, [project.namespace.becomes(Namespace), project] - %td.job-status - - case project.import_status - - when 'finished' - %span - %i.fa.fa-check - = _('done') - - when 'started' - %i.fa.fa-spinner.fa-spin - = _('started') - - else - = project.human_import_status_name - - - @repos.each do |repo| - %tr{ id: "repo_#{repo.owner}___#{repo.slug}" } - %td - = link_to repo.full_name, "https://bitbucket.org/#{repo.full_name}", target: '_blank', rel: 'noopener noreferrer' - %td.import-target - %fieldset.row - .input-group - .project-path.input-group-prepend - - if current_user.can_select_namespace? - - selected = params[:namespace_id] || :current_user - - opts = current_user.can_create_group? ? { extra_group: Group.new(name: repo.owner, path: repo.owner) } : {} - = select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: 'select2 js-select-namespace', tabindex: 1 } - - else - = text_field_tag :path, current_user.namespace_path, class: "input-group-text input-large form-control", tabindex: 1, disabled: true - %span.input-group-prepend - .input-group-text / - = text_field_tag :path, sanitize_project_name(repo.slug), class: "input-mini form-control", tabindex: 2, autofocus: true, required: true - %td.import-actions.job-status - = button_tag class: 'btn btn-import js-add-to-import' do - = _('Import') - = icon('spinner spin', class: 'loading-icon') - - @incompatible_repos.each do |repo| - %tr{ id: "repo_#{repo.owner}___#{repo.slug}" } - %td - = link_to repo.full_name, "https://bitbucket.org/#{repo.full_name}", target: '_blank', rel: 'noopener noreferrer' - %td.import-target - %td.import-actions-job-status - = label_tag _('Incompatible Project'), nil, class: 'label badge-danger' - - - if @incompatible_repos.any? - %p - = _("One or more of your Bitbucket projects cannot be imported into GitLab directly because they use Subversion or Mercurial for version control, rather than Git.") - - link_to_git = link_to(_('Git'), 'https://www.atlassian.com/git/tutorials/migrating-overview') - - link_to_import_flow = link_to(_('import flow'), status_import_bitbucket_path) - = _("Please convert them to %{link_to_git}, and go through the %{link_to_import_flow} again.").html_safe % { link_to_git: link_to_git, link_to_import_flow: link_to_import_flow } - - .js-importer-status{ data: { jobs_import_path: "#{jobs_import_bitbucket_path}", import_path: "#{import_bitbucket_path}" } } += render 'import/githubish_status', provider: 'bitbucket' diff --git a/app/views/import/bitbucket_server/new.html.haml b/app/views/import/bitbucket_server/new.html.haml index 2eac8d0c5a1..735535ffc36 100644 --- a/app/views/import/bitbucket_server/new.html.haml +++ b/app/views/import/bitbucket_server/new.html.haml @@ -1,7 +1,7 @@ - title = _('Bitbucket Server Import') - page_title title - breadcrumb_title title -- header_title "Projects", root_path +- header_title _("Projects"), root_path %h3.page-title = icon 'bitbucket-square', text: _('Import repositories from Bitbucket Server') @@ -17,7 +17,7 @@ .form-group.row = label_tag :bitbucket_server_url, 'Username', class: 'col-form-label col-md-2' .col-md-4 - = text_field_tag :bitbucket_username, '', class: 'form-control gl-mr-3', placeholder: _('username'), size: 40 + = text_field_tag :bitbucket_server_username, '', class: 'form-control gl-mr-3', placeholder: _('username'), size: 40 .form-group.row = label_tag :personal_access_token, 'Password/Personal Access Token', class: 'col-form-label col-md-2' .col-md-4 diff --git a/app/views/import/bitbucket_server/status.html.haml b/app/views/import/bitbucket_server/status.html.haml index 3e16f449831..a24a1c1fb05 100644 --- a/app/views/import/bitbucket_server/status.html.haml +++ b/app/views/import/bitbucket_server/status.html.haml @@ -1,98 +1,8 @@ -- page_title 'Bitbucket Server import' -- header_title 'Projects', root_path +- page_title _('Bitbucket Server import') +- header_title _('Projects'), root_path %h3.page-title %i.fa.fa-bitbucket-square = _('Import projects from Bitbucket Server') -- if Feature.enabled?(:new_import_ui) - = render 'import/githubish_status', provider: 'bitbucket_server', extra_data: { reconfigure_path: configure_import_bitbucket_server_path } -- else - - if @repos.any? - %p.light - = _('Select projects you want to import.') - .btn-group - - if @incompatible_repos.any? - = button_tag class: 'btn btn-import btn-success js-import-all' do - = _('Import all compatible projects') - = icon('spinner spin', class: 'loading-icon') - - else - = button_tag class: 'btn btn-import btn-success js-import-all' do - = _('Import all projects') - = icon('spinner spin', class: 'loading-icon') - - .btn-group - = link_to('Reconfigure', configure_import_bitbucket_server_path, class: 'btn btn-primary', method: :post) - - .input-btn-group.float-right - = form_tag status_import_bitbucket_server_path, :method => 'get' do - = text_field_tag :filter, sanitize(params[:filter]), class: 'form-control append-bottom-10', placeholder: _('Filter your projects by name'), size: 40, autoFocus: true - - .table-responsive.prepend-top-10 - %table.table.import-jobs - %colgroup.import-jobs-from-col - %colgroup.import-jobs-to-col - %colgroup.import-jobs-status-col - %thead - %tr - %th= _('From Bitbucket Server') - %th= _('To GitLab') - %th= _('Status') - %tbody - - @already_added_projects.each do |project| - %tr{ id: "project_#{project.id}", class: "#{project_status_css_class(project.import_status)}" } - %td - = link_to project.import_source, project.import_source, target: '_blank', rel: 'noopener noreferrer' - %td - = link_to project.full_path, [project.namespace.becomes(Namespace), project] - %td.job-status - - case project.import_status - - when 'finished' - = icon('check', text: 'Done') - - when 'started' - = icon('spin', text: 'started') - - else - = project.human_import_status_name - - - @repos.each do |repo| - %tr{ id: "repo_#{repo.project_key}___#{repo.slug}", data: { project: repo.project_key, repository: repo.slug } } - %td - = sanitize(link_to(repo.browse_url, repo.browse_url, target: '_blank', rel: 'noopener noreferrer'), attributes: %w(href target rel)) - %td.import-target - %fieldset.row - .input-group - .project-path.input-group-prepend - - if current_user.can_select_namespace? - - selected = params[:namespace_id] || :extra_group - - opts = current_user.can_create_group? ? { extra_group: Group.new(name: sanitize_project_name(repo.project_key), path: sanitize_project_name(repo.project_key)) } : {} - = select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: 'select2 js-select-namespace', tabindex: 1 } - - else - = text_field_tag :path, current_user.namespace_path, class: "input-group-text input-large form-control", tabindex: 1, disabled: true - %span.input-group-prepend - .input-group-text / - = text_field_tag :path, sanitize_project_name(repo.slug), class: "input-mini form-control", tabindex: 2, required: true - %td.import-actions.job-status - = button_tag class: 'btn btn-import js-add-to-import' do - Import - = icon('spinner spin', class: 'loading-icon') - - @incompatible_repos.each do |repo| - %tr{ id: "repo_#{repo.project_key}___#{repo.slug}" } - %td - = sanitize(link_to(repo.browse_url, repo.browse_url, target: '_blank', rel: 'noopener noreferrer'), attributes: %w(href target rel)) - %td.import-target - %td.import-actions-job-status - = label_tag 'Incompatible Project', nil, class: 'label badge-danger' - - - if @incompatible_repos.any? - %p - One or more of your Bitbucket Server projects cannot be imported into GitLab - directly because they use Subversion or Mercurial for version control, - rather than Git. Please convert - = link_to 'them to Git,', 'https://www.atlassian.com/git/tutorials/migrating-overview' - and go through the - = link_to 'import flow', status_import_bitbucket_server_path - again. - - = paginate_without_count(@collection) - - .js-importer-status{ data: { jobs_import_path: "#{jobs_import_bitbucket_server_path}", import_path: "#{import_bitbucket_server_path}" } } += render 'import/githubish_status', provider: 'bitbucket_server', extra_data: { reconfigure_path: configure_import_bitbucket_server_path } diff --git a/app/views/import/fogbugz/status.html.haml b/app/views/import/fogbugz/status.html.haml index 75529487aa4..f201c0e83fe 100644 --- a/app/views/import/fogbugz/status.html.haml +++ b/app/views/import/fogbugz/status.html.haml @@ -4,63 +4,8 @@ %i.fa.fa-bug = _('Import projects from FogBugz') -- if Feature.enabled?(:new_import_ui) - %p.light - - link_to_customize = link_to('customize', new_user_map_import_fogbugz_path) - = _('Optionally, you can %{link_to_customize} how FogBugz email addresses and usernames are imported into GitLab.').html_safe % { link_to_customize: link_to_customize } - %hr - = render 'import/githubish_status', provider: 'fogbugz', filterable: false -- else - - if @repos.any? - %p.light - = _('Select projects you want to import.') - %p.light - - link_to_customize = link_to('customize', new_user_map_import_fogbugz_path) - = _('Optionally, you can %{link_to_customize} how FogBugz email addresses and usernames are imported into GitLab.').html_safe % { link_to_customize: link_to_customize } - %hr - %p - = button_tag class: 'btn btn-import btn-success js-import-all' do - = _('Import all projects') - = icon("spinner spin", class: "loading-icon") - - .table-responsive - %table.table.import-jobs - %colgroup.import-jobs-from-col - %colgroup.import-jobs-to-col - %colgroup.import-jobs-status-col - %thead - %tr - %th= _("From FogBugz") - %th= _("To GitLab") - %th= _("Status") - %tbody - - @already_added_projects.each do |project| - %tr{ id: "project_#{project.id}", class: "#{project_status_css_class(project.import_status)}" } - %td - = project.import_source - %td - = link_to project.full_path, [project.namespace.becomes(Namespace), project] - %td.job-status - - case project.import_status - - when 'finished' - %span - %i.fa.fa-check - = _("done") - - when 'started' - %i.fa.fa-spinner.fa-spin - = _("started") - - else - = project.human_import_status_name - - - @repos.each do |repo| - %tr{ id: "repo_#{repo.id}" } - %td - = repo.name - %td.import-target - #{current_user.username}/#{repo.name} - %td.import-actions.job-status - = button_tag class: "btn btn-import js-add-to-import" do - = _("Import") - = icon("spinner spin", class: "loading-icon") - - .js-importer-status{ data: { jobs_import_path: "#{jobs_import_fogbugz_path}", import_path: "#{import_fogbugz_path}" } } +%p.light + - link_to_customize = link_to('customize', new_user_map_import_fogbugz_path) + = _('Optionally, you can %{link_to_customize} how FogBugz email addresses and usernames are imported into GitLab.').html_safe % { link_to_customize: link_to_customize } +%hr += render 'import/githubish_status', provider: 'fogbugz', filterable: false diff --git a/app/views/import/gitlab/status.html.haml b/app/views/import/gitlab/status.html.haml index a12b69ae5f9..5513849be3d 100644 --- a/app/views/import/gitlab/status.html.haml +++ b/app/views/import/gitlab/status.html.haml @@ -1,58 +1,7 @@ - page_title _("GitLab.com import") - header_title _("Projects"), root_path %h3.page-title - %i.fa.fa-heart + = sprite_icon('heart', size: 16, css_class: 'gl-vertical-align-middle') = _('Import projects from GitLab.com') -- if Feature.enabled?(:new_import_ui) - = render 'import/githubish_status', provider: 'gitlab', filterable: false -- else - %p.light - = _('Select projects you want to import.') - %hr - %p - = button_tag class: "btn btn-import btn-success js-import-all" do - = _('Import all projects') - = icon("spinner spin", class: "loading-icon") - - .table-responsive - %table.table.import-jobs - %colgroup.import-jobs-from-col - %colgroup.import-jobs-to-col - %colgroup.import-jobs-status-col - %thead - %tr - %th= _('From GitLab.com') - %th= _('To this GitLab instance') - %th= _('Status') - %tbody - - @already_added_projects.each do |project| - %tr{ id: "project_#{project.id}", class: "#{project_status_css_class(project.import_status)}" } - %td - = link_to project.import_source, "https://gitlab.com/#{project.import_source}", target: "_blank" - %td - = link_to project.full_path, [project.namespace.becomes(Namespace), project] - %td.job-status - - case project.import_status - - when 'finished' - %span - %i.fa.fa-check - = _('done') - - when 'started' - %i.fa.fa-spinner.fa-spin - = _('started') - - else - = project.human_import_status_name - - - @repos.each do |repo| - %tr{ id: "repo_#{repo["id"]}" } - %td - = link_to repo["path_with_namespace"], "https://gitlab.com/#{repo["path_with_namespace"]}", target: "_blank", rel: 'noopener noreferrer' - %td.import-target - = import_project_target(repo['namespace']['path'], repo['name']) - %td.import-actions.job-status - = button_tag class: "btn btn-import js-add-to-import" do - = _('Import') - = icon("spinner spin", class: "loading-icon") - - .js-importer-status{ data: { jobs_import_path: "#{jobs_import_gitlab_path}", import_path: "#{import_gitlab_path}" } } += render 'import/githubish_status', provider: 'gitlab', filterable: false diff --git a/app/views/import/gitlab_projects/new.html.haml b/app/views/import/gitlab_projects/new.html.haml index feebbccf46a..b667d2aa0d7 100644 --- a/app/views/import/gitlab_projects/new.html.haml +++ b/app/views/import/gitlab_projects/new.html.haml @@ -1,8 +1,9 @@ - page_title _("GitLab Import") - header_title _("Projects"), root_path -%h3.page-title - = icon('gitlab') +%h3.page-title.d-flex + .gl-display-flex.gl-align-items-center.gl-justify-content-center + = sprite_icon('tanuki', size: 16, css_class: 'gl-mr-2') = _('Import an exported GitLab project') %hr diff --git a/app/views/import/manifest/new.html.haml b/app/views/import/manifest/new.html.haml index df00c4d2179..852f269f2ed 100644 --- a/app/views/import/manifest/new.html.haml +++ b/app/views/import/manifest/new.html.haml @@ -1,5 +1,5 @@ -- page_title "Manifest file import" -- header_title "Projects", root_path +- page_title _("Manifest file import") +- header_title _("Projects"), root_path %h3.page-title = _('Manifest file import') diff --git a/app/views/import/manifest/status.html.haml b/app/views/import/manifest/status.html.haml index 3d4abc32b88..e85162ad1b4 100644 --- a/app/views/import/manifest/status.html.haml +++ b/app/views/import/manifest/status.html.haml @@ -1,5 +1,5 @@ -- page_title "Manifest import" -- header_title "Projects", root_path +- page_title _("Manifest import") +- header_title _("Projects"), root_path - provider = 'manifest' %h3.page-title diff --git a/app/views/instance_statistics/cohorts/index.html.haml b/app/views/instance_statistics/cohorts/index.html.haml index 5333f8b7a1f..a038246bd53 100644 --- a/app/views/instance_statistics/cohorts/index.html.haml +++ b/app/views/instance_statistics/cohorts/index.html.haml @@ -1,11 +1,12 @@ - breadcrumb_title _("Cohorts") +- page_title _("Cohorts") - if @cohorts = render 'cohorts_table' - else .bs-callout.bs-callout-warning.clearfix %p - - usage_ping_path = help_page_path('user/admin_area/settings/usage_statistics', anchor: 'usage-ping') + - usage_ping_path = help_page_path('development/telemetry/usage_ping') - usage_ping_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: usage_ping_path } = s_('User Cohorts are only shown when the %{usage_ping_link_start}usage ping%{usage_ping_link_end} is enabled.').html_safe % { usage_ping_link_start: usage_ping_link_start, usage_ping_link_end: '</a>'.html_safe } - if current_user.admin? diff --git a/app/views/instance_statistics/dev_ops_score/_callout.html.haml b/app/views/instance_statistics/dev_ops_score/_callout.html.haml index 64eb72c0d8d..31ae7721f5f 100644 --- a/app/views/instance_statistics/dev_ops_score/_callout.html.haml +++ b/app/views/instance_statistics/dev_ops_score/_callout.html.haml @@ -1,4 +1,4 @@ -.prepend-top-default +.gl-mt-3 .user-callout{ data: { uid: 'dev_ops_score_intro_callout_dismissed' } } .bordered-box.landing.content-block %button.btn.btn-default.close.js-close-callout{ type: 'button', diff --git a/app/views/instance_statistics/dev_ops_score/_disabled.html.haml b/app/views/instance_statistics/dev_ops_score/_disabled.html.haml index da27ea17b61..bd808218f75 100644 --- a/app/views/instance_statistics/dev_ops_score/_disabled.html.haml +++ b/app/views/instance_statistics/dev_ops_score/_disabled.html.haml @@ -4,7 +4,7 @@ %h4= _('Usage ping is not enabled') - if !current_user.admin? %p - - usage_ping_path = help_page_path('user/admin_area/settings/usage_statistics', anchor: 'usage-ping') + - usage_ping_path = help_page_path('development/telemetry/usage_ping') - usage_ping_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: usage_ping_path } = s_('In order to enable instance-level analytics, please ask an admin to enable %{usage_ping_link_start}usage ping%{usage_ping_link_end}.').html_safe % { usage_ping_link_start: usage_ping_link_start, usage_ping_link_end: '</a>'.html_safe } - if current_user.admin? diff --git a/app/views/instance_statistics/dev_ops_score/index.html.haml b/app/views/instance_statistics/dev_ops_score/index.html.haml index 44c6e9664db..215624d27ce 100644 --- a/app/views/instance_statistics/dev_ops_score/index.html.haml +++ b/app/views/instance_statistics/dev_ops_score/index.html.haml @@ -5,7 +5,7 @@ - if usage_ping_enabled && show_callout?('dev_ops_score_intro_callout_dismissed') = render 'callout' - .prepend-top-default + .gl-mt-3 - if !usage_ping_enabled = render 'disabled' - elsif @metric.blank? diff --git a/app/views/invites/show.html.haml b/app/views/invites/show.html.haml index 30ab5781014..2bcd64d0690 100644 --- a/app/views/invites/show.html.haml +++ b/app/views/invites/show.html.haml @@ -20,21 +20,19 @@ = link_to group.name, group_url(group) as #{@member.human_access}. -- is_member = @member.source.users.include?(current_user) - -- if is_member +- if member? %p - member_source = @member.source.is_a?(Group) ? _("group") : _("project") = _("However, you are already a member of this %{member_source}. Sign in using a different account to accept the invitation.") % { member_source: member_source } -- if @member.invite_email != current_user.email +- if !current_user_matches_invite? %p - mail_to_invite_email = mail_to(@member.invite_email) - mail_to_current_user = mail_to(current_user.email) - link_to_current_user = link_to(current_user.to_reference, user_url(current_user)) = _("Note that this invitation was sent to %{mail_to_invite_email}, but you are signed in as %{link_to_current_user} with email %{mail_to_current_user}.").html_safe % { mail_to_invite_email: mail_to_invite_email, mail_to_current_user: mail_to_current_user, link_to_current_user: link_to_current_user } -- unless is_member +- unless member? .actions = link_to _("Accept invitation"), accept_invite_url(@token), method: :post, class: "btn btn-success" - = link_to _("Decline"), decline_invite_url(@token), method: :post, class: "btn btn-danger prepend-left-10" + = link_to _("Decline"), decline_invite_url(@token), method: :post, class: "btn btn-danger gl-ml-3" diff --git a/app/views/kaminari/gitlab/_paginator.html.haml b/app/views/kaminari/gitlab/_paginator.html.haml index 1b2edc0ad22..91998147966 100644 --- a/app/views/kaminari/gitlab/_paginator.html.haml +++ b/app/views/kaminari/gitlab/_paginator.html.haml @@ -6,7 +6,7 @@ -# remote: data-remote -# paginator: the paginator that renders the pagination tags inside = paginator.render do - .gl-pagination.prepend-top-default + .gl-pagination.gl-mt-3 %ul.pagination.justify-content-center = prev_page_tag - each_page do |page| diff --git a/app/views/kaminari/gitlab/_without_count.html.haml b/app/views/kaminari/gitlab/_without_count.html.haml index d13f6ca5fa8..dc9dcbeed1d 100644 --- a/app/views/kaminari/gitlab/_without_count.html.haml +++ b/app/views/kaminari/gitlab/_without_count.html.haml @@ -1,4 +1,4 @@ -.gl-pagination.prepend-top-default +.gl-pagination.gl-mt-3 %ul.pagination.justify-content-center - if previous_path %li.page-item.prev diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index 886d4109ff5..d1311f17b72 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -25,6 +25,8 @@ %meta{ 'http-equiv' => 'X-UA-Compatible', content: 'IE=edge' } + = render 'layouts/startup_js' + -# Open Graph - http://ogp.me/ %meta{ property: 'og:type', content: "object" } %meta{ property: 'og:site_name', content: site_name } @@ -51,7 +53,6 @@ = stylesheet_link_tag "application_dark", media: "all" - else = stylesheet_link_tag "application", media: "all" - = stylesheet_link_tag "print", media: "print" = stylesheet_link_tag "disable_animations", media: "all" if Rails.env.test? || Gitlab.config.gitlab['disable_animations'] = stylesheet_link_tag 'performance_bar' if performance_bar_enabled? diff --git a/app/views/layouts/_img_loader.html.haml b/app/views/layouts/_img_loader.html.haml new file mode 100644 index 00000000000..cddcd6e0af6 --- /dev/null +++ b/app/views/layouts/_img_loader.html.haml @@ -0,0 +1,17 @@ += javascript_tag nonce: true do + :plain + if ('loading' in HTMLImageElement.prototype) { + document.querySelectorAll('img.lazy').forEach(img => { + img.loading = 'lazy'; + let imgUrl = img.dataset.src; + // Only adding width + height for avatars for now + if (imgUrl.indexOf('/avatar/') > -1 && imgUrl.indexOf('?') === -1) { + const targetWidth = img.getAttribute('width') || img.width; + imgUrl += `?width=${targetWidth}`; + } + img.src = imgUrl; + img.removeAttribute('data-src'); + img.classList.remove('lazy'); + img.classList.add('js-lazy-loaded', 'qa-js-lazy-loaded'); + }); + } diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index d1cf83b2a9f..72b88fa8f7f 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -7,6 +7,7 @@ = render 'shared/outdated_browser' = render_if_exists 'layouts/header/users_over_license_banner' = render_if_exists "layouts/header/licensed_user_count_threshold" + = render_if_exists "layouts/header/token_expiry_notification" = render "layouts/broadcast" = render "layouts/header/read_only_banner" = render "layouts/nav/classification_level_banner" diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml index 97d00bce11b..81fe0798bd1 100644 --- a/app/views/layouts/_search.html.haml +++ b/app/views/layouts/_search.html.haml @@ -2,7 +2,7 @@ = form_tag search_path, method: :get, class: 'form-inline' do |f| .search-input-container .search-input-wrap - .dropdown + .dropdown{ data: { url: search_autocomplete_path } } = search_field_tag 'search', nil, placeholder: _('Search or jump to…'), class: 'search-input dropdown-menu-toggle no-outline js-search-dashboard-options', spellcheck: false, @@ -37,3 +37,6 @@ -# workaround for non-JS feature specs, see spec/support/helpers/search_helpers.rb - if ENV['RAILS_ENV'] == 'test' %noscript= button_tag 'Search' + .search-autocomplete-opts.hide{ :'data-autocomplete-path' => search_autocomplete_path, + :'data-autocomplete-project-id' => search_context.project.try(:id), + :'data-autocomplete-project-ref' => search_context.ref } diff --git a/app/views/layouts/_startup_js.html.haml b/app/views/layouts/_startup_js.html.haml new file mode 100644 index 00000000000..3eb68df07c6 --- /dev/null +++ b/app/views/layouts/_startup_js.html.haml @@ -0,0 +1,13 @@ +- return unless page_startup_api_calls.present? + += javascript_tag nonce: true do + :plain + var gl = window.gl || {}; + gl.startup_calls = #{page_startup_api_calls.to_json}; + if (gl.startup_calls && window.fetch) { + Object.keys(gl.startup_calls).forEach(apiCall => { + gl.startup_calls[apiCall] = { + fetchCall: fetch(apiCall) + }; + }); + } diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index eb58115451d..58408ec822c 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -13,6 +13,6 @@ = render 'layouts/page', sidebar: sidebar, nav: nav = footer_message - = render_if_exists "shared/onboarding_guide" + = render 'layouts/img_loader' = yield :scripts_body diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml index d568086f4a4..4c659241f99 100644 --- a/app/views/layouts/header/_current_user_dropdown.html.haml +++ b/app/views/layouts/header/_current_user_dropdown.html.haml @@ -35,7 +35,6 @@ = link_to _("Help"), help_path %li.d-md-none = link_to _("Support"), support_url - = render_if_exists "shared/learn_gitlab_menu_item" %li.d-md-none = link_to _("Submit feedback"), "https://about.gitlab.com/submit-feedback" - if current_user_menu?(:help) || current_user_menu?(:settings) || current_user_menu?(:profile) diff --git a/app/views/layouts/header/_help_dropdown.html.haml b/app/views/layouts/header/_help_dropdown.html.haml index 2b3f5d266b0..ad4e0f1f4b2 100644 --- a/app/views/layouts/header/_help_dropdown.html.haml +++ b/app/views/layouts/header/_help_dropdown.html.haml @@ -9,7 +9,6 @@ %button.js-shortcuts-modal-trigger{ type: "button" } = _("Keyboard shortcuts") %span.text-secondary.float-right{ "aria-hidden": true }= '?'.html_safe - = render_if_exists "shared/learn_gitlab_menu_item" %li.divider %li = link_to _("Submit feedback"), "https://about.gitlab.com/submit-feedback" diff --git a/app/views/layouts/header/_new_dropdown.haml b/app/views/layouts/header/_new_dropdown.haml index 3cbfb24a868..4bfac76ec5b 100644 --- a/app/views/layouts/header/_new_dropdown.haml +++ b/app/views/layouts/header/_new_dropdown.haml @@ -15,6 +15,7 @@ %li= link_to _('New project'), new_project_path(namespace_id: @group.id) - if create_group_subgroup %li= link_to _('New subgroup'), new_group_path(parent_id: @group.id) + = render_if_exists 'layouts/header/create_epic_new_dropdown_item' %li.divider %li.dropdown-bold-header GitLab diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml index 0b23a06f5a9..e6cfd7d56bb 100644 --- a/app/views/layouts/nav/_dashboard.html.haml +++ b/app/views/layouts/nav/_dashboard.html.haml @@ -26,16 +26,16 @@ %ul - if dashboard_nav_link?(:groups) %li.d-md-none - = link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups' do + = link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups', data: { qa_selector: 'groups_link' } do = _('Groups') - if dashboard_nav_link?(:activity) = nav_link(path: 'dashboard#activity') do - = link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity' do + = link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity', data: { qa_selector: 'activity_link' } do = _('Activity') - if dashboard_nav_link?(:milestones) = nav_link(controller: 'dashboard/milestones') do - = link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones' do + = link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones', data: { qa_selector: 'milestones_link' } do = _('Milestones') - if dashboard_nav_link?(:snippets) diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml index 28e52dc85db..e72535b8824 100644 --- a/app/views/layouts/nav/sidebar/_admin.html.haml +++ b/app/views/layouts/nav/sidebar/_admin.html.haml @@ -8,7 +8,7 @@ = _('Admin Area') %ul.sidebar-top-level-items{ data: { qa_selector: 'admin_sidebar_overview_submenu_content' } } = nav_link(controller: %w(dashboard admin admin/projects users groups jobs runners gitaly_servers), html_options: {class: 'home'}) do - = link_to admin_root_path, class: 'shortcuts-tree' do + = link_to admin_root_path do .nav-icon-container = sprite_icon('overview') %span.nav-item-name @@ -216,7 +216,7 @@ %strong.fly-out-top-item-name = _('Appearance') - = nav_link(controller: :application_settings) do + = nav_link(controller: [:application_settings, :integrations]) do = link_to general_admin_application_settings_path do .nav-icon-container = sprite_icon('settings') @@ -224,7 +224,7 @@ = _('Settings') %ul.sidebar-sub-level-items.qa-admin-sidebar-settings-submenu - = nav_link(controller: :application_settings, html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: [:application_settings, :integrations], html_options: { class: "fly-out-top-item" } ) do = link_to general_admin_application_settings_path do %strong.fly-out-top-item-name = _('Settings') @@ -233,7 +233,7 @@ = link_to general_admin_application_settings_path, title: _('General'), class: 'qa-admin-settings-general-item' do %span = _('General') - = nav_link(path: 'application_settings#integrations') do + = 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 %span = _('Integrations') diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml index cd9765289a4..909d72edb31 100644 --- a/app/views/layouts/nav/sidebar/_group.html.haml +++ b/app/views/layouts/nav/sidebar/_group.html.haml @@ -81,7 +81,7 @@ - if group_sidebar_link?(:milestones) = nav_link(path: 'milestones#index') do - = link_to group_milestones_path(@group), title: _('Milestones') do + = link_to group_milestones_path(@group), title: _('Milestones'), data: { qa_selector: 'group_milestones_link' } do %span = _('Milestones') @@ -123,6 +123,9 @@ = render 'layouts/nav/sidebar/analytics_links', links: group_analytics_navbar_links(@group, current_user) + - if group_sidebar_link?(:wiki) + = render 'layouts/nav/sidebar/wiki_link', wiki_url: @group.wiki.web_url + - if group_sidebar_link?(:group_members) = nav_link(path: 'group_members#index') do = link_to group_group_members_path(@group) do diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index 16902ebe1d4..d59c75de6d2 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -37,7 +37,7 @@ - if project_nav_tab? :files = nav_link(controller: sidebar_repository_paths, unless: -> { current_path?('projects/graphs#charts') }) do - = link_to project_tree_path(@project), class: 'shortcuts-tree qa-project-menu-repo' do + = link_to project_tree_path(@project), class: 'shortcuts-tree', data: { qa_selector: "repository_link" } do .nav-icon-container = sprite_icon('doc-text') %span.nav-item-name#js-onboarding-repo-link @@ -58,11 +58,11 @@ = _('Commits') = nav_link(html_options: {class: branches_tab_class}) do - = link_to project_branches_path(@project), class: 'qa-branches-link', id: 'js-onboarding-branches-link' do + = link_to project_branches_path(@project), data: { qa_selector: "branches_link" }, id: 'js-onboarding-branches-link' do = _('Branches') = nav_link(controller: [:tags]) do - = link_to project_tags_path(@project) do + = link_to project_tags_path(@project), data: { qa_selector: "tags_link" } do = _('Tags') = nav_link(path: 'graphs#show') do @@ -80,7 +80,7 @@ = render_if_exists 'projects/sidebar/repository_locked_files' - if project_nav_tab? :issues - = nav_link(controller: @project.issues_enabled? ? [:issues, :labels, :milestones, :boards] : :issues) do + = nav_link(controller: @project.issues_enabled? ? ['projects/issues', :labels, :milestones, :boards] : 'projects/issues') do = link_to project_issues_path(@project), class: 'shortcuts-issues qa-issues-item' do .nav-icon-container = sprite_icon('issues') @@ -91,7 +91,7 @@ = number_with_delimiter(@project.open_issues_count(current_user)) %ul.sidebar-sub-level-items - = nav_link(controller: :issues, action: :index, html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: 'projects/issues', action: :index, html_options: { class: "fly-out-top-item" } ) do = link_to project_issues_path(@project) do %strong.fly-out-top-item-name = _('Issues') @@ -114,25 +114,29 @@ %span = _('Labels') - = render_if_exists 'projects/sidebar/issues_service_desk' + = render 'projects/sidebar/issues_service_desk' = nav_link(controller: :milestones) do = link_to project_milestones_path(@project), title: _('Milestones'), class: 'qa-milestones-link' do %span = _('Milestones') - - if project_nav_tab? :external_issue_tracker - = nav_link do - - issue_tracker = @project.external_issue_tracker - = link_to issue_tracker.issue_tracker_path, class: 'shortcuts-external_tracker' do - .nav-icon-container - = sprite_icon('external-link') - %span.nav-item-name - = issue_tracker.title - %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(html_options: { class: "fly-out-top-item" } ) do - = link_to issue_tracker.issue_tracker_path do - %strong.fly-out-top-item-name - = issue_tracker.title + + - if project_nav_tab?(:external_issue_tracker) + - issue_tracker = @project.external_issue_tracker + - if issue_tracker.is_a?(JiraService) && project_jira_issues_integration? + = render_if_exists 'layouts/nav/sidebar/project_jira_issues_link', issue_tracker: issue_tracker + - else + = nav_link do + = link_to issue_tracker.issue_tracker_path, target: '_blank', rel: 'noopener noreferrer', class: 'shortcuts-external_tracker' do + .nav-icon-container + = sprite_icon('external-link') + %span.nav-item-name + = issue_tracker.title + %ul.sidebar-sub-level-items.is-fly-out-only + = nav_link(html_options: { class: "fly-out-top-item" } ) do + = link_to issue_tracker.issue_tracker_path, target: '_blank', rel: 'noopener noreferrer' do + %strong.fly-out-top-item-name + = issue_tracker.title - if (project_nav_tab? :labels) && !@project.issues_enabled? = nav_link(controller: [:labels]) do @@ -289,19 +293,22 @@ = render 'layouts/nav/sidebar/analytics_links', links: project_analytics_navbar_links(@project, current_user) - - if project_nav_tab? :wiki - - wiki_url = wiki_path(@project.wiki) - = nav_link(controller: :wikis) do - = link_to wiki_url, class: 'shortcuts-wiki', data: { qa_selector: 'wiki_link' } do + - if project_nav_tab?(:confluence) + - confluence_url = project_wikis_confluence_path(@project) + = nav_link do + = link_to confluence_url, class: 'shortcuts-confluence' do .nav-icon-container - = sprite_icon('book') + = image_tag 'confluence.svg', alt: _('Confluence') %span.nav-item-name - = _('Wiki') + = _('Confluence') %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :wikis, html_options: { class: "fly-out-top-item" } ) do - = link_to wiki_url do + = nav_link(html_options: { class: 'fly-out-top-item' } ) do + = link_to confluence_url, target: '_blank', rel: 'noopener noreferrer' do %strong.fly-out-top-item-name - = _('Wiki') + = _('Confluence') + + - if project_nav_tab? :wiki + = render 'layouts/nav/sidebar/wiki_link', wiki_url: wiki_path(@project.wiki) - if project_nav_tab?(:external_wiki) - external_wiki_url = @project.external_wiki.external_wiki_url @@ -344,7 +351,7 @@ - if project_nav_tab? :settings = nav_link(path: sidebar_settings_paths) do - = link_to edit_project_path(@project), class: 'shortcuts-tree' do + = link_to edit_project_path(@project) do .nav-icon-container = sprite_icon('settings') %span.nav-item-name.qa-settings-item#js-onboarding-settings-link diff --git a/app/views/layouts/nav/sidebar/_wiki_link.html.haml b/app/views/layouts/nav/sidebar/_wiki_link.html.haml new file mode 100644 index 00000000000..b6b63b75fcc --- /dev/null +++ b/app/views/layouts/nav/sidebar/_wiki_link.html.haml @@ -0,0 +1,11 @@ += nav_link(controller: :wikis) do + = link_to wiki_url, class: 'shortcuts-wiki', data: { qa_selector: 'wiki_link' } do + .nav-icon-container + = sprite_icon('book') + %span.nav-item-name + = _('Wiki') + %ul.sidebar-sub-level-items.is-fly-out-only + = nav_link(controller: :wikis, html_options: { class: "fly-out-top-item" } ) do + = link_to wiki_url do + %strong.fly-out-top-item-name + = _('Wiki') diff --git a/app/views/layouts/service_desk.html.haml b/app/views/layouts/service_desk.html.haml new file mode 100644 index 00000000000..26d15a74403 --- /dev/null +++ b/app/views/layouts/service_desk.html.haml @@ -0,0 +1,24 @@ +%html{ lang: "en" } + %head + %meta{ content: "text/html; charset=utf-8", "http-equiv" => "Content-Type" } + -# haml-lint:disable NoPlainNodes + %title + GitLab + -# haml-lint:enable NoPlainNodes + = stylesheet_link_tag 'notify' + = yield :head + %body + .content + = yield + .footer{ style: "margin-top: 10px;" } + %p + — + %br + = link_to "Unsubscribe", @unsubscribe_url + + -# EE-specific start + - if Gitlab::CurrentSettings.email_additional_text.present? + %br + %br + = Gitlab::Utils.nlbr(Gitlab::CurrentSettings.email_additional_text) + -# EE-specific end diff --git a/app/views/layouts/snippets.html.haml b/app/views/layouts/snippets.html.haml index cde2b467392..6cc53ba3342 100644 --- a/app/views/layouts/snippets.html.haml +++ b/app/views/layouts/snippets.html.haml @@ -1,3 +1,4 @@ +- page_title _("Snippets") - header_title _("Snippets"), snippets_path - snippets_upload_path = snippets_upload_path(@snippet, current_user) diff --git a/app/views/notify/closed_merge_request_email.html.haml b/app/views/notify/closed_merge_request_email.html.haml index 2aa753e0d55..6caa0e59e8f 100644 --- a/app/views/notify/closed_merge_request_email.html.haml +++ b/app/views/notify/closed_merge_request_email.html.haml @@ -1,2 +1,3 @@ %p - Merge Request #{@merge_request.to_reference} was closed by #{sanitize_name(@updated_by.name)} + Merge Request #{merge_request_reference_link(@merge_request)} + was closed by #{sanitize_name(@updated_by.name)} diff --git a/app/views/notify/closed_merge_request_email.text.haml b/app/views/notify/closed_merge_request_email.text.haml index 6e84f9fb355..8546da2d7f0 100644 --- a/app/views/notify/closed_merge_request_email.text.haml +++ b/app/views/notify/closed_merge_request_email.text.haml @@ -1,6 +1,6 @@ Merge Request #{@merge_request.to_reference} was closed by #{sanitize_name(@updated_by.name)} -Merge Request url: #{project_merge_request_url(@merge_request.target_project, @merge_request)} +Merge Request URL: #{project_merge_request_url(@merge_request.target_project, @merge_request)} = merge_path_description(@merge_request, 'to') diff --git a/app/views/notify/merge_request_status_email.html.haml b/app/views/notify/merge_request_status_email.html.haml index ffb416abf72..a15c5a752d4 100644 --- a/app/views/notify/merge_request_status_email.html.haml +++ b/app/views/notify/merge_request_status_email.html.haml @@ -1,2 +1,3 @@ %p - Merge Request #{@merge_request.to_reference} was #{@mr_status} by #{sanitize_name(@updated_by.name)} + Merge Request #{merge_request_reference_link(@merge_request)} + was #{@mr_status} by #{sanitize_name(@updated_by.name)} diff --git a/app/views/notify/merge_request_status_email.text.haml b/app/views/notify/merge_request_status_email.text.haml index e3b24bbd405..3d7115856d4 100644 --- a/app/views/notify/merge_request_status_email.text.haml +++ b/app/views/notify/merge_request_status_email.text.haml @@ -1,6 +1,6 @@ Merge Request #{@merge_request.to_reference} was #{@mr_status} by #{sanitize_name(@updated_by.name)} -Merge Request url: #{project_merge_request_url(@merge_request.target_project, @merge_request)} +Merge Request URL: #{project_merge_request_url(@merge_request.target_project, @merge_request)} = merge_path_description(@merge_request, 'to') diff --git a/app/views/notify/merge_request_unmergeable_email.html.haml b/app/views/notify/merge_request_unmergeable_email.html.haml index 7ec0c1ef390..ee459a26551 100644 --- a/app/views/notify/merge_request_unmergeable_email.html.haml +++ b/app/views/notify/merge_request_unmergeable_email.html.haml @@ -1,2 +1,2 @@ %p - Merge Request #{link_to @merge_request.to_reference, project_merge_request_url(@merge_request.target_project, @merge_request)} can no longer be merged due to conflict. + Merge Request #{merge_request_reference_link(@merge_request)} can no longer be merged due to conflict. diff --git a/app/views/notify/merge_request_unmergeable_email.text.haml b/app/views/notify/merge_request_unmergeable_email.text.haml index e9708a297d7..412a0887186 100644 --- a/app/views/notify/merge_request_unmergeable_email.text.haml +++ b/app/views/notify/merge_request_unmergeable_email.text.haml @@ -1,6 +1,6 @@ Merge Request #{@merge_request.to_reference} can no longer be merged due to conflict. -Merge Request url: #{project_merge_request_url(@merge_request.target_project, @merge_request)} +Merge Request URL: #{project_merge_request_url(@merge_request.target_project, @merge_request)} = merge_path_description(@merge_request, 'to') diff --git a/app/views/notify/merge_when_pipeline_succeeds_email.html.haml b/app/views/notify/merge_when_pipeline_succeeds_email.html.haml new file mode 100644 index 00000000000..4db213fb229 --- /dev/null +++ b/app/views/notify/merge_when_pipeline_succeeds_email.html.haml @@ -0,0 +1,159 @@ +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> +%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" } + %meta{ content: "IE=edge", "http-equiv" => "X-UA-Compatible" } + %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; } + + /* 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; + } + + /* ANDROID MARGIN HACK */ + body { margin:0 !important; } + div[style*="margin: 16px 0"] { margin:0 !important; } + + @media only screen and (max-width: 639px) { + body, #body { + min-width: 320px !important; + } + table.wrapper { + width: 100% !important; + min-width: 320px !important; + } + table.wrapper > tbody > tr > td { + border-left: 0 !important; + border-right: 0 !important; + border-radius: 0 !important; + padding-left: 10px !important; + padding-right: 10px !important; + } + } + + ul.assignees-list { + list-style: none; + padding: 0px; + display: block; + margin-top: 0px; + } + ul.assignees-list li { + display: inline-block; + padding-right: 12px; + padding-top: 8px; + } + + %body{ style: "background-color:#fafafa;margin:0;padding:0;text-align:center;min-width:640px;width:100%;height:100%;font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;" } + %table#body{ border: "0", cellpadding: "0", cellspacing: "0", style: "background-color:#fafafa;margin:0;padding:0;text-align:center;min-width:640px;width:100%;" } + %tbody + %tr.line + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;background-color:#6b4fbb;height:4px;font-size:4px;line-height:4px;" } + %tr.header + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" } + %img{ alt: "GitLab", height: "50", src: image_url('mailers/ci_pipeline_notif_v1/gitlab-logo.gif'), width: "55" } + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;" } + %table.wrapper{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:640px;margin:0 auto;border-collapse:separate;border-spacing:0;" } + %tbody + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;background-color:#ffffff;text-align:left;padding:18px 25px;border:1px solid #ededed;border-radius:3px;overflow:hidden;" } + %table.content{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;border-collapse:separate;border-spacing:0;" } + %tbody + %tr.success + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:10px;border-radius:3px;font-size:14px;line-height:1.3;text-align:center;overflow:hidden;color:#ffffff;background-color:#31af64;" } + %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;margin:0 auto;" } + %tbody + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;vertical-align:middle;color:#ffffff;text-align:center;padding-right:5px;" } + %img{ alt: "✓", height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-check-green-inverted.gif'), style: "display:block;", width: "13" } + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;vertical-align:middle;color:#ffffff;text-align:center;" } + %span= _('Merge request was scheduled to merge after pipeline succeeds') + %tr.spacer + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;height:18px;font-size:18px;line-height:18px;" } + + %tr.section + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;line-height:1.4;text-align:center;padding:0 15px;border:1px solid #ededed;border-radius:3px;overflow:hidden;" } + %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;width:100%;" } + %tbody + %tr{ style: 'width:100%;' } + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;text-align:center;" } + %img{ src: image_url('mailers/approval/icon-merge-request-gray.gif'), style: "height:18px;width:18px;margin-bottom:-4px;", alt: "Merge request icon" } + %span{ style: "font-weight: 600;color:#333333;" }= _('Merge request') + %a{ href: merge_request_url(@merge_request), style: "font-weight: 600;color:#3777b0;text-decoration:none" }= @merge_request.to_reference + %span= _('was scheduled to merge after pipeline succeeds by') + %img.avatar{ height: "24", src: avatar_icon_for_user(@mwps_set_by, 24, only_path: false), style: "border-radius:12px;margin:-7px 0 -7px 3px;", width: "24", alt: "Avatar" } + %a.muted{ href: user_url(@mwps_set_by), style: "color:#333333;text-decoration:none;" } + = @mwps_set_by.name + %tr.spacer + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;height:18px;font-size:18px;line-height:18px;" } + + %tr.section + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:0 15px;border:1px solid #ededed;border-radius:3px;overflow:hidden;" } + %table.info{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;" } + %tbody + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;" }= _('Project') + -# haml-lint:disable NoPlainNodes + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;" } + - namespace_name = @project.group ? @project.group.name : @project.namespace.owner.name + - namespace_url = @project.group ? group_url(@project.group) : user_url(@project.namespace.owner) + %a.muted{ href: namespace_url, style: "color:#333333;text-decoration:none;" } + = namespace_name + \/ + %a.muted{ href: project_url(@project), style: "color:#333333;text-decoration:none;" } + = @project.name + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" }= _('Branch') + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" } + %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" } + %tbody + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" } + %img{ height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-branch-gray.gif'), style: "display:block;", width: "13", alt: "Branch icon" } + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" } + %span.muted{ style: "color:#333333;text-decoration:none;" } + = @merge_request.source_branch + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" }= _('Author') + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" } + %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" } + %tbody + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" } + %img.avatar{ height: "24", src: avatar_icon_for_user(@merge_request.author, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" } + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" } + %a.muted{ href: user_url(@merge_request.author), style: "color:#333333;text-decoration:none;" } + = @merge_request.author.name + + - if @merge_request.assignees.any? + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } + = assignees_label(@merge_request, include_value: false) + %td{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; margin: 0; padding: 14px 0 0px 5px; font-size: 15px; line-height: 1.4; color: #333333; font-weight: 400; width: 75%; border-top-style: solid; border-top-color: #ededed; border-top-width: 1px; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; mso-table-lspace: 0pt; mso-table-rspace: 0pt;" } + %ul.assignees-list{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; font-size: 15px; line-height: 1.4; padding-right: 5px; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; mso-table-lspace: 0pt; mso-table-rspace: 0pt;" } + - @merge_request.assignees.each do |assignee| + %li + %img.avatar{ alt: "Avatar", height: "24", src: avatar_icon_for_user(assignee, 24, only_path: false), style: "border-radius: 12px; max-width: 100%; height: auto; -ms-interpolation-mode: bicubic; margin: -2px 0;", width: "24" } + %a.muted{ href: user_url(assignee), style: "color: #333333; text-decoration: none; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; vertical-align: top;" } + = assignee.name + + = render_if_exists 'layouts/mailer/additional_text' + + %tr.footer + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" } + %img{ alt: "GitLab", height: "33", src: image_url('mailers/ci_pipeline_notif_v1/gitlab-logo-full-horizontal.gif'), style: "display:block;margin:0 auto 1em;", width: "90" } + %div + - manage_notifications_link = link_to(_("Manage all notifications"), profile_notifications_url, style: "color:#3777b0;text-decoration:none;") + - help_link = link_to(_("Help"), help_url, style: "color:#3777b0;text-decoration:none;") + = _("You're receiving this email because of your account on %{host}. %{manage_notifications_link} · %{help_link}").html_safe % { host: Gitlab.config.gitlab.host, manage_notifications_link: manage_notifications_link, help_link: help_link } diff --git a/app/views/notify/merge_when_pipeline_succeeds_email.text.haml b/app/views/notify/merge_when_pipeline_succeeds_email.text.haml new file mode 100644 index 00000000000..fdc23a6af0f --- /dev/null +++ b/app/views/notify/merge_when_pipeline_succeeds_email.text.haml @@ -0,0 +1,8 @@ +Merge Request #{@merge_request.to_reference} was scheduled to merge after pipeline succeeds by #{sanitize_name(@mwps_set_by.name)} + +Merge Request url: #{project_merge_request_url(@merge_request.target_project, @merge_request)} + += merge_path_description(@merge_request, 'to') + +Author: #{sanitize_name(@merge_request.author_name)} += assignees_label(@merge_request) diff --git a/app/views/notify/merged_merge_request_email.html.haml b/app/views/notify/merged_merge_request_email.html.haml index 341aa6f8103..c84c0d1d14b 100644 --- a/app/views/notify/merged_merge_request_email.html.haml +++ b/app/views/notify/merged_merge_request_email.html.haml @@ -1,2 +1,2 @@ %p - Merge Request #{link_to @merge_request.to_reference, project_merge_request_url(@merge_request.target_project, @merge_request)} was merged + Merge Request #{merge_request_reference_link(@merge_request)} was merged diff --git a/app/views/notify/new_issue_email.html.haml b/app/views/notify/new_issue_email.html.haml index 52e110a98f6..7f0a50e9248 100644 --- a/app/views/notify/new_issue_email.html.haml +++ b/app/views/notify/new_issue_email.html.haml @@ -1,5 +1,5 @@ %p.details - #{link_to @issue.author_name, user_url(@issue.author)} created an issue #{link_to @issue.to_reference(full: false), issue_url(@issue)}: + #{link_to @issue.author_name, user_url(@issue.author)} created an issue #{issue_reference_link(@issue)}: - if @issue.assignees.any? %p diff --git a/app/views/notify/new_mention_in_merge_request_email.html.haml b/app/views/notify/new_mention_in_merge_request_email.html.haml index b061f9c106e..ddcf287e501 100644 --- a/app/views/notify/new_mention_in_merge_request_email.html.haml +++ b/app/views/notify/new_mention_in_merge_request_email.html.haml @@ -1,4 +1,4 @@ %p - You have been mentioned in Merge Request #{@merge_request.to_reference} + You have been mentioned in Merge Request #{merge_request_reference_link(@merge_request)} = render template: 'notify/new_merge_request_email' diff --git a/app/views/notify/push_to_merge_request_email.html.haml b/app/views/notify/push_to_merge_request_email.html.haml index 97258833cfc..3e9f9b442e0 100644 --- a/app/views/notify/push_to_merge_request_email.html.haml +++ b/app/views/notify/push_to_merge_request_email.html.haml @@ -1,7 +1,7 @@ %h3 = sanitize_name(@updated_by_user.name) pushed new commits to merge request - = link_to(@merge_request.to_reference, project_merge_request_url(@merge_request.target_project, @merge_request)) + = merge_request_reference_link(@merge_request) - if @existing_commits.any? - count = @existing_commits.size diff --git a/app/views/notify/push_to_merge_request_email.text.haml b/app/views/notify/push_to_merge_request_email.text.haml index 10c8e158846..5c2005a47e5 100644 --- a/app/views/notify/push_to_merge_request_email.text.haml +++ b/app/views/notify/push_to_merge_request_email.text.haml @@ -1,6 +1,6 @@ #{sanitize_name(@updated_by_user.name)} pushed new commits to merge request #{@merge_request.to_reference} -\ -#{url_for(project_merge_request_url(@merge_request.target_project, @merge_request))} + +Merge Request URL: #{project_merge_request_url(@merge_request.target_project, @merge_request)} \ - if @existing_commits.any? - count = @existing_commits.size diff --git a/app/views/notify/resolved_all_discussions_email.html.haml b/app/views/notify/resolved_all_discussions_email.html.haml index 502b8f21e35..0b3c56c9bd1 100644 --- a/app/views/notify/resolved_all_discussions_email.html.haml +++ b/app/views/notify/resolved_all_discussions_email.html.haml @@ -1,2 +1,3 @@ %p - All discussions on Merge Request #{@merge_request.to_reference} were resolved by #{sanitize_name(@resolved_by.name)} + All discussions on Merge Request #{merge_request_reference_link(@merge_request)} + were resolved by #{sanitize_name(@resolved_by.name)} diff --git a/app/views/notify/service_desk_new_note_email.html.haml b/app/views/notify/service_desk_new_note_email.html.haml new file mode 100644 index 00000000000..7c6be6688d0 --- /dev/null +++ b/app/views/notify/service_desk_new_note_email.html.haml @@ -0,0 +1,5 @@ +- if Gitlab::CurrentSettings.email_author_in_body + %div + #{link_to @note.author_name, user_url(@note.author)} wrote: +%div + = markdown(@note.note, pipeline: :email, author: @note.author) diff --git a/app/views/notify/service_desk_new_note_email.text.erb b/app/views/notify/service_desk_new_note_email.text.erb new file mode 100644 index 00000000000..208953a437d --- /dev/null +++ b/app/views/notify/service_desk_new_note_email.text.erb @@ -0,0 +1,6 @@ +New response for issue #<%= @issue.iid %>: + +Author: <%= sanitize_name(@note.author_name) %> + +<%= @note.note %> +<%# EE-specific start %><%= render_if_exists 'layouts/mailer/additional_text'%><%# EE-specific end %> diff --git a/app/views/notify/service_desk_thank_you_email.html.haml b/app/views/notify/service_desk_thank_you_email.html.haml new file mode 100644 index 00000000000..a3407acd9ba --- /dev/null +++ b/app/views/notify/service_desk_thank_you_email.html.haml @@ -0,0 +1,2 @@ +%p + Thank you for your support request! We are tracking your request as ticket ##{@issue.iid}, and will respond as soon as we can. diff --git a/app/views/notify/service_desk_thank_you_email.text.erb b/app/views/notify/service_desk_thank_you_email.text.erb new file mode 100644 index 00000000000..8281607a4a8 --- /dev/null +++ b/app/views/notify/service_desk_thank_you_email.text.erb @@ -0,0 +1,6 @@ +Thank you for your support request! We are tracking your request as ticket #<%= @issue.iid %>, and will respond as soon as we can. + +To unsubscribe from this issue, please paste the following link into your browser: + +<%= @unsubscribe_url %> +<%# EE-specific start %><%= render_if_exists 'layouts/mailer/additional_text' %><%# EE-specific end %> diff --git a/app/views/profiles/_event_table.html.haml b/app/views/profiles/_event_table.html.haml index c65c4fd0d81..b952868e4e3 100644 --- a/app/views/profiles/_event_table.html.haml +++ b/app/views/profiles/_event_table.html.haml @@ -5,7 +5,7 @@ - events.each do |event| %li %span.description - = audit_icon(event.details[:with], class: "append-right-5") + = audit_icon(event.details[:with], class: "gl-mr-2") = _('Signed in with %{authentication} authentication') % { authentication: event.details[:with]} %span.float-right= time_ago_with_tooltip(event.created_at) diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index f4a97206a19..ea2f888c129 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -5,7 +5,7 @@ .alert.alert-info = s_('Profiles|Some options are unavailable for LDAP accounts') -.row.prepend-top-default +.row.gl-mt-3 .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 = s_('Profiles|Two-Factor Authentication') @@ -22,7 +22,7 @@ %hr - if display_providers_on_profile? - .row.prepend-top-default + .row.gl-mt-3 .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 = s_('Profiles|Social sign-in') @@ -32,7 +32,7 @@ = render 'providers', providers: button_based_providers, group_saml_identities: local_assigns[:group_saml_identities] %hr - if current_user.can_change_username? - .row.prepend-top-default + .row.gl-mt-3 .col-lg-4.profile-settings-sidebar %h4.gl-mt-0.warning-title = s_('Profiles|Change username') @@ -45,7 +45,7 @@ #update-username{ data: data } %hr -.row.prepend-top-default +.row.gl-mt-3 .col-lg-4.profile-settings-sidebar %h4.gl-mt-0.danger-title = s_('Profiles|Delete account') @@ -72,4 +72,4 @@ - else %p = s_("Profiles|You don't have access to delete this user.") -.append-bottom-default +.gl-mb-3 diff --git a/app/views/profiles/active_sessions/_active_session.html.haml b/app/views/profiles/active_sessions/_active_session.html.haml index f3ad0c4c8ad..9ae75fe6b8e 100644 --- a/app/views/profiles/active_sessions/_active_session.html.haml +++ b/app/views/profiles/active_sessions/_active_session.html.haml @@ -1,7 +1,7 @@ - is_current_session = active_session.current?(session) %li.list-group-item - .float-left.append-right-10{ data: { toggle: 'tooltip' }, title: active_session.human_device_type } + .float-left.gl-mr-3{ data: { toggle: 'tooltip' }, title: active_session.human_device_type } = active_session_device_type_icon(active_session) .description.float-left @@ -27,6 +27,6 @@ - unless is_current_session .float-right - = link_to profile_active_session_path(active_session.public_id), data: { confirm: _('Are you sure? The device will be signed out of GitLab.') }, method: :delete, class: "btn btn-danger prepend-left-10" do + = link_to profile_active_session_path(active_session.public_id), data: { confirm: _('Are you sure? The device will be signed out of GitLab.') }, method: :delete, class: "btn btn-danger gl-ml-3" do %span.sr-only= _('Revoke') = _('Revoke') diff --git a/app/views/profiles/active_sessions/index.html.haml b/app/views/profiles/active_sessions/index.html.haml index 6d01d055f0c..f444f236cfc 100644 --- a/app/views/profiles/active_sessions/index.html.haml +++ b/app/views/profiles/active_sessions/index.html.haml @@ -1,14 +1,14 @@ - page_title _('Active Sessions') - @content_class = "limit-container-width" unless fluid_layout -.row.prepend-top-default +.row.gl-mt-3 .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 = page_title %p = _('This is a list of devices that have logged into your account. Revoke any sessions that you do not recognize.') .col-lg-8 - .append-bottom-default + .gl-mb-3 .card.border-0 %ul.list-group.list-group-flush diff --git a/app/views/profiles/audit_log.html.haml b/app/views/profiles/audit_log.html.haml index 02aadcc5c8b..aec855c790e 100644 --- a/app/views/profiles/audit_log.html.haml +++ b/app/views/profiles/audit_log.html.haml @@ -1,7 +1,7 @@ - page_title _('Authentication log') - @content_class = "limit-container-width" unless fluid_layout -.row.prepend-top-default +.row.gl-mt-3 .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 = page_title diff --git a/app/views/profiles/chat_names/index.html.haml b/app/views/profiles/chat_names/index.html.haml index 05870e0e221..e0b0f839455 100644 --- a/app/views/profiles/chat_names/index.html.haml +++ b/app/views/profiles/chat_names/index.html.haml @@ -1,7 +1,7 @@ - page_title _('Chat') - @content_class = "limit-container-width" unless fluid_layout -.row.prepend-top-default +.row.gl-mt-3 .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 = page_title diff --git a/app/views/profiles/chat_names/new.html.haml b/app/views/profiles/chat_names/new.html.haml index d86941b7a29..5bed9e0d771 100644 --- a/app/views/profiles/chat_names/new.html.haml +++ b/app/views/profiles/chat_names/new.html.haml @@ -12,4 +12,4 @@ = submit_tag "Authorize", class: "btn btn-success wide float-left" = form_tag deny_profile_chat_names_path, method: :delete do = hidden_field_tag :token, @chat_name_token.token - = submit_tag "Deny", class: "btn btn-danger prepend-left-10" + = submit_tag "Deny", class: "btn btn-danger gl-ml-3" diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml index e90bda0e187..fa7ab0666cc 100644 --- a/app/views/profiles/emails/index.html.haml +++ b/app/views/profiles/emails/index.html.haml @@ -1,7 +1,7 @@ - page_title _('Emails') - @content_class = "limit-container-width" unless fluid_layout -.row.prepend-top-default +.row.gl-mt-3 .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 = page_title @@ -14,12 +14,12 @@ .form-group = f.label :email, _('Email'), class: 'label-bold' = f.text_field :email, class: 'form-control', data: { qa_selector: 'email_address_field' } - .prepend-top-default + .gl-mt-3 = f.submit _('Add email address'), class: 'btn btn-success', data: { qa_selector: 'add_email_address_button' } %hr %h4.gl-mt-0 = _('Linked emails (%{email_count})') % { email_count: @emails.load.size + 1 } - .account-well.append-bottom-default + .account-well.gl-mb-3 %ul %li = _('Your Primary Email will be used for avatar detection.') @@ -56,8 +56,8 @@ %span.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: 'btn btn-sm btn-warning prepend-left-10' + = link_to confirm_title, resend_confirmation_instructions_profile_email_path(email), method: :put, class: 'btn btn-sm btn-warning gl-ml-3' - = link_to profile_email_path(email), data: { confirm: _('Are you sure?'), qa_selector: 'delete_email_link'}, method: :delete, class: 'btn btn-sm btn-danger prepend-left-10' do + = link_to profile_email_path(email), data: { confirm: _('Are you sure?'), qa_selector: 'delete_email_link'}, method: :delete, class: 'btn btn-sm btn-danger gl-ml-3' do %span.sr-only= _('Remove') = icon('trash') diff --git a/app/views/profiles/gpg_keys/_form.html.haml b/app/views/profiles/gpg_keys/_form.html.haml index 225487b2638..2fb07adc006 100644 --- a/app/views/profiles/gpg_keys/_form.html.haml +++ b/app/views/profiles/gpg_keys/_form.html.haml @@ -6,5 +6,5 @@ = f.label :key, s_('Profiles|Key'), class: 'label-bold' = f.text_area :key, class: "form-control", rows: 8, required: true, placeholder: _("Don't paste the private part of the GPG key. Paste the public part which begins with '-----BEGIN PGP PUBLIC KEY BLOCK-----'.") - .prepend-top-default + .gl-mt-3 = f.submit s_('Profiles|Add key'), class: "btn btn-success" diff --git a/app/views/profiles/gpg_keys/_key.html.haml b/app/views/profiles/gpg_keys/_key.html.haml index 2de5cf2f506..7bbb0235cd8 100644 --- a/app/views/profiles/gpg_keys/_key.html.haml +++ b/app/views/profiles/gpg_keys/_key.html.haml @@ -1,5 +1,5 @@ %li.key-list-item - .float-left.append-right-10 + .float-left.gl-mr-3 = icon 'key', class: "settings-list-icon d-none d-sm-block" .key-list-item-info - key.emails_with_verified_status.map do |email, verified| @@ -19,9 +19,9 @@ .float-right %span.key-created-at = s_('Profiles|Created %{time_ago}'.html_safe) % { time_ago:time_ago_with_tooltip(key.created_at)} - = link_to profile_gpg_key_path(key), data: { confirm: _('Are you sure? Removing this GPG key does not affect already signed commits.') }, method: :delete, class: "btn btn-danger prepend-left-10" do + = link_to profile_gpg_key_path(key), data: { confirm: _('Are you sure? Removing this GPG key does not affect already signed commits.') }, method: :delete, class: "btn btn-danger gl-ml-3" do %span.sr-only= _('Remove') = icon('trash') - = link_to revoke_profile_gpg_key_path(key), data: { confirm: _('Are you sure? All commits that were signed with this GPG key will be unverified.') }, method: :put, class: "btn btn-danger prepend-left-10" do + = link_to revoke_profile_gpg_key_path(key), data: { confirm: _('Are you sure? All commits that were signed with this GPG key will be unverified.') }, method: :put, class: "btn btn-danger gl-ml-3" do %span.sr-only= _('Revoke') = _('Revoke') diff --git a/app/views/profiles/gpg_keys/index.html.haml b/app/views/profiles/gpg_keys/index.html.haml index 31610e7505b..053cb3547ba 100644 --- a/app/views/profiles/gpg_keys/index.html.haml +++ b/app/views/profiles/gpg_keys/index.html.haml @@ -1,7 +1,7 @@ - page_title _('GPG Keys') - @content_class = "limit-container-width" unless fluid_layout -.row.prepend-top-default +.row.gl-mt-3 .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 = page_title @@ -17,5 +17,5 @@ %hr %h5 = _('Your GPG keys (%{count})') % { count:@gpg_keys.count} - .append-bottom-default + .gl-mb-3 = render 'key_table' diff --git a/app/views/profiles/keys/_form.html.haml b/app/views/profiles/keys/_form.html.haml index 7709aa8f4b9..078b5907623 100644 --- a/app/views/profiles/keys/_form.html.haml +++ b/app/views/profiles/keys/_form.html.haml @@ -10,7 +10,7 @@ .col.form-group = f.label :title, _('Title'), class: 'label-bold' = f.text_field :title, class: "form-control input-lg qa-key-title-field", required: true, placeholder: s_('Profiles|e.g. My MacBook key') - %p.form-text.text-muted= s_('Profiles|Give your individual key a title') + %p.form-text.text-muted= s_('Profiles|Give your individual key a title. This will be publically visible.') .col.form-group = f.label :expires_at, s_('Profiles|Expires at'), class: 'label-bold' @@ -23,5 +23,5 @@ %button.btn.btn-success.js-add-ssh-key-validation-confirm-submit= _("Yes, add it") - .prepend-top-default + .gl-mt-3 = f.submit s_('Profiles|Add key'), class: "btn btn-success js-add-ssh-key-validation-original-submit qa-add-key-button" diff --git a/app/views/profiles/keys/_key.html.haml b/app/views/profiles/keys/_key.html.haml index b227041c9de..c9ab7b6fbd3 100644 --- a/app/views/profiles/keys/_key.html.haml +++ b/app/views/profiles/keys/_key.html.haml @@ -1,5 +1,5 @@ %li.d-flex.align-items-center.key-list-item - .append-right-10 + .gl-mr-3 - if key.valid? - if key.expired? %span.d-inline-block.has-tooltip{ title: s_('Profiles|Your key has expired') } @@ -17,15 +17,15 @@ = key.fingerprint .key-list-item-dates.d-flex.align-items-start.justify-content-between - %span.last-used-at.append-right-10 + %span.last-used-at.gl-mr-3 = s_('Profiles|Last used:') = key.last_used_at ? time_ago_with_tooltip(key.last_used_at) : _('Never') - %span.expires.append-right-10 + %span.expires.gl-mr-3 = s_('Profiles|Expires:') = key.expires_at ? key.expires_at.to_date : _('Never') %span.key-created-at = s_('Profiles|Created %{time_ago}'.html_safe) % { time_ago:time_ago_with_tooltip(key.created_at)} - if key.can_delete? - = link_to path_to_key(key, is_admin), data: { confirm: _('Are you sure?')}, method: :delete, class: "btn btn-transparent prepend-left-10 align-baseline" do + = link_to path_to_key(key, is_admin), data: { confirm: _('Are you sure?')}, method: :delete, class: "btn btn-transparent gl-ml-3 align-baseline" do %span.sr-only= _('Remove') = sprite_icon('remove', size: 16) diff --git a/app/views/profiles/keys/_key_details.html.haml b/app/views/profiles/keys/_key_details.html.haml index 88deb0f11cb..59d953678e7 100644 --- a/app/views/profiles/keys/_key_details.html.haml +++ b/app/views/profiles/keys/_key_details.html.haml @@ -1,5 +1,5 @@ - is_admin = defined?(admin) ? true : false -.row.prepend-top-default +.row.gl-mt-3 .col-md-4 .card .card-header diff --git a/app/views/profiles/keys/index.html.haml b/app/views/profiles/keys/index.html.haml index 788c67b3704..7b7c24f3ac8 100644 --- a/app/views/profiles/keys/index.html.haml +++ b/app/views/profiles/keys/index.html.haml @@ -1,7 +1,7 @@ - page_title _('SSH Keys') - @content_class = "limit-container-width" unless fluid_layout -.row.prepend-top-default +.row.gl-mt-3 .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 = page_title @@ -12,7 +12,7 @@ = _('Add an SSH key') %p.profile-settings-content - generate_link_url = help_page_path("ssh/README", anchor: 'generating-a-new-ssh-key-pair') - - existing_link_url = help_page_path("ssh/README", anchor: 'locating-an-existing-ssh-key-pair') + - existing_link_url = help_page_path("ssh/README", anchor: 'review-existing-ssh-keys') - generate_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: generate_link_url } - existing_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: existing_link_url } = _('To add an SSH key you need to %{generate_link_start}generate one%{link_end} or use an %{existing_link_start}existing key%{link_end}.').html_safe % { generate_link_start: generate_link_start, existing_link_start: existing_link_start, link_end: '</a>'.html_safe } @@ -20,5 +20,5 @@ %hr %h5 = _('Your SSH keys (%{count})') % { count:@keys.count } - .append-bottom-default + .gl-mb-3 = render 'key_table' diff --git a/app/views/profiles/notifications/_group_settings.html.haml b/app/views/profiles/notifications/_group_settings.html.haml index a25cd78fb0b..404bb224655 100644 --- a/app/views/profiles/notifications/_group_settings.html.haml +++ b/app/views/profiles/notifications/_group_settings.html.haml @@ -2,7 +2,7 @@ .gl-responsive-table-row.notification-list-item .table-section.section-40 - %span.notification.fa.fa-holder.append-right-5 + %span.notification.fa.fa-holder.gl-mr-2 = notification_icon(notification_icon_level(setting, emails_disabled)) %span.str-truncated diff --git a/app/views/profiles/notifications/_project_settings.html.haml b/app/views/profiles/notifications/_project_settings.html.haml index 63a77b335b6..f9172ae87aa 100644 --- a/app/views/profiles/notifications/_project_settings.html.haml +++ b/app/views/profiles/notifications/_project_settings.html.haml @@ -1,7 +1,7 @@ - emails_disabled = project.emails_disabled? %li.notification-list-item - %span.notification.fa.fa-holder.append-right-5 + %span.notification.fa.fa-holder.gl-mr-2 = notification_icon(notification_icon_level(setting, emails_disabled)) %span.str-truncated diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml index 498f80aed2b..ab04d977a4d 100644 --- a/app/views/profiles/notifications/show.html.haml +++ b/app/views/profiles/notifications/show.html.haml @@ -9,7 +9,7 @@ %li= msg = hidden_field_tag :notification_type, 'global' - .row.prepend-top-default + .row.gl-mt-3 .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 = page_title @@ -21,7 +21,7 @@ %h5.gl-mt-0 = _('Global notification settings') - = form_for @user, url: profile_notifications_path, method: :put, html: { class: 'update-notifications prepend-top-default' } do |f| + = form_for @user, url: profile_notifications_path, method: :put, html: { class: 'update-notifications gl-mt-3' } do |f| = render_if_exists 'profiles/notifications/email_settings', form: f = label_tag :global_notification_level, "Global notification level", class: "label-bold" @@ -47,7 +47,7 @@ = _('Projects (%{count})') % { count: @project_notifications.size } %p.account-well = _('To specify the notification level per project of a group you belong to, you need to visit project page and change notification level there.') - .append-bottom-default + .gl-mb-3 %ul.bordered-list - @project_notifications.each do |setting| = render 'project_settings', setting: setting, project: setting.source diff --git a/app/views/profiles/passwords/edit.html.haml b/app/views/profiles/passwords/edit.html.haml index 9deaf7f84be..fe16c2e2f28 100644 --- a/app/views/profiles/passwords/edit.html.haml +++ b/app/views/profiles/passwords/edit.html.haml @@ -2,7 +2,7 @@ - page_title _('Password') - @content_class = "limit-container-width" unless fluid_layout -.row.prepend-top-default +.row.gl-mt-3 .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 = page_title @@ -29,7 +29,7 @@ .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' } - .prepend-top-default.append-bottom-default - = f.submit _('Save password'), class: "btn btn-success append-right-10", data: { qa_selector: 'save_password_button' } + .gl-mt-3.gl-mb-3 + = f.submit _('Save password'), class: "btn btn-success gl-mr-3", data: { qa_selector: 'save_password_button' } - unless @user.password_automatically_set? = link_to _('I forgot my password'), reset_profile_password_path, method: :put diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 769502e0026..11750f2a6d5 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.prepend-top-default +.row.gl-mt-3 .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 = page_title @@ -33,7 +33,7 @@ revoke_route_helper: ->(token) { revoke_profile_personal_access_token_path(token) } %hr -.row.prepend-top-default +.row.gl-mt-3 .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 = s_('AccessTokens|Feed token') @@ -51,7 +51,7 @@ - if incoming_email_token_enabled? %hr - .row.prepend-top-default + .row.gl-mt-3 .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 = s_('AccessTokens|Incoming email token') @@ -69,7 +69,7 @@ - if static_objects_external_storage_enabled? %hr - .row.prepend-top-default + .row.gl-mt-3 .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 cc44d137848..659b3066603 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -1,18 +1,19 @@ - page_title _('Preferences') - @content_class = "limit-container-width" unless fluid_layout -= form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { class: 'row prepend-top-default js-preferences-form' } do |f| += form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { class: 'row gl-mt-3 js-preferences-form' } do |f| .col-lg-4.application-theme %h4.gl-mt-0 = s_('Preferences|Navigation theme') %p = s_('Preferences|Customize the appearance of the application header and navigation sidebar.') .col-lg-8.application-theme - - Gitlab::Themes.each do |theme| - = label_tag do - .preview{ class: theme.css_class } - = f.radio_button :theme_id, theme.id, checked: Gitlab::Themes.for_user(@user).id == theme.id - = theme.name + .row + - Gitlab::Themes.each do |theme| + %label.col-6.col-sm-4.col-md-3.gl-mb-5.gl-text-center + .preview{ class: theme.css_class } + = f.radio_button :theme_id, theme.id, checked: Gitlab::Themes.for_user(@user).id == theme.id + = theme.name .col-sm-12 %hr @@ -69,6 +70,13 @@ = f.check_box :show_whitespace_in_diffs, class: 'form-check-input' = f.label :show_whitespace_in_diffs, class: 'form-check-label' do = s_('Preferences|Show whitespace changes in diffs') + - if Feature.enabled?(:view_diffs_file_by_file) + .form-group.form-check + = f.check_box :view_diffs_file_by_file, class: 'form-check-input' + = f.label :view_diffs_file_by_file, class: 'form-check-label' do + = s_("Preferences|Show one file at a time on merge request's Changes tab") + .form-text.text-muted + = s_("Preferences|Instead of all the files changed, show only one file at a time. To switch between files, use the file browser.") .form-group = f.label :tab_width, s_('Preferences|Tab width'), class: 'label-bold' = f.number_field :tab_width, diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 78fdcdef3c4..f4aa0b98e37 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -1,8 +1,9 @@ - breadcrumb_title s_("Profiles|Edit Profile") +- page_title s_("Profiles|Edit Profile") - @content_class = "limit-container-width" unless fluid_layout - gravatar_link = link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host -= bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user prepend-top-default js-quick-submit gl-show-field-errors' }, authenticity_token: true do |f| += 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 @@ -24,13 +25,13 @@ .md = brand_profile_image_guidelines .col-lg-8 - .clearfix.avatar-image.append-bottom-default + .clearfix.avatar-image.gl-mb-3 = link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do = image_tag avatar_icon_for_user(@user, 160), alt: '', class: 'avatar s160' %h5.gl-mt-0= s_("Profiles|Upload new avatar") - .prepend-top-5.append-bottom-10 + .gl-mt-2.append-bottom-10 %button.btn.js-choose-user-avatar-button{ type: 'button' }= s_("Profiles|Choose file...") - %span.avatar-file-name.prepend-left-default.js-avatar-filename= s_("Profiles|No file chosen") + %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? @@ -117,7 +118,7 @@ = 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") - .prepend-top-default.append-bottom-default + .gl-mt-3.gl-mb-3 = f.submit s_("Profiles|Update profile settings"), class: 'btn btn-success' = link_to _("Cancel"), user_path(current_user), class: 'btn btn-cancel' diff --git a/app/views/profiles/two_factor_auths/_codes.html.haml b/app/views/profiles/two_factor_auths/_codes.html.haml index be0af977011..68cd4875a33 100644 --- a/app/views/profiles/two_factor_auths/_codes.html.haml +++ b/app/views/profiles/two_factor_auths/_codes.html.haml @@ -9,5 +9,5 @@ %span.monospace= code .d-flex - = link_to _('Proceed'), profile_account_path, class: 'btn btn-success append-right-10' + = link_to _('Proceed'), profile_account_path, class: 'btn btn-success gl-mr-3', data: { qa_selector: 'proceed_button' } = link_to _('Download codes'), "data:text/plain;charset=utf-8,#{CGI.escape(@codes.join("\n"))}", download: "gitlab-recovery-codes.txt", class: 'btn btn-default' diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index 7e566361848..0fde3e5fb10 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -3,7 +3,7 @@ - @content_class = "limit-container-width" unless fluid_layout .js-two-factor-auth{ 'data-two-factor-skippable' => "#{two_factor_skippable?}", 'data-two_factor_skip_url' => skip_profile_two_factor_auth_path } - .row.prepend-top-default + .row.gl-mt-3 .col-lg-4 %h4.gl-mt-0 = _('Register Two-Factor Authenticator') @@ -19,7 +19,7 @@ = link_to _('Disable two-factor authentication'), profile_two_factor_auth_path, method: :delete, data: { confirm: _('Are you sure? This will invalidate your registered applications and U2F devices.') }, - class: 'btn btn-danger append-right-10' + class: 'btn btn-danger gl-mr-3' = form_tag codes_profile_two_factor_auth_path, {style: 'display: inline-block', method: :post} do |f| = submit_tag _('Regenerate recovery codes'), class: 'btn' @@ -39,7 +39,7 @@ = _('To add the entry manually, provide the following details to the application on your phone.') %p.gl-mt-0.gl-mb-0 = _('Account: %{account}') % { account: @account_string } - %p.gl-mt-0.gl-mb-0 + %p.gl-mt-0.gl-mb-0{ data: { qa_selector: 'otp_secret_content' } } = _('Key: %{key}') %{ key: current_user.otp_secret.scan(/.{4}/).join(' ') } %p.two-factor-new-manual-content = _('Time based: Yes') @@ -49,13 +49,13 @@ = @error .form-group = label_tag :pin_code, _('Pin code'), class: "label-bold" - = text_field_tag :pin_code, nil, class: "form-control", required: true - .prepend-top-default - = submit_tag _('Register with two-factor app'), class: 'btn btn-success' + = text_field_tag :pin_code, nil, class: "form-control", required: true, data: { qa_selector: 'pin_code_field' } + .gl-mt-3 + = submit_tag _('Register with two-factor app'), class: 'btn btn-success', data: { qa_selector: 'register_2fa_app_button' } %hr - .row.prepend-top-default + .row.gl-mt-3 .col-lg-4 %h4.gl-mt-0 = _('Register Universal Two-Factor (U2F) Device') diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index 20d4084f428..1562cc065f1 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -1,27 +1,22 @@ - is_project_overview = local_assigns.fetch(:is_project_overview, false) -- commit = local_assigns.fetch(:commit) { @repository.commit } - ref = local_assigns.fetch(:ref) { current_ref } - project = local_assigns.fetch(:project) { @project } -- content_url = local_assigns.fetch(:content_url) { @tree.readme ? project_blob_path(@project, tree_join(@ref, @tree.readme.path)) : project_tree_path(@project, @ref) } - show_auto_devops_callout = show_auto_devops_callout?(@project) +- add_page_startup_api_call logs_file_project_ref_path(@project, ref, @path, format: "json", offset: 0) +- if @tree.readme + - add_page_startup_api_call project_blob_path(@project, tree_join(@ref, @tree.readme.path), viewer: "rich", format: "json") #tree-holder.tree-holder.clearfix .nav-block = render 'projects/tree/tree_header', tree: @tree - - if vue_file_list_enabled? - #js-last-commit - - elsif commit - = render 'shared/commit_well', commit: commit, ref: ref, project: project + #js-last-commit - if is_project_overview - .project-buttons.append-bottom-default{ class: ("js-show-on-project-root" if vue_file_list_enabled?) } + .project-buttons.gl-mb-3.js-show-on-project-root = render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout) - - if vue_file_list_enabled? - #js-tree-list{ data: vue_file_list_data(project, ref) } - - 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' - - else - = render 'projects/tree/tree_content', tree: @tree, content_url: content_url + #js-tree-list{ data: vue_file_list_data(project, ref) } + - 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/_flash_messages.html.haml b/app/views/projects/_flash_messages.html.haml index 4739689b419..ab8275ba5e4 100644 --- a/app/views/projects/_flash_messages.html.haml +++ b/app/views/projects/_flash_messages.html.haml @@ -9,4 +9,4 @@ = render 'shared/auto_devops_implicitly_enabled_banner', project: project = render_if_exists 'projects/above_size_limit_warning', project: project = render_if_exists 'shared/shared_runners_minutes_limit', project: project, classes: [container_class, ("limit-container-width" unless fluid_layout)] - = render 'shared/namespace_storage_limit_alert', namespace: project.namespace, classes: [container_class, ("limit-container-width" unless fluid_layout)] + = render_if_exists 'shared/namespace_storage_limit_alert', namespace: project.namespace, classes: [container_class, ("limit-container-width" unless fluid_layout)] diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 6f8375f80be..9966baf78f4 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -3,14 +3,14 @@ - max_project_topic_length = 15 - emails_disabled = @project.emails_disabled? -.project-home-panel{ class: [("empty-project" if empty_repo), ("js-show-on-project-root" if vue_file_list_enabled?)] } +.project-home-panel.js-show-on-project-root{ class: [("empty-project" if empty_repo)] } .row.gl-mb-3 .home-panel-title-row.col-md-12.col-lg-6.d-flex - .avatar-container.rect-avatar.s64.home-panel-avatar.append-right-default.float-none + .avatar-container.rect-avatar.s64.home-panel-avatar.gl-mr-3.float-none = project_icon(@project, alt: @project.name, class: 'avatar avatar-tile s64', width: 64, height: 64) .d-flex.flex-column.flex-wrap.align-items-baseline .d-inline-flex.align-items-baseline - %h1.home-panel-title.gl-mt-3.append-bottom-5{ data: { qa_selector: 'project_name_content' } } + %h1.home-panel-title.gl-mt-3.gl-mb-2{ data: { qa_selector: 'project_name_content' } } = @project.name %span.visibility-icon.text-secondary.gl-ml-2.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) } = visibility_level_icon(@project.visibility_level, fw: false, options: {class: 'icon'}) @@ -24,10 +24,10 @@ = render 'shared/members/access_request_links', source: @project - if @project.tag_list.present? %span.home-panel-topic-list.mt-2.w-100.d-inline-flex - = sprite_icon('tag', size: 16, css_class: 'icon append-right-4') + = sprite_icon('tag', size: 16, css_class: 'icon gl-mr-2') - @project.topics_to_show.each do |topic| - - project_topics_classes = "badge badge-pill badge-secondary append-right-5" + - project_topics_classes = "badge badge-pill badge-secondary gl-mr-2" - explore_project_topic_path = explore_projects_path(tag: topic) - if topic.length > max_project_topic_length %a{ class: "#{ project_topics_classes } str-truncated-30 has-tooltip", data: { container: "body" }, title: topic, href: explore_project_topic_path } diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml index 3ae37254e39..bb278fbf311 100644 --- a/app/views/projects/_import_project_pane.html.haml +++ b/app/views/projects/_import_project_pane.html.haml @@ -9,7 +9,8 @@ - if gitlab_project_import_enabled? .import_gitlab_project.has-tooltip{ data: { container: 'body' } } = link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit', **tracking_attrs(track_label, 'click_button', 'gitlab_export') do - = icon('gitlab', text: 'GitLab export') + = sprite_icon('tanuki') + = _("GitLab export") - if github_import_enabled? %div @@ -32,7 +33,8 @@ %div = link_to status_import_gitlab_path, class: "btn import_gitlab #{'how_to_import_link' unless gitlab_import_configured?}", **tracking_attrs(track_label, 'click_button', 'gitlab_com') do - = icon('gitlab', text: 'GitLab.com') + = sprite_icon('tanuki') + = _("GitLab.com") - unless gitlab_import_configured? = render 'projects/gitlab_import_modal' diff --git a/app/views/projects/_merge_request_settings.html.haml b/app/views/projects/_merge_request_settings.html.haml index dc3a3fcc647..5ffdeef3558 100644 --- a/app/views/projects/_merge_request_settings.html.haml +++ b/app/views/projects/_merge_request_settings.html.haml @@ -4,6 +4,9 @@ = render 'projects/merge_request_merge_options_settings', project: @project, form: form +- if Feature.enabled?(:squash_options, @project) + = render 'projects/merge_request_squash_options_settings', form: form + = render 'projects/merge_request_merge_checks_settings', project: @project, form: form = render 'projects/merge_request_merge_suggestions_settings', project: @project, form: form diff --git a/app/views/projects/_merge_request_squash_options_settings.html.haml b/app/views/projects/_merge_request_squash_options_settings.html.haml new file mode 100644 index 00000000000..a5dbfeb16d8 --- /dev/null +++ b/app/views/projects/_merge_request_squash_options_settings.html.haml @@ -0,0 +1,42 @@ +- form = local_assigns.fetch(:form) + += form.fields_for :project_setting do |settings| + .form-group + %b= s_('ProjectSettings|Squash commits when merging') + %p.text-secondary + = s_('ProjectSettings|Set the default behavior and availability of this option in merge requests. Changes made are also applied to existing merge requests.') + = link_to "What is squashing?", + help_page_path('user/project/merge_requests/squash_and_merge.md'), + target: '_blank' + + .form-check.gl-mb-2 + = settings.radio_button :squash_option, :never, class: "form-check-input" + = label_tag :project_project_setting_attributes_squash_option_never, class: 'form-check-label' do + .gl-font-weight-bold + = s_('ProjectSettings|Do not allow') + .text-secondary + = s_('ProjectSettings|Squashing is never performed and the checkbox is hidden.') + + .form-check.gl-mb-2 + = settings.radio_button :squash_option, :default_off, class: "form-check-input" + = label_tag :project_project_setting_attributes_squash_option_default_off, class: 'form-check-label' do + .gl-font-weight-bold + = s_('ProjectSettings|Allow') + .text-secondary + = s_('ProjectSettings|Checkbox is visible and unselected by default.') + + .form-check.gl-mb-2 + = settings.radio_button :squash_option, :default_on, class: "form-check-input" + = label_tag :project_project_setting_attributes_squash_option_default_on, class: 'form-check-label' do + .gl-font-weight-bold + = s_('ProjectSettings|Encourage') + .text-secondary + = s_('ProjectSettings|Checkbox is visible and selected by default.') + + .form-check.gl-mb-2 + = settings.radio_button :squash_option, :always, class: "form-check-input" + = label_tag :project_project_setting_attributes_squash_option_always, class: 'form-check-label' do + .gl-font-weight-bold + = s_('ProjectSettings|Require') + .text-secondary + = s_('ProjectSettings|Squashing is always performed. Checkbox is visible and selected, and users cannot change it.') diff --git a/app/views/projects/_readme.html.haml b/app/views/projects/_readme.html.haml index 32624ac225b..da3133dfe15 100644 --- a/app/views/projects/_readme.html.haml +++ b/app/views/projects/_readme.html.haml @@ -1,10 +1,14 @@ - if (readme = @repository.readme) && readme.rich_viewer + .tree-holder + .nav-block.mt-0 + = render 'projects/tree/tree_header', tree: @tree %article.file-holder.readme-holder{ id: 'readme', class: ("limited-width-container" unless fluid_layout) } - .js-file-title.file-title - = blob_icon readme.mode, readme.name - = link_to project_blob_path(@project, tree_join(@ref, readme.path)) do - %strong - = readme.name + .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: namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, readme.path), viewer: :rich, format: :json) - else diff --git a/app/views/projects/_remove.html.haml b/app/views/projects/_remove.html.haml index 6c84fbfeeb3..528d802261c 100644 --- a/app/views/projects/_remove.html.haml +++ b/app/views/projects/_remove.html.haml @@ -4,7 +4,6 @@ %h4.danger-title= _('Remove project') %p %strong= _('Removing the project will delete its repository and all related resources including issues, merge requests etc.') - = form_tag(project_path(project), method: :delete) do - %p - %strong= _('Removed projects cannot be restored!') - = button_to _('Remove project'), '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_project_message(project) } + %p + %strong= _('Removed projects cannot be restored!') + #js-confirm-project-remove{ data: { form_path: project_path(project), confirm_phrase: project.path, warning_message: remove_project_message(project) } } diff --git a/app/views/projects/_service_desk_settings.html.haml b/app/views/projects/_service_desk_settings.html.haml new file mode 100644 index 00000000000..e6842bbb939 --- /dev/null +++ b/app/views/projects/_service_desk_settings.html.haml @@ -0,0 +1,19 @@ +- expanded = expanded_by_default? +%section.settings.js-service-desk-setting-wrapper.no-animate#js-service-desk{ class: ('expanded' if expanded) } + .settings-header + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Service Desk') + %button.btn.js-settings-toggle + = expanded ? _('Collapse') : _('Expand') + - link_start = "<a href='#{help_page_path('user/project/service_desk')}' target='_blank' rel='noopener noreferrer'>".html_safe + %p= _('Enable/disable your service desk. %{link_start}Learn more about service desk%{link_end}.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } + .settings-content + - if ::Gitlab::ServiceDesk.supported? + .js-service-desk-setting-root{ data: { endpoint: project_service_desk_path(@project), + enabled: "#{@project.service_desk_enabled}", + incoming_email: (@project.service_desk_address if @project.service_desk_enabled), + selected_template: "#{@project.service_desk_setting&.issue_template_key}", + outgoing_name: "#{@project.service_desk_setting&.outgoing_name}", + project_key: "#{@project.service_desk_setting&.project_key}", + templates: issuable_templates_names(Issue.new) } } + - elsif show_callout?('promote_service_desk_dismissed') + = render 'shared/promotions/promote_servicedesk' diff --git a/app/views/projects/_wiki.html.haml b/app/views/projects/_wiki.html.haml index 6f90bf50b91..991c95153da 100644 --- a/app/views/projects/_wiki.html.haml +++ b/app/views/projects/_wiki.html.haml @@ -1,6 +1,6 @@ - if @wiki_home.present? %div{ class: container_class } - .md.prepend-top-default.append-bottom-default + .md.gl-mt-3.gl-mb-3 = render_wiki_content(@wiki_home) - else - can_create_wiki = can?(current_user, :create_wiki, @project) diff --git a/app/views/projects/artifacts/browse.html.haml b/app/views/projects/artifacts/browse.html.haml index 7abac2d14e4..ff56cb53720 100644 --- a/app/views/projects/artifacts/browse.html.haml +++ b/app/views/projects/artifacts/browse.html.haml @@ -1,5 +1,5 @@ - breadcrumb_title _('Artifacts') -- page_title @path.presence, 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs' +- page_title @path.presence, _('Artifacts'), "#{@build.name} (##{@build.id})", _('Jobs') = render "projects/jobs/header" diff --git a/app/views/projects/artifacts/file.html.haml b/app/views/projects/artifacts/file.html.haml index 808b4acc8f3..1ad70506be4 100644 --- a/app/views/projects/artifacts/file.html.haml +++ b/app/views/projects/artifacts/file.html.haml @@ -1,4 +1,4 @@ -- page_title @path, 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs' +- page_title @path, _('Artifacts'), "#{@build.name} (##{@build.id})", _('Jobs') = render "projects/jobs/header" diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml index 0591c3180ea..a2d6b2e18a9 100644 --- a/app/views/projects/blame/show.html.haml +++ b/app/views/projects/blame/show.html.haml @@ -1,4 +1,4 @@ -- page_title "Blame", @blob.path, @ref +- page_title _("Blame"), @blob.path, @ref - link_icon = icon("link") #blob-content-holder.tree-holder diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml index 032df24a603..b06ae31e73f 100644 --- a/app/views/projects/blob/_editor.html.haml +++ b/app/views/projects/blob/_editor.html.haml @@ -2,19 +2,19 @@ - file_name = params[:id].split("/").last ||= "" - is_markdown = Gitlab::MarkupHelper.gitlab_markdown?(file_name) -.file-holder-bottom-radius.file-holder.file.append-bottom-default +.file-holder-bottom-radius.file-holder.file.gl-mb-3 .js-file-title.file-title.align-items-center.clearfix{ data: { current_action: action } } - .editor-ref.block-truncated + .editor-ref.block-truncated.has-tooltip{ title: ref } = sprite_icon('fork', size: 12) = ref - if current_action?(:edit) || current_action?(:update) - %span.pull-left.append-right-10 + %span.pull-left.gl-mr-3 = text_field_tag 'file_path', (params[:file_path] || @path), class: 'form-control new-file-path js-file-path-name-input' = render 'template_selectors' - if current_action?(:new) || current_action?(:create) - %span.pull-left.append-right-10 + %span.pull-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' : '') @@ -40,7 +40,7 @@ = select_tag :encoding, options_for_select([ "base64", "text" ], "text"), class: 'select2', tabindex: '-1' .file-editor.code - %pre.js-edit-mode-pane.qa-editor#editor= params[:content] || local_assigns[:blob_data] + %pre.js-edit-mode-pane.qa-editor#editor{ data: { 'editor-loading': true } }= params[:content] || local_assigns[:blob_data] - if local_assigns[:path] .js-edit-mode-pane#preview.hide .center diff --git a/app/views/projects/blob/_header_content.html.haml b/app/views/projects/blob/_header_content.html.haml index 6527c6021a0..32adfb320ff 100644 --- a/app/views/projects/blob/_header_content.html.haml +++ b/app/views/projects/blob/_header_content.html.haml @@ -10,4 +10,4 @@ = number_to_human_size(blob.raw_size) - if blob.stored_externally? && blob.external_storage == :lfs - %span.badge.label-lfs.append-right-5 LFS + %span.badge.label-lfs.gl-mr-2 LFS diff --git a/app/views/projects/blob/_viewer.html.haml b/app/views/projects/blob/_viewer.html.haml index b9663bbba15..a0d82ffd2c7 100644 --- a/app/views/projects/blob/_viewer.html.haml +++ b/app/views/projects/blob/_viewer.html.haml @@ -5,7 +5,7 @@ - external_embed = local_assigns.fetch(:external_embed, false) - viewer_url = local_assigns.fetch(:viewer_url) { url_for(safe_params.merge(viewer: viewer.type, format: :json)) } if load_async -.blob-viewer{ data: { type: viewer.type, rich_type: rich_type, url: viewer_url }, class: ('hidden' if hidden) } +.blob-viewer{ data: { type: viewer.type, rich_type: rich_type, url: viewer_url, path: viewer.blob.path }, class: ('hidden' if hidden) } - if render_error = render 'projects/blob/render_error', viewer: viewer - elsif load_async diff --git a/app/views/projects/blob/_viewer_switcher.html.haml b/app/views/projects/blob/_viewer_switcher.html.haml index 5e0d70b2ca9..df81e509c85 100644 --- a/app/views/projects/blob/_viewer_switcher.html.haml +++ b/app/views/projects/blob/_viewer_switcher.html.haml @@ -5,8 +5,8 @@ .btn-group.js-blob-viewer-switcher.ml-2{ role: "group" }> - simple_label = "Display #{simple_viewer.switcher_title}" %button.btn.btn-default.btn-sm.js-blob-viewer-switch-btn.has-tooltip{ 'aria-label' => simple_label, title: simple_label, data: { viewer: 'simple', container: 'body' } }> - = icon(simple_viewer.switcher_icon) + = sprite_icon(simple_viewer.switcher_icon) - rich_label = "Display #{rich_viewer.switcher_title}" %button.btn.btn-default.btn-sm.js-blob-viewer-switch-btn.has-tooltip{ 'aria-label' => rich_label, title: rich_label, data: { viewer: 'rich', container: 'body' } }> - = icon(rich_viewer.switcher_icon) + = sprite_icon(rich_viewer.switcher_icon) diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml index 870e37488cf..1319c58eb38 100644 --- a/app/views/projects/blob/edit.html.haml +++ b/app/views/projects/blob/edit.html.haml @@ -1,7 +1,8 @@ -- breadcrumb_title "Repository" -- page_title "Edit", @blob.path, @ref -- content_for :page_specific_javascripts do - = page_specific_javascript_tag('lib/ace.js') +- breadcrumb_title _("Repository") +- page_title _("Edit"), @blob.path, @ref +- unless Feature.enabled?(:monaco_blobs) + - content_for :page_specific_javascripts do + = page_specific_javascript_tag('lib/ace.js') - if @conflict .alert.alert-danger diff --git a/app/views/projects/blob/new.html.haml b/app/views/projects/blob/new.html.haml index 8f166e9aa16..2420c4a4bd5 100644 --- a/app/views/projects/blob/new.html.haml +++ b/app/views/projects/blob/new.html.haml @@ -1,7 +1,9 @@ -- breadcrumb_title "Repository" -- page_title "New File", @path.presence, @ref -- content_for :page_specific_javascripts do - = page_specific_javascript_tag('lib/ace.js') +- breadcrumb_title _("Repository") +- page_title _("New File"), @path.presence, @ref +- unless Feature.enabled?(:monaco_blobs) + - content_for :page_specific_javascripts do + = page_specific_javascript_tag('lib/ace.js') + .editor-title-row %h3.page-title.blob-new-page-title New file diff --git a/app/views/projects/blob/viewers/_license.html.haml b/app/views/projects/blob/viewers/_license.html.haml index fb9d0b99d09..7ac0e7bb579 100644 --- a/app/views/projects/blob/viewers/_license.html.haml +++ b/app/views/projects/blob/viewers/_license.html.haml @@ -1,6 +1,6 @@ - license = viewer.license -= icon('balance-scale fw') += sprite_icon('scale', size: 16) This project is licensed under the = succeed '.' do %strong= license.name diff --git a/app/views/projects/blob/viewers/_loading.html.haml b/app/views/projects/blob/viewers/_loading.html.haml index df1f3e4e01b..5fbe9b0df0c 100644 --- a/app/views/projects/blob/viewers/_loading.html.haml +++ b/app/views/projects/blob/viewers/_loading.html.haml @@ -1,2 +1,2 @@ -.text-center.prepend-top-default.append-bottom-default +.text-center.gl-mt-3.gl-mb-3 = icon('spinner spin 2x', 'aria-hidden' => 'true', 'aria-label' => 'Loading content…', class: 'qa-spinner') diff --git a/app/views/projects/blob/viewers/_metrics_dashboard_yml.html.haml b/app/views/projects/blob/viewers/_metrics_dashboard_yml.html.haml index fc8683e1d19..ecbf6d9005d 100644 --- a/app/views/projects/blob/viewers/_metrics_dashboard_yml.html.haml +++ b/app/views/projects/blob/viewers/_metrics_dashboard_yml.html.haml @@ -8,4 +8,4 @@ - viewer.errors.messages.each do |error| %li= error.join(': ') -= link_to _('Learn more'), help_page_path('user/project/integrations/prometheus.md', anchor: 'defining-custom-dashboards-per-project') += link_to _('Learn more'), help_page_path('operations/metrics/dashboards/index.md', anchor: 'defining-custom-dashboards-per-project') diff --git a/app/views/projects/blob/viewers/_sketch.html.haml b/app/views/projects/blob/viewers/_sketch.html.haml index b4b6492b92f..aa8d1dd326f 100644 --- a/app/views/projects/blob/viewers/_sketch.html.haml +++ b/app/views/projects/blob/viewers/_sketch.html.haml @@ -1,3 +1,3 @@ .file-content#js-sketch-viewer{ data: { endpoint: blob_raw_path } } - .js-loading-icon.text-center.prepend-top-default.append-bottom-default.js-loading-icon{ 'aria-label' => 'Loading Sketch preview' } + .js-loading-icon.text-center.gl-mt-3.gl-mb-3.js-loading-icon{ 'aria-label' => 'Loading Sketch preview' } = icon('spinner spin 2x', 'aria-hidden' => 'true'); diff --git a/app/views/projects/blob/viewers/_stl.html.haml b/app/views/projects/blob/viewers/_stl.html.haml index 55dd8cba7fe..6983c3cc81b 100644 --- a/app/views/projects/blob/viewers/_stl.html.haml +++ b/app/views/projects/blob/viewers/_stl.html.haml @@ -1,7 +1,7 @@ .file-content.is-stl-loading .text-center#js-stl-viewer{ data: { endpoint: blob_raw_path } } - = icon('spinner spin 2x', class: 'prepend-top-default append-bottom-default', 'aria-hidden' => 'true', 'aria-label' => 'Loading') - .text-center.prepend-top-default.append-bottom-default.stl-controls + = icon('spinner spin 2x', class: 'gl-mt-3 gl-mb-3', 'aria-hidden' => 'true', 'aria-label' => 'Loading') + .text-center.gl-mt-3.gl-mb-3.stl-controls .btn-group %button.btn.btn-default.btn-sm.js-material-changer{ data: { type: 'wireframe' } } Wireframe diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml index 2e9be28df86..ed7dbdeae93 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.prepend-left-5 default + %span.badge.badge-primary.gl-ml-2 default - elsif merged - %span.badge.badge-info.has-tooltip.prepend-left-5{ title: s_('Branches|Merged into %{default_branch}') % { default_branch: @repository.root_ref } } + %span.badge.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.prepend-left-5 + %span.badge.badge-success.gl-ml-2 = s_('Branches|protected') = render_if_exists 'projects/branches/diverged_from_upstream', branch: branch @@ -41,7 +41,7 @@ - if branch.name != @repository.root_ref = link_to project_compare_index_path(@project, from: @repository.root_ref, to: branch.name), - class: "btn btn-default js-onboarding-compare-branches #{'prepend-left-10' unless merge_project}", + class: "btn btn-default js-onboarding-compare-branches #{'gl-ml-3' unless merge_project}", method: :post, title: s_('Branches|Compare') do = s_('Branches|Compare') diff --git a/app/views/projects/branches/new.html.haml b/app/views/projects/branches/new.html.haml index af8887b0c39..97e46aaa710 100644 --- a/app/views/projects/branches/new.html.haml +++ b/app/views/projects/branches/new.html.haml @@ -1,4 +1,4 @@ -- page_title "New Branch" +- page_title _("New Branch") - default_ref = params[:ref] || @project.default_branch - if @error diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml index b12be8a91d6..7ce143a86b3 100644 --- a/app/views/projects/buttons/_clone.html.haml +++ b/app/views/projects/buttons/_clone.html.haml @@ -3,7 +3,7 @@ .git-clone-holder.js-git-clone-holder %a#clone-dropdown.btn.btn-primary.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } } - %span.append-right-4.js-clone-dropdown-label + %span.gl-mr-2.js-clone-dropdown-label = _('Clone') = sprite_icon("chevron-down", css_class: "icon") %ul.p-3.dropdown-menu.dropdown-menu-large.dropdown-menu-selectable.clone-options-dropdown.qa-clone-options{ class: dropdown_class } diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml index 445752d0a15..1d0ad6dcde6 100644 --- a/app/views/projects/buttons/_download.html.haml +++ b/app/views/projects/buttons/_download.html.haml @@ -12,13 +12,7 @@ %h5.m-0.dropdown-bold-header= _('Download source code') .dropdown-menu-content = render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: nil - - if vue_file_list_enabled? - #js-directory-downloads{ data: { links: directory_download_links(project, ref, archive_prefix).to_json } } - - elsif directory? - %section.border-top.pt-1.mt-1 - %h5.m-0.dropdown-bold-header= _('Download this directory') - .dropdown-menu-content - = render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: @path + #js-directory-downloads{ data: { links: directory_download_links(project, ref, archive_prefix).to_json } } - if pipeline && pipeline.latest_builds_with_artifacts.any? %section.border-top.pt-1.mt-1 %h5.m-0.dropdown-bold-header= _('Download artifacts') diff --git a/app/views/projects/cleanup/_show.html.haml b/app/views/projects/cleanup/_show.html.haml index 02e8bad69b9..52855d7ee12 100644 --- a/app/views/projects/cleanup/_show.html.haml +++ b/app/views/projects/cleanup/_show.html.haml @@ -20,7 +20,7 @@ = _("Upload object map") %button.btn.btn-default.js-choose-file{ type: "button" } = _("Choose a file") - %span.prepend-left-default.js-filename + %span.gl-ml-3.js-filename = _("No file selected") = f.file_field :bfg_object_map, class: "hidden js-object-map-input", required: true .form-text.text-muted diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index 4442bdcdf1d..71cf6ca6922 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -22,10 +22,10 @@ .header-action-buttons - if defined?(@notes_count) && @notes_count > 0 - %span.btn.disabled.btn-grouped.d-none.d-sm-block.append-right-10.has-tooltip{ title: n_("%d comment on this commit", "%d comments on this commit", @notes_count) % @notes_count } + %span.btn.disabled.btn-grouped.d-none.d-sm-block.gl-mr-3.has-tooltip{ title: n_("%d comment on this commit", "%d comments on this commit", @notes_count) % @notes_count } = sprite_icon('comment') = @notes_count - = link_to project_tree_path(@project, @commit), class: "btn btn-default append-right-10 d-none d-sm-none d-md-inline" do + = link_to project_tree_path(@project, @commit), class: "btn btn-default gl-mr-3 d-none d-sm-none d-md-inline" do #{ _('Browse files') } .dropdown.inline %a.btn.btn-default.dropdown-toggle.qa-options-button.d-md-inline{ data: { toggle: "dropdown" } } diff --git a/app/views/projects/commit/_limit_exceeded_message.html.haml b/app/views/projects/commit/_limit_exceeded_message.html.haml index 7d3c0582d0b..ace1be787fb 100644 --- a/app/views/projects/commit/_limit_exceeded_message.html.haml +++ b/app/views/projects/commit/_limit_exceeded_message.html.haml @@ -1,4 +1,4 @@ -.has-tooltip{ class: "limit-box limit-box-#{objects} prepend-left-5", data: { title: _('Project has too many %{label_for_message} to search') % { label_for_message: label_for_message } } } +.has-tooltip{ class: "limit-box limit-box-#{objects} gl-ml-2", data: { title: _('Project has too many %{label_for_message} to search') % { label_for_message: label_for_message } } } .limit-icon - if objects == :branch = sprite_icon('fork', size: 12) diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml index 7722a3523a1..737e4f66dd2 100644 --- a/app/views/projects/commits/show.html.haml +++ b/app/views/projects/commits/show.html.haml @@ -14,18 +14,18 @@ %ul.breadcrumb.repo-breadcrumb = commits_breadcrumbs #js-author-dropdown{ data: { 'commits_path': project_commits_path(@project), 'project_id': @project.id } } - .tree-controls.d-none.d-sm-none.d-md-block + .tree-controls - if @merge_request.present? - .control + .control.d-none.d-md-block = link_to _("View open merge request"), project_merge_request_path(@project, @merge_request), class: 'btn' - elsif create_mr_button?(@repository.root_ref, @ref) - .control + .control.d-none.d-md-block = link_to _("Create merge request"), create_mr_path(@repository.root_ref, @ref), class: 'btn btn-success' .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: _('Filter by commit message'), id: 'commits-search', class: 'form-control search-text-input input-short', spellcheck: false } - .control + = search_field_tag :search, params[:search], { placeholder: _('Search by message'), id: 'commits-search', class: 'form-control search-text-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' do = icon("rss") diff --git a/app/views/projects/compare/_form.html.haml b/app/views/projects/compare/_form.html.haml index f5a4889b4bb..d10fa69ff47 100644 --- a/app/views/projects/compare/_form.html.haml +++ b/app/views/projects/compare/_form.html.haml @@ -1,7 +1,7 @@ = 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 icon('exchange'), { from: params[:to], to: params[:from] }, class: 'commits-compare-switch has-tooltip btn btn-white', title: 'Swap revisions' + = link_to sprite_icon('substitute'), { from: params[:to], to: params[:from] }, class: 'commits-compare-switch has-tooltip btn 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 @@ -26,6 +26,6 @@ = button_tag s_("CompareBranches|Compare"), class: "btn btn-success commits-compare-btn" - if @merge_request.present? - = link_to _("View open merge request"), project_merge_request_path(@project, @merge_request), class: 'prepend-left-10 btn' + = link_to _("View open merge request"), project_merge_request_path(@project, @merge_request), class: 'gl-ml-3 btn' - elsif create_mr_button? - = link_to _("Create merge request"), create_mr_path, class: 'prepend-left-10 btn' + = link_to _("Create merge request"), create_mr_path, class: 'gl-ml-3 btn' diff --git a/app/views/projects/compare/index.html.haml b/app/views/projects/compare/index.html.haml index 02f2b104ce3..93ee1bed809 100644 --- a/app/views/projects/compare/index.html.haml +++ b/app/views/projects/compare/index.html.haml @@ -1,5 +1,5 @@ -- breadcrumb_title "Compare Revisions" -- page_title "Compare" +- breadcrumb_title _("Compare Revisions") +- page_title _("Compare") %h3.page-title = _("Compare Git revisions") diff --git a/app/views/projects/confluences/show.html.haml b/app/views/projects/confluences/show.html.haml new file mode 100644 index 00000000000..b87780db4cd --- /dev/null +++ b/app/views/projects/confluences/show.html.haml @@ -0,0 +1,13 @@ +- breadcrumb_title _('Confluence') +- page_title _('Confluence') += render layout: 'shared/empty_states/wikis_layout', locals: { image_path: 'illustrations/wiki_login_empty.svg' } do + %h4 + = s_('WikiEmpty|Confluence is enabled') + %p + - wiki_confluence_epic_link_url = 'https://gitlab.com/groups/gitlab-org/-/epics/3629' + - wiki_confluence_epic_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: wiki_confluence_epic_link_url } + = s_("WikiEmpty|You've enabled the Confluence Workspace integration. Your wiki will be viewable directly within Confluence. We are hard at work integrating Confluence more seamlessly into GitLab. If you'd like to stay up to date, follow our %{wiki_confluence_epic_link_start}Confluence epic%{wiki_confluence_epic_link_end}.").html_safe % { wiki_confluence_epic_link_start: wiki_confluence_epic_link_start, wiki_confluence_epic_link_end: '</a>'.html_safe } + = link_to @project.confluence_service.confluence_url, target: '_blank', rel: 'noopener noreferrer', class: 'btn btn-success external-url', title: s_('WikiEmpty|Go to Confluence') do + = sprite_icon('external-link') + = s_('WikiEmpty|Go to Confluence') + diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index b6c30c680e4..090fc602ebb 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -1,4 +1,4 @@ -- page_title "Value Stream Analytics" +- page_title _("Value Stream Analytics") #cycle-analytics{ "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project) } } - if @cycle_analytics_no_data diff --git a/app/views/projects/default_branch/_show.html.haml b/app/views/projects/default_branch/_show.html.haml index 6a09004143e..38bec0361b0 100644 --- a/app/views/projects/default_branch/_show.html.haml +++ b/app/views/projects/default_branch/_show.html.haml @@ -26,6 +26,6 @@ %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") - = link_to icon('question-circle'), help_page_path('user/project/issues/managing_issues.html', anchor: 'disabling-automatic-issue-closing'), target: '_blank' + = link_to icon('question-circle'), help_page_path('user/project/issues/managing_issues.md', anchor: 'disabling-automatic-issue-closing'), target: '_blank' = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/projects/deploy_keys/edit.html.haml b/app/views/projects/deploy_keys/edit.html.haml index 0ce93eef369..7fa7036245c 100644 --- a/app/views/projects/deploy_keys/edit.html.haml +++ b/app/views/projects/deploy_keys/edit.html.haml @@ -1,4 +1,4 @@ -- page_title 'Edit Deploy Key' +- page_title _('Edit Deploy Key') %h3.page-title= _('Edit Deploy Key') %hr diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml index cf7fe36af9d..4b76dde681e 100644 --- a/app/views/projects/diffs/_diffs.html.haml +++ b/app/views/projects/diffs/_diffs.html.haml @@ -16,6 +16,8 @@ = diff_merge_request_whitespace_link(diffs.project, @merge_request, class: 'd-none d-sm-inline-block') - elsif current_controller?(:compare) = diff_compare_whitespace_link(diffs.project, params[:from], params[:to], class: 'd-none d-sm-inline-block') + - elsif current_controller?(:wikis) + = toggle_whitespace_link(url_for(params_with_whitespace), class: 'd-none d-sm-inline-block') .btn-group = inline_diff_btn = parallel_diff_btn diff --git a/app/views/projects/diffs/_file_header.html.haml b/app/views/projects/diffs/_file_header.html.haml index 6a1bff8640c..f954b09abee 100644 --- a/app/views/projects/diffs/_file_header.html.haml +++ b/app/views/projects/diffs/_file_header.html.haml @@ -37,4 +37,4 @@ #{diff_file.a_mode} → #{diff_file.b_mode} - if diff_file.stored_externally? && diff_file.external_storage == :lfs - %span.badge.label-lfs.append-right-5 LFS + %span.badge.label-lfs.gl-mr-2 LFS diff --git a/app/views/projects/diffs/_stats.html.haml b/app/views/projects/diffs/_stats.html.haml index 17c1764e8a4..0e2a1165ad3 100644 --- a/app/views/projects/diffs/_stats.html.haml +++ b/app/views/projects/diffs/_stats.html.haml @@ -4,7 +4,7 @@ Showing %button.diff-stats-summary-toggler.js-diff-stats-dropdown{ type: "button", data: { toggle: "dropdown", display: "static" } }< = pluralize(diff_files.size, "changed file") - = icon("caret-down", class: "prepend-left-5") + = icon("caret-down", class: "gl-ml-2") %span.diff-stats-additions-deletions-expanded#diff-stats with %strong.cgreen= pluralize(sum_added_lines, 'addition') @@ -30,7 +30,7 @@ - else %strong.diff-changed-blank-file-name = s_('Diffs|No file name available') - %span.diff-changed-file-path.prepend-top-5= diff_file_path_text(diff_file) + %span.diff-changed-file-path.gl-mt-2= diff_file_path_text(diff_file) %span.diff-changed-stats %span.cgreen< +#{diff_file.added_lines} diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index 3c6fb5b19a4..e63b615115a 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -56,7 +56,7 @@ = render_if_exists 'projects/settings/default_issue_template' -= render_if_exists 'projects/service_desk_settings' += render 'projects/service_desk_settings' %section.qa-advanced-settings.settings.advanced-settings.no-animate#js-project-advanced-settings{ class: ('expanded' if expanded) } .settings-header diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml index 6b1455acd08..bfb22aa8025 100644 --- a/app/views/projects/empty.html.haml +++ b/app/views/projects/empty.html.haml @@ -1,5 +1,7 @@ - @content_class = "limit-container-width" unless fluid_layout +- default_branch_name = Gitlab::CurrentSettings.default_branch_name.presence || "master" - breadcrumb_title _("Details") +- page_title _("Details") = render partial: 'flash_messages', locals: { project: @project } @@ -46,7 +48,7 @@ git commit -m "add README" - if @project.can_current_user_push_to_default_branch? %span>< - git push -u origin master + git push -u origin #{ default_branch_name } %fieldset %h5= _('Push an existing folder') @@ -59,7 +61,7 @@ git commit -m "Initial commit" - if @project.can_current_user_push_to_default_branch? %span>< - git push -u origin master + git push -u origin #{ default_branch_name } %fieldset %h5= _('Push an existing Git repository') diff --git a/app/views/projects/environments/_form.html.haml b/app/views/projects/environments/_form.html.haml index efe80a4877c..39eda493d69 100644 --- a/app/views/projects/environments/_form.html.haml +++ b/app/views/projects/environments/_form.html.haml @@ -1,9 +1,9 @@ -.row.prepend-top-default.append-bottom-default +.row.gl-mt-3.gl-mb-3 .col-lg-3 %h4.gl-mt-0 = _("Environments") %p - - link_to_read_more = link_to(_("Read more about environments"), help_page_path("ci/environments/index.md")) + - link_to_read_more = link_to(_("More information"), help_page_path("ci/environments/index.md")) = _("Environments allow you to track deployments of your application %{link_to_read_more}.").html_safe % { link_to_read_more: link_to_read_more } = form_for [@project.namespace.becomes(Namespace), @project, @environment], html: { class: 'col-lg-9' } do |f| diff --git a/app/views/projects/find_file/show.html.haml b/app/views/projects/find_file/show.html.haml index 971107675ab..786af3714a6 100644 --- a/app/views/projects/find_file/show.html.haml +++ b/app/views/projects/find_file/show.html.haml @@ -1,4 +1,4 @@ -- page_title "Find File", @ref +- page_title _("Find File"), @ref .file-finder-holder.tree-holder.clearfix.js-file-finder{ 'data-file-find-url': "#{escape_javascript(project_files_path(@project, @ref, format: :json))}", 'data-find-tree-url': escape_javascript(project_tree_path(@project, @ref)), 'data-blob-url-template': escape_javascript(project_blob_path(@project, @id || @commit.id)) } .nav-block @@ -23,5 +23,5 @@ = _('There are no matching files') %p.text-secondary = _('Try using a different search term to find the file you are looking for.') - .text-center.prepend-top-default.loading + .text-center.gl-mt-3.loading .spinner.spinner-md diff --git a/app/views/projects/forks/_fork_button.html.haml b/app/views/projects/forks/_fork_button.html.haml index 70064722832..eec02a50b85 100644 --- a/app/views/projects/forks/_fork_button.html.haml +++ b/app/views/projects/forks/_fork_button.html.haml @@ -2,17 +2,17 @@ - can_create_project = current_user.can?(:create_projects, namespace) - if forked_project = namespace.find_fork_of(@project) - .bordered-box.fork-thumbnail.text-center.prepend-left-default.append-right-default.prepend-top-default.append-bottom-default.forked + .bordered-box.fork-thumbnail.text-center.gl-ml-3.gl-mr-3.gl-mt-3.gl-mb-3.forked = link_to project_path(forked_project) do - if /no_((\w*)_)*avatar/.match(avatar) = group_icon(namespace, class: "avatar rect-avatar s100 identicon mx-auto") - else .avatar-container.s100.mx-auto = image_tag(avatar, class: "avatar s100") - %h5.prepend-top-default + %h5.gl-mt-3 = namespace.human_name - else - .bordered-box.fork-thumbnail.text-center.prepend-left-default.append-right-default.prepend-top-default.append-bottom-default{ class: ("disabled" unless can_create_project) } + .bordered-box.fork-thumbnail.text-center.gl-ml-3.gl-mr-3.gl-mt-3.gl-mb-3{ class: ("disabled" unless can_create_project) } = link_to project_forks_path(@project, namespace_key: namespace.id), method: "POST", class: ("disabled has-tooltip" unless can_create_project), @@ -22,5 +22,5 @@ - else .avatar-container.s100.mx-auto = image_tag(avatar, class: "avatar s100") - %h5.prepend-top-default{ data: { qa_selector: 'fork_namespace_content', qa_name: namespace.human_name } } + %h5.gl-mt-3{ data: { qa_selector: 'fork_namespace_content', qa_name: namespace.human_name } } = namespace.human_name diff --git a/app/views/projects/forks/new.html.haml b/app/views/projects/forks/new.html.haml index 763e31c4a8b..887081d0f35 100644 --- a/app/views/projects/forks/new.html.haml +++ b/app/views/projects/forks/new.html.haml @@ -1,6 +1,6 @@ - page_title _("Fork project") -.row.prepend-top-default +.row.gl-mt-3 .col-lg-3 %h4.gl-mt-0 = _("Fork project") @@ -9,13 +9,13 @@ .col-lg-9 - if @namespaces.present? .fork-thumbnail-container.js-fork-content - %h5.gl-mt-0.gl-mb-0.prepend-left-default.append-right-default + %h5.gl-mt-0.gl-mb-0.gl-ml-3.gl-mr-3 = _("Select a namespace to fork the project") - @namespaces.each do |namespace| = render 'fork_button', namespace: namespace - else %strong = _("No available namespaces to fork the project.") - %p.prepend-top-default + %p.gl-mt-3 = _("You must have permission to create a project in a namespace before forking.") diff --git a/app/views/projects/hook_logs/_index.html.haml b/app/views/projects/hook_logs/_index.html.haml index e7b924c65bf..a8a4eef65b3 100644 --- a/app/views/projects/hook_logs/_index.html.haml +++ b/app/views/projects/hook_logs/_index.html.haml @@ -1,4 +1,4 @@ -.row.gl-mt-7.append-bottom-default +.row.gl-mt-7.gl-mb-3 .col-lg-3 %h4.gl-mt-0 Recent Deliveries diff --git a/app/views/projects/hook_logs/show.html.haml b/app/views/projects/hook_logs/show.html.haml index a6a3f56c28c..8a8c396a9e4 100644 --- a/app/views/projects/hook_logs/show.html.haml +++ b/app/views/projects/hook_logs/show.html.haml @@ -2,11 +2,11 @@ - add_to_breadcrumbs _('Webhook Settings'), namespace_project_hooks_path - page_title _('Webhook Logs') -.row.prepend-top-default.append-bottom-default +.row.gl-mt-3.gl-mb-3 .col-lg-3 %h4.gl-mt-0 Request details .col-lg-9 - = link_to 'Resend Request', @hook_log.present.retry_path, method: :post, class: "btn btn-default float-right prepend-left-10" + = link_to 'Resend Request', @hook_log.present.retry_path, method: :post, class: "btn btn-default float-right gl-ml-3" = render partial: 'shared/hook_logs/content', locals: { hook_log: @hook_log } diff --git a/app/views/projects/hooks/edit.html.haml b/app/views/projects/hooks/edit.html.haml index 15100840c0a..e0ef0c0d3f9 100644 --- a/app/views/projects/hooks/edit.html.haml +++ b/app/views/projects/hooks/edit.html.haml @@ -2,11 +2,11 @@ - add_to_breadcrumbs _('Webhook Settings'), namespace_project_hooks_path - page_title _('Webhook') -.row.prepend-top-default +.row.gl-mt-3 .col-lg-3 = render 'shared/web_hooks/title_and_docs', hook: @hook - .col-lg-9.append-bottom-default + .col-lg-9.gl-mb-3 = form_for [@project.namespace.becomes(Namespace), @project, @hook], as: :hook, url: project_hook_path(@project, @hook) do |f| = render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook } diff --git a/app/views/projects/hooks/index.html.haml b/app/views/projects/hooks/index.html.haml index 169a5cc9d6b..1845bd190d3 100644 --- a/app/views/projects/hooks/index.html.haml +++ b/app/views/projects/hooks/index.html.haml @@ -2,11 +2,11 @@ - breadcrumb_title _('Webhook Settings') - page_title _('Webhooks') -.row.prepend-top-default +.row.gl-mt-3 .col-lg-4 = render 'shared/web_hooks/title_and_docs', hook: @hook - .col-lg-8.append-bottom-default + .col-lg-8.gl-mb-3 = form_for @hook, as: :hook, url: polymorphic_path([@project.namespace.becomes(Namespace), @project, :hooks]) do |f| = render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook } = f.submit 'Add webhook', class: 'btn btn-success' diff --git a/app/views/projects/import/jira/show.html.haml b/app/views/projects/import/jira/show.html.haml index fe6cc6fa828..3c0664e4d5f 100644 --- a/app/views/projects/import/jira/show.html.haml +++ b/app/views/projects/import/jira/show.html.haml @@ -3,4 +3,5 @@ jira_integration_path: edit_project_service_path(@project, :jira), is_jira_configured: @project.jira_service&.active? && @project.jira_service&.valid_connection?.to_s, in_progress_illustration: image_path('illustrations/export-import.svg'), + project_id: @project.id, setup_illustration: image_path('illustrations/manual_action.svg') } } diff --git a/app/views/projects/imports/new.html.haml b/app/views/projects/imports/new.html.haml index bd0ab2c19f2..58981ca1556 100644 --- a/app/views/projects/imports/new.html.haml +++ b/app/views/projects/imports/new.html.haml @@ -1,4 +1,4 @@ -- page_title "Import repository" +- page_title _("Import repository") %h3.page-title Import repository diff --git a/app/views/projects/issues/_alert_moved_from_service_desk.html.haml b/app/views/projects/issues/_alert_moved_from_service_desk.html.haml new file mode 100644 index 00000000000..a6f969f8b10 --- /dev/null +++ b/app/views/projects/issues/_alert_moved_from_service_desk.html.haml @@ -0,0 +1,10 @@ +- return unless show_moved_service_desk_issue_warning?(issue) +- service_desk_link_url = help_page_path('user/project/service_desk') +- service_desk_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: service_desk_link_url } + +.hide.gl-alert.gl-alert-warning.js-alert-moved-from-service-desk-warning.gl-mt-5{ role: 'alert' } + = sprite_icon('warning', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') } + = sprite_icon('close', size: 16, css_class: 'gl-icon') + .gl-alert-body.gl-mr-3 + = s_('This project does not have %{service_desk_link_start}Service Desk%{service_desk_link_end} enabled, so the user who created the issue will no longer receive email notifications about new activity.').html_safe % { service_desk_link_start: service_desk_link_start, service_desk_link_end: '</a>'.html_safe } diff --git a/app/views/projects/issues/_by_email_description.html.haml b/app/views/projects/issues/_by_email_description.html.haml index f2d58534903..0ff852352e1 100644 --- a/app/views/projects/issues/_by_email_description.html.haml +++ b/app/views/projects/issues/_by_email_description.html.haml @@ -1,6 +1,6 @@ The subject will be used as the title of the new issue, and the message will be the description. -= link_to 'Quick actions', help_page_path('user/project/quick_actions'), target: '_blank', tabindex: -1 += link_to 'Quick actions', help_page_path('user/project/quick_actions'), target: '_blank' and styling with -= link_to 'Markdown', help_page_path('user/markdown'), target: '_blank', tabindex: -1 += link_to 'Markdown', help_page_path('user/markdown'), target: '_blank' are supported. diff --git a/app/views/projects/issues/_design_management.html.haml b/app/views/projects/issues/_design_management.html.haml index 96f1dc0155c..045f032e6e7 100644 --- a/app/views/projects/issues/_design_management.html.haml +++ b/app/views/projects/issues/_design_management.html.haml @@ -1,15 +1,27 @@ - if @project.design_management_enabled? - .js-design-management{ data: { project_path: @project.full_path, issue_iid: @issue.iid, issue_path: project_issue_path(@project, @issue) } } + - if Feature.enabled?(:design_management_moved, @project, default_enabled: true) + .js-design-management-new{ data: { project_path: @project.full_path, issue_iid: @issue.iid, issue_path: project_issue_path(@project, @issue) } } + - else + .js-design-management{ data: { project_path: @project.full_path, issue_iid: @issue.iid, issue_path: project_issue_path(@project, @issue) } } - else - .mt-4 - .row.empty-state - .col-12 - .text-content - %h4.center - = _('The one place for your designs') - %p.center - - requirements_link_url = help_page_path('user/project/issues/design_management', anchor: 'requirements') - - requirements_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: requirements_link_url } - - support_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: support_url } - - link_end = '</a>'.html_safe - = s_("DesignManagement|To enable design management, you'll need to %{requirements_link_start}meet the requirements%{requirements_link_end}. If you need help, reach out to our %{support_link_start}support team%{support_link_end} for assistance.").html_safe % { requirements_link_start: requirements_link_start, requirements_link_end: link_end, support_link_start: support_link_start, support_link_end: link_end } + - if Feature.enabled?(:design_management_moved, @project, default_enabled: true) + .row.empty-state.design-dropzone-border.gl-mt-5 + .text-content.center.gl-font-weight-bold + - requirements_link_url = help_page_path('user/project/issues/design_management', anchor: 'requirements') + - requirements_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: requirements_link_url } + - support_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: support_url } + - link_end = '</a>'.html_safe + = s_("DesignManagement|To enable design management, you'll need to %{requirements_link_start}meet the requirements%{requirements_link_end}. If you need help, reach out to our %{support_link_start}support team%{support_link_end} for assistance.").html_safe % { requirements_link_start: requirements_link_start, requirements_link_end: link_end, support_link_start: support_link_start, support_link_end: link_end } + - else + .mt-4 + .row.empty-state + .col-12 + .text-content + %h4.center + = _('The one place for your designs') + %p.center + - requirements_link_url = help_page_path('user/project/issues/design_management', anchor: 'requirements') + - requirements_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: requirements_link_url } + - support_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: support_url } + - link_end = '</a>'.html_safe + = s_("DesignManagement|To enable design management, you'll need to %{requirements_link_start}meet the requirements%{requirements_link_end}. If you need help, reach out to our %{support_link_start}support team%{support_link_end} for assistance.").html_safe % { requirements_link_start: requirements_link_start, requirements_link_end: link_end, support_link_start: support_link_start, support_link_end: link_end } diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml index 9c129fa9ecc..bcc74e8d1d9 100644 --- a/app/views/projects/issues/_discussion.html.haml +++ b/app/views/projects/issues/_discussion.html.haml @@ -7,7 +7,7 @@ %section.issuable-discussion.js-vue-notes-event #js-vue-notes{ data: { notes_data: notes_data(@issue).to_json, - noteable_data: serialize_issuable(@issue, with_blocking_issues: Feature.enabled?(:prevent_closing_blocked_issues, @issue.project)), + noteable_data: serialize_issuable(@issue, with_blocking_issues: true), noteable_type: 'Issue', target_type: 'issue', current_user_data: UserSerializer.new.represent(current_user, {only_path: true}, CurrentUserEntity).to_json } } diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index e325d585d0c..e7cd35497e8 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -47,7 +47,7 @@ .issuable-meta %ul.controls - - if issue.moved? + - if issue.closed? && issue.moved? %li.issuable-status = _('CLOSED (MOVED)') - elsif issue.closed? diff --git a/app/views/projects/issues/_issues.html.haml b/app/views/projects/issues/_issues.html.haml index 7d539c9d749..c0383c57e63 100644 --- a/app/views/projects/issues/_issues.html.haml +++ b/app/views/projects/issues/_issues.html.haml @@ -1,9 +1,14 @@ -- empty_state_path = local_assigns.fetch(:empty_state_path, 'shared/empty_states/issues') +- if Feature.enabled?(:vue_issuables_list, @project) + .js-issuables-list{ data: { endpoint: expose_url(api_v4_projects_issues_path(id: @project.id)), + 'can-bulk-edit': @can_bulk_update.to_json, + 'empty-svg-path': image_path('illustrations/issues.svg'), + 'sort-key': @sort } } +- else + - empty_state_path = local_assigns.fetch(:empty_state_path, 'shared/empty_states/issues') + %ul.content-list.issues-list.issuable-list{ class: ("manual-ordering" if @sort == 'relative_position') } + = render partial: "projects/issues/issue", collection: @issues + - if @issues.blank? + = render empty_state_path -%ul.content-list.issues-list.issuable-list{ class: ("manual-ordering" if @sort == 'relative_position') } - = render partial: "projects/issues/issue", collection: @issues - - if @issues.blank? - = render empty_state_path - -- if @issues.present? - = paginate @issues, theme: "gitlab", total_pages: @total_pages + - if @issues.present? + = paginate @issues, theme: "gitlab", total_pages: @total_pages diff --git a/app/views/projects/issues/_nav_btns.html.haml b/app/views/projects/issues/_nav_btns.html.haml index 71c9bb36936..cc6ca4aca4a 100644 --- a/app/views/projects/issues/_nav_btns.html.haml +++ b/app/views/projects/issues/_nav_btns.html.haml @@ -14,7 +14,7 @@ = render 'projects/issues/import_csv/button' - if @can_bulk_update - = button_tag _("Edit issues"), class: "btn btn-default append-right-10 js-bulk-update-toggle" + = button_tag _("Edit issues"), class: "btn btn-default gl-mr-3 js-bulk-update-toggle" - if show_new_issue_link?(@project) = link_to _("New issue"), new_project_issue_path(@project, issue: { assignee_id: finder.assignee.try(:id), diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml index 73904354a12..9bbab925f6a 100644 --- a/app/views/projects/issues/_new_branch.html.haml +++ b/app/views/projects/issues/_new_branch.html.haml @@ -41,7 +41,7 @@ = _('Create branch') %li.divider.droplab-item-ignore - %li.droplab-item-ignore.gl-ml-3.gl-mr-3.prepend-top-16 + %li.droplab-item-ignore.gl-ml-3.gl-mr-3.gl-mt-5 - if can_create_confidential_merge_request? #js-forked-project{ data: { namespace_path: @project.namespace.full_path, project_path: @project.full_path, new_fork_path: new_project_fork_path(@project), help_page_path: help_page_path('user/project/merge_requests') } } .form-group diff --git a/app/views/projects/issues/_service_desk_info_content.html.haml b/app/views/projects/issues/_service_desk_info_content.html.haml new file mode 100644 index 00000000000..ddd8e545043 --- /dev/null +++ b/app/views/projects/issues/_service_desk_info_content.html.haml @@ -0,0 +1,39 @@ +- is_empty_state = @issues.blank? +- service_desk_enabled = @project.service_desk_enabled? + +- callout_selector = is_empty_state ? 'empty-state' : 'non-empty-state media' +- svg_path = !is_empty_state ? 'shared/empty_states/icons/service_desk_callout.svg' : 'shared/empty_states/icons/service_desk_empty_state.svg' +- can_edit_project_settings = can?(current_user, :admin_project, @project) +- title_text = _("Use Service Desk to connect with your users (e.g. to offer customer support) through email right inside GitLab") + +- if Gitlab::ServiceDesk.supported? + %div{ class: "#{callout_selector}" } + .svg-content + = render svg_path + + %div{ class: is_empty_state ? "text-content" : "prepend-top-10 gl-ml-3" } + - if is_empty_state + %h4= title_text + - else + %h5= title_text + + - if can_edit_project_settings && service_desk_enabled + %p + = _("Have your users email") + %code= @project.service_desk_address + + %span= _("Those emails automatically become issues (with the comments becoming the email conversation) listed here.") + = link_to _('Read more'), help_page_path('user/project/service_desk') + + - if can_edit_project_settings && !service_desk_enabled + %div{ class: is_empty_state ? "text-center" : "prepend-top-10" } + = link_to _("Turn on Service Desk"), edit_project_path(@project), class: 'btn btn-success' +- else + .empty-state + .svg-content + = render 'shared/empty_states/icons/service_desk_setup.svg' + .text-content + %h4= _('Service Desk is enabled but not yet active') + %p + = _("You must set up incoming email before it becomes active.") + = link_to _('More information'), help_page_path('administration/incoming_email', anchor: 'set-it-up') diff --git a/app/views/projects/issues/edit.html.haml b/app/views/projects/issues/edit.html.haml index 1b7d878c38c..353ff9c1cc2 100644 --- a/app/views/projects/issues/edit.html.haml +++ b/app/views/projects/issues/edit.html.haml @@ -1,4 +1,4 @@ -- page_title "Edit", "#{@issue.title} (#{@issue.to_reference})", "Issues" +- page_title _("Edit"), "#{@issue.title} (#{@issue.to_reference})", _("Issues") %h3.page-title Edit Issue ##{@issue.iid} diff --git a/app/views/projects/issues/export_csv/_modal.html.haml b/app/views/projects/issues/export_csv/_modal.html.haml index 9fdeb901b56..342c3ba27bb 100644 --- a/app/views/projects/issues/export_csv/_modal.html.haml +++ b/app/views/projects/issues/export_csv/_modal.html.haml @@ -12,7 +12,7 @@ .modal-body .modal-subheader = icon('check', { class: 'checkmark' }) - %strong.prepend-left-10 + %strong.gl-ml-3 - issues_count = issuables_count_for_state(:issues, params[:state]) = n_('%d issue selected', '%d issues selected', issues_count) % issues_count .modal-text diff --git a/app/views/projects/issues/import_csv/_button.html.haml b/app/views/projects/issues/import_csv/_button.html.haml index 7119b22daef..ea8f53f7342 100644 --- a/app/views/projects/issues/import_csv/_button.html.haml +++ b/app/views/projects/issues/import_csv/_button.html.haml @@ -3,7 +3,7 @@ .dropdown.btn-group %button.btn.rounded-right.text-center{ class: ('has-tooltip' if type == :icon), title: (_('Import issues') if type == :icon), - data: { toggle: 'dropdown' }, 'aria-label' => _('Import issues'), 'aria-haspopup' => 'true', 'aria-expanded' => 'false' } + data: { toggle: 'dropdown', qa_selector: 'import_issues_button' }, 'aria-label' => _('Import issues'), 'aria-haspopup' => 'true', 'aria-expanded' => 'false' } - if type == :icon = sprite_icon('import') - else @@ -13,4 +13,5 @@ %button{ data: { toggle: 'modal', target: '.issues-import-modal' } } = _('Import CSV') - if can_edit - %li= link_to _('Import from Jira'), project_import_jira_path(@project) + %li{ data: { qa_selector: 'import_from_jira_link' } } + = link_to _('Import from Jira'), project_import_jira_path(@project) diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml index 826a62e39d3..cfc423da57a 100644 --- a/app/views/projects/issues/index.html.haml +++ b/app/views/projects/issues/index.html.haml @@ -1,6 +1,6 @@ - @can_bulk_update = can?(current_user, :admin_issue, @project) -- page_title "Issues" +- page_title _("Issues") - new_issue_email = @project.new_issuable_address(current_user, 'issue') = content_for :meta_tags do diff --git a/app/views/projects/issues/service_desk.html.haml b/app/views/projects/issues/service_desk.html.haml new file mode 100644 index 00000000000..9b0b3ebc9e0 --- /dev/null +++ b/app/views/projects/issues/service_desk.html.haml @@ -0,0 +1,21 @@ +- @can_bulk_update = false + +- page_title _("Service Desk") + +- content_for :breadcrumbs_extra do + = render "projects/issues/nav_btns", show_export_button: false, show_rss_button: false + +- support_bot_attrs = UserSerializer.new.represent(User.support_bot).to_json + +%div{ class: "js-service-desk-issues service-desk-issues", data: { support_bot: support_bot_attrs } } + .top-area + = render 'shared/issuable/nav', type: :issues + .nav-controls.d-block.d-sm-none + = render "projects/issues/nav_btns", show_feed_buttons: false, show_import_button: false, show_export_button: false + + - if @issues.present? + = render 'shared/issuable/search_bar', type: :issues + = render 'service_desk_info_content' + + .issues-holder + = render 'projects/issues/issues', empty_state_path: 'service_desk_info_content' diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 4d24b510267..2a0dc5e30b9 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -11,7 +11,7 @@ - can_create_issue = show_new_issue_link?(@project) = render_if_exists "projects/issues/alert_blocked", issue: @issue, current_user: current_user -= render_if_exists "projects/issues/alert_moved_from_service_desk", issue: @issue += render "projects/issues/alert_moved_from_service_desk", issue: @issue .detail-page-header .detail-page-header-body @@ -24,14 +24,11 @@ %span.d-none.d-sm-block Open .issuable-meta - - if @issue.confidential - .issuable-warning-icon.inline= sprite_icon('eye-slash', size: 16, css_class: 'icon') - - if @issue.discussion_locked? - .issuable-warning-icon.inline= sprite_icon('lock', size: 16, css_class: 'icon') + #js-issuable-header-warnings = issuable_meta(@issue, @project, "Issue") %a.btn.btn-default.float-right.d-block.d-sm-none.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" } - = icon('angle-double-left') + = sprite_icon('chevron-double-lg-left') .detail-page-header-actions.js-issuable-actions.js-issuable-buttons{ data: { "action": "close-reopen" } } .clearfix.issue-btn-group.dropdown @@ -77,6 +74,9 @@ - if @issue.sentry_issue.present? #js-sentry-error-stack-trace{ data: error_details_data(@project, @issue.sentry_issue.sentry_issue_identifier) } + - if Feature.enabled?(:design_management_moved, @project, default_enabled: true) + = render 'projects/issues/design_management' + = render_if_exists 'projects/issues/related_issues' #js-related-merge-requests{ data: { endpoint: expose_path(api_v4_projects_issues_related_merge_requests_path(id: @project.id, issue_iid: @issue.iid)), project_namespace: @project.namespace.path, project_path: @project.path } } @@ -86,14 +86,17 @@ -# This element is filled in using JavaScript. .content-block.emoji-block.emoji-block-sticky - .row - .col-md-12.col-lg-4.js-noteable-awards + .row.gl-m-0.gl-justify-content-space-between + .js-noteable-awards = render 'award_emoji/awards_block', awardable: @issue, inline: true - .col-md-12.col-lg-8.new-branch-col + .new-branch-col #js-vue-sort-issue-discussions #js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(@issue), notes_filters: UserPreference.notes_filters.to_json } } = render 'new_branch' if show_new_branch_button? - = render 'projects/issues/tabs' + - if Feature.enabled?(:design_management_moved, @project, default_enabled: true) + = render 'projects/issues/discussion' + - else + = render 'projects/issues/tabs' = render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @issue.assignees diff --git a/app/views/projects/jobs/index.html.haml b/app/views/projects/jobs/index.html.haml index 5acb2af08e4..4f537ee8014 100644 --- a/app/views/projects/jobs/index.html.haml +++ b/app/views/projects/jobs/index.html.haml @@ -1,4 +1,4 @@ -- page_title "Jobs" +- page_title _("Jobs") .top-area - build_path_proc = ->(scope) { project_jobs_path(@project, scope: scope) } diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml index 2e322c7db23..df98a1c7cce 100644 --- a/app/views/projects/jobs/show.html.haml +++ b/app/views/projects/jobs/show.html.haml @@ -5,4 +5,6 @@ - content_for :page_specific_javascripts do = stylesheet_link_tag 'page_bundles/xterm' += render_if_exists "shared/shared_runners_minutes_limit_flash_message" + #js-job-vue-app{ data: jobs_data } diff --git a/app/views/projects/jobs/terminal.html.haml b/app/views/projects/jobs/terminal.html.haml index 5439a4b5d5c..01f40543926 100644 --- a/app/views/projects/jobs/terminal.html.haml +++ b/app/views/projects/jobs/terminal.html.haml @@ -1,7 +1,7 @@ -- add_to_breadcrumbs 'Jobs', project_jobs_path(@project) +- add_to_breadcrumbs _('Jobs'), project_jobs_path(@project) - add_to_breadcrumbs "##{@build.id}", project_job_path(@project, @build) -- breadcrumb_title 'Terminal' -- page_title 'Terminal', "#{@build.name} (##{@build.id})", 'Jobs' +- breadcrumb_title _('Terminal') +- page_title _('Terminal'), "#{@build.name} (##{@build.id})", _('Jobs') - content_for :page_specific_javascripts do = stylesheet_link_tag "xterm.css" diff --git a/app/views/projects/labels/edit.html.haml b/app/views/projects/labels/edit.html.haml index b7996f0dad1..343900359b4 100644 --- a/app/views/projects/labels/edit.html.haml +++ b/app/views/projects/labels/edit.html.haml @@ -1,6 +1,6 @@ -- add_to_breadcrumbs "Labels", project_labels_path(@project) -- breadcrumb_title "Edit" -- page_title "Edit", @label.name, "Labels" +- add_to_breadcrumbs _("Labels"), project_labels_path(@project) +- breadcrumb_title _("Edit") +- page_title _("Edit"), @label.name, _("Labels") %h3.page-title Edit Label diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml index 760d81136c6..ba47712211d 100644 --- a/app/views/projects/labels/index.html.haml +++ b/app/views/projects/labels/index.html.haml @@ -1,4 +1,4 @@ -- page_title "Labels" +- page_title _("Labels") - can_admin_label = can?(current_user, :admin_label, @project) - search = params[:search] - subscribed = params[:subscribed] @@ -52,5 +52,5 @@ = render 'shared/empty_states/labels' %template#js-badge-item-template - %li.label-link-item.js-priority-badge.inline.prepend-left-10 + %li.label-link-item.js-priority-badge.inline.gl-ml-3 .label-badge.label-badge-blue= _('Prioritized label') diff --git a/app/views/projects/labels/new.html.haml b/app/views/projects/labels/new.html.haml index 96ce0eba2c6..38bd6102437 100644 --- a/app/views/projects/labels/new.html.haml +++ b/app/views/projects/labels/new.html.haml @@ -1,6 +1,6 @@ -- add_to_breadcrumbs "Labels", project_labels_path(@project) -- breadcrumb_title "New" -- page_title "New Label" +- add_to_breadcrumbs _("Labels"), project_labels_path(@project) +- breadcrumb_title _("New") +- page_title _("New Label") %h3.page-title New Label diff --git a/app/views/projects/merge_requests/_approvals_count.html.haml b/app/views/projects/merge_requests/_approvals_count.html.haml new file mode 100644 index 00000000000..464cba1bb2d --- /dev/null +++ b/app/views/projects/merge_requests/_approvals_count.html.haml @@ -0,0 +1,13 @@ +- merge_request = local_assigns.fetch(:merge_request) +- self_approved = merge_request.approved_by?(current_user) +- total = merge_request.approvals.size + +- if total > 0 + - final_text = n_("%d approver", "%d approvers", total) % total + - final_self_text = n_("%d approver (you've approved)", "%d approvers (you've approved)", total) % total + + - approval_icon = sprite_icon((self_approved ? 'approval-solid' : 'approval'), size: 16, css_class: 'align-middle') + + %li.d-none.d-sm-inline-block.has-tooltip.text-success{ title: self_approved ? final_self_text : final_text } + = approval_icon + = _("Approved") diff --git a/app/views/projects/merge_requests/_discussion.html.haml b/app/views/projects/merge_requests/_discussion.html.haml index 3303aa72604..ecb51aca847 100644 --- a/app/views/projects/merge_requests/_discussion.html.haml +++ b/app/views/projects/merge_requests/_discussion.html.haml @@ -5,7 +5,7 @@ - if @merge_request.reopenable? = link_to 'Reopen merge request', merge_request_path(@merge_request, merge_request: { state_event: :reopen }), method: :put, class: "btn btn-nr btn-comment btn-reopen reopen-mr-link js-note-target-close js-note-target-reopen", title: "Reopen merge request", data: { original_text: "Reopen merge request", alternative_text: "Comment & reopen merge request"} %comment-and-resolve-btn{ "inline-template" => true } - %button.btn.btn-nr.btn-default.append-right-10.js-comment-resolve-button{ "v-if" => "showButton", type: "submit", data: { project_path: "#{project_path(@merge_request.project)}" } } + %button.btn.btn-nr.btn-default.gl-mr-3.js-comment-resolve-button{ "v-if" => "showButton", type: "submit", data: { project_path: "#{project_path(@merge_request.project)}" } } {{ buttonText }} #notes= render "shared/notes/notes_with_form", :autocomplete => true diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml index a753ee50c43..d3e98bac7f9 100644 --- a/app/views/projects/merge_requests/_merge_request.html.haml +++ b/app/views/projects/merge_requests/_merge_request.html.haml @@ -55,7 +55,7 @@ - if merge_request.assignees.any? %li.d-flex = render 'shared/issuable/assignees', project: merge_request.project, issuable: merge_request - = render_if_exists 'projects/merge_requests/approvals_count', merge_request: merge_request + = render 'projects/merge_requests/approvals_count', merge_request: merge_request = render 'shared/issuable_meta_data', issuable: merge_request diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml index d1e8dc3a834..72931448432 100644 --- a/app/views/projects/merge_requests/_mr_title.html.haml +++ b/app/views/projects/merge_requests/_mr_title.html.haml @@ -20,7 +20,7 @@ = issuable_meta(@merge_request, @project, "Merge request") %a.btn.btn-default.float-right.d-block.d-sm-none.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" } - = icon('angle-double-left') + = sprite_icon('chevron-double-lg-left') .detail-page-header-actions.js-issuable-actions .clearfix.issue-btn-group.dropdown diff --git a/app/views/projects/merge_requests/_nav_btns.html.haml b/app/views/projects/merge_requests/_nav_btns.html.haml index b7498216334..2ef10365c18 100644 --- a/app/views/projects/merge_requests/_nav_btns.html.haml +++ b/app/views/projects/merge_requests/_nav_btns.html.haml @@ -1,5 +1,5 @@ - if @can_bulk_update - = button_tag "Edit merge requests", class: "btn append-right-10 js-bulk-update-toggle" + = button_tag "Edit merge requests", class: "btn gl-mr-3 js-bulk-update-toggle" - if merge_project = link_to new_merge_request_path, class: "btn btn-success", title: "New merge request" do New merge request diff --git a/app/views/projects/merge_requests/_widget.html.haml b/app/views/projects/merge_requests/_widget.html.haml index 6aba5c98d52..16b08cbf648 100644 --- a/app/views/projects/merge_requests/_widget.html.haml +++ b/app/views/projects/merge_requests/_widget.html.haml @@ -7,10 +7,13 @@ window.gl.mrWidgetData = #{serialize_issuable(@merge_request, serializer: 'widget', issues_links: true)} window.gl.mrWidgetData.squash_before_merge_help_path = '#{help_page_path("user/project/merge_requests/squash_and_merge")}'; - window.gl.mrWidgetData.troubleshooting_docs_path = '#{help_page_path('user/project/merge_requests/reviewing_and_managing_merge_requests.md', anchor: 'troubleshooting')}'; + window.gl.mrWidgetData.ci_troubleshooting_docs_path = '#{help_page_path('ci/troubleshooting.md')}'; + window.gl.mrWidgetData.mr_troubleshooting_docs_path = '#{help_page_path('user/project/merge_requests/reviewing_and_managing_merge_requests.md', anchor: 'troubleshooting')}'; window.gl.mrWidgetData.pipeline_must_succeed_docs_path = '#{help_page_path('user/project/merge_requests/merge_when_pipeline_succeeds.md', anchor: 'only-allow-merge-requests-to-be-merged-if-the-pipeline-succeeds')}'; - window.gl.mrWidgetData.security_approvals_help_page_path = '#{help_page_path('user/application_security/index.html', anchor: 'security-approvals-in-merge-requests-ultimate')}'; + window.gl.mrWidgetData.security_approvals_help_page_path = '#{help_page_path('user/application_security/index.md', anchor: 'security-approvals-in-merge-requests-ultimate')}'; window.gl.mrWidgetData.eligible_approvers_docs_path = '#{help_page_path('user/project/merge_requests/merge_request_approvals', anchor: 'eligible-approvers')}'; + window.gl.mrWidgetData.approvals_help_path = '#{help_page_path("user/project/merge_requests/merge_request_approvals")}'; window.gl.mrWidgetData.pipelines_empty_svg_path = '#{image_path('illustrations/pipelines_empty.svg')}'; + window.gl.mrWidgetData.codequality_help_path = '#{help_page_path("user/project/merge_requests/code_quality", anchor: "code-quality-reports")}'; #js-vue-mr-widget.mr-widget diff --git a/app/views/projects/merge_requests/conflicts/show.html.haml b/app/views/projects/merge_requests/conflicts/show.html.haml index d933675eac5..6c23661fb86 100644 --- a/app/views/projects/merge_requests/conflicts/show.html.haml +++ b/app/views/projects/merge_requests/conflicts/show.html.haml @@ -1,4 +1,4 @@ -- page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests" +- page_title _("Merge Conflicts"), "#{@merge_request.title} (#{@merge_request.to_reference}", _("Merge Requests") - content_for :page_specific_javascripts do = page_specific_javascript_tag('lib/ace.js') = render "projects/merge_requests/mr_title" diff --git a/app/views/projects/merge_requests/creations/_new_submit.html.haml b/app/views/projects/merge_requests/creations/_new_submit.html.haml index 0fb4d9ae70f..fdf0bfe8e50 100644 --- a/app/views/projects/merge_requests/creations/_new_submit.html.haml +++ b/app/views/projects/merge_requests/creations/_new_submit.html.haml @@ -20,8 +20,8 @@ .merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') } .merge-request-tabs-container .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller - .fade-left= icon('angle-left') - .fade-right= icon('angle-right') + .fade-left= sprite_icon('chevron-lg-left', size: 12) + .fade-right= sprite_icon('chevron-lg-right', size: 12) %ul.merge-request-tabs.nav.nav-tabs.nav-links.no-top.no-bottom.js-tabs-affix %li.commits-tab.new-tab = link_to url_for(safe_params), data: {target: 'div#commits', action: 'new', toggle: 'tabvue'} do diff --git a/app/views/projects/merge_requests/creations/new.html.haml b/app/views/projects/merge_requests/creations/new.html.haml index 0f618826305..ad4980fa57f 100644 --- a/app/views/projects/merge_requests/creations/new.html.haml +++ b/app/views/projects/merge_requests/creations/new.html.haml @@ -1,6 +1,6 @@ -- add_to_breadcrumbs "Merge Requests", project_merge_requests_path(@project) -- breadcrumb_title "New" -- page_title "New Merge Request" +- add_to_breadcrumbs _("Merge Requests"), project_merge_requests_path(@project) +- breadcrumb_title _("New") +- page_title _("New Merge Request") - if @merge_request.can_be_created && !params[:change_branches] = render 'new_submit' diff --git a/app/views/projects/merge_requests/diffs/_commit_widget.html.haml b/app/views/projects/merge_requests/diffs/_commit_widget.html.haml index 066c8d5dba6..efc052ca791 100644 --- a/app/views/projects/merge_requests/diffs/_commit_widget.html.haml +++ b/app/views/projects/merge_requests/diffs/_commit_widget.html.haml @@ -3,7 +3,7 @@ - `assets/javascripts/diffs/components/commit_widget.vue` -#----------------------------------------------------------------- - if @commit - .info-well.d-none.d-sm-block.prepend-top-default + .info-well.d-none.d-sm-block.gl-mt-3 .well-segment %ul.blob-commit-info = render 'projects/commits/commit', commit: @commit, merge_request: @merge_request, view_details: true diff --git a/app/views/projects/merge_requests/edit.html.haml b/app/views/projects/merge_requests/edit.html.haml index 318c9d809c1..a4bb790ce0b 100644 --- a/app/views/projects/merge_requests/edit.html.haml +++ b/app/views/projects/merge_requests/edit.html.haml @@ -1,4 +1,4 @@ -- page_title "Edit", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests" +- page_title _("Edit"), "#{@merge_request.title} (#{@merge_request.to_reference}", _("Merge Requests") %h3.page-title Edit Merge Request #{@merge_request.to_reference} diff --git a/app/views/projects/merge_requests/index.html.haml b/app/views/projects/merge_requests/index.html.haml index 4e30f09b9a2..36b1cf0796f 100644 --- a/app/views/projects/merge_requests/index.html.haml +++ b/app/views/projects/merge_requests/index.html.haml @@ -2,7 +2,7 @@ - merge_project = merge_request_source_project_for_project(@project) - new_merge_request_path = project_new_merge_request_path(merge_project) if merge_project -- page_title "Merge Requests" +- page_title _("Merge Requests") - new_merge_request_email = @project.new_issuable_address(current_user, 'merge_request') = render 'projects/last_push' diff --git a/app/views/projects/merge_requests/invalid.html.haml b/app/views/projects/merge_requests/invalid.html.haml index 749228a9664..7b831aa2d01 100644 --- a/app/views/projects/merge_requests/invalid.html.haml +++ b/app/views/projects/merge_requests/invalid.html.haml @@ -1,4 +1,4 @@ -- page_title "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests" +- page_title "#{@merge_request.title} (#{@merge_request.to_reference}", _("Merge Requests") .merge-request = render "projects/merge_requests/mr_title" diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index 90bc2504cb4..03fa9758587 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -1,14 +1,15 @@ - @gfm_form = true - @content_class = "limit-container-width" unless fluid_layout -- add_to_breadcrumbs "Merge Requests", project_merge_requests_path(@project) +- add_to_breadcrumbs _("Merge Requests"), project_merge_requests_path(@project) - breadcrumb_title @merge_request.to_reference -- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", "Merge Requests" +- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", _("Merge Requests") - page_description @merge_request.description - page_card_attributes @merge_request.card_attributes - suggest_changes_help_path = help_page_path('user/discussions/index.md', anchor: 'suggest-changes') - number_of_pipelines = @pipelines.size +- mr_action = j(params[:tab].presence || 'show') -.merge-request{ data: { mr_action: j(params[:tab].presence || 'show'), url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project), lock_version: @merge_request.lock_version } } +.merge-request{ data: { mr_action: mr_action, url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project), lock_version: @merge_request.lock_version } } = render "projects/merge_requests/mr_title" .merge-request-details.issuable-details{ data: { id: @merge_request.project.id } } @@ -76,9 +77,11 @@ = render "projects/merge_requests/tabs/pane", name: "pipelines", id: "pipelines", class: "pipelines" do - if number_of_pipelines.nonzero? = render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_project_merge_request_path(@project, @merge_request) + - if mr_action === "diffs" + - add_page_startup_api_call @endpoint_metadata_url = render "projects/merge_requests/tabs/pane", name: "diffs", id: "js-diffs-app", class: "diffs", data: { "is-locked": @merge_request.discussion_locked?, endpoint: diffs_project_merge_request_path(@project, @merge_request, 'json', request.query_parameters), - endpoint_metadata: diffs_metadata_project_json_merge_request_path(@project, @merge_request, 'json', request.query_parameters), + endpoint_metadata: @endpoint_metadata_url, endpoint_batch: diffs_batch_project_json_merge_request_path(@project, @merge_request, 'json', request.query_parameters), endpoint_coverage: @coverage_path, help_page_path: suggest_changes_help_path, @@ -88,7 +91,8 @@ is_fluid_layout: fluid_layout.to_s, dismiss_endpoint: user_callouts_path, show_suggest_popover: show_suggest_popover?.to_s, - show_whitespace_default: @show_whitespace_default.to_s } + show_whitespace_default: @show_whitespace_default.to_s, + file_by_file_default: @file_by_file_default.to_s } .mr-loading-status .loading.hide diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml index a3083fa2081..eeff91f631c 100644 --- a/app/views/projects/milestones/_form.html.haml +++ b/app/views/projects/milestones/_form.html.haml @@ -7,13 +7,13 @@ .col-form-label.col-sm-2 = f.label :title, _('Title') .col-sm-10 - = f.text_field :title, maxlength: 255, class: 'qa-milestone-title form-control', required: true, autofocus: true + = f.text_field :title, maxlength: 255, class: 'form-control', data: { qa_selector: 'milestone_title_field' }, required: true, autofocus: true .form-group.row.milestone-description .col-form-label.col-sm-2 = f.label :description, _('Description') .col-sm-10 = render layout: 'shared/md_preview', locals: { url: preview_markdown_path(@project) } do - = render 'shared/zen', f: f, attr: :description, classes: 'qa-milestone-description note-textarea', placeholder: _('Write milestone description...') + = render 'shared/zen', f: f, attr: :description, classes: 'note-textarea', qa_selector: 'milestone_description_field', placeholder: _('Write milestone description...') = render 'shared/notes/hints' .clearfix .error-alert @@ -21,7 +21,7 @@ .form-actions - if @milestone.new_record? - = f.submit _('Create milestone'), class: 'btn-success btn qa-milestone-create-button' + = f.submit _('Create milestone'), class: 'btn-success btn', data: { qa_selector: 'create_milestone_button' } = link_to _('Cancel'), project_milestones_path(@project), class: 'btn btn-cancel' - else = f.submit _('Save changes'), class: 'btn-success btn' diff --git a/app/views/projects/milestones/index.html.haml b/app/views/projects/milestones/index.html.haml index c89566dac90..2bab2a0fb03 100644 --- a/app/views/projects/milestones/index.html.haml +++ b/app/views/projects/milestones/index.html.haml @@ -7,7 +7,7 @@ = render 'shared/milestones/search_form' = render 'shared/milestones_sort_dropdown' - if can?(current_user, :admin_milestone, @project) - = link_to new_project_milestone_path(@project), class: 'btn btn-success qa-new-project-milestone', title: _('New milestone') do + = link_to new_project_milestone_path(@project), class: 'btn btn-success', data: { qa_selector: "new_project_milestone_link" }, title: _('New milestone') do = _('New milestone') .milestones diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml index b83204c27e3..5239af82ba6 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -9,10 +9,10 @@ = render_if_exists 'shared/milestones/burndown', milestone: @milestone, project: @project - if can?(current_user, :read_issue, @project) && @milestone.total_issues_count.zero? - .alert.alert-success.prepend-top-default + .alert.alert-success.gl-mt-3 %span= _('Assign some issues to this milestone.') - elsif @milestone.complete? && @milestone.active? - .alert.alert-success.prepend-top-default + .alert.alert-success.gl-mt-3 %span= _('All issues for this milestone are closed. You may close this milestone now.') = render 'shared/milestones/tabs', milestone: @milestone diff --git a/app/views/projects/mirrors/_instructions.html.haml b/app/views/projects/mirrors/_instructions.html.haml index 7ff6c0a2019..15c9076c1ab 100644 --- a/app/views/projects/mirrors/_instructions.html.haml +++ b/app/views/projects/mirrors/_instructions.html.haml @@ -1,4 +1,4 @@ -.account-well.prepend-top-default.append-bottom-default +.account-well.gl-mt-3.gl-mb-3 %ul %li = _('The repository must be accessible over <code>http://</code>, diff --git a/app/views/projects/mirrors/_ssh_host_keys.html.haml b/app/views/projects/mirrors/_ssh_host_keys.html.haml index 90236dc0c48..236ede32d31 100644 --- a/app/views/projects/mirrors/_ssh_host_keys.html.haml +++ b/app/views/projects/mirrors/_ssh_host_keys.html.haml @@ -3,7 +3,7 @@ - verified_at = mirror.ssh_known_hosts_verified_at .form-group.js-ssh-host-keys-section{ class: ('collapse' unless mirror.ssh_mirror_url?) } - %button.btn.btn-inverted.btn-secondary.inline.js-detect-host-keys.append-right-10{ type: 'button', data: { qa_selector: 'detect_host_keys' } } + %button.btn.btn-inverted.btn-secondary.inline.js-detect-host-keys.gl-mr-3{ type: 'button', data: { qa_selector: 'detect_host_keys' } } .js-spinner.d-none.spinner.mr-1 = _('Detect host keys') .fingerprint-ssh-info.js-fingerprint-ssh-info.prepend-top-10.append-bottom-10{ class: ('collapse' unless mirror.ssh_mirror_url?) } @@ -28,6 +28,6 @@ = _('Input host keys manually') %span.label-hide = _('Hide host keys manual input') - .js-ssh-known-hosts.collapse.prepend-top-default + .js-ssh-known-hosts.collapse.gl-mt-3 = f.label :ssh_known_hosts, _('SSH host keys'), class: 'label-bold' = f.text_area :ssh_known_hosts, class: 'form-control known-hosts js-known-hosts', rows: '10' diff --git a/app/views/projects/network/show.html.haml b/app/views/projects/network/show.html.haml index 6821453cffa..d134bfb488e 100644 --- a/app/views/projects/network/show.html.haml +++ b/app/views/projects/network/show.html.haml @@ -1,5 +1,5 @@ -- breadcrumb_title "Graph" -- page_title "Graph", @ref +- breadcrumb_title _("Graph") +- page_title _("Graph"), @ref = render "head" %div{ class: container_class } .project-network @@ -16,5 +16,5 @@ - if @commit .network-graph{ data: { url: @url, commit_url: @commit_url, ref: @ref, commit_id: @commit.id } } - .text-center.prepend-top-default + .text-center.gl-mt-3 .spinner.spinner-md diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index 81a778f76f4..d5099f80ea4 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -4,7 +4,7 @@ - header_title _("Projects"), dashboard_projects_path - active_tab = local_assigns.fetch(:active_tab, 'blank') -.project-edit-container.prepend-top-default +.project-edit-container.gl-mt-3 .project-edit-errors = render 'projects/errors' @@ -16,7 +16,7 @@ %h4.gl-mt-0 = _('New project') %p - - among_other_things_link = link_to _('among other things'), help_page_path("user/project/index.md", anchor: "projects-features"), target: '_blank' + - among_other_things_link = link_to _('among other things'), help_page_path("user/project/index.md", anchor: "project-features"), target: '_blank' = _('A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}.').html_safe % { among_other_things_link: among_other_things_link } %p = _('All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings.') diff --git a/app/views/projects/no_repo.html.haml b/app/views/projects/no_repo.html.haml index 08772a0188b..d5030a02cdd 100644 --- a/app/views/projects/no_repo.html.haml +++ b/app/views/projects/no_repo.html.haml @@ -1,4 +1,5 @@ - breadcrumb_title _("Details") +- page_title _("Details") %h2 %i.fa.fa-warning @@ -14,7 +15,7 @@ = link_to project_repository_path(@project), method: :post, class: 'btn btn-primary' do #{ _('Create empty repository') } - %strong.prepend-left-10.append-right-10 or + %strong.gl-ml-3.gl-mr-3 or = link_to new_project_import_path(@project), class: 'btn' do #{ _('Import repository') } diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml index 7de7dd3b98b..d725098752d 100644 --- a/app/views/projects/notes/_actions.html.haml +++ b/app/views/projects/notes/_actions.html.haml @@ -45,7 +45,7 @@ - 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' } do + = 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 %span.link-highlight = custom_icon('icon_pencil') diff --git a/app/views/projects/notes/_more_actions_dropdown.html.haml b/app/views/projects/notes/_more_actions_dropdown.html.haml index 2f0394538bb..8cf1b6b9294 100644 --- a/app/views/projects/notes/_more_actions_dropdown.html.haml +++ b/app/views/projects/notes/_more_actions_dropdown.html.haml @@ -2,7 +2,7 @@ - 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' } do + = 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') %ul.dropdown-menu.more-actions-dropdown.dropdown-open-left @@ -14,6 +14,6 @@ = _('Report abuse to admin') - if note_editable %li - = link_to note_url(note), method: :delete, data: { confirm: 'Are you sure you want to delete this comment?' }, remote: true, class: 'js-note-delete' do + = link_to note_url(note), method: :delete, data: { confirm: 'Are you sure you want to delete this comment?', qa_selector: 'delete_comment_button' }, remote: true, class: 'js-note-delete' do %span.text-danger = _('Delete comment') diff --git a/app/views/projects/pages/show.html.haml b/app/views/projects/pages/show.html.haml index 4b7810ea357..fc69b390bde 100644 --- a/app/views/projects/pages/show.html.haml +++ b/app/views/projects/pages/show.html.haml @@ -1,4 +1,4 @@ -- page_title 'Pages' +- page_title _('Pages') - if @project.pages_enabled? %h3.page-title.with-button diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml index 8d88f0be083..f48763cb544 100644 --- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml +++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml @@ -27,8 +27,8 @@ %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' do - = icon('play') + = 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 + = 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 = s_('PipelineSchedules|Take ownership') diff --git a/app/views/projects/pipelines/_stage.html.haml b/app/views/projects/pipelines/_stage.html.haml index 3feb99cfcd7..0651ad6fdb8 100644 --- a/app/views/projects/pipelines/_stage.html.haml +++ b/app/views/projects/pipelines/_stage.html.haml @@ -1,5 +1,5 @@ - grouped_statuses = @stage.statuses.latest_ordered.group_by(&:status) -- HasStatus::ORDERED_STATUSES.each do |ordered_status| +- Ci::HasStatus::ORDERED_STATUSES.each do |ordered_status| - grouped_statuses.fetch(ordered_status, []).each do |status| %li = render 'ci/status/dropdown_graph_badge', subject: status diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index 92edde034a6..590ae72a2ff 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -24,7 +24,7 @@ %li.js-tests-tab-link = link_to test_report_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-tests', action: 'test_report', toggle: 'tab' }, class: 'test-tab' do = s_('TestReports|Tests') - %span.badge.badge-pill.js-test-report-badge-counter + %span.badge.badge-pill.js-test-report-badge-counter= Feature.enabled?(:build_report_summary, @project) ? @pipeline.test_report_summary.total_count : '' = render_if_exists "projects/pipelines/tabs_holder", pipeline: @pipeline, project: @project .tab-content @@ -83,8 +83,10 @@ - if dag_pipeline_tab_enabled #js-tab-dag.tab-pane - #js-pipeline-dag-vue{ data: { pipeline_data_path: dag_project_pipeline_path(@project, @pipeline) } } + #js-pipeline-dag-vue{ data: { pipeline_data_path: dag_project_pipeline_path(@project, @pipeline), empty_svg_path: image_path('illustrations/empty-state/empty-dag-md.svg'), dag_doc_path: help_page_path('ci/yaml/README.md', anchor: 'needs')} } #js-tab-tests.tab-pane - #js-pipeline-tests-detail + #js-pipeline-tests-detail{ data: { full_report_endpoint: test_report_project_pipeline_path(@project, @pipeline, format: :json), + summary_endpoint: Feature.enabled?(:build_report_summary, @project) ? summary_project_pipeline_tests_path(@project, @pipeline, format: :json) : '', + count_endpoint: test_reports_count_project_pipeline_path(@project, @pipeline, format: :json) } } = render_if_exists "projects/pipelines/tabs_content", pipeline: @pipeline, project: @project diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml index fa4a77a692a..05f8a126a02 100644 --- a/app/views/projects/pipelines/index.html.haml +++ b/app/views/projects/pipelines/index.html.haml @@ -7,6 +7,7 @@ params: params.to_json, "help-page-path" => help_page_path('ci/quick_start/README'), "help-auto-devops-path" => help_page_path('topics/autodevops/index.md'), + "pipeline-schedule-url" => pipeline_schedules_path(@project), "empty-state-svg-path" => image_path('illustrations/pipelines_empty.svg'), "error-state-svg-path" => image_path('illustrations/pipelines_failed.svg'), "no-pipelines-svg-path" => image_path('illustrations/pipelines_pending.svg'), diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml index f39968eecef..2b2133b8296 100644 --- a/app/views/projects/pipelines/show.html.haml +++ b/app/views/projects/pipelines/show.html.haml @@ -20,6 +20,4 @@ - else = render "projects/pipelines/with_tabs", pipeline: @pipeline -.js-pipeline-details-vue{ data: { endpoint: project_pipeline_path(@project, @pipeline, format: :json), - test_report_endpoint: test_report_project_pipeline_path(@project, @pipeline, format: :json), - test_reports_count_endpoint: test_reports_count_project_pipeline_path(@project, @pipeline, format: :json) } } +.js-pipeline-details-vue{ data: { endpoint: project_pipeline_path(@project, @pipeline, format: :json) } } diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index c24a9061146..ba964e5cd37 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -1,7 +1,8 @@ - page_title _("Members") - can_admin_project_members = can?(current_user, :admin_project_member, @project) -.row.prepend-top-default +.js-remove-member-modal +.row.gl-mt-3 .col-lg-12 - if project_can_be_shared? %h4 diff --git a/app/views/projects/project_templates/_built_in_templates.html.haml b/app/views/projects/project_templates/_built_in_templates.html.haml index eb41a3e0785..43352952b37 100644 --- a/app/views/projects/project_templates/_built_in_templates.html.haml +++ b/app/views/projects/project_templates/_built_in_templates.html.haml @@ -1,6 +1,6 @@ - Gitlab::ProjectTemplate.all.each do |template| .template-option.d-flex.align-items-center{ data: { qa_selector: 'template_option_row' } } - .logo.append-right-10.px-1 + .logo.gl-mr-3.px-1 = image_tag template.logo, size: 32, class: "btn-template-icon icon-#{template.name}" .description %strong @@ -9,7 +9,7 @@ .text-muted = template.description .controls.d-flex.align-items-center - %a.btn.btn-default.append-right-10{ href: template.preview, rel: 'noopener noreferrer', target: '_blank', data: { track_label: "template_preview", track_property: template.name, track_event: "click_button", track_value: "" } } + %a.btn.btn-default.gl-mr-3{ href: template.preview, rel: 'noopener noreferrer', target: '_blank', data: { track_label: "template_preview", track_property: template.name, track_event: "click_button", track_value: "" } } = _("Preview") %label.btn.btn-success.template-button.choose-template.gl-mb-0{ for: template.name } %input{ type: "radio", autocomplete: "off", name: "project[template_name]", id: template.name, value: template.name, data: { track_label: "template_use", track_property: template.name, track_event: "click_button", track_value: "" } } diff --git a/app/views/projects/project_templates/_project_fields_form.html.haml b/app/views/projects/project_templates/_project_fields_form.html.haml index c96010550d8..201e2d5b5fb 100644 --- a/app/views/projects/project_templates/_project_fields_form.html.haml +++ b/app/views/projects/project_templates/_project_fields_form.html.haml @@ -5,7 +5,7 @@ .input-group.template-input-group .input-group-prepend .input-group-text - .selected-icon.append-right-10 + .selected-icon.gl-mr-3 .selected-template .input-group-append %button.btn.btn-default.change-template{ type: "button" } diff --git a/app/views/projects/protected_branches/shared/_matching_branch.html.haml b/app/views/projects/protected_branches/shared/_matching_branch.html.haml index 2c76bf87945..9145be5d2f2 100644 --- a/app/views/projects/protected_branches/shared/_matching_branch.html.haml +++ b/app/views/projects/protected_branches/shared/_matching_branch.html.haml @@ -3,7 +3,7 @@ = link_to matching_branch.name, project_ref_path(@project, matching_branch.name), class: 'ref-name' - if @project.root_ref?(matching_branch.name) - %span.badge.badge-info.prepend-left-5 default + %span.badge.badge-info.gl-ml-2 default %td - commit = @project.commit(matching_branch.name) = link_to(commit.short_id, project_commit_path(@project, commit.id), class: 'commit-sha') diff --git a/app/views/projects/protected_branches/show.html.haml b/app/views/projects/protected_branches/show.html.haml index ffaf118a5e3..c671757a603 100644 --- a/app/views/projects/protected_branches/show.html.haml +++ b/app/views/projects/protected_branches/show.html.haml @@ -1,6 +1,6 @@ -- page_title @protected_ref.name, "Protected Branches" +- page_title @protected_ref.name, _("Protected Branches") -.row.prepend-top-default.append-bottom-default +.row.gl-mt-3.gl-mb-3 .col-lg-3 %h4.gl-mt-0.ref-name = @protected_ref.name diff --git a/app/views/projects/protected_tags/_create_protected_tag.html.haml b/app/views/projects/protected_tags/_create_protected_tag.html.haml index f53b81cada6..d19a6401fc8 100644 --- a/app/views/projects/protected_tags/_create_protected_tag.html.haml +++ b/app/views/projects/protected_tags/_create_protected_tag.html.haml @@ -3,6 +3,7 @@ = dropdown_tag('Select', options: { toggle_class: 'js-allowed-to-create wide', dropdown_class: 'dropdown-menu-selectable capitalize-header', - data: { field_name: 'protected_tag[create_access_levels_attributes][0][access_level]', input_id: 'create_access_levels_attributes' }}) + dropdown_qa_selector: 'access_levels_content', + data: { field_name: 'protected_tag[create_access_levels_attributes][0][access_level]', input_id: 'create_access_levels_attributes', qa_selector: 'access_levels_dropdown' }}) = render 'projects/protected_tags/shared/create_protected_tag' diff --git a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml index 020e6e187a6..8a6ae53a7c4 100644 --- a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml +++ b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml @@ -25,4 +25,4 @@ = yield :create_access_levels .card-footer - = f.submit 'Protect', class: 'btn-success btn', disabled: true + = f.submit 'Protect', class: 'btn-success btn', disabled: true, data: { qa_selector: 'protect_tag_button' } diff --git a/app/views/projects/protected_tags/shared/_dropdown.html.haml b/app/views/projects/protected_tags/shared/_dropdown.html.haml index 824a8604f6f..9c7f532fa29 100644 --- a/app/views/projects/protected_tags/shared/_dropdown.html.haml +++ b/app/views/projects/protected_tags/shared/_dropdown.html.haml @@ -6,7 +6,7 @@ footer_content: true, data: { show_no: true, show_any: true, show_upcoming: true, selected: params[:protected_tag_name], - project_id: @project.try(:id) } }) do + project_id: @project.try(:id), qa_selector: 'tags_dropdown' } }) do %ul.dropdown-footer-list %li diff --git a/app/views/projects/protected_tags/shared/_index.html.haml b/app/views/projects/protected_tags/shared/_index.html.haml index b0c87ac8c17..4bf3ce09fc7 100644 --- a/app/views/projects/protected_tags/shared/_index.html.haml +++ b/app/views/projects/protected_tags/shared/_index.html.haml @@ -1,6 +1,6 @@ - expanded = expanded_by_default? -%section.settings.no-animate#js-protected-tags-settings{ class: ('expanded' if expanded) } +%section.settings.no-animate#js-protected-tags-settings{ class: ('expanded' if expanded), data: { qa_selector: 'protected_tag_settings_content' } } .settings-header %h4 Protected Tags diff --git a/app/views/projects/protected_tags/shared/_matching_tag.html.haml b/app/views/projects/protected_tags/shared/_matching_tag.html.haml index 133c76cd2ad..bf030d36cd6 100644 --- a/app/views/projects/protected_tags/shared/_matching_tag.html.haml +++ b/app/views/projects/protected_tags/shared/_matching_tag.html.haml @@ -3,7 +3,7 @@ = link_to matching_tag.name, project_ref_path(@project, matching_tag.name), class: 'ref-name' - if @project.root_ref?(matching_tag.name) - %span.badge.badge-info.prepend-left-5 default + %span.badge.badge-info.gl-ml-2 default %td - commit = @project.commit(matching_tag.name) = link_to(commit.short_id, project_commit_path(@project, commit.id), class: 'commit-sha') diff --git a/app/views/projects/protected_tags/shared/_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_protected_tag.html.haml index cc6f0309123..b0563163c9c 100644 --- a/app/views/projects/protected_tags/shared/_protected_tag.html.haml +++ b/app/views/projects/protected_tags/shared/_protected_tag.html.haml @@ -3,7 +3,7 @@ %span.ref-name= protected_tag.name - if @project.root_ref?(protected_tag.name) - %span.badge.badge-info.prepend-left-5 default + %span.badge.badge-info.gl-ml-2 default %td - if protected_tag.wildcard? - matching_tags = protected_tag.matching(repository.tags) diff --git a/app/views/projects/protected_tags/show.html.haml b/app/views/projects/protected_tags/show.html.haml index 6f4535a0b3f..c8052e6ae8d 100644 --- a/app/views/projects/protected_tags/show.html.haml +++ b/app/views/projects/protected_tags/show.html.haml @@ -1,6 +1,6 @@ -- page_title @protected_ref.name, "Protected Tags" +- page_title @protected_ref.name, _("Protected Tags") -.row.prepend-top-default.append-bottom-default +.row.gl-mt-3.gl-mb-3 .col-lg-3 %h4.gl-mt-0.ref-name = @protected_ref.name diff --git a/app/views/projects/refs/logs_tree.js.haml b/app/views/projects/refs/logs_tree.js.haml deleted file mode 100644 index 506bf54b3f8..00000000000 --- a/app/views/projects/refs/logs_tree.js.haml +++ /dev/null @@ -1,23 +0,0 @@ -- @logs.each do |content_data| - - file_name = content_data[:file_name] - - commit = content_data[:commit] - - next unless commit - - :plain - var row = $("table.table_#{@hex_path} tr.file_#{hexdigest(file_name)}"); - row.find("td.tree-time-ago").html('#{escape_javascript time_ago_with_tooltip(commit.committed_date)}'); - row.find("td.tree-commit").html('#{escape_javascript render("projects/tree/tree_commit_column", commit: commit)}'); - - = render_if_exists 'projects/refs/logs_tree_lock_label', lock_label: content_data[:lock_label] - -- if @more_log_url - :plain - if($('#tree-slider').length) { - // Load more commit logs for each file in tree - // if we still on the same page - var url = "#{escape_javascript(@more_log_url)}"; - gl.utils.ajaxGet(url); - } - -:plain - gl.utils.localTimeAgo($('.js-timeago', 'table.table_#{@hex_path} tbody')); diff --git a/app/views/projects/releases/new.html.haml b/app/views/projects/releases/new.html.haml new file mode 100644 index 00000000000..4348035a324 --- /dev/null +++ b/app/views/projects/releases/new.html.haml @@ -0,0 +1,3 @@ +- page_title s_('Releases|New Release') + +#js-new-release-page{ data: data_for_new_release_page } diff --git a/app/views/projects/serverless/functions/index.html.haml b/app/views/projects/serverless/functions/index.html.haml index 2f1da453c0a..b21965915a2 100644 --- a/app/views/projects/serverless/functions/index.html.haml +++ b/app/views/projects/serverless/functions/index.html.haml @@ -1,6 +1,6 @@ - @content_class = "limit-container-width" unless fluid_layout -- breadcrumb_title 'Serverless' -- page_title 'Serverless' +- breadcrumb_title _('Serverless') +- page_title _('Serverless') - status_path = project_serverless_functions_path(@project, format: :json) - clusters_path = project_clusters_path(@project) diff --git a/app/views/projects/services/_form.html.haml b/app/views/projects/services/_form.html.haml index e6761807409..2e49e74a9b3 100644 --- a/app/views/projects/services/_form.html.haml +++ b/app/views/projects/services/_form.html.haml @@ -1,4 +1,7 @@ -.row.prepend-top-default.append-bottom-default +- 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 %h4.gl-mt-0 = @service.title @@ -11,10 +14,10 @@ %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: { 'can-test' => @service.can_test?, 'test-url' => test_project_service_path(@project, @service) } }) do |form| - = render 'shared/service_settings', form: form, service: @service - .footer-block.row-content-block + = render 'shared/service_settings', form: form, integration: @service + .footer-block.row-content-block{ :class => "#{'gl-display-none' if @service.is_a?(AlertsService)}" } %input{ id: 'services_redirect_to', type: 'hidden', name: 'redirect_to', value: request.referrer } - = service_save_button + = service_save_button(disabled: @service.is_a?(AlertsService)) = link_to _('Cancel'), project_settings_integrations_path(@project), class: 'btn btn-cancel' diff --git a/app/views/projects/services/alerts/_help.html.haml b/app/views/projects/services/alerts/_help.html.haml index 4b09d1d9d0e..7abd198bea5 100644 --- a/app/views/projects/services/alerts/_help.html.haml +++ b/app/views/projects/services/alerts/_help.html.haml @@ -1,6 +1 @@ -.js-alerts-service-settings{ data: { activated: @service.activated?.to_s, - form_path: scoped_integration_path(@service), - authorization_key: @service.token, - url: @service.url || _('<namespace / project>'), - alerts_setup_url: help_page_path('user/project/integrations/generic_alerts.html', anchor: 'setting-up-generic-alerts'), - alerts_usage_url: help_page_path('user/project/operations/alert_management.html') } } +.js-alerts-service-settings{ data: alerts_settings_data(disabled: true) } diff --git a/app/views/projects/services/alerts/_top.html.haml b/app/views/projects/services/alerts/_top.html.haml new file mode 100644 index 00000000000..ebc93978832 --- /dev/null +++ b/app/views/projects/services/alerts/_top.html.haml @@ -0,0 +1,8 @@ +.row + .col-lg-12 + .gl-alert.gl-alert-info.js-alerts-moved-alert{ role: 'alert' } + = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + .gl-alert-body + = _('You can now manage alert endpoint configuration in the Alerts section on the Operations settings page. 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 new-gl-button' diff --git a/app/views/projects/services/prometheus/_configuration_banner.html.haml b/app/views/projects/services/prometheus/_configuration_banner.html.haml index dfcb1c5d240..b4e8458d8b9 100644 --- a/app/views/projects/services/prometheus/_configuration_banner.html.haml +++ b/app/views/projects/services/prometheus/_configuration_banner.html.haml @@ -12,14 +12,14 @@ .svg-container = image_tag 'illustrations/monitoring/getting_started.svg' .col-sm-10 - %p.text-success.prepend-top-default + %p.text-success.gl-mt-3 = s_('PrometheusService|Prometheus is being automatically managed on your clusters') = link_to s_('PrometheusService|Manage clusters'), project_clusters_path(project), class: 'btn' - else .col-sm-2 = image_tag 'illustrations/monitoring/loading.svg' .col-sm-10 - %p.prepend-top-default + %p.gl-mt-3 = s_('PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments') = link_to s_('PrometheusService|Install Prometheus on clusters'), project_clusters_path(project), class: 'btn btn-success' diff --git a/app/views/projects/services/prometheus/_custom_metrics.html.haml b/app/views/projects/services/prometheus/_custom_metrics.html.haml index 210d0f37d65..3642460467b 100644 --- a/app/views/projects/services/prometheus/_custom_metrics.html.haml +++ b/app/views/projects/services/prometheus/_custom_metrics.html.haml @@ -3,7 +3,7 @@ .col-lg-3 %p = s_('PrometheusService|Custom metrics require Prometheus installed on a cluster with environment scope "*" OR a manually configured Prometheus to be available.') - = link_to s_('PrometheusService|More information'), help_page_path('user/project/integrations/prometheus', anchor: 'adding-custom-metrics'), target: '_blank', rel: "noopener noreferrer" + = link_to s_('PrometheusService|More information'), help_page_path('operations/metrics/index.md', anchor: 'adding-custom-metrics'), target: '_blank', rel: "noopener noreferrer" .col-lg-9 .card.custom-monitored-metrics.js-panel-custom-monitored-metrics{ data: { qa_selector: 'custom_metrics_container', active_custom_metrics: project_prometheus_metrics_path(project), environments_data: environments_list_data, service_active: "#{@service.active}" } } diff --git a/app/views/projects/services/prometheus/_external_alerts.html.haml b/app/views/projects/services/prometheus/_external_alerts.html.haml index 24ff0cc88a3..b27b1ab8723 100644 --- a/app/views/projects/services/prometheus/_external_alerts.html.haml +++ b/app/views/projects/services/prometheus/_external_alerts.html.haml @@ -3,6 +3,6 @@ - notify_url = notify_project_prometheus_alerts_url(@project, format: :json) - authorization_key = @project.alerting_setting.try(:token) -- learn_more_url = help_page_path('user/project/integrations/prometheus', anchor: 'external-prometheus-instances') +- learn_more_url = help_page_path('operations/metrics/alerts.md', anchor: 'external-prometheus-instances') -#js-settings-prometheus-alerts{ data: { notify_url: notify_url, authorization_key: authorization_key, change_key_url: reset_alerting_token_project_settings_operations_path(@project), learn_more_url: learn_more_url } } +#js-settings-prometheus-alerts{ data: { notify_url: notify_url, authorization_key: authorization_key, change_key_url: reset_alerting_token_project_settings_operations_path(@project), learn_more_url: learn_more_url, disabled: true } } diff --git a/app/views/projects/services/prometheus/_help.html.haml b/app/views/projects/services/prometheus/_help.html.haml index 1b5b794a7aa..c5b3fd31efa 100644 --- a/app/views/projects/services/prometheus/_help.html.haml +++ b/app/views/projects/services/prometheus/_help.html.haml @@ -1,7 +1,7 @@ - if @project = render 'projects/services/prometheus/configuration_banner', project: @project, service: @service -%h4.append-bottom-default +%h4.gl-mb-3 = s_('PrometheusService|Manual configuration') %p = s_('PrometheusService|Select the Active checkbox to override the Auto Configuration with custom settings. If unchecked, Auto Configuration settings are used.') diff --git a/app/views/projects/services/prometheus/_metrics.html.haml b/app/views/projects/services/prometheus/_metrics.html.haml index 3bd5f69f67e..9f5160f3dd5 100644 --- a/app/views/projects/services/prometheus/_metrics.html.haml +++ b/app/views/projects/services/prometheus/_metrics.html.haml @@ -33,5 +33,5 @@ .flash-notice .flash-text = s_("PrometheusService|To set up automatic monitoring, add the environment variable %{variable} to exporter's queries." % { variable: "<code>$CI_ENVIRONMENT_SLUG</code>" }).html_safe - = link_to s_('PrometheusService|More information'), help_page_path('user/project/integrations/prometheus', anchor: 'metrics-and-labels') + = link_to s_('PrometheusService|More information'), help_page_path('operations/metrics/dashboards/variables.md', anchor: 'query-variables') %ul.list-unstyled.metrics-list.js-missing-var-metrics-list diff --git a/app/views/projects/services/prometheus/_show.html.haml b/app/views/projects/services/prometheus/_show.html.haml index 728a52f024f..9ce61ed5c13 100644 --- a/app/views/projects/services/prometheus/_show.html.haml +++ b/app/views/projects/services/prometheus/_show.html.haml @@ -3,7 +3,7 @@ %h4.gl-mt-0 = s_('PrometheusService|Metrics') -.row.append-bottom-default.prometheus-metrics-monitoring.js-prometheus-metrics-monitoring +.row.gl-mb-3.prometheus-metrics-monitoring.js-prometheus-metrics-monitoring = render 'projects/services/prometheus/metrics', project: @project = render 'projects/services/prometheus/external_alerts', project: @project diff --git a/app/views/projects/services/prometheus/_top.html.haml b/app/views/projects/services/prometheus/_top.html.haml new file mode 100644 index 00000000000..338414be5ab --- /dev/null +++ b/app/views/projects/services/prometheus/_top.html.haml @@ -0,0 +1,10 @@ +- return unless @service.manual_configuration? + +.row + .col-lg-12 + .gl-alert.gl-alert-info.js-alerts-moved-alert{ role: 'alert' } + = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + .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' diff --git a/app/views/projects/settings/_general.html.haml b/app/views/projects/settings/_general.html.haml index 5eeebe4160f..3ffa029a25d 100644 --- a/app/views/projects/settings/_general.html.haml +++ b/app/views/projects/settings/_general.html.haml @@ -31,7 +31,7 @@ = render_if_exists 'shared/repository_size_limit_setting', form: f, type: :project - .form-group.prepend-top-default.append-bottom-20 + .form-group.gl-mt-3.append-bottom-20 .avatar-container.s90 = project_icon(@project, alt: _('Project avatar'), class: 'avatar project-avatar s90') = f.label :avatar, _('Project avatar'), class: 'label-bold d-block' diff --git a/app/views/projects/settings/access_tokens/index.html.haml b/app/views/projects/settings/access_tokens/index.html.haml index 092f9c2333c..4992288a8c8 100644 --- a/app/views/projects/settings/access_tokens/index.html.haml +++ b/app/views/projects/settings/access_tokens/index.html.haml @@ -4,7 +4,7 @@ - type_plural = _('project access tokens') - @content_class = 'limit-container-width' unless fluid_layout -.row.prepend-top-default +.row.gl-mt-3 .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 = page_title diff --git a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml index 8b84acb67c1..7284b4bb55d 100644 --- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml +++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml @@ -40,18 +40,18 @@ = form.radio_button :deploy_strategy, 'continuous', class: 'form-check-input' = form.label :deploy_strategy_continuous, class: 'form-check-label' do = s_('CICD|Continuous deployment to production') - = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md', anchor: 'auto-deploy'), target: '_blank' + = link_to icon('question-circle'), help_page_path('topics/autodevops/stages.md', anchor: 'auto-deploy'), target: '_blank' .form-check = form.radio_button :deploy_strategy, 'timed_incremental', class: 'form-check-input' = form.label :deploy_strategy_timed_incremental, class: 'form-check-label' do = s_('CICD|Continuous deployment to production using timed incremental rollout') - = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md', anchor: 'timed-incremental-rollout-to-production-premium'), target: '_blank' + = link_to icon('question-circle'), help_page_path('topics/autodevops/customize.md', anchor: 'timed-incremental-rollout-to-production-premium'), target: '_blank' .form-check = form.radio_button :deploy_strategy, 'manual', class: 'form-check-input' = form.label :deploy_strategy_manual, class: 'form-check-label' do = s_('CICD|Automatic deployment to staging, manual deployment to production') - = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md', anchor: 'incremental-rollout-to-production-premium'), target: '_blank' + = link_to icon('question-circle'), help_page_path('topics/autodevops/customize.md', anchor: 'incremental-rollout-to-production-premium'), target: '_blank' = f.submit _('Save changes'), class: "btn btn-success prepend-top-15", data: { qa_selector: 'save_changes_button' } diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml index a1809cecafb..e8e5a5f0256 100644 --- a/app/views/projects/settings/ci_cd/_form.html.haml +++ b/app/views/projects/settings/ci_cd/_form.html.haml @@ -1,4 +1,4 @@ -.row.prepend-top-default +.row.gl-mt-3 .col-lg-12 = form_for @project, url: project_settings_ci_cd_path(@project, anchor: 'js-general-pipeline-settings') do |f| = form_errors(@project) @@ -147,5 +147,5 @@ %hr -.row.prepend-top-default +.row.gl-mt-3 = render partial: 'badge', collection: @badges diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index 4e14426a069..b5452fcca55 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -66,11 +66,11 @@ %section.settings.no-animate#js-registry-policies{ class: ('expanded' if expanded) } .settings-header %h4 - = _("Container Registry tag expiration policy") - = link_to icon('question-circle'), help_page_path('user/packages/container_registry/index', anchor: 'expiration-policy'), target: '_blank', rel: 'noopener noreferrer' + = _("Cleanup policy for tags") %button.btn.js-settings-toggle{ type: 'button' } = expanded ? _('Collapse') : _('Expand') %p - = _("Expiration policy for the Container Registry is a perfect solution for keeping the Registry space down while still enjoying the full power of GitLab CI/CD.") + = _("Save space and find tags in the Container Registry more easily. Enable the cleanup policy to remove stale tags and keep only the ones you need.") + = link_to _('More information'), help_page_path('user/packages/container_registry/index', anchor: 'cleanup-policy', target: '_blank', rel: 'noopener noreferrer') .settings-content = render 'projects/registry/settings/index' diff --git a/app/views/projects/settings/integrations/show.html.haml b/app/views/projects/settings/integrations/show.html.haml index e7a509abc8b..d9068bde847 100644 --- a/app/views/projects/settings/integrations/show.html.haml +++ b/app/views/projects/settings/integrations/show.html.haml @@ -3,7 +3,7 @@ - page_title _('Integrations') - if show_webhooks_moved_alert? - .gl-alert.gl-alert-info.js-webhooks-moved-alert.prepend-top-default{ role: 'alert', data: { feature_id: UserCalloutsHelper::WEBHOOKS_MOVED, dismiss_endpoint: user_callouts_path } } + .gl-alert.gl-alert-info.js-webhooks-moved-alert.gl-mt-3{ role: 'alert', data: { feature_id: UserCalloutsHelper::WEBHOOKS_MOVED, dismiss_endpoint: user_callouts_path } } = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') } = sprite_icon('close', size: 16, css_class: 'gl-icon') diff --git a/app/views/projects/settings/operations/_alert_management.html.haml b/app/views/projects/settings/operations/_alert_management.html.haml new file mode 100644 index 00000000000..f8f3ecb6273 --- /dev/null +++ b/app/views/projects/settings/operations/_alert_management.html.haml @@ -0,0 +1,14 @@ +- return unless can?(current_user, :admin_operations, @project) +- expanded = expanded_by_default? + +%section.settings.no-animate#js-alert-management-settings{ class: ('expanded' if expanded) } + .settings-header + %h3{ :class => "h4" } + = _('Alerts') + %button.btn.js-settings-toggle{ type: 'button' } + = _('Expand') + %p + = _('Display alerts from all your monitoring tools directly within GitLab.') + = link_to _('More information'), help_page_path('user/project/operations/alert_management'), target: '_blank', rel: 'noopener noreferrer' + .settings-content + .js-alerts-settings{ data: alerts_settings_data } diff --git a/app/views/projects/settings/operations/_configuration_banner.html.haml b/app/views/projects/settings/operations/_configuration_banner.html.haml index bdbc9b7d69d..69bbd0edac7 100644 --- a/app/views/projects/settings/operations/_configuration_banner.html.haml +++ b/app/views/projects/settings/operations/_configuration_banner.html.haml @@ -12,13 +12,13 @@ .svg-container = image_tag 'illustrations/monitoring/getting_started.svg' .col-sm-10 - %p.text-success.prepend-top-default + %p.text-success.gl-mt-3 = s_('PrometheusService|Prometheus is being automatically managed on your clusters') = link_to s_('PrometheusService|Manage clusters'), project_clusters_path(project), class: 'btn' - else .col-sm-2 = image_tag 'illustrations/monitoring/loading.svg' .col-sm-10 - %p.prepend-top-default + %p.gl-mt-3 = s_('PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments') = link_to s_('PrometheusService|Install Prometheus on clusters'), project_clusters_path(project), class: 'btn btn-success' diff --git a/app/views/projects/settings/operations/_incidents.html.haml b/app/views/projects/settings/operations/_incidents.html.haml index 3b1b0a00380..e7236cdec69 100644 --- a/app/views/projects/settings/operations/_incidents.html.haml +++ b/app/views/projects/settings/operations/_incidents.html.haml @@ -1,32 +1 @@ -- templates = [] -- setting = project_incident_management_setting -- templates = setting.available_issue_templates.map { |t| [t.name, t.key] } - -%section.settings.no-animate.qa-incident-management-settings{ data: { qa_selector: 'incidents_settings_content' } } - .settings-header - %h3{ :class => "h4" }= _('Incidents') - %button.btn.js-settings-toggle{ type: 'button' } - = _('Expand') - %p - = _('Action to take when receiving an alert.') - = link_to help_page_path('user/project/integrations/prometheus', anchor: 'taking-action-on-incidents-ultimate') do - = _('More information') - .settings-content - = form_for @project, url: project_settings_operations_path(@project), method: :patch do |f| - = form_errors(@project.incident_management_setting) - .form-group - = f.fields_for :incident_management_setting_attributes, setting do |form| - .form-group - = form.check_box :create_issue, data: { qa_selector: 'create_issue_checkbox' } - = form.label :create_issue, _('Create an issue. Issues are created for each alert triggered.'), class: 'form-check-label' - .form-group.col-sm-8 - = form.label :issue_template_key, class: 'label-bold' do - = _('Issue template (optional)') - = link_to icon('question-circle'), help_page_path('user/project/description_templates', anchor: 'creating-issue-templates'), target: '_blank', rel: 'noopener noreferrer' - .select-wrapper - = form.select :issue_template_key, templates, {include_blank: 'No template selected'}, class: "form-control select-control", data: { qa_selector: 'incident_templates_dropdown' } - = icon('chevron-down') - .form-group - = form.check_box :send_email - = form.label :send_email, _('Send a separate email notification to Developers.'), class: 'form-check-label' - = f.submit _('Save changes'), class: 'btn btn-success', data: { qa_selector: 'save_changes_button' } +.js-incidents-settings{ data: operations_settings_data } diff --git a/app/views/projects/settings/operations/_prometheus.html.haml b/app/views/projects/settings/operations/_prometheus.html.haml index b0fa750e131..7ccc829662d 100644 --- a/app/views/projects/settings/operations/_prometheus.html.haml +++ b/app/views/projects/settings/operations/_prometheus.html.haml @@ -11,7 +11,7 @@ - if @project = render 'projects/settings/operations/configuration_banner', project: @project, service: service - %b.append-bottom-default + %b.gl-mb-3 = s_('PrometheusService|Manual configuration') %p = s_('PrometheusService|Select the Active checkbox to override the Auto Configuration with custom settings. If unchecked, Auto Configuration settings are used.') diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml index 9e4fbf81ca4..103828ee0a0 100644 --- a/app/views/projects/settings/operations/show.html.haml +++ b/app/views/projects/settings/operations/show.html.haml @@ -2,6 +2,7 @@ - page_title _('Operations Settings') - breadcrumb_title _('Operations Settings') += render 'projects/settings/operations/alert_management', alerts_service: alerts_service, prometheus_service: prometheus_service = render 'projects/settings/operations/incidents' = render 'projects/settings/operations/error_tracking' = render 'projects/settings/operations/prometheus', service: prometheus_service if Feature.enabled?(:settings_operations_prometheus_service) diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index 17bc10af58a..4a521f2f46e 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -1,4 +1,5 @@ - breadcrumb_title _("Details") +- page_title _("Projects") - @content_class = "limit-container-width" unless fluid_layout = content_for :meta_tags do @@ -6,10 +7,6 @@ = render partial: 'flash_messages', locals: { project: @project } -- if !@project.empty_repo? && can?(current_user, :download_code, @project) && !vue_file_list_enabled? - - signatures_path = project_signatures_path(@project, @project.default_branch) - .js-signature-container{ data: { 'signatures-path': signatures_path } } - %div{ class: [("limit-container-width" unless fluid_layout)] } = render "projects/last_push" diff --git a/app/views/projects/sidebar/_issues_service_desk.html.haml b/app/views/projects/sidebar/_issues_service_desk.html.haml new file mode 100644 index 00000000000..2730fe37f28 --- /dev/null +++ b/app/views/projects/sidebar/_issues_service_desk.html.haml @@ -0,0 +1,3 @@ += nav_link(controller: :issues, action: :service_desk ) do + = link_to service_desk_project_issues_path(@project), title: 'Service Desk' do + = _('Service Desk') diff --git a/app/views/projects/snippets/_actions.html.haml b/app/views/projects/snippets/_actions.html.haml index 6aedab36e1b..e4645101765 100644 --- a/app/views/projects/snippets/_actions.html.haml +++ b/app/views/projects/snippets/_actions.html.haml @@ -14,7 +14,7 @@ = link_to _('Submit as spam'), mark_as_spam_project_snippet_path(@project, @snippet), method: :post, class: 'btn btn-grouped btn-spam', title: _('Submit as spam') - if can?(current_user, :create_snippet, @project) || can?(current_user, :update_snippet, @snippet) .d-block.d-sm-none.dropdown - %button.btn.btn-default.btn-block.gl-mb-0.prepend-top-5{ data: { toggle: "dropdown" } } + %button.btn.btn-default.btn-block.gl-mb-0.gl-mt-2{ data: { toggle: "dropdown" } } = _('Options') = icon('caret-down') .dropdown-menu.dropdown-menu-full-width diff --git a/app/views/projects/starrers/_starrer.html.haml b/app/views/projects/starrers/_starrer.html.haml index 377d62f8abd..d8a2c72d9ce 100644 --- a/app/views/projects/starrers/_starrer.html.haml +++ b/app/views/projects/starrers/_starrer.html.haml @@ -13,7 +13,7 @@ %span.cgray= starrer.user.to_reference - if starrer.user == current_user - %span.badge.badge-success.prepend-left-5= _("It's you") + %span.badge.badge-success.gl-ml-2= _("It's you") .block-truncated = time_ago_with_tooltip(starrer.starred_since) diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml index 79a00b00fa6..59c7d0401d1 100644 --- a/app/views/projects/tags/_tag.html.haml +++ b/app/views/projects/tags/_tag.html.haml @@ -26,7 +26,7 @@ = _("Release") = link_to release.name, project_releases_path(@project, anchor: release.tag), class: 'tag-release-link' - if release.description.present? - .md.prepend-top-default + .md.gl-mt-3 = markdown_field(release, :description) .row-fixed-content.controls.flex-row @@ -38,5 +38,5 @@ - 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 = icon("pencil") - = link_to project_tag_path(@project, tag.name), class: "btn btn-remove remove-row has-tooltip prepend-left-10 #{protected_tag?(@project, tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), method: :delete, data: { confirm: s_('TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?') % { tag_name: tag.name }, container: 'body' }, remote: true do + = link_to project_tag_path(@project, tag.name), class: "btn btn-remove remove-row has-tooltip gl-ml-3 #{protected_tag?(@project, tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), method: :delete, data: { confirm: s_('TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?') % { tag_name: tag.name }, container: 'body' }, remote: true do = icon("trash-o") diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml index 6ad7cf1848f..e3d3f2226a8 100644 --- a/app/views/projects/tags/index.html.haml +++ b/app/views/projects/tags/index.html.haml @@ -24,7 +24,7 @@ %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' do + = link_to new_project_tag_path(@project), class: 'btn btn-success new-tag-btn', 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 d-none d-sm-inline-block has-tooltip' do = icon("rss") diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml index 5aabfdd022a..c32318df7cc 100644 --- a/app/views/projects/tags/new.html.haml +++ b/app/views/projects/tags/new.html.haml @@ -14,7 +14,7 @@ .form-group.row = label_tag :tag_name, nil, class: 'col-form-label col-sm-2' .col-sm-10 - = text_field_tag :tag_name, params[:tag_name], required: true, autofocus: true, class: 'form-control' + = text_field_tag :tag_name, params[:tag_name], required: true, autofocus: true, class: 'form-control', data: { qa_selector: "tag_name_field" } .form-group.row = label_tag :ref, 'Create from', class: 'col-form-label col-sm-2' .col-sm-10.create-from @@ -29,7 +29,7 @@ .form-group.row = label_tag :message, nil, class: 'col-form-label col-sm-2' .col-sm-10 - = text_area_tag :message, @message, required: false, class: 'form-control', rows: 5 + = text_area_tag :message, @message, required: false, class: 'form-control', rows: 5, data: { qa_selector: "tag_message_field" } .form-text.text-muted = tag_description_help_text %hr @@ -40,17 +40,17 @@ - link_start = '<a href="%{url}" rel="noopener noreferrer" target="_blank">'.html_safe - releases_page_path = project_releases_path(@project) - releases_page_link_start = link_start % { url: releases_page_path } - - docs_url = help_page_path('user/project/releases/index.md', anchor: 'creating-a-release') + - docs_url = help_page_path('user/project/releases/index.md', anchor: 'create-a-release') - docs_link_start = link_start % { url: docs_url } - link_end = '</a>'.html_safe - replacements = { releases_page_link_start: releases_page_link_start, docs_link_start: docs_link_start, link_end: link_end } = s_('TagsPage|Optionally, create a public Release of your project, based on this tag. Release notes are displayed on the %{releases_page_link_start}Releases%{link_end} page. %{docs_link_start}More information%{link_end}').html_safe % replacements = render layout: 'shared/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do - = render 'shared/zen', attr: :release_description, classes: 'note-textarea', placeholder: s_('TagsPage|Write your release notes or drag files here…'), current_text: @release_description + = render 'shared/zen', attr: :release_description, classes: 'note-textarea', placeholder: s_('TagsPage|Write your release notes or drag files here…'), current_text: @release_description, qa_selector: 'release_notes_field' = render 'shared/notes/hints' .form-actions - = button_tag s_('TagsPage|Create tag'), class: 'btn btn-success' + = button_tag s_('TagsPage|Create tag'), class: 'btn btn-success', data: { qa_selector: "create_tag_button" } = link_to s_('TagsPage|Cancel'), project_tags_path(@project), class: 'btn btn-cancel' -# haml-lint:disable InlineJavaScript %script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe diff --git a/app/views/projects/tags/releases/edit.html.haml b/app/views/projects/tags/releases/edit.html.haml index a3746808440..896dbe454e6 100644 --- a/app/views/projects/tags/releases/edit.html.haml +++ b/app/views/projects/tags/releases/edit.html.haml @@ -1,6 +1,6 @@ -- add_to_breadcrumbs "Tags", project_tags_path(@project) +- add_to_breadcrumbs _("Tags"), project_tags_path(@project) - breadcrumb_title @tag.name -- page_title "Edit", @tag.name, "Tags" +- page_title _("Edit"), @tag.name, _("Tags") .sub-header-block.no-bottom-space .oneline @@ -14,6 +14,6 @@ = render 'shared/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here…" = render 'shared/notes/hints' .error-alert - .prepend-top-default + .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" diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml index 6f53a687fb9..edb0577cebd 100644 --- a/app/views/projects/tags/show.html.haml +++ b/app/views/projects/tags/show.html.haml @@ -9,7 +9,7 @@ .top-area.multi-line.flex-wrap .nav-text .title - %span.item-title.ref-name + %span.item-title.ref-name{ data: { qa_selector: 'tag_name_content' } } = icon('tag') = @tag.name - if protected_tag?(@project, @tag) @@ -56,12 +56,12 @@ %i.fa.fa-trash-o - if @tag.message.present? - %pre.wrap + %pre.wrap{ data: { qa_selector: 'tag_message_content' } } = strip_signature(@tag.message) -.append-bottom-default.prepend-top-default +.gl-mb-3.gl-mt-3 - if @release.description.present? - .description.md + .description.md{ data: { qa_selector: 'tag_release_notes_content' } } = markdown_field(@release, :description) - else = s_('TagsPage|This tag has no release notes.') diff --git a/app/views/projects/tree/_readme.html.haml b/app/views/projects/tree/_readme.html.haml index 3e3804ae204..6d2bdda8254 100644 --- a/app/views/projects/tree/_readme.html.haml +++ b/app/views/projects/tree/_readme.html.haml @@ -1,5 +1,5 @@ - if readme.rich_viewer - %article.file-holder.readme-holder{ id: 'readme', class: [("limited-width-container" unless fluid_layout), ("js-show-on-root" if vue_file_list_enabled?)] } + %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 diff --git a/app/views/projects/tree/_tree_commit_column.html.haml b/app/views/projects/tree/_tree_commit_column.html.haml deleted file mode 100644 index 065fef606d5..00000000000 --- a/app/views/projects/tree/_tree_commit_column.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -- full_title = markdown_field(commit, :full_title) -%span.str-truncated - = link_to_html full_title, project_commit_path(@project, commit.id), title: full_title, class: 'tree-commit-link' diff --git a/app/views/projects/tree/_tree_content.html.haml b/app/views/projects/tree/_tree_content.html.haml index c65420d537b..a4427c6eedb 100644 --- a/app/views/projects/tree/_tree_content.html.haml +++ b/app/views/projects/tree/_tree_content.html.haml @@ -10,7 +10,7 @@ - if @path.present? %tr.tree-item %td.tree-item-file-name - = link_to "..", project_tree_path(@project, up_dir_path), class: 'prepend-left-10' + = link_to "..", project_tree_path(@project, up_dir_path), class: 'gl-ml-3' %td %td.d-none.d-sm-table-cell diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index d5f7673488f..eab6d750a02 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -5,92 +5,17 @@ .tree-ref-holder = render 'shared/ref_switcher', destination: 'tree', path: @path, show_create: true - - if on_top_of_branch? - - addtotree_toggle_attributes = { 'data-toggle': 'dropdown', 'data-target': '.add-to-tree-dropdown', 'data-boundary': 'window' } - - else - - addtotree_toggle_attributes = { title: _("You can only add files when you are on a branch"), data: { container: 'body' }, class: 'disabled has-tooltip' } - - - if vue_file_list_enabled? - #js-repo-breadcrumb{ data: breadcrumb_data_attributes } - - else - %ul.breadcrumb.repo-breadcrumb - %li.breadcrumb-item - = link_to project_tree_path(@project, @ref) do - = @project.path - - path_breadcrumbs do |title, path| - %li.breadcrumb-item - = link_to truncate(title, length: 40), project_tree_path(@project, tree_join(@ref, path)) - - - if can_collaborate || can_create_mr_from_fork - %li.breadcrumb-item - %button.btn.add-to-tree.qa-add-to-tree{ addtotree_toggle_attributes, type: 'button' } - = sprite_icon('plus', size: 16, css_class: 'float-left') - = sprite_icon('chevron-down', size: 16, css_class: 'float-left') - - if on_top_of_branch? - .add-to-tree-dropdown - %ul.dropdown-menu - - if can_edit_tree? - %li.dropdown-header - #{ _('This directory') } - %li - = link_to project_new_blob_path(@project, @id), class: 'qa-new-file-option' do - #{ _('New file') } - %li - = link_to '#modal-upload-blob', { 'data-target' => '#modal-upload-blob', 'data-toggle' => 'modal' } do - #{ _('Upload file') } - %li - = link_to '#modal-create-new-dir', { 'data-target' => '#modal-create-new-dir', 'data-toggle' => 'modal' } do - #{ _('New directory') } - - elsif can_create_mr_from_fork - %li - - continue_params = { to: project_new_blob_path(@project, @id), - notice: edit_in_new_fork_notice, - 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 fork_path, method: :post do - #{ _('New file') } - %li - - continue_params = { to: request.fullpath, - notice: edit_in_new_fork_notice + " Try to upload a file 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 fork_path, method: :post do - #{ _('Upload file') } - %li - - continue_params = { to: request.fullpath, - notice: edit_in_new_fork_notice + " Try to create a new directory 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 fork_path, method: :post do - #{ _('New directory') } - - - if can?(current_user, :push_code, @project) - %li.divider - %li.dropdown-header - #{ _('This repository') } - %li - = link_to new_project_branch_path(@project) do - #{ _('New branch') } - %li - = link_to new_project_tag_path(@project) do - #{ _('New tag') } + #js-repo-breadcrumb{ data: breadcrumb_data_attributes } .tree-controls .d-block.d-sm-flex.flex-wrap.align-items-start.gl-children-ml-sm-3< = render_if_exists 'projects/tree/lock_link' - - if vue_file_list_enabled? - #js-tree-history-link.d-inline-block{ data: { history_link: project_commits_path(@project, @ref) } } - - else - = link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn' + #js-tree-history-link.d-inline-block{ data: { history_link: project_commits_path(@project, @ref) } } = render 'projects/find_file_link' - if can_collaborate || current_user&.already_forked?(@project) - - if vue_file_list_enabled? - #js-tree-web-ide-link.d-inline-block - - else - = link_to ide_edit_path(@project, @ref, @path), class: 'btn btn-default qa-web-ide-button' do - = _('Web IDE') + #js-tree-web-ide-link.d-inline-block - elsif can_create_mr_from_fork = link_to '#modal-confirm-fork', class: 'btn btn-default qa-web-ide-button', data: { target: '#modal-confirm-fork', toggle: 'modal'} do = _('Web IDE') diff --git a/app/views/projects/tree/_tree_row.html.haml b/app/views/projects/tree/_tree_row.html.haml index 8a27ea66523..300cd5423bf 100644 --- a/app/views/projects/tree/_tree_row.html.haml +++ b/app/views/projects/tree/_tree_row.html.haml @@ -14,7 +14,7 @@ %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.prepend-left-5 LFS + %span.badge.label-lfs.gl-ml-2 LFS - elsif tree_row_type == :commit = tree_icon('archive', tree_row.mode, tree_row.name) diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml index 65f5bc31d2e..3dd12a7b641 100644 --- a/app/views/projects/tree/show.html.haml +++ b/app/views/projects/tree/show.html.haml @@ -1,13 +1,9 @@ - breadcrumb_title _("Repository") - @content_class = "limit-container-width" unless fluid_layout -- signatures_path = namespace_project_signatures_path(namespace_id: @project.namespace.full_path, project_id: @project.path, id: @last_commit) - page_title @path.presence || _("Files"), @ref = content_for :meta_tags do = auto_discovery_link_tag(:atom, project_commits_url(@project, @ref, rss_url_options), title: "#{@project.name}:#{@ref} commits") -- unless vue_file_list_enabled? - .js-signature-container{ data: { 'signatures-path': signatures_path } } - = render 'projects/last_push' = render 'projects/files', commit: @last_commit, project: @project, ref: @ref, content_url: project_tree_path(@project, @id) diff --git a/app/views/projects/triggers/_index.html.haml b/app/views/projects/triggers/_index.html.haml index 4ca070cb162..4e097f345c2 100644 --- a/app/views/projects/triggers/_index.html.haml +++ b/app/views/projects/triggers/_index.html.haml @@ -1,4 +1,4 @@ -.row.prepend-top-default.append-bottom-default.triggers-container +.row.gl-mt-3.gl-mb-3.triggers-container .col-lg-12 .card .card-header @@ -21,7 +21,7 @@ %th = render partial: 'projects/triggers/trigger', collection: @triggers, as: :trigger - else - %p.settings-message.text-center.append-bottom-default + %p.settings-message.text-center.gl-mb-3 No triggers have been created yet. Add one using the form above. .card-footer @@ -38,7 +38,7 @@ %code REF_NAME with the trigger token and the branch or tag name respectively. - %h5.prepend-top-default + %h5.gl-mt-3 Use cURL %p.light @@ -51,7 +51,7 @@ -F token=TOKEN \ -F ref=REF_NAME \ #{builds_trigger_url(@project.id)} - %h5.prepend-top-default + %h5.gl-mt-3 Use .gitlab-ci.yml %p.light @@ -66,7 +66,7 @@ stage: deploy script: - "curl -X POST -F token=TOKEN -F ref=REF_NAME #{builds_trigger_url(@project.id)}" - %h5.prepend-top-default + %h5.gl-mt-3 Use webhook %p.light @@ -76,7 +76,7 @@ %pre :plain #{builds_trigger_url(@project.id, ref: 'REF_NAME')}?token=TOKEN - %h5.prepend-top-default + %h5.gl-mt-3 Pass job variables %p.light diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml index d80248f7e80..3036e918160 100644 --- a/app/views/projects/triggers/_trigger.html.haml +++ b/app/views/projects/triggers/_trigger.html.haml @@ -31,7 +31,7 @@ - revoke_trigger_confirmation = "By revoking a trigger you will break any processes making use of it. Are you sure?" - if can?(current_user, :admin_trigger, trigger) = link_to edit_project_trigger_path(@project, trigger), method: :get, title: "Edit", class: "btn btn-default btn-sm" do - %i.fa.fa-pencil + = sprite_icon('pencil') - if can?(current_user, :manage_trigger, trigger) = link_to project_trigger_path(@project, trigger), data: { confirm: revoke_trigger_confirmation }, method: :delete, title: "Revoke", class: "btn btn-default btn-warning btn-sm btn-trigger-revoke" do %i.fa.fa-trash diff --git a/app/views/projects/triggers/edit.html.haml b/app/views/projects/triggers/edit.html.haml index e287f05fe6a..ee2714e1eb9 100644 --- a/app/views/projects/triggers/edit.html.haml +++ b/app/views/projects/triggers/edit.html.haml @@ -1,6 +1,6 @@ -- page_title "Trigger" +- page_title _("Trigger") -.row.prepend-top-default.append-bottom-default +.row.gl-mt-3.gl-mb-3 .col-lg-12 %h4.gl-mt-0 Update trigger diff --git a/app/views/projects/wikis/git_access.html.haml b/app/views/projects/wikis/git_access.html.haml index 208dedc988b..1db4554541d 100644 --- a/app/views/projects/wikis/git_access.html.haml +++ b/app/views/projects/wikis/git_access.html.haml @@ -2,8 +2,7 @@ - page_title s_("WikiClone|Git Access"), _("Wiki") .wiki-page-header.top-area.has-sidebar-toggle.py-3.flex-column.flex-lg-row - %button.btn.btn-default.d-block.d-sm-block.d-md-none.float-right.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" } - = icon('angle-double-left') + = wiki_sidebar_toggle_button .git-access-header.w-100.d-flex.flex-column.justify-content-center %span diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml index db7769fa743..d6e38ddd5c6 100644 --- a/app/views/search/_category.html.haml +++ b/app/views/search/_category.html.haml @@ -3,8 +3,8 @@ = search_filter_link 'users', _("Users") .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller - .fade-left= icon('angle-left') - .fade-right= icon('angle-right') + .fade-left= sprite_icon('chevron-lg-left', size: 12) + .fade-right= sprite_icon('chevron-lg-right', size: 12) %ul.nav-links.search-filter.scrolling-tabs.nav.nav-tabs - if @project - if project_search_tabs?(:blobs) diff --git a/app/views/search/results/_issue.html.haml b/app/views/search/results/_issue.html.haml index 1f055cdfa31..b88e9a75053 100644 --- a/app/views/search/results/_issue.html.haml +++ b/app/views/search/results/_issue.html.haml @@ -4,7 +4,7 @@ = link_to namespace_project_issue_path(issue.project.namespace.becomes(Namespace), issue.project, issue) do %span.term.str-truncated= issue.title - if issue.closed? - %span.badge.badge-danger.prepend-left-5= _("Closed") + %span.badge.badge-danger.gl-ml-2= _("Closed") .float-right ##{issue.iid} - if issue.description.present? .description.term diff --git a/app/views/search/results/_merge_request.html.haml b/app/views/search/results/_merge_request.html.haml index 074bb9bce8d..45b6cb06753 100644 --- a/app/views/search/results/_merge_request.html.haml +++ b/app/views/search/results/_merge_request.html.haml @@ -3,9 +3,9 @@ = link_to namespace_project_merge_request_path(merge_request.target_project.namespace.becomes(Namespace), merge_request.target_project, merge_request) do %span.term.str-truncated= merge_request.title - if merge_request.merged? - %span.badge.badge-primary.prepend-left-5= _("Merged") + %span.badge.badge-primary.gl-ml-2= _("Merged") - elsif merge_request.closed? - %span.badge.badge-danger.prepend-left-5= _("Closed") + %span.badge.badge-danger.gl-ml-2= _("Closed") .float-right= merge_request.to_reference - if merge_request.description.present? .description.term diff --git a/app/views/search/results/_note.html.haml b/app/views/search/results/_note.html.haml index 6717939d034..b67bc71941a 100644 --- a/app/views/search/results/_note.html.haml +++ b/app/views/search/results/_note.html.haml @@ -4,7 +4,7 @@ .search-result-row %h5.note-search-caption.str-truncated - %i.fa.fa-comment + = sprite_icon('comment', size: 16, css_class: 'gl-vertical-align-text-bottom') = link_to_member(project, note.author, avatar: false) - link_to_project = link_to(project.full_name, project) = _("commented on %{link_to_project}").html_safe % { link_to_project: link_to_project } diff --git a/app/views/search/show.html.haml b/app/views/search/show.html.haml index f300e1d4841..869890cdf31 100644 --- a/app/views/search/show.html.haml +++ b/app/views/search/show.html.haml @@ -7,7 +7,7 @@ = _('Search') = render_if_exists 'search/form_elasticsearch', attrs: { class: 'ml-sm-auto' } -.prepend-top-default +.gl-mt-3 = render 'search/form' - if @search_term = render 'search/category' diff --git a/app/views/sent_notifications/unsubscribe.html.haml b/app/views/sent_notifications/unsubscribe.html.haml index 1eecbe3bc0e..7aeecf26c39 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: 'btn btn-primary append-right-10' - = link_to _('Cancel'), new_user_session_path, class: 'btn append-right-10' + class: 'btn btn-primary gl-mr-3' + = link_to _('Cancel'), new_user_session_path, class: 'btn gl-mr-3' diff --git a/app/views/shared/_commit_well.html.haml b/app/views/shared/_commit_well.html.haml index 6f1fe9bfdc5..48fe258d01f 100644 --- a/app/views/shared/_commit_well.html.haml +++ b/app/views/shared/_commit_well.html.haml @@ -1,4 +1,4 @@ -.info-well.d-none.d-sm-block.project-last-commit.append-bottom-default +.info-well.d-none.d-sm-block.project-last-commit.gl-mb-3 .well-segment %ul.blob-commit-info = render 'projects/commits/commit', commit: commit, ref: ref, project: project diff --git a/app/views/shared/_event_filter.html.haml b/app/views/shared/_event_filter.html.haml index 1b2e8d3799d..03534bf78d1 100644 --- a/app/views/shared/_event_filter.html.haml +++ b/app/views/shared/_event_filter.html.haml @@ -1,8 +1,8 @@ - show_group_events = local_assigns.fetch(:show_group_events, false) .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller.flex-fill - .fade-left= icon('angle-left') - .fade-right= icon('angle-right') + .fade-left= sprite_icon('chevron-lg-left', size: 12) + .fade-right= sprite_icon('chevron-lg-right', size: 12) %ul.nav-links.event-filter.scrolling-tabs.nav.nav-tabs = event_filter_link EventFilter::ALL, _('All'), s_('EventFilterBy|Filter by all') - if event_filter_visible(:repository) @@ -15,6 +15,8 @@ = render_if_exists 'events/epics_filter' - if comments_visible? = event_filter_link EventFilter::COMMENTS, _('Comments'), s_('EventFilterBy|Filter by comments') - - if Feature.enabled?(:wiki_events) && (@project.nil? || @project.has_wiki?) + - if @project.nil? || @project.has_wiki? = event_filter_link EventFilter::WIKI, _('Wiki'), s_('EventFilterBy|Filter by wiki') + - if event_filter_visible(:designs) + = event_filter_link EventFilter::DESIGNS, _('Designs'), s_('EventFilterBy|Filter by designs') = event_filter_link EventFilter::TEAM, _('Team'), s_('EventFilterBy|Filter by team') diff --git a/app/views/shared/_field.html.haml b/app/views/shared/_field.html.haml index 2480014ea42..076c87400e0 100644 --- a/app/views/shared/_field.html.haml +++ b/app/views/shared/_field.html.haml @@ -22,7 +22,7 @@ - elsif type == 'checkbox' = form.check_box name - elsif type == 'select' - = form.select name, options_for_select(choices, value ? value : default_choice), {}, { class: "form-control"} # rubocop:disable Style/RedundantCondition + = form.select name, options_for_select(choices, value || default_choice), {}, { class: "form-control"} - elsif type == 'password' = form.password_field name, autocomplete: "new-password", placeholder: placeholder, class: "form-control", required: value.blank? && required, data: { qa_selector: "#{name.downcase.gsub('\s', '')}_field" } - if help diff --git a/app/views/shared/_issuable_meta_data.html.haml b/app/views/shared/_issuable_meta_data.html.haml index 7807371285c..d704eae2090 100644 --- a/app/views/shared/_issuable_meta_data.html.haml +++ b/app/views/shared/_issuable_meta_data.html.haml @@ -2,7 +2,7 @@ - issue_votes = @issuable_meta_data[issuable.id] - upvotes, downvotes = issue_votes.upvotes, issue_votes.downvotes - issuable_path = issuable_path(issuable, anchor: 'notes') -- issuable_mr = @issuable_meta_data[issuable.id].merge_requests_count(current_user) +- issuable_mr = @issuable_meta_data[issuable.id].merge_requests_count - if issuable_mr > 0 %li.issuable-mr.d-none.d-sm-block.has-tooltip{ title: _('Related merge requests') } @@ -11,15 +11,15 @@ - if upvotes > 0 %li.issuable-upvotes.d-none.d-sm-block.has-tooltip{ title: _('Upvotes') } - = icon('thumbs-up') + = sprite_icon('thumb-up', size: 16, css_class: "vertical-align-middle") = upvotes - if downvotes > 0 %li.issuable-downvotes.d-none.d-sm-block.has-tooltip{ title: _('Downvotes') } - = icon('thumbs-down') + = sprite_icon('thumb-down', size: 16, css_class: "vertical-align-middle") = downvotes %li.issuable-comments.d-none.d-sm-block = link_to issuable_path, class: ['has-tooltip', ('no-comments' if note_count.zero?)], title: _('Comments') do - = icon('comments') + = sprite_icon('comments', size: 16, css_class: 'gl-vertical-align-text-bottom') = note_count diff --git a/app/views/shared/_label_row.html.haml b/app/views/shared/_label_row.html.haml index cd303dd7a3d..3d2ae772135 100644 --- a/app/views/shared/_label_row.html.haml +++ b/app/views/shared/_label_row.html.haml @@ -6,7 +6,7 @@ .label-name = render_label(label, tooltip: false) .label-description - .append-right-default.prepend-left-default + .label-description-wrapper - if label.description.present? .description-text = markdown_field(label, :description) @@ -20,6 +20,6 @@ = link_to_label(label, type: :merge_request) { _('Merge requests') } - if force_priority · - %li.label-link-item.priority-badge.js-priority-badge.inline.prepend-left-10 + %li.label-link-item.priority-badge.js-priority-badge.inline.gl-ml-3 .label-badge.label-badge-blue= _('Prioritized label') = render_if_exists 'shared/label_row_epics_link', label: label diff --git a/app/views/shared/_md_preview.html.haml b/app/views/shared/_md_preview.html.haml index f5f24b2f0ce..c3818b9f7ae 100644 --- a/app/views/shared/_md_preview.html.haml +++ b/app/views/shared/_md_preview.html.haml @@ -11,10 +11,10 @@ .md-header %ul.nav.nav-tabs.nav-links.clearfix %li.md-header-tab.active - %button.js-md-write-button{ tabindex: -1 } + %button.js-md-write-button = _("Write") %li.md-header-tab - %button.js-md-preview-button{ tabindex: -1 } + %button.js-md-preview-button = _("Preview") %li.md-header-toolbar.active diff --git a/app/views/shared/_milestone_expired.html.haml b/app/views/shared/_milestone_expired.html.haml index 48a97a18ca9..2261e9e3121 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.append-bottom-5= _('Expired') + .status-box.status-box-expired.gl-mb-2= _('Expired') - if milestone.upcoming? - .status-box.status-box-mr-merged.append-bottom-5= _('Upcoming') + .status-box.status-box-mr-merged.gl-mb-2= _('Upcoming') - if milestone.closed? - .status-box.status-box-closed.append-bottom-5= _('Closed') + .status-box.status-box-closed.gl-mb-2= _('Closed') diff --git a/app/views/shared/_milestones_sort_dropdown.html.haml b/app/views/shared/_milestones_sort_dropdown.html.haml index 9a1db831ad3..06da990e071 100644 --- a/app/views/shared/_milestones_sort_dropdown.html.haml +++ b/app/views/shared/_milestones_sort_dropdown.html.haml @@ -1,4 +1,4 @@ -.dropdown.inline.prepend-left-10 +.dropdown.inline.gl-ml-3 %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown' } } %span.light - if @sort.present? diff --git a/app/views/shared/_namespace_storage_limit_alert.html.haml b/app/views/shared/_namespace_storage_limit_alert.html.haml deleted file mode 100644 index 95f27cde15b..00000000000 --- a/app/views/shared/_namespace_storage_limit_alert.html.haml +++ /dev/null @@ -1,26 +0,0 @@ -- return unless current_user - -- payload = namespace_storage_alert(namespace) -- return if payload.empty? - -- alert_level = payload[:alert_level] -- root_namespace = payload[:root_namespace] - -- style = namespace_storage_alert_style(alert_level) -- icon = namespace_storage_alert_icon(alert_level) -- link = namespace_storage_usage_link(root_namespace) - -%div{ class: [classes, 'js-namespace-storage-alert'] } - .gl-pt-5.gl-pb-3 - .gl-alert{ class: "gl-alert-#{style}", role: 'alert' } - = sprite_icon(icon, css_class: "gl-icon gl-alert-icon") - .gl-alert-title - %h4.gl-alert-title= payload[:usage_message] - - if alert_level != :error - %button.js-namespace-storage-alert-dismiss.gl-alert-dismiss.gl-cursor-pointer{ type: 'button', 'aria-label' => _('Dismiss'), data: { id: root_namespace.id, level: alert_level } } - = sprite_icon('close', size: 16, css_class: 'gl-icon') - .gl-alert-body - = payload[:explanation_message] - - if link - .gl-alert-actions - = link_to(_('Manage storage usage'), link, class: "btn gl-alert-action btn-md gl-button btn-#{style}") diff --git a/app/views/shared/_service_settings.html.haml b/app/views/shared/_service_settings.html.haml index 92b86c6fec1..7d93dca22f5 100644 --- a/app/views/shared/_service_settings.html.haml +++ b/app/views/shared/_service_settings.html.haml @@ -1,22 +1,23 @@ -= form_errors(@service) += form_errors(integration) -- if lookup_context.template_exists?('help', "projects/services/#{@service.to_param}", true) - = render "projects/services/#{@service.to_param}/help", subject: @service -- elsif @service.help.present? +- 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 @service.help + = markdown integration.help .service-settings - .js-vue-integration-settings{ data: { show_active: @service.show_active_box?.to_s, activated: (@service.active || @service.new_record?).to_s, type: @service.to_param, merge_request_events: @service.merge_requests_events.to_s, -commit_events: @service.commit_events.to_s, enable_comments: @service.comment_on_event_enabled.to_s, comment_detail: @service.comment_detail, trigger_events: trigger_events_for_service, fields: fields_for_service } } + - if @admin_integration + .js-vue-admin-integration-settings{ data: integration_form_data(@admin_integration) } + .js-vue-integration-settings{ data: integration_form_data(integration) } - - if show_service_trigger_events? + - if show_service_trigger_events?(integration) .form-group.row %label.col-form-label.col-sm-2= _('Trigger') .col-sm-10 - - @service.configurable_events.each do |event| + - integration.configurable_events.each do |event| .form-group .form-check = form.check_box service_event_field_name(event), class: 'form-check-input' @@ -24,14 +25,14 @@ commit_events: @service.commit_events.to_s, enable_comments: @service.comment_on %strong = event.humanize - - field = @service.event_field(event) + - field = integration.event_field(event) - if field = form.text_field field[:name], class: "form-control", placeholder: field[:placeholder] %p.text-muted - = @service.class.event_description(event) + = integration.class.event_description(event) - unless integration_form_refactor? - - @service.global_fields.each do |field| + - integration.global_fields.each do |field| = render 'shared/field', form: form, field: field diff --git a/app/views/shared/_sidebar_toggle_button.html.haml b/app/views/shared/_sidebar_toggle_button.html.haml index c7546073e5c..1431966c83d 100644 --- a/app/views/shared/_sidebar_toggle_button.html.haml +++ b/app/views/shared/_sidebar_toggle_button.html.haml @@ -1,6 +1,6 @@ %a.toggle-sidebar-button.js-toggle-sidebar.qa-toggle-sidebar.rspec-toggle-sidebar{ role: "button", type: "button", title: "Toggle sidebar" } - = sprite_icon('angle-double-left', css_class: 'icon-angle-double-left') - = sprite_icon('angle-double-right', css_class: 'icon-angle-double-right') + = sprite_icon('chevron-double-lg-left', css_class: 'icon-chevron-double-lg-left') + = sprite_icon('chevron-double-lg-right', css_class: 'icon-chevron-double-lg-right') %span.collapse-text= _("Collapse sidebar") = button_tag class: 'close-nav-button', type: 'button' do diff --git a/app/views/shared/_zen.html.haml b/app/views/shared/_zen.html.haml index 8dd0e5a92a7..914409d0e65 100644 --- a/app/views/shared/_zen.html.haml +++ b/app/views/shared/_zen.html.haml @@ -14,6 +14,6 @@ supports_autocomplete: supports_autocomplete, qa_selector: qa_selector } - else - = text_area_tag attr, current_text, class: classes, placeholder: placeholder + = text_area_tag attr, current_text, data: { qa_selector: qa_selector }, class: classes, placeholder: placeholder %a.zen-control.zen-control-leave.js-zen-leave.gl-text-gray-700{ href: "#" } = sprite_icon('compress', size: 16) diff --git a/app/views/shared/access_tokens/_form.html.haml b/app/views/shared/access_tokens/_form.html.haml index 680626f7880..820a6cbd15d 100644 --- a/app/views/shared/access_tokens/_form.html.haml +++ b/app/views/shared/access_tokens/_form.html.haml @@ -30,5 +30,5 @@ = f.label :scopes, _('Scopes'), class: 'label-bold' = render 'shared/tokens/scopes_form', prefix: prefix, token: token, scopes: scopes - .prepend-top-default + .gl-mt-3 = f.submit _('Create %{type}') % { type: type }, class: '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 5518c31cb06..55231cb9429 100644 --- a/app/views/shared/access_tokens/_table.html.haml +++ b/app/views/shared/access_tokens/_table.html.haml @@ -16,6 +16,9 @@ %tr %th= _('Name') %th= s_('AccessTokens|Created') + %th + = _('Last Used') + = link_to icon('question-circle'), help_page_path('user/profile/personal_access_tokens.md', anchor: 'token-activity'), target: '_blank' %th= _('Expires') %th= _('Scopes') %th @@ -25,9 +28,18 @@ %td= token.name %td= token.created_at.to_date.to_s(:medium) %td + - if token.last_used_at? + %span.token-last-used-label= _(time_ago_with_tooltip(token.last_used_at)) + - else + %span.token-never-used-label= _('Never') + %td - if token.expires? - %span{ class: ('text-warning' if token.expires_soon?) } - = _('In %{time_to_now}') % { time_to_now: distance_of_time_in_words_to_now(token.expires_at) } + - if token.expires_at.past? || token.expires_at.today? + %span{ class: 'text-danger has-tooltip', title: _('Expiration not enforced') } + = _('Expired') + - else + %span{ class: ('text-warning' if token.expires_soon?) } + = _('In %{time_to_now}') % { time_to_now: distance_of_time_in_words_to_now(token.expires_at) } - else %span.token-never-expires-label= _('Never') %td= token.scopes.present? ? token.scopes.join(', ') : _('<no scopes selected>') diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml index 902b6d19f82..b68c7cd4d52 100644 --- a/app/views/shared/boards/_show.html.haml +++ b/app/views/shared/boards/_show.html.haml @@ -13,7 +13,6 @@ - content_for :page_specific_javascripts do -# haml-lint:disable InlineJavaScript - %script#js-board-template{ type: "text/x-template" }= render "shared/boards/components/board" %script#js-board-modal-filter{ type: "text/x-template" }= render "shared/issuable/search_bar", type: :boards_modal, show_sorting_dropdown: false %script#js-board-promotion{ type: "text/x-template" }= render_if_exists "shared/promotions/promote_issue_board" diff --git a/app/views/shared/boards/components/_board.html.haml b/app/views/shared/boards/components/_board.html.haml deleted file mode 100644 index 2a5b72d478a..00000000000 --- a/app/views/shared/boards/components/_board.html.haml +++ /dev/null @@ -1,82 +0,0 @@ --# Please have a look at app/assets/javascripts/boards/components/board_column.vue - This haml file is deprecated and will be deleted soon, please change the Vue app - https://gitlab.com/gitlab-org/gitlab/-/issues/212300 -.board.h-100.px-2.align-top.ws-normal{ ":class" => '{ "is-draggable": !list.preset, "is-expandable": list.isExpandable, "is-collapsed": !list.isExpanded, "board-type-assignee": list.type === "assignee" }', - ":data-id" => "list.id", data: { qa_selector: "board_list" } } - .board-inner.d-flex.flex-column.position-relative.h-100.rounded - %header.board-header{ ":class" => '{ "has-border": list.label && list.label.color, "position-relative": list.isExpanded, "position-absolute position-top-0 position-left-0 w-100 h-100": !list.isExpanded }', ":style" => "{ borderTopColor: (list.label && list.label.color ? list.label.color : null) }", data: { qa_selector: "board_list_header" } } - %h3.board-title.m-0.d-flex.js-board-handle{ ":class" => '{ "user-can-drag": (!disabled && !list.preset), "border-bottom-0": !list.isExpanded }' } - - .board-title-caret.no-drag{ "v-if": "list.isExpandable", - "aria-hidden": "true", - ":aria-label": "caretTooltip", - ":title": "caretTooltip", - "v-tooltip": "", - data: { placement: "bottom" }, - "@click": "toggleExpanded" } - %i.fa.fa-fw{ ":class": '{ "fa-caret-right": list.isExpanded, "fa-caret-down": !list.isExpanded }' } - = render_if_exists "shared/boards/components/list_milestone" - - %a.user-avatar-link.js-no-trigger{ "v-if": "list.type === \"assignee\"", ":href": "list.assignee.path" } - -# haml-lint:disable AltText - %img.avatar.s20.has-tooltip{ height: "20", width: "20", ":src": "list.assignee.avatar", ":alt": "list.assignee.name" } - - .board-title-text - %span.board-title-main-text.block-truncated{ "v-if": "list.type !== \"label\"", - ":title" => '((list.label && list.label.description) || list.title || "")', - data: { container: "body" }, - ":class": "{ 'has-tooltip': !['backlog', 'closed'].includes(list.type), 'd-block': list.type === 'milestone' }" } - {{ list.title }} - - %span.board-title-sub-text.prepend-left-5.has-tooltip{ "v-if": "list.type === \"assignee\"", - ":title" => '(list.assignee && list.assignee.username || "")' } - @{{ list.assignee.username }} - - %gl-label{ "v-if" => " list.type === \"label\"", - ":background-color" => "list.label.color", - ":title" => "list.label.title", - ":description" => "list.label.description", - "tooltipPlacement" => "bottom", - ":size" => '(!list.isExpanded ? "sm" : "")', - ":scoped" => "showScopedLabels(list.label)" } - - - if can?(current_user, :admin_list, current_board_parent) - %board-delete{ "inline-template" => true, - ":list" => "list", - "v-if" => "!list.preset && list.id" } - %button.board-delete.no-drag.p-0.border-0.has-tooltip.float-right{ type: "button", title: _("Delete list"), ":class": "{ 'd-none': !list.isExpanded }", "aria-label" => _("Delete list"), data: { placement: "bottom" }, "@click.stop" => "deleteBoard" } - = icon("trash") - - .issue-count-badge.pr-0.no-drag.text-secondary{ "v-if" => "showBoardListAndBoardInfo" } - %span.d-inline-flex - %gl-tooltip{ ":target" => "() => $refs.issueCount", ":title" => "issuesTooltip" } - %span.issue-count-badge-count{ "ref" => "issueCount" } - %icon.mr-1{ name: "issues" } - %issue-count{ ":maxIssueCount" => "list.maxIssueCount", - ":issuesSize" => "list.issuesSize" } - = render_if_exists "shared/boards/components/list_weight" - - %gl-button-group.board-list-button-group.pl-2{ "v-if" => "isNewIssueShown || isSettingsShown" } - %gl-deprecated-button.issue-count-badge-add-button.no-drag{ type: "button", - "@click" => "showNewIssueForm", - "v-if" => "isNewIssueShown", - ":class": "{ 'd-none': !list.isExpanded, 'rounded-right': isNewIssueShown && !isSettingsShown }", - "aria-label" => _("New issue"), - "ref" => "newIssueBtn" } - = icon("plus") - %gl-tooltip{ ":target" => "() => $refs.newIssueBtn" } - = _("New Issue") - = render_if_exists 'shared/boards/components/list_settings' - - %board-list{ "v-if" => "showBoardListAndBoardInfo", - ":list" => "list", - ":issues" => "list.issues", - ":loading" => "list.loading", - ":disabled" => "disabled", - ":issue-link-base" => "issueLinkBase", - ":root-path" => "rootPath", - ":groupId" => ((current_board_parent.id if @group) || 'null'), - "ref" => "board-list" } - - if can?(current_user, :admin_list, current_board_parent) - %board-blank-state{ "v-if" => 'list.id == "blank"' } - = render_if_exists 'shared/boards/board_promotion_state' diff --git a/app/views/shared/dashboard/_no_filter_selected.html.haml b/app/views/shared/dashboard/_no_filter_selected.html.haml index 32246dac4c7..48c844d93e8 100644 --- a/app/views/shared/dashboard/_no_filter_selected.html.haml +++ b/app/views/shared/dashboard/_no_filter_selected.html.haml @@ -1,6 +1,6 @@ .row.empty-state.text-center .col-12 - .svg-130.prepend-top-default + .svg-130.gl-mt-3 = image_tag 'illustrations/issue-dashboard_results-without-filter.svg' .col-12 .text-content diff --git a/app/views/shared/deploy_tokens/_form.html.haml b/app/views/shared/deploy_tokens/_form.html.haml index 512644518fa..8d74e12e943 100644 --- a/app/views/shared/deploy_tokens/_form.html.haml +++ b/app/views/shared/deploy_tokens/_form.html.haml @@ -45,5 +45,5 @@ = 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') - .prepend-top-default + .gl-mt-3 = f.submit s_('DeployTokens|Create deploy token'), class: 'btn btn-success qa-create-deploy-token' 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 a9728dc841f..738f2f9db70 100644 --- a/app/views/shared/deploy_tokens/_new_deploy_token.html.haml +++ b/app/views/shared/deploy_tokens/_new_deploy_token.html.haml @@ -8,11 +8,11 @@ = 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.prepend-top-5.text-success= s_("DeployTokens|Use this username as a login.") + %span.deploy-token-help-block.gl-mt-2.text-success= s_("DeployTokens|Use this username as a login.") .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.prepend-top-5.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= s_("DeployTokens|Use this token as a password. Make sure you save it - you won't be able to access it again.") diff --git a/app/views/shared/empty_states/_wikis.html.haml b/app/views/shared/empty_states/_wikis.html.haml index ff5ee801969..656acafd416 100644 --- a/app/views/shared/empty_states/_wikis.html.haml +++ b/app/views/shared/empty_states/_wikis.html.haml @@ -11,6 +11,10 @@ %p.text-left = messages.dig(:writable, :body) = create_link + - if show_enable_confluence_integration?(@wiki.container) + = link_to s_('WikiEmpty|Enable the Confluence Wiki integration'), + edit_project_service_path(@project, :confluence), + class: 'btn', title: s_('WikiEmpty|Enable the Confluence Wiki integration') - elsif @project && can?(current_user, :read_issue, @project) - issues_link = link_to s_('WikiEmptyIssueMessage|issue tracker'), project_issues_path(@project) diff --git a/app/views/shared/empty_states/_wikis_layout.html.haml b/app/views/shared/empty_states/_wikis_layout.html.haml index d44017299b8..3b100f832b2 100644 --- a/app/views/shared/empty_states/_wikis_layout.html.haml +++ b/app/views/shared/empty_states/_wikis_layout.html.haml @@ -1,4 +1,4 @@ -.row.empty-state +.row.empty-state.empty-state-wiki .col-12 .svg-content.qa-svg-content = image_tag image_path diff --git a/app/views/shared/empty_states/icons/_service_desk_callout.svg b/app/views/shared/empty_states/icons/_service_desk_callout.svg new file mode 100644 index 00000000000..2886388279e --- /dev/null +++ b/app/views/shared/empty_states/icons/_service_desk_callout.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="78" height="82" viewBox="0 0 78 82"><g fill="none" fill-rule="evenodd"><path fill="#F9F9F9" d="M2.12 42c-.08.99-.12 1.99-.12 3 0 20.435 16.565 37 37 37s37-16.565 37-37c0-1.01-.04-2.01-.12-3C74.353 61.032 58.425 76 39 76 19.575 76 3.647 61.032 2.12 42z"/><path fill="#EEE" fill-rule="nonzero" d="M39 78C17.46 78 0 60.54 0 39S17.46 0 39 0s39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S58.33 4 39 4 4 19.67 4 39s15.67 35 35 35z"/><rect width="7" height="1" x="59" y="38" fill="#E1DBF2" rx=".5"/><path fill="#6B4FBB" d="M60.5 42a3.5 3.5 0 0 0 0-7v7z"/><rect width="7" height="1" x="12" y="38" fill="#E1DBF2" transform="matrix(-1 0 0 1 31 0)" rx=".5"/><path fill="#6B4FBB" d="M17.5 42a3.5 3.5 0 0 1 0-7v7z"/><path fill="#E1DBF1" fill-rule="nonzero" d="M39 58c10.493 0 19-8.507 19-19s-8.507-19-19-19-19 8.507-19 19 8.507 19 19 19zm0 4c-12.703 0-23-10.297-23-23s10.297-23 23-23 23 10.297 23 23-10.297 23-23 23z"/><path fill="#6B4FBB" d="M35 56a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/><path fill="#E1DBF1" fill-rule="nonzero" d="M26.5 40c0 4.143 3.355 7.5 7.494 7.5h10.012A7.497 7.497 0 0 0 51.5 40c0-4.143-3.355-7.5-7.494-7.5H33.994A7.497 7.497 0 0 0 26.5 40zm-3 0c0-5.799 4.698-10.5 10.494-10.5h10.012C49.802 29.5 54.5 34.2 54.5 40c0 5.799-4.698 10.5-10.494 10.5H33.994C28.198 50.5 23.5 45.8 23.5 40z"/><path fill="#6B4FBB" fill-rule="nonzero" d="M35.255 42.406a1 1 0 1 1 1.872-.703 2.001 2.001 0 0 0 3.76-.038 1 1 0 1 1 1.886.665 4 4 0 0 1-7.518.076zM31.5 40a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm15 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z"/><path fill="#6B4FBB" d="M38 22h2a1 1 0 0 1 0 2h-2a1 1 0 0 1 0-2zm0 3h2a1 1 0 0 1 0 2h-2a1 1 0 0 1 0-2z" style="mix-blend-mode:multiply"/></g></svg>
\ No newline at end of file diff --git a/app/views/shared/empty_states/icons/_service_desk_empty_state.svg b/app/views/shared/empty_states/icons/_service_desk_empty_state.svg new file mode 100644 index 00000000000..04c4870be07 --- /dev/null +++ b/app/views/shared/empty_states/icons/_service_desk_empty_state.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="226" height="178" viewBox="0 0 226 178"><g fill="none" fill-rule="evenodd"><path fill="#EEE" fill-rule="nonzero" d="M109.496 165.895c2.06.108 4.113.134 6.158.08 1.104-.03 1.975-.95 1.945-2.055-.03-1.104-.95-1.975-2.055-1.945-1.94.053-3.886.028-5.84-.074-1.102-.057-2.043.79-2.1 1.893-.06 1.104.788 2.045 1.89 2.102zm18.408-1.245c2.02-.386 4.023-.853 6-1.4 1.066-.295 1.69-1.396 1.396-2.46-.295-1.066-1.397-1.69-2.46-1.396-1.875.52-3.772.96-5.686 1.327-1.085.208-1.797 1.255-1.59 2.34.207 1.085 1.255 1.797 2.34 1.59zm17.572-5.636c1.865-.86 3.696-1.795 5.486-2.803.962-.54 1.303-1.76.762-2.723-.542-.962-1.762-1.303-2.724-.762-1.697.955-3.43 1.84-5.2 2.656-1.002.464-1.44 1.652-.978 2.655.462 1.003 1.65 1.44 2.654.98zm44.342-74.897c-.142-2.056-.367-4.1-.674-6.127-.165-1.092-1.184-1.844-2.276-1.678-1.092.165-1.844 1.184-1.68 2.276.29 1.92.505 3.857.64 5.805.076 1.102 1.03 1.934 2.133 1.857 1.103-.076 1.934-1.03 1.858-2.133zm-3.505-18.144c-.632-1.956-1.343-3.884-2.13-5.78-.425-1.02-1.595-1.504-2.615-1.08-1.02.424-1.503 1.594-1.08 2.614.747 1.797 1.42 3.624 2.02 5.476.34 1.05 1.467 1.628 2.518 1.288 1.05-.34 1.627-1.466 1.287-2.517zm-7.754-16.73c-1.083-1.745-2.235-3.447-3.454-5.1-.655-.89-1.907-1.08-2.797-.423-.89.655-1.08 1.907-.424 2.796 1.155 1.568 2.247 3.18 3.273 4.835.58.94 1.814 1.23 2.753.647.938-.582 1.228-1.815.646-2.754zm-11.582-14.446c-1.468-1.437-2.993-2.814-4.572-4.128-.85-.708-2.11-.592-2.816.256-.707.85-.592 2.11.257 2.817 1.496 1.246 2.942 2.55 4.334 3.913.79.773 2.057.76 2.83-.03.772-.79.758-2.057-.032-2.83zm-101.422-4.91c-1.6 1.288-3.148 2.64-4.64 4.05-.802.76-.837 2.026-.078 2.828.76.802 2.025.837 2.827.078 1.415-1.338 2.882-2.62 4.4-3.84.86-.692.996-1.95.303-2.812-.692-.86-1.95-.996-2.812-.303zM52.7 43.062c-1.25 1.632-2.433 3.313-3.546 5.04-.6.93-.33 2.167.597 2.765.93.6 2.167.33 2.766-.597 1.055-1.637 2.176-3.23 3.36-4.777.67-.878.504-2.133-.374-2.804-.877-.672-2.132-.505-2.803.372zm-9.373 15.924c-.82 1.882-1.56 3.8-2.226 5.745-.356 1.047.2 2.183 1.247 2.54 1.045.358 2.182-.2 2.54-1.246.63-1.844 1.333-3.66 2.108-5.443.44-1.012-.023-2.19-1.036-2.63-1.014-.44-2.192.023-2.633 1.036zm-5.26 17.74c-.34 2.02-.6 4.058-.777 6.11-.096 1.102.72 2.07 1.82 2.167 1.1.095 2.07-.72 2.165-1.82.17-1.947.415-3.88.737-5.793.183-1.09-.552-2.12-1.64-2.304-1.09-.183-2.122.552-2.305 1.64zM74.87 155.55c1.772 1.038 3.585 2.005 5.437 2.897.995.48 2.19.062 2.67-.933.48-.995.062-2.19-.933-2.67-1.755-.845-3.473-1.76-5.152-2.745-.953-.56-2.178-.24-2.737.714-.558.954-.238 2.18.715 2.738zm16.97 7.34c1.966.578 3.96 1.078 5.975 1.498 1.082.225 2.14-.47 2.366-1.55.226-1.082-.468-2.14-1.55-2.366-1.91-.398-3.798-.872-5.662-1.42-1.06-.312-2.172.294-2.483 1.354-.312 1.06.294 2.17 1.354 2.483z"/><path fill="#F9F9F9" d="M2.12 130c-.08.99-.12 1.99-.12 3 0 20.435 16.565 37 37 37s37-16.565 37-37c0-1.01-.04-2.01-.12-3-1.527 19.032-17.455 34-36.88 34-19.425 0-35.353-14.968-36.88-34z"/><path fill="#EEE" fill-rule="nonzero" d="M39 166c-21.54 0-39-17.46-39-39s17.46-39 39-39 39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S58.33 92 39 92 4 107.67 4 127s15.67 35 35 35z"/><path fill="#FDC4A8" fill-rule="nonzero" d="M53.925 116.226c-.277-.144-.59-.226-.925-.226H25c-.323 0-.628.076-.898.212l14.663 13.406c.39.357.99.348 1.37-.02l13.79-13.372zm1.075 4.53L42.92 132.47c-1.898 1.84-4.902 1.885-6.854.1L23 120.624V138c0 1.105.895 2 2 2h28c1.105 0 2-.895 2-2v-17.244zM25 112h28c3.314 0 6 2.686 6 6v20c0 3.314-2.686 6-6 6H25c-3.314 0-6-2.686-6-6v-20c0-3.314 2.686-6 6-6z"/><g><path fill="#F9F9F9" d="M150.12 131c-.08.99-.12 1.99-.12 3 0 20.435 16.565 37 37 37s37-16.565 37-37c0-1.01-.04-2.01-.12-3-1.527 19.032-17.455 34-36.88 34-19.425 0-35.353-14.968-36.88-34z"/><path fill="#EEE" fill-rule="nonzero" d="M187 167c-21.54 0-39-17.46-39-39s17.46-39 39-39 39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35s-15.67-35-35-35-35 15.67-35 35 15.67 35 35 35z"/><path fill="#E1DBF1" fill-rule="nonzero" d="M180.51 137H199c1.105 0 2-.895 2-2v-16c0-1.105-.895-2-2-2h-24c-1.105 0-2 .895-2 2v22.743l7.51-4.743zm1.157 4l-9.6 6.062c-.32.202-.69.31-1.067.31-1.105 0-2-.896-2-2V119c0-3.314 2.686-6 6-6h24c3.314 0 6 2.686 6 6v16c0 3.314-2.686 6-6 6h-17.333z"/><path fill="#6B4FBB" d="M180 129c-1.105 0-2-.895-2-2s.895-2 2-2 2 .895 2 2-.895 2-2 2zm7 0c-1.105 0-2-.895-2-2s.895-2 2-2 2 .895 2 2-.895 2-2 2zm7 0c-1.105 0-2-.895-2-2s.895-2 2-2 2 .895 2 2-.895 2-2 2z"/></g><g><path fill="#F9F9F9" d="M76.12 42c-.08.99-.12 1.99-.12 3 0 20.435 16.565 37 37 37s37-16.565 37-37c0-1.01-.04-2.01-.12-3-1.527 19.032-17.455 34-36.88 34-19.425 0-35.353-14.968-36.88-34z"/><path fill="#EEE" fill-rule="nonzero" d="M113 78c-21.54 0-39-17.46-39-39S91.46 0 113 0s39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S132.33 4 113 4 78 19.67 78 39s15.67 35 35 35z"/><g transform="translate(133 35)"><rect width="7" height="1" y="3" fill="#E1DBF2" rx=".5"/><path fill="#6B4FBB" d="M1.5 7C3.433 7 5 5.433 5 3.5S3.433 0 1.5 0v7z"/></g><g transform="matrix(-1 0 0 1 93 35)"><rect width="7" height="1" y="3" fill="#E1DBF2" rx=".5"/><path fill="#6B4FBB" d="M1.5 7C3.433 7 5 5.433 5 3.5S3.433 0 1.5 0v7z"/></g><path fill="#E1DBF1" fill-rule="nonzero" d="M113 58c10.493 0 19-8.507 19-19s-8.507-19-19-19-19 8.507-19 19 8.507 19 19 19zm0 4c-12.703 0-23-10.297-23-23s10.297-23 23-23 23 10.297 23 23-10.297 23-23 23z"/><path fill="#6B4FBB" d="M109 56c-.552 0-1-.448-1-1s.448-1 1-1 1 .448 1 1-.448 1-1 1zm4 0c-.552 0-1-.448-1-1s.448-1 1-1 1 .448 1 1-.448 1-1 1zm4 0c-.552 0-1-.448-1-1s.448-1 1-1 1 .448 1 1-.448 1-1 1z"/><path fill="#E1DBF1" fill-rule="nonzero" d="M97.5 40c0-5.8 4.698-10.5 10.494-10.5h10.012c5.796 0 10.494 4.7 10.494 10.5s-4.698 10.5-10.494 10.5h-10.012C102.198 50.5 97.5 45.8 97.5 40zm3 0c0 4.143 3.355 7.5 7.494 7.5h10.012c4.14 0 7.494-3.358 7.494-7.5 0-4.143-3.355-7.5-7.494-7.5h-10.012c-4.14 0-7.494 3.358-7.494 7.5z"/><path fill="#6B4FBB" fill-rule="nonzero" d="M109.255 42.406c-.195-.517.067-1.093.584-1.287.516-.196 1.093.066 1.287.583.29.774 1.033 1.297 1.873 1.297.855 0 1.608-.542 1.887-1.335.184-.52.755-.794 1.276-.61.52.183.794.754.61 1.275-.56 1.587-2.063 2.67-3.773 2.67-1.68 0-3.164-1.046-3.745-2.594zM105.5 40c-.828 0-1.5-.672-1.5-1.5s.672-1.5 1.5-1.5 1.5.672 1.5 1.5-.672 1.5-1.5 1.5zm15 0c-.828 0-1.5-.672-1.5-1.5s.672-1.5 1.5-1.5 1.5.672 1.5 1.5-.672 1.5-1.5 1.5z"/><path fill="#6B4FBB" d="M112 22h2c.552 0 1 .448 1 1s-.448 1-1 1h-2c-.552 0-1-.448-1-1s.448-1 1-1zm0 3h2c.552 0 1 .448 1 1s-.448 1-1 1h-2c-.552 0-1-.448-1-1s.448-1 1-1z" style="mix-blend-mode:multiply"/></g></g></svg> diff --git a/app/views/shared/empty_states/icons/_service_desk_setup.svg b/app/views/shared/empty_states/icons/_service_desk_setup.svg new file mode 100644 index 00000000000..bb791b58593 --- /dev/null +++ b/app/views/shared/empty_states/icons/_service_desk_setup.svg @@ -0,0 +1,39 @@ +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="430" height="167" viewBox="0 0 430 167"> + <defs> + <rect id="a" width="81" height="4" x="96" y="88"/> + </defs> + <g fill="none" fill-rule="evenodd"> + <g transform="translate(282 2)"> + <rect width="40" height="4" x="25" y="86" fill="#DFDFDF" rx="2"/> + <rect width="22" height="4" y="86" fill="#DFDFDF" rx="2"/> + <path stroke="#DFDFDF" stroke-linecap="round" stroke-width="4" d="M63,88 C87.300529,88 107,68.300529 107,44 C107,19.699471 87.300529,0 63,0 C38.699471,0 19,19.699471 19,44 C19,55.4692579 23.3882741,65.9135795 30.5774088,73.7455512"/> + <path stroke="#DFDFDF" stroke-linecap="round" stroke-width="4" d="M52,142 L119,142 C133.911688,142 146,129.911688 146,115 C146,100.088312 133.911688,88 119,88 C104.088312,88 92,100.088312 92,115 C92,122.037954 94.6928046,128.446969 99.104319,133.252952" transform="matrix(1 0 0 -1 0 230)"/> + <path fill="#A7A7A7" d="M128 106C129.6569 106 131 107.343145 131 109L131 121C131 122.6569 129.6569 124 128 124L114.06641 124 109.250585 126.78325C108.250579 127.3612 107 126.63955 107 125.48455L107 109C107 107.343145 108.343147 106 110 106L128 106zM128 109L110 109 110 122.8852 113.26184 121 128 121 128 109zM114.5 113.5C115.32842 113.5 116 114.17158 116 115 116 115.82842 115.32842 116.5 114.5 116.5 113.67158 116.5 113 115.82842 113 115 113 114.17158 113.67158 113.5 114.5 113.5zM119 113.5C119.82842 113.5 120.5 114.17158 120.5 115 120.5 115.82842 119.82842 116.5 119 116.5 118.17158 116.5 117.5 115.82842 117.5 115 117.5 114.17158 118.17158 113.5 119 113.5zM123.5 113.5C124.32845 113.5 125 114.17158 125 115 125 115.82842 124.32845 116.5 123.5 116.5 122.67155 116.5 122 115.82842 122 115 122 114.17158 122.67155 113.5 123.5 113.5zM47 36C47 33.790862 48.790862 32 51 32L75 32C77.2092 32 79 33.790862 79 36L79 52C79 54.2092 77.2092 56 75 56L51 56C48.790862 56 47 54.2092 47 52L47 36zM51 36L75 36 75 36.0154 63.0079 42.93904 51 36.0063 51 36zM51 40.6251L51 52 75 52 75 40.6342 63.0079 47.55786 51 40.6251z"/> + </g> + <path stroke="#C2B7E6" stroke-linecap="round" stroke-width="4" d="M276.5,20 L276.5,165"/> + <use fill="#6E49CB" xlink:href="#a"/> + <use fill="#FFFFFF" fill-opacity=".6" xlink:href="#a"/> + <g transform="translate(172 40)"> + <path fill="#6E49CB" fill-rule="nonzero" d="M64.5083266,2.16939521 C64.5598976,1.31008332 65.1555623,0.580183202 65.9870892,0.357376239 L67.0659897,0.0682857185 C67.8975166,-0.154521245 68.7783275,0.179758436 69.2526452,0.898158883 L71.0838835,3.67168101 C71.8604055,3.69835108 72.6253745,3.80075177 73.3696161,3.97339039 L75.8570965,1.76768551 C76.501214,1.19651341 77.4383928,1.10164098 78.1839968,1.53205032 L79.1513003,2.09052325 C79.8969043,2.52093259 80.2832521,3.38015574 80.1106561,4.22354464 L79.4443144,7.48050479 C79.9657604,8.03872555 80.4370489,8.65007844 80.8482561,9.30920953 L84.1658391,9.50834112 C85.025263,9.55988206 85.7551052,10.1555623 85.9779122,10.9870892 L86.2670027,12.0659897 C86.4898096,12.8975166 86.1555879,13.778312 85.4370754,14.2526597 L82.6635301,16.0839042 C82.6369953,16.86039 82.534498,17.6253848 82.3620332,18.3695798 L84.5676029,20.8570965 C85.1387232,21.5010208 85.2337633,22.4383618 84.8032767,23.1839864 L84.2448038,24.1512899 C83.8142654,24.8967214 82.9552293,25.2832262 82.111821,25.1106354 L78.8547318,24.4441212 C78.2965242,24.9657707 77.6852679,25.4370334 77.0260789,25.8482561 L76.8269473,29.1658391 C76.7754063,30.025263 76.1797261,30.7551052 75.3481992,30.9779122 L74.2692987,31.2670027 C73.4377718,31.4898096 72.5569764,31.1555879 72.0826287,30.4370754 L70.2513842,27.6635301 C69.4749563,27.6369798 68.7098843,27.5345032 67.9657472,27.3620229 L65.478263,29.5677909 C64.8341648,30.1389578 63.89683,30.2337892 63.1512826,29.8032819 L62.1839598,29.2448141 C61.4384642,28.8145 61.0520043,27.9552448 61.2245757,27.1118417 L61.8910899,23.8547525 C61.369479,23.2965346 60.898313,22.6852524 60.486955,22.0260996 L57.1693952,21.8269618 C56.3100833,21.7753908 55.5801832,21.1797261 55.3573762,20.3481992 L55.0682857,19.2692987 C54.8454788,18.4377718 55.1797584,17.5569609 55.8981589,17.0826432 L58.671681,15.2514049 C58.6983614,14.4749215 58.8007311,13.7098367 58.9733555,12.9656196 L56.7676172,10.4781688 C56.1964503,9.83407059 56.1015416,8.89675656 56.5319717,8.15122986 L57.0904394,7.18390704 C57.5208695,6.43838035 58.380086,6.05193078 59.2234504,6.22451259 L62.4805641,6.89104094 C63.0387487,6.36945971 63.6501081,5.89827293 64.3091888,5.48695498 L64.5083266,2.16939521 Z M72.7381966,23.3950508 C77.00585,22.2515365 79.5385651,17.8647453 78.3950508,13.5970918 C77.2515158,9.32936108 72.8647453,6.79672328 68.5970918,7.94023759 C64.3293611,9.0837726 61.7967026,13.4704658 62.9402376,17.7381966 C64.0837519,22.00585 68.4704658,24.5385858 72.7381966,23.3950508 Z"/> + <path fill="#EFEDF8" stroke="#6E49CB" stroke-width="4" d="M27.08832,20.735088 C27.63276,19.10172 29.16132,18 30.88304,18 L33.11696,18 C34.83868,18 36.36724,19.10172 36.91168,20.735088 L39.01368,27.04104 C40.5,27.49452 41.9248,28.08832 43.2732,28.80708 L49.2204,25.8336 C50.7604,25.0636 52.62,25.36544 53.8376,26.58288 L55.4172,28.16248 C56.6348,29.37992 56.9364,31.2398 56.1664,32.77976 L53.1932,38.7268 C53.9116,40.07512 54.5056,41.50012 54.9588,42.98632 L61.2648,45.08832 C62.8984,45.63276 64,47.16132 64,48.88304 L64,51.11696 C64,52.83868 62.8984,54.36724 61.2648,54.91168 L54.9588,57.01368 C54.5056,58.5 53.9116,59.9248 53.1932,61.2732 L56.1664,67.2204 C56.9364,68.76 56.6348,70.62 55.4172,71.8376 L53.8376,73.4172 C52.62,74.6344 50.7604,74.9364 49.2204,74.1664 L43.2732,71.1928 C41.9248,71.9116 40.5,72.5056 39.01368,72.9588 L36.91168,79.2648 C36.36724,80.8984 34.83868,82 33.11696,82 L30.88304,82 C29.16132,82 27.63276,80.8984 27.08832,79.2648 L24.98632,72.9588 C23.50012,72.5056 22.07516,71.9116 20.72688,71.1932 L14.77964,74.1668 C13.23968,74.9368 11.3798,74.6348 10.16236,73.4172 L8.58272,71.8376 C7.36528,70.6204 7.06348,68.7604 7.83344,67.2204 L10.80704,61.2732 C10.08832,59.9248 9.49452,58.5 9.04104,57.01368 L2.735088,54.91168 C1.10172,54.36724 0,52.83868 0,51.11696 L0,48.88304 C0,47.16132 1.10172,45.63276 2.735088,45.08832 L9.04104,42.98632 C9.49452,41.50008 10.08832,40.07504 10.80704,38.72668 L7.83348,32.77952 C7.06348,31.23956 7.36532,29.37968 8.58276,28.16224 L10.16236,26.5826 C11.3798,25.36516 13.23972,25.06336 14.77964,25.83332 L20.72688,28.80696 C22.0752,28.08828 23.50016,27.49448 24.98632,27.04104 L27.08832,20.735088 Z M32,66 C40.8364,66 48,58.8364 48,50 C48,41.16344 40.8364,34 32,34 C23.16344,34 16,41.16344 16,50 C16,58.8364 23.16344,66 32,66 Z"/> + <circle cx="32" cy="50" r="10" stroke="#6E49CB" stroke-linecap="round" stroke-width="2"/> + </g> + <g stroke="#FC6D26" transform="translate(123 78)"> + <circle cx="12" cy="12" r="11" fill="#FFFFFF" stroke-width="2"/> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M8,12.25 C9.8974359,14.0833333 10.8461538,15 10.8461538,15 C10.8461538,15 12.8974359,13 17,9"/> + </g> + <g transform="translate(0 40)"> + <circle cx="50" cy="50" r="48" fill="#FFFFFF" stroke="#FC6D26" stroke-width="4"/> + <circle cx="21" cy="50" r="4" fill="#6E49CB"/> + <circle cx="79" cy="50" r="4" fill="#6E49CB"/> + <circle cx="50" cy="50" r="27" fill="#FFFFFF" stroke="#E1DBF1" stroke-width="4"/> + <rect width="38" height="24" x="31" y="38" fill="#FFFFFF" stroke="#E1DBF1" stroke-width="2" rx="12"/> + <circle cx="50" cy="69" r="2" fill="#6E49CB"/> + <circle cx="50" cy="69" r="2" fill="#6E49CB"/> + <circle cx="55" cy="69" r="1" fill="#6E49CB"/> + <circle cx="45" cy="69" r="1" fill="#6E49CB"/> + <path stroke="#6E49CB" stroke-linecap="round" stroke-width="2" d="M48 30L52 30M15 50L19 50M81 50L85 50M48 33.5L52 33.5"/> + <path fill="#6E49CB" d="M54.214 52.70154C54.9314 53.11584 55.177 54.0332 54.7628 54.7506 54.2804 55.5856 53.58722 56.2792 52.7524 56.7618 51.91758 57.2442 50.97058 57.4988 50.00632 57.5000085 49.04208 57.5012 48.09448 57.2488 47.25856 56.768 46.42264 56.2874 45.72774 55.5956 45.24358 54.7616 44.8276 54.0452 45.07118 53.12726 45.7876 52.71128 46.4443183 52.3299833 47.2704031 52.5028667 47.7239338 53.0861543L47.83798 53.2553C48.05804 53.63434 48.3739 53.94886 48.75388 54.1674 49.13384 54.3858 49.56456 54.5006 50.00286 54.5 50.44116 54.4994 50.8716 54.3838 51.25108 54.1644 51.554648 53.988944 51.8170384 53.7520992 52.0220822 53.470055L52.16486 53.2503C52.57918 52.53292 53.49658 52.28722 54.214 52.70154zM41 46C42.10456 46 43 46.89544 43 48 43 49.10456 42.10456 50 41 50 39.89544 50 39 49.10456 39 48 39 46.89544 39.89544 46 41 46zM59 46C60.1046 46 61 46.89544 61 48 61 49.10456 60.1046 50 59 50 57.89544 50 57 49.10456 57 48 57 46.89544 57.89544 46 59 46z"/> + </g> + </g> +</svg> diff --git a/app/views/shared/file_hooks/_index.html.haml b/app/views/shared/file_hooks/_index.html.haml index 436bd305df1..cab0adf159b 100644 --- a/app/views/shared/file_hooks/_index.html.haml +++ b/app/views/shared/file_hooks/_index.html.haml @@ -1,6 +1,6 @@ - file_hooks = Gitlab::FileHook.files -.row.prepend-top-default +.row.gl-mt-3 .col-lg-4 %h4.gl-mt-0 = _('File Hooks') @@ -9,7 +9,7 @@ = link_to _('For more information, see the File Hooks documentation.'), help_page_path('administration/file_hooks') - .col-lg-8.append-bottom-default + .col-lg-8.gl-mb-3 - if file_hooks.any? .card .card-header diff --git a/app/views/shared/form_elements/_description.html.haml b/app/views/shared/form_elements/_description.html.haml index 77af4f09408..413df29da77 100644 --- a/app/views/shared/form_elements/_description.html.haml +++ b/app/views/shared/form_elements/_description.html.haml @@ -1,20 +1,16 @@ - project = local_assigns.fetch(:project) - model = local_assigns.fetch(:model) - - - - form = local_assigns.fetch(:form) - placeholder = model.is_a?(MergeRequest) ? _('Describe the goal of the changes and what reviewers should be aware of.') : _('Write a comment or drag your files here…') -- supports_quick_actions = model.new_record? -- if supports_quick_actions - - preview_url = preview_markdown_path(project, target_type: model.class.name) -- else - - preview_url = preview_markdown_path(project) +- supports_quick_actions = true +- preview_url = preview_markdown_path(project, target_type: model.class.name) .form-group.row.detail-page-description = form.label :description, 'Description', class: 'col-form-label col-sm-2' .col-sm-10 + - if model.is_a?(MergeRequest) + = hidden_field_tag :merge_request_diff_head_sha, model.diff_head_sha - if model.is_a?(Issuable) = render 'shared/issuable/form/template_selector', issuable: model diff --git a/app/views/shared/groups/_dropdown.html.haml b/app/views/shared/groups/_dropdown.html.haml index f4915440cb2..9d2d3ce20c7 100644 --- a/app/views/shared/groups/_dropdown.html.haml +++ b/app/views/shared/groups/_dropdown.html.haml @@ -8,7 +8,7 @@ - else - default_sort_by = sort_value_recently_created -.dropdown.inline.js-group-filter-dropdown-wrap.append-right-10 +.dropdown.inline.js-group-filter-dropdown-wrap.gl-mr-3 %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' } %span.dropdown-label = options_hash[default_sort_by] diff --git a/app/views/shared/icons/_icon_service_desk.svg b/app/views/shared/icons/_icon_service_desk.svg new file mode 100644 index 00000000000..2886388279e --- /dev/null +++ b/app/views/shared/icons/_icon_service_desk.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="78" height="82" viewBox="0 0 78 82"><g fill="none" fill-rule="evenodd"><path fill="#F9F9F9" d="M2.12 42c-.08.99-.12 1.99-.12 3 0 20.435 16.565 37 37 37s37-16.565 37-37c0-1.01-.04-2.01-.12-3C74.353 61.032 58.425 76 39 76 19.575 76 3.647 61.032 2.12 42z"/><path fill="#EEE" fill-rule="nonzero" d="M39 78C17.46 78 0 60.54 0 39S17.46 0 39 0s39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S58.33 4 39 4 4 19.67 4 39s15.67 35 35 35z"/><rect width="7" height="1" x="59" y="38" fill="#E1DBF2" rx=".5"/><path fill="#6B4FBB" d="M60.5 42a3.5 3.5 0 0 0 0-7v7z"/><rect width="7" height="1" x="12" y="38" fill="#E1DBF2" transform="matrix(-1 0 0 1 31 0)" rx=".5"/><path fill="#6B4FBB" d="M17.5 42a3.5 3.5 0 0 1 0-7v7z"/><path fill="#E1DBF1" fill-rule="nonzero" d="M39 58c10.493 0 19-8.507 19-19s-8.507-19-19-19-19 8.507-19 19 8.507 19 19 19zm0 4c-12.703 0-23-10.297-23-23s10.297-23 23-23 23 10.297 23 23-10.297 23-23 23z"/><path fill="#6B4FBB" d="M35 56a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/><path fill="#E1DBF1" fill-rule="nonzero" d="M26.5 40c0 4.143 3.355 7.5 7.494 7.5h10.012A7.497 7.497 0 0 0 51.5 40c0-4.143-3.355-7.5-7.494-7.5H33.994A7.497 7.497 0 0 0 26.5 40zm-3 0c0-5.799 4.698-10.5 10.494-10.5h10.012C49.802 29.5 54.5 34.2 54.5 40c0 5.799-4.698 10.5-10.494 10.5H33.994C28.198 50.5 23.5 45.8 23.5 40z"/><path fill="#6B4FBB" fill-rule="nonzero" d="M35.255 42.406a1 1 0 1 1 1.872-.703 2.001 2.001 0 0 0 3.76-.038 1 1 0 1 1 1.886.665 4 4 0 0 1-7.518.076zM31.5 40a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm15 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z"/><path fill="#6B4FBB" d="M38 22h2a1 1 0 0 1 0 2h-2a1 1 0 0 1 0-2zm0 3h2a1 1 0 0 1 0 2h-2a1 1 0 0 1 0-2z" style="mix-blend-mode:multiply"/></g></svg>
\ No newline at end of file diff --git a/app/views/shared/integrations/edit.html.haml b/app/views/shared/integrations/edit.html.haml index 927d2410132..a996f72e2f4 100644 --- a/app/views/shared/integrations/edit.html.haml +++ b/app/views/shared/integrations/edit.html.haml @@ -1,5 +1,6 @@ - add_to_breadcrumbs _('Integrations'), scoped_integrations_path - breadcrumb_title @integration.title - page_title @integration.title, _('Integrations') +- @content_class = 'limit-container-width' unless fluid_layout = render 'shared/integrations/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 ae0e5e45afe..b6cf23faff8 100644 --- a/app/views/shared/issuable/_board_create_list_dropdown.html.haml +++ b/app/views/shared/issuable/_board_create_list_dropdown.html.haml @@ -1,4 +1,4 @@ -.dropdown.prepend-left-10#js-add-list +.dropdown.gl-ml-3#js-add-list %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 diff --git a/app/views/shared/issuable/_bulk_update_sidebar.html.haml b/app/views/shared/issuable/_bulk_update_sidebar.html.haml index 4bc6c1dee37..ec7ff127ed5 100644 --- a/app/views/shared/issuable/_bulk_update_sidebar.html.haml +++ b/app/views/shared/issuable/_bulk_update_sidebar.html.haml @@ -1,4 +1,6 @@ - type = local_assigns.fetch(:type) +- bulk_issue_health_status_flag = Feature.enabled?(:bulk_update_health_status, @project&.group, default_enabled: true) && type == :issues && @project&.group&.feature_available?(:issuable_health_status) +- epic_bulk_edit_flag = @project&.group&.feature_available?(:epics) && type == :issues %aside.issues-bulk-update.js-right-sidebar.right-sidebar{ "aria-live" => "polite", data: { 'signed-in': current_user.present? } } .issuable-sidebar.hidden @@ -26,6 +28,13 @@ - field_name = "update[assignee_ids][]" = dropdown_tag(_("Select assignee"), options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: _("Assign to"), filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable", placeholder: _("Search authors"), data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: field_name } }) + - if epic_bulk_edit_flag + .block + .title + = _('Epic') + .filter-item.epic-bulk-edit + #js-epic-select-root{ data: { group_id: @project&.group&.id, show_header: "true" } } + %input{ id: 'issue_epic_id', type: 'hidden', name: 'update[epic_id]' } .block .title = _('Milestone') @@ -36,6 +45,13 @@ = _('Labels') .filter-item.labels-filter = render "shared/issuable/label_dropdown", classes: ["js-filter-bulk-update", "js-multiselect"], dropdown_title: _("Apply a label"), show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true, default_label: _("Labels") }, label_name: _("Select labels"), no_default_styles: true + - if bulk_issue_health_status_flag + .block + .title + = _('Health status') + .filter-item.health-status.health-status-filter + #js-bulk-update-health-status-root + %input{ id: 'issue_health_status_value', type: 'hidden', name: 'update[health_status]' } .block .title = _('Subscriptions') diff --git a/app/views/shared/issuable/_close_reopen_button.html.haml b/app/views/shared/issuable/_close_reopen_button.html.haml index 5f7cfdc9d03..59d0c46b92f 100644 --- a/app/views/shared/issuable/_close_reopen_button.html.haml +++ b/app/views/shared/issuable/_close_reopen_button.html.haml @@ -1,6 +1,5 @@ - is_current_user = issuable_author_is_current_user(issuable) - display_issuable_type = issuable_display_type(issuable) -- button_method = issuable_close_reopen_button_method(issuable) - are_close_and_open_buttons_hidden = issuable_button_hidden?(issuable, true) && issuable_button_hidden?(issuable, false) - add_blocked_class = false - if defined? warn_before_close @@ -8,11 +7,13 @@ - if is_current_user - if can_update - = link_to _("Close %{display_issuable_type}") % { display_issuable_type: display_issuable_type }, close_issuable_path(issuable), method: button_method, - class: "d-none d-sm-none d-md-block btn btn-grouped btn-close js-btn-issue-action #{issuable_button_visibility(issuable, true)} #{(add_blocked_class ? 'btn-issue-blocked' : '')}", title: _("Close %{display_issuable_type}") % { display_issuable_type: display_issuable_type }, data: { qa_selector: 'close_issue_button' } + %button{ class: "d-none d-sm-none d-md-block btn btn-grouped btn-close js-btn-issue-action #{issuable_button_visibility(issuable, true)} #{(add_blocked_class ? 'btn-issue-blocked' : '')}", + data: { remote: 'true', endpoint: close_issuable_path(issuable), qa_selector: 'close_issue_button' } } + = _("Close %{display_issuable_type}") % { display_issuable_type: display_issuable_type } - if can_reopen - = link_to _("Reopen %{display_issuable_type}") % { display_issuable_type: display_issuable_type }, reopen_issuable_path(issuable), method: button_method, - class: "d-none d-sm-none d-md-block btn btn-grouped btn-reopen js-btn-issue-action #{issuable_button_visibility(issuable, false)}", title: _("Reopen %{display_issuable_type}") % { display_issuable_type: display_issuable_type }, data: { qa_selector: 'reopen_issue_button' } + %button{ class: "d-none d-sm-none d-md-block btn btn-grouped btn-reopen js-btn-issue-action #{issuable_button_visibility(issuable, false)}", + data: { remote: 'true', endpoint: reopen_issuable_path(issuable), qa_selector: 'reopen_issue_button' } } + = _("Reopen %{display_issuable_type}") % { display_issuable_type: display_issuable_type } - else - if can_update && !are_close_and_open_buttons_hidden = render 'shared/issuable/close_reopen_report_toggle', issuable: issuable, warn_before_close: add_blocked_class diff --git a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml index 9d718083d2d..3fc6a3b545b 100644 --- a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml +++ b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml @@ -4,14 +4,13 @@ - button_responsive_class = 'd-none d-sm-none d-md-block' - button_class = "#{button_responsive_class} btn btn-grouped js-issuable-close-button js-btn-issue-action issuable-close-button" - toggle_class = "#{button_responsive_class} btn btn-nr dropdown-toggle js-issuable-close-toggle" -- button_method = issuable_close_reopen_button_method(issuable) - add_blocked_class = false - if defined? warn_before_close - add_blocked_class = !issuable.closed? && warn_before_close -.float-left.btn-group.prepend-left-10.issuable-close-dropdown.droplab-dropdown.js-issuable-close-dropdown - = link_to "#{display_button_action} #{display_issuable_type}", close_reopen_issuable_path(issuable), - method: button_method, class: "#{button_class} btn-#{button_action} #{(add_blocked_class ? 'btn-issue-blocked' : '')}", title: "#{display_button_action} #{display_issuable_type}", data: { qa_selector: 'close_issue_button' } +.float-left.btn-group.gl-ml-3.issuable-close-dropdown.droplab-dropdown.js-issuable-close-dropdown + %button{ class: "#{button_class} btn-#{button_action} #{(add_blocked_class ? 'btn-issue-blocked' : '')}", data: { qa_selector: 'close_issue_button', endpoint: close_reopen_issuable_path(issuable) } } + #{display_button_action} #{display_issuable_type} = button_tag type: 'button', class: "#{toggle_class} btn-#{button_action}-color", data: { 'dropdown-trigger' => '#issuable-close-menu' }, 'aria-label' => _('Toggle dropdown') do @@ -20,7 +19,7 @@ %ul#issuable-close-menu.js-issuable-close-menu.dropdown-menu{ data: { dropdown: true } } %li.close-item{ class: "#{issuable_button_visibility(issuable, true) || 'droplab-item-selected'}", data: { text: _("Close %{display_issuable_type}") % { display_issuable_type: display_issuable_type }, url: close_issuable_path(issuable), - button_class: "#{button_class} btn-close", toggle_class: "#{toggle_class} btn-close-color", method: button_method } } + button_class: "#{button_class} btn-close", toggle_class: "#{toggle_class} btn-close-color" } } %button.btn.btn-transparent = icon('check', class: 'icon') .description @@ -30,7 +29,7 @@ %li.reopen-item{ class: "#{issuable_button_visibility(issuable, false) || 'droplab-item-selected'}", data: { text: _("Reopen %{display_issuable_type}") % { display_issuable_type: display_issuable_type }, url: reopen_issuable_path(issuable), - button_class: "#{button_class} btn-reopen", toggle_class: "#{toggle_class} btn-reopen-color", method: button_method } } + button_class: "#{button_class} btn-reopen", toggle_class: "#{toggle_class} btn-reopen-color" } } %button.btn.btn-transparent = icon('check', class: 'icon') .description diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 1b3ad484bcc..f54457b8b33 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -35,7 +35,7 @@ = render_if_exists 'shared/issuable/approvals', issuable: issuable, presenter: presenter, form: form -= render 'shared/issuable/form/merge_params', issuable: issuable += render 'shared/issuable/form/merge_params', issuable: issuable, project: project = render 'shared/issuable/form/contribution', issuable: issuable, form: form @@ -69,7 +69,7 @@ = link_to 'Delete', polymorphic_path([@project.namespace.becomes(Namespace), @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.namespace.becomes(Namespace), @project, issuable]), class: 'btn btn-grouped btn-cancel' - %span.append-right-10 + %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' - else diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index d53ec4d4eeb..0b5700e5413 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -135,7 +135,7 @@ %li.filter-dropdown-item %button.btn.btn-link{ type: 'button' } %gl-emoji - %span.js-data-value.prepend-left-10 + %span.js-data-value.gl-ml-3 {{name}} #js-dropdown-wip.filtered-search-input-dropdown-menu.dropdown-menu %ul.filter-dropdown{ data: { dropdown: true } } @@ -172,7 +172,7 @@ - if user_can_admin_list = render 'shared/issuable/board_create_list_dropdown', board: board - if @project - #js-add-issues-btn.prepend-left-10{ data: { can_admin_list: can?(current_user, :admin_list, @project) } } + #js-add-issues-btn.gl-ml-3{ data: { can_admin_list: can?(current_user, :admin_list, @project) } } - if Feature.enabled?(:boards_with_swimlanes, @group) #js-board-epics-swimlanes-toggle #js-toggle-focus-btn diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index ab4bd88cfe5..00113b2c2c0 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -42,7 +42,7 @@ = _('Milestone') = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - if can_edit_issuable - = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { track_label: "right_sidebar", track_property: "milestone", track_event: "click_edit_button", track_value: "" } + = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { qa_selector: "edit_milestone_link", track_label: "right_sidebar", track_property: "milestone", track_event: "click_edit_button", track_value: "" } .value.hide-collapsed - if milestone.present? = link_to milestone[:title], milestone[:web_url], class: "bold has-tooltip", title: sidebar_milestone_remaining_days(milestone), data: { container: "body", html: 'true', boundary: 'viewport', qa_selector: 'milestone_link', qa_title: milestone[:title] } @@ -107,7 +107,7 @@ = _('Labels') = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - if can_edit_issuable - = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link qa-edit-link-labels float-right', data: { track_label: "right_sidebar", track_property: "labels", track_event: "click_edit_button", track_value: "" } + = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { qa_selector: "edit_labels_link", track_label: "right_sidebar", track_property: "labels", track_event: "click_edit_button", track_value: "" } .value.issuable-show-labels.dont-hide.hide-collapsed{ class: ("has-labels" if selected_labels.any?), data: { qa_selector: 'labels_block' } } - if selected_labels.any? - selected_labels.each do |label_hash| diff --git a/app/views/shared/issuable/_sort_dropdown.html.haml b/app/views/shared/issuable/_sort_dropdown.html.haml index 9c151dc96f3..81dbecb430b 100644 --- a/app/views/shared/issuable/_sort_dropdown.html.haml +++ b/app/views/shared/issuable/_sort_dropdown.html.haml @@ -2,7 +2,7 @@ - sort_title = issuable_sort_option_title(sort_value) - viewing_issues = controller.controller_name == 'issues' || controller.action_name == 'issues' -.dropdown.inline.prepend-left-10.issue-sort-dropdown +.dropdown.inline.gl-ml-3.issue-sort-dropdown .btn-group{ role: 'group' } .btn-group{ role: 'group' } %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' } diff --git a/app/views/shared/issuable/form/_branch_chooser.html.haml b/app/views/shared/issuable/form/_branch_chooser.html.haml index 3794a3b3845..1823c5279e5 100644 --- a/app/views/shared/issuable/form/_branch_chooser.html.haml +++ b/app/views/shared/issuable/form/_branch_chooser.html.haml @@ -18,7 +18,7 @@ - elsif issuable.for_fork? %code= issuable.target_project_path + ":" - unless issuable.new_record? - %span.dropdown.prepend-left-5.d-inline-block + %span.dropdown.gl-ml-2.d-inline-block = form.hidden_field(:target_branch, { class: 'target_branch js-target-branch-select ref-name mw-xl', data: { placeholder: _('Select branch'), endpoint: refs_project_path(@project, sort: 'updated_desc', find: 'branches') }}) diff --git a/app/views/shared/issuable/form/_contribution.html.haml b/app/views/shared/issuable/form/_contribution.html.haml index a78231b37ce..dc6abfd2c9e 100644 --- a/app/views/shared/issuable/form/_contribution.html.haml +++ b/app/views/shared/issuable/form/_contribution.html.haml @@ -11,7 +11,7 @@ %label.col-form-label.col-sm-2 = _('Contribution') .col-sm-10 - .form-check.prepend-top-5 + .form-check.gl-mt-2 = form.check_box :allow_collaboration, disabled: !issuable.can_allow_collaboration?(current_user), class: 'form-check-input' = form.label :allow_collaboration, class: 'form-check-label' do = _('Allow commits from members who can merge to the target branch.') diff --git a/app/views/shared/issuable/form/_default_templates.html.haml b/app/views/shared/issuable/form/_default_templates.html.haml index 49a5ce926b3..3dc244677e2 100644 --- a/app/views/shared/issuable/form/_default_templates.html.haml +++ b/app/views/shared/issuable/form/_default_templates.html.haml @@ -1,4 +1,4 @@ %p.form-text.text-muted Add - = link_to 'description templates', help_page_path('user/project/description_templates'), tabindex: -1 + = link_to 'description templates', help_page_path('user/project/description_templates') to help your contributors communicate effectively! diff --git a/app/views/shared/issuable/form/_merge_params.html.haml b/app/views/shared/issuable/form/_merge_params.html.haml index 1b557214e02..6f1023474a1 100644 --- a/app/views/shared/issuable/form/_merge_params.html.haml +++ b/app/views/shared/issuable/form/_merge_params.html.haml @@ -1,4 +1,5 @@ - issuable = local_assigns.fetch(:issuable) +- project = local_assigns.fetch(:project) - return unless issuable.is_a?(MergeRequest) - return if issuable.closed_without_fork? @@ -9,14 +10,22 @@ = _('Merge options') .col-sm-10 - if issuable.can_remove_source_branch?(current_user) - .form-check.append-bottom-default + .form-check.gl-mb-3 = hidden_field_tag 'merge_request[force_remove_source_branch]', '0', id: nil = check_box_tag 'merge_request[force_remove_source_branch]', '1', issuable.force_remove_source_branch?, class: 'form-check-input' = label_tag 'merge_request[force_remove_source_branch]', class: 'form-check-label' do Delete source branch when merge request is accepted. - .form-check - = hidden_field_tag 'merge_request[squash]', '0', id: nil - = check_box_tag 'merge_request[squash]', '1', issuable.squash, class: 'form-check-input' - = label_tag 'merge_request[squash]', class: 'form-check-label' do - Squash commits when merge request is accepted. - = link_to icon('question-circle'), help_page_path('user/project/merge_requests/squash_and_merge'), target: '_blank' + - if !project.squash_never? + .form-check + - if project.squash_always? + = hidden_field_tag 'merge_request[squash]', '1', id: nil + = check_box_tag 'merge_request[squash]', '1', project.squash_enabled_by_default?, class: 'form-check-input', disabled: 'true' + - else + = hidden_field_tag 'merge_request[squash]', '0', id: nil + = check_box_tag 'merge_request[squash]', '1', issuable_squash_option?(issuable, project), class: 'form-check-input' + = label_tag 'merge_request[squash]', class: 'form-check-label' do + Squash commits when merge request is accepted. + = link_to icon('question-circle'), help_page_path('user/project/merge_requests/squash_and_merge'), target: '_blank' + - if project.squash_always? + .gl-text-gray-600 + = _('Required in this project.') diff --git a/app/views/shared/issuable/form/_title.html.haml b/app/views/shared/issuable/form/_title.html.haml index 75e9ab547ce..355a6627b8f 100644 --- a/app/views/shared/issuable/form/_title.html.haml +++ b/app/views/shared/issuable/form/_title.html.haml @@ -11,7 +11,7 @@ - if issuable.respond_to?(:work_in_progress?) .form-text.text-muted .js-wip-explanation - %a.js-toggle-wip{ href: '', tabindex: -1 } + %a.js-toggle-wip{ href: '' } Remove the %code WIP: prefix from the title @@ -22,7 +22,7 @@ - if has_wip_commits It looks like you have some WIP commits in this branch. %br - %a.js-toggle-wip{ href: '', tabindex: -1 } + %a.js-toggle-wip{ href: '' } Start the title with %code WIP: to prevent a diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index f7d90a588c7..79dc3043e8d 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -62,12 +62,12 @@ - if show_controls && member.source == current_resource - if member.can_resend_invite? - = link_to icon('paper-plane'), polymorphic_path([:resend_invite, member]), + = link_to sprite_icon('paper-airplane', size: 16), polymorphic_path([:resend_invite, member]), method: :post, class: 'btn btn-default align-self-center mr-sm-2', title: _('Resend invite') - - if user != current_user && member.can_update? && !user&.project_bot? + - if user != current_user && member.can_update? = form_for member, remote: true, html: { class: "js-edit-member-form form-group #{'d-sm-flex' unless force_mobile_view}" } do |f| = f.hidden_field :access_level .member-form-control.dropdown{ class: [("mr-sm-2 d-sm-inline-block" unless force_mobile_view)] } @@ -117,12 +117,10 @@ method: :delete, data: { confirm: leave_confirmation_message(member.source) }, class: "btn btn-remove align-self-center m-0 #{'ml-sm-2' unless force_mobile_view}" - - elsif !user&.project_bot? - = link_to member, - method: :delete, - data: { confirm: remove_member_message(member), qa_selector: 'delete_member_button' }, - class: "btn btn-remove align-self-center m-0 #{'ml-sm-2' unless force_mobile_view}", - title: remove_member_title(member) do + - else + %button{ data: { member_path: member_path(member.member), message: remove_member_message(member), is_access_request: member.request?.to_s, qa_selector: 'delete_member_button' }, + class: "js-remove-member-button btn btn-remove align-self-center m-0 #{'ml-sm-2' unless force_mobile_view}", + title: remove_member_title(member) } %span{ class: ('d-block d-sm-none' unless force_mobile_view) } = _("Delete") - unless force_mobile_view diff --git a/app/views/shared/members/_requests.html.haml b/app/views/shared/members/_requests.html.haml index 1f62c3cbcf4..e1e7aa36a78 100644 --- a/app/views/shared/members/_requests.html.haml +++ b/app/views/shared/members/_requests.html.haml @@ -4,7 +4,7 @@ - return if requesters.empty? -.card.prepend-top-default{ class: ('card-mobile' if force_mobile_view ) } +.card.gl-mt-3{ class: ('card-mobile' if force_mobile_view ) } .card-header = _("Users requesting access to") %strong= membership_source.name diff --git a/app/views/shared/milestones/_deprecation_message.html.haml b/app/views/shared/milestones/_deprecation_message.html.haml index ba5eb54f017..27cd6d75232 100644 --- a/app/views/shared/milestones/_deprecation_message.html.haml +++ b/app/views/shared/milestones/_deprecation_message.html.haml @@ -1,6 +1,6 @@ .banner-callout.compact.milestone-deprecation-message.js-milestone-deprecation-message.prepend-top-20 .banner-graphic= image_tag 'illustrations/milestone_removing-page.svg' - .banner-body.prepend-left-10.append-right-10 + .banner-body.gl-ml-3.gl-mr-3 %h5.banner-title.gl-mt-0= _('This page will be removed in a future release.') %p.milestone-banner-text= _('Use group milestones to manage issues from multiple projects in the same milestone.') = button_tag _('Promote these project milestones into a group milestone.'), class: 'btn btn-link js-popover-link text-align-left milestone-banner-link' diff --git a/app/views/shared/milestones/_description.html.haml b/app/views/shared/milestones/_description.html.haml index 5ff110bf94b..76d6c765ed6 100644 --- a/app/views/shared/milestones/_description.html.haml +++ b/app/views/shared/milestones/_description.html.haml @@ -1,8 +1,9 @@ .detail-page-description.milestone-detail - %h2.title + %h2{ data: { qa_selector: "milestone_title_content" } } + .title = markdown_field(milestone, :title) - if milestone.try(:description).present? - %div + %div{ data: { qa_selector: "milestone_description_content" } } .description.md = markdown_field(milestone, :description) diff --git a/app/views/shared/milestones/_form_dates.html.haml b/app/views/shared/milestones/_form_dates.html.haml index 6dbc460d9bf..e995584309a 100644 --- a/app/views/shared/milestones/_form_dates.html.haml +++ b/app/views/shared/milestones/_form_dates.html.haml @@ -3,11 +3,11 @@ .col-form-label.col-sm-2 = f.label :start_date, _('Start Date') .col-sm-10 - = f.text_field :start_date, class: "datepicker form-control", placeholder: _('Select start date'), autocomplete: 'off' - %a.inline.float-right.prepend-top-5.js-clear-start-date{ href: "#" }= _('Clear start date') + = f.text_field :start_date, class: "datepicker form-control", data: { qa_selector: "start_date_field" }, placeholder: _('Select start date'), autocomplete: 'off' + %a.inline.float-right.gl-mt-2.js-clear-start-date{ href: "#" }= _('Clear start date') .form-group.row .col-form-label.col-sm-2 = f.label :due_date, _('Due Date') .col-sm-10 - = f.text_field :due_date, class: "datepicker form-control", placeholder: _('Select due date'), autocomplete: 'off' - %a.inline.float-right.prepend-top-5.js-clear-due-date{ href: "#" }= _('Clear due date') + = f.text_field :due_date, class: "datepicker form-control", data: { qa_selector: "due_date_field" }, placeholder: _('Select due date'), autocomplete: 'off' + %a.inline.float-right.gl-mt-2.js-clear-due-date{ href: "#" }= _('Clear due date') diff --git a/app/views/shared/milestones/_header.html.haml b/app/views/shared/milestones/_header.html.haml index 99a46f1fb85..ea90b674b34 100644 --- a/app/views/shared/milestones/_header.html.haml +++ b/app/views/shared/milestones/_header.html.haml @@ -33,4 +33,4 @@ = render 'shared/milestones/delete_button' %button.btn.btn-default.btn-grouped.float-right.d-block.d-sm-none.js-sidebar-toggle{ type: 'button' } - = icon('angle-double-left') + = sprite_icon('chevron-double-lg-left') diff --git a/app/views/shared/milestones/_issues_tab.html.haml b/app/views/shared/milestones/_issues_tab.html.haml index 6684f6d752a..dc54eefbaa9 100644 --- a/app/views/shared/milestones/_issues_tab.html.haml +++ b/app/views/shared/milestones/_issues_tab.html.haml @@ -6,7 +6,7 @@ .flash-warning#milestone-issue-count-warning = milestone_issues_count_message(@milestone) -.row.prepend-top-default +.row.gl-mt-3 .col-md-4 = render 'shared/milestones/issuables', args.merge(title: s_('Milestones|Unstarted Issues (open and unassigned)'), issuables: issues.opened.unassigned, id: 'unassigned', show_counter: true) .col-md-4 diff --git a/app/views/shared/milestones/_merge_requests_tab.haml b/app/views/shared/milestones/_merge_requests_tab.haml index 4dba2473efc..0dbf2b27c8d 100644 --- a/app/views/shared/milestones/_merge_requests_tab.haml +++ b/app/views/shared/milestones/_merge_requests_tab.haml @@ -1,7 +1,7 @@ - args = { show_project_name: local_assigns.fetch(:show_project_name, false), show_full_project_name: local_assigns.fetch(:show_full_project_name, false) } -.row.prepend-top-default +.row.gl-mt-3 .col-md-3 = render 'shared/milestones/issuables', args.merge(title: _('Work in progress (open and unassigned)'), issuables: merge_requests.opened.unassigned, id: 'unassigned', show_counter: true) .col-md-3 diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index 31505d2d9fb..ae5bf9572bd 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -5,17 +5,18 @@ %li{ class: "milestone milestone-#{milestone.closed? ? 'closed' : 'open'}", id: custom_dom_id } .row .col-sm-6 - .append-bottom-5 - %strong= link_to truncate(milestone.title, length: 100), milestone_path(milestone) + .gl-mb-2 + %strong{ data: { qa_selector: "milestone_link", qa_milestone_title: milestone.title } } + = link_to truncate(milestone.title, length: 100), milestone_path(milestone) - if @group = " - #{milestone_type}" - if milestone.due_date || milestone.start_date - .text-tertiary.append-bottom-5 + .text-tertiary.gl-mb-2 = milestone_date_range(milestone) - recent_releases, total_count, more_count = recent_releases_with_counts(milestone) - unless total_count.zero? - .text-tertiary.append-bottom-5.milestone-release-links + .text-tertiary.gl-mb-2.milestone-release-links = sprite_icon("rocket", size: 12) = n_('Release', 'Releases', total_count) - recent_releases.each do |release| diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml index 160f6487439..7fd657ec2dd 100644 --- a/app/views/shared/milestones/_sidebar.html.haml +++ b/app/views/shared/milestones/_sidebar.html.haml @@ -24,7 +24,7 @@ - if @project && can?(current_user, :admin_milestone, @project) = link_to s_('MilestoneSidebar|Edit'), edit_project_milestone_path(@project, @milestone), class: 'js-sidebar-dropdown-toggle edit-link float-right' .value - %span.value-content + %span.value-content{ data: { qa_selector: 'start_date_content' } } - if milestone.start_date %span.bold= milestone.start_date.to_s(:medium) - else @@ -60,7 +60,7 @@ - if @project && can?(current_user, :admin_milestone, @project) = link_to s_('MilestoneSidebar|Edit'), edit_project_milestone_path(@project, @milestone), class: 'js-sidebar-dropdown-toggle edit-link float-right' .value.hide-collapsed - %span.value-content + %span.value-content{ data: { qa_selector: 'due_date_content' } } - if milestone.due_date %span.bold= milestone.due_date.to_s(:medium) - else diff --git a/app/views/shared/milestones/_tab_loading.html.haml b/app/views/shared/milestones/_tab_loading.html.haml index dfca6a184be..fe1184114e9 100644 --- a/app/views/shared/milestones/_tab_loading.html.haml +++ b/app/views/shared/milestones/_tab_loading.html.haml @@ -1,2 +1,2 @@ -.text-center.prepend-top-default +.text-center.gl-mt-3 .spinner.spinner-md diff --git a/app/views/shared/milestones/_tabs.html.haml b/app/views/shared/milestones/_tabs.html.haml index 538ebe79641..34f476241c6 100644 --- a/app/views/shared/milestones/_tabs.html.haml +++ b/app/views/shared/milestones/_tabs.html.haml @@ -1,6 +1,6 @@ .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller - .fade-left= icon('angle-left') - .fade-right= icon('angle-right') + .fade-left= sprite_icon('chevron-lg-left', size: 12) + .fade-right= sprite_icon('chevron-lg-right', size: 12) %ul.nav-links.scrolling-tabs.js-milestone-tabs.nav.nav-tabs %li.nav-item = link_to '#tab-issues', class: 'nav-link active', data: { toggle: 'tab', show: '.tab-issues-buttons' } do diff --git a/app/views/shared/milestones/_top.html.haml b/app/views/shared/milestones/_top.html.haml index 49df00940b7..4d209c30e7b 100644 --- a/app/views/shared/milestones/_top.html.haml +++ b/app/views/shared/milestones/_top.html.haml @@ -7,7 +7,7 @@ = render 'shared/milestones/description', milestone: milestone - if milestone.complete? && milestone.active? - .alert.alert-success.prepend-top-default + .alert.alert-success.gl-mt-3 %span = _('All issues for this milestone are closed.') = group ? _('You may close the milestone now.') : _('Navigate to the project to close the milestone.') diff --git a/app/views/shared/notes/_comment_button.html.haml b/app/views/shared/notes/_comment_button.html.haml index 8d74eacc7dc..e151e55d0d2 100644 --- a/app/views/shared/notes/_comment_button.html.haml +++ b/app/views/shared/notes/_comment_button.html.haml @@ -1,7 +1,7 @@ - noteable_name = @note.noteable.human_class_name -.float-left.btn-group.append-right-10.droplab-dropdown.comment-type-dropdown.js-comment-type-dropdown - %input.btn.btn-nr.btn-success.js-comment-button.js-comment-submit-button{ type: 'submit', value: _('Comment') } +.float-left.btn-group.gl-mr-3.droplab-dropdown.comment-type-dropdown.js-comment-type-dropdown + %input.btn.btn-nr.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 btn-nr 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 diff --git a/app/views/shared/notes/_edit_form.html.haml b/app/views/shared/notes/_edit_form.html.haml index 244c191af12..79feb12bed5 100644 --- a/app/views/shared/notes/_edit_form.html.haml +++ b/app/views/shared/notes/_edit_form.html.haml @@ -3,12 +3,12 @@ = hidden_field_tag :target_id, '', class: 'js-form-target-id' = hidden_field_tag :target_type, '', class: 'js-form-target-type' = render layout: 'shared/md_preview', locals: { url: preview_markdown_path(project), referenced_users: true } do - = render 'shared/zen', attr: 'note[note]', classes: 'note-textarea js-note-text js-task-list-field', placeholder: _("Write a comment or drag your files here…") + = render 'shared/zen', attr: 'note[note]', classes: 'note-textarea js-note-text js-task-list-field', qa_selector: 'edit_note_field', placeholder: _("Write a comment or drag your files here…") = render 'shared/notes/hints' .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-nr btn-success js-comment-save-button' + = submit_tag _('Save comment'), class: 'btn btn-nr btn-success js-comment-save-button', data: { qa_selector: 'save_comment_button' } %button.btn.btn-nr.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 40e36728642..f1686417f8d 100644 --- a/app/views/shared/notes/_form.html.haml +++ b/app/views/shared/notes/_form.html.haml @@ -26,7 +26,7 @@ .discussion-form-container.discussion-with-resolve-btn.flex-column.p-0 = render layout: 'shared/md_preview', locals: { url: preview_url, referenced_users: true } do - = render 'shared/zen', f: f, + = render 'shared/zen', f: f, qa_selector: 'note_field', attr: :note, classes: 'note-textarea js-note-text', placeholder: _("Write a comment or drag your files here…"), diff --git a/app/views/shared/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml index 902a6e9b363..abd5d8cd9db 100644 --- a/app/views/shared/notes/_hints.html.haml +++ b/app/views/shared/notes/_hints.html.haml @@ -1,10 +1,10 @@ - supports_quick_actions = local_assigns.fetch(:supports_quick_actions, false) .comment-toolbar.clearfix .toolbar-text - = link_to _('Markdown'), help_page_path('user/markdown'), target: '_blank', tabindex: -1 + = link_to _('Markdown'), help_page_path('user/markdown'), target: '_blank' - if supports_quick_actions and - = link_to _('quick actions'), help_page_path('user/project/quick_actions'), target: '_blank', tabindex: -1 + = link_to _('quick actions'), help_page_path('user/project/quick_actions'), target: '_blank' are - else is @@ -12,24 +12,23 @@ %span.uploading-container %span.uploading-progress-container.hide - = icon('file-image-o', class: 'toolbar-button-icon') + = sprite_icon('media', size: 16, css_class: 'gl-icon gl-vertical-align-text-bottom') %span.attaching-file-message -# Populated by app/assets/javascripts/dropzone_input.js %span.uploading-progress 0% - %span.uploading-spinner - .toolbar-button-icon.spinner.align-text-top + = loading_icon(css_class: 'align-text-bottom gl-mr-2') %span.uploading-error-container.hide %span.uploading-error-icon - = icon('file-image-o', class: 'toolbar-button-icon') + = sprite_icon('media', size: 16, css_class: 'gl-icon gl-vertical-align-text-bottom') %span.uploading-error-message -# Populated by app/assets/javascripts/dropzone_input.js %button.retry-uploading-link{ type: 'button' }= _("Try again") or %button.attach-new-file.markdown-selector{ type: 'button' }= _("attach a new file") - %button.markdown-selector.button-attach-file.btn-link{ type: 'button', tabindex: '-1' } - = icon('file-image-o', class: 'toolbar-button-icon') + %button.btn.markdown-selector.button-attach-file.btn-link{ type: 'button', tabindex: '-1' } + = sprite_icon('media', size: 16) %span.text-attach-file<> = _("Attach a file") diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml index e6c8e13c5c1..95450a5df3c 100644 --- a/app/views/shared/notes/_note.html.haml +++ b/app/views/shared/notes/_note.html.haml @@ -34,7 +34,7 @@ %span.note-header-author-name.bold = note.author.name = user_status(note.author) - %span.note-headline-light + %span.note-headline-light{ data: { qa_selector: 'note_author_content' } } = note.author.to_reference %span.note-headline-light.note-headline-meta - if note.system @@ -51,7 +51,7 @@ - else = render 'projects/notes/actions', note: note, note_editable: note_editable .note-body{ class: note_editable ? 'js-task-list-container' : '' } - .note-text.md + .note-text.md{ data: { qa_selector: 'note_content' } } = markdown_field(note, :note) = edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago') .original-note-content.hidden{ data: { post_url: note_url(note), target_id: note.noteable.id, target_type: note.noteable.class.name.underscore } } diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml index 002189e6ecd..fa103ad447a 100644 --- a/app/views/shared/notes/_notes_with_form.html.haml +++ b/app/views/shared/notes/_notes_with_form.html.haml @@ -18,12 +18,12 @@ .timeline-content.timeline-content-form = render "shared/notes/form", view: diff_view, supports_autocomplete: autocomplete - elsif !current_user - .disabled-comment.text-center.prepend-top-default + .disabled-comment.text-center.gl-mt-3 - link_to_register = link_to(_("register"), new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'), class: 'js-register-link') - link_to_sign_in = link_to(_("sign in"), new_session_path(:user, redirect_to_referer: 'yes'), class: 'js-sign-in-link') = _("Please %{link_to_register} or %{link_to_sign_in} to comment").html_safe % { link_to_register: link_to_register, link_to_sign_in: link_to_sign_in } - elsif discussion_locked - .disabled-comment.text-center.prepend-top-default + .disabled-comment.text-center.gl-mt-3 %span.issuable-note-warning = sprite_icon('lock', size: 16, css_class: 'icon') %span diff --git a/app/views/shared/notifications/_new_button.html.haml b/app/views/shared/notifications/_new_button.html.haml index 796ff095eea..fbcfec5fd96 100644 --- a/app/views/shared/notifications/_new_button.html.haml +++ b/app/views/shared/notifications/_new_button.html.haml @@ -8,7 +8,7 @@ - else - button_title = _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) } - .js-notification-dropdown.notification-dropdown.home-panel-action-button.prepend-top-default.gl-mr-3.dropdown.inline + .js-notification-dropdown.notification-dropdown.home-panel-action-button.gl-mt-3.gl-mr-3.dropdown.inline = form_for notification_setting, remote: true, html: { class: "inline notification-form no-label" } do |f| = hidden_setting_source_input(notification_setting) = hidden_field_tag "hide_label", true diff --git a/app/views/shared/projects/_edit_information.html.haml b/app/views/shared/projects/_edit_information.html.haml index 9230e045a81..5a2f4328837 100644 --- a/app/views/shared/projects/_edit_information.html.haml +++ b/app/views/shared/projects/_edit_information.html.haml @@ -1,5 +1,5 @@ - unless can?(current_user, :push_code, @project) - .inline.prepend-left-10 + .inline.gl-ml-3 - if @project.branch_allows_collaboration?(current_user, selected_branch) = commit_in_single_accessible_branch - else diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index fc3f1a8d1c1..626e94e0202 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -12,11 +12,10 @@ - css_class += " no-description" if project.description.blank? && !show_last_commit_as_description - cache_key = project_list_cache_key(project, pipeline_status: pipeline_status) - updated_tooltip = time_ago_with_tooltip(project.last_activity_date) -- show_pipeline_status_icon = pipeline_status && can?(current_user, :read_cross_project) && project.pipeline_status.has_status? && can?(current_user, :read_build, project) +- show_pipeline_status_icon = pipeline_status && can?(current_user, :read_cross_project) && project.pipeline_status.has_status? && can?(current_user, :read_build, project) && project.last_pipeline.present? - css_controls_class = compact_mode ? [] : ["flex-lg-row", "justify-content-lg-between"] - css_controls_class << "with-pipeline-status" if show_pipeline_status_icon - avatar_container_class = project.creator && use_creator_avatar ? '' : 'rect-avatar' -- license_name = project_license_name(project) %li.project-row.d-flex{ class: css_class } = cache(cache_key) do @@ -40,13 +39,13 @@ %span.project-name< = project.name - %span.metadata-info.visibility-icon.append-right-10.gl-mt-3.text-secondary.has-tooltip{ data: { container: 'body', placement: 'top' }, title: visibility_icon_description(project) } + %span.metadata-info.visibility-icon.gl-mr-3.gl-mt-3.text-secondary.has-tooltip{ data: { container: 'body', placement: 'top' }, title: visibility_icon_description(project) } = visibility_level_icon(project.visibility_level, fw: true) - - if explore_projects_tab? && license_name - %span.metadata-info.d-inline-flex.align-items-center.append-right-10.gl-mt-3 - = sprite_icon('scale', size: 14, css_class: 'append-right-4') - = license_name + - if explore_projects_tab? && project_license_name(project) + %span.metadata-info.d-inline-flex.align-items-center.gl-mr-3.gl-mt-3 + = sprite_icon('scale', size: 14, css_class: 'gl-mr-2') + = project_license_name(project) - if !explore_projects_tab? && access&.nonzero? -# haml-lint:disable UnnecessaryStringOutput @@ -59,10 +58,10 @@ = render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: project - if show_last_commit_as_description - .description.d-none.d-sm-block.append-right-default + .description.d-none.d-sm-block.gl-mr-3 = link_to_markdown(project.commit.title, project_commit_path(project, project.commit), class: "commit-row-message") - elsif project.description.present? - .description.d-none.d-sm-block.append-right-default + .description.d-none.d-sm-block.gl-mr-3 = markdown_field(project, :description) .controls.d-flex.flex-sm-column.align-items-center.align-items-sm-end.flex-wrap.flex-shrink-0.text-secondary{ class: css_controls_class.join(" ") } @@ -77,25 +76,25 @@ = link_to project_starrers_path(project), class: "d-flex align-items-center icon-wrapper stars has-tooltip", title: _('Stars'), data: { container: 'body', placement: 'top' } do - = sprite_icon('star', size: 14, css_class: 'append-right-4') + = sprite_icon('star', size: 14, css_class: 'gl-mr-2') = number_with_delimiter(project.star_count) - if forks = link_to project_forks_path(project), class: "align-items-center icon-wrapper forks has-tooltip", title: _('Forks'), data: { container: 'body', placement: 'top' } do - = sprite_icon('fork', size: 14, css_class: 'append-right-4') + = sprite_icon('fork', size: 14, css_class: 'gl-mr-2') = number_with_delimiter(project.forks_count) - if show_merge_request_count?(disabled: !merge_requests, compact_mode: compact_mode) = link_to project_merge_requests_path(project), class: "d-none d-xl-flex align-items-center icon-wrapper merge-requests has-tooltip", title: _('Merge Requests'), data: { container: 'body', placement: 'top' } do - = sprite_icon('git-merge', size: 14, css_class: 'append-right-4') + = sprite_icon('git-merge', size: 14, css_class: 'gl-mr-2') = number_with_delimiter(project.open_merge_requests_count) - if show_issue_count?(disabled: !issues, compact_mode: compact_mode) = link_to project_issues_path(project), class: "d-none d-xl-flex align-items-center icon-wrapper issues has-tooltip", title: _('Issues'), data: { container: 'body', placement: 'top' } do - = sprite_icon('issues', size: 14, css_class: 'append-right-4') + = sprite_icon('issues', size: 14, css_class: 'gl-mr-2') = number_with_delimiter(project.open_issues_count) .updated-note %span diff --git a/app/views/shared/promotions/_promote_servicedesk.html.haml b/app/views/shared/promotions/_promote_servicedesk.html.haml new file mode 100644 index 00000000000..f7f65c34c75 --- /dev/null +++ b/app/views/shared/promotions/_promote_servicedesk.html.haml @@ -0,0 +1,13 @@ +.user-callout.promotion-callout.js-service-desk-callout#promote_service_desk{ data: { uid: 'promote_service_desk_dismissed' } } + .bordered-box.content-block + %button.btn.btn-default.close.js-close-callout{ type: 'button', 'aria-label' => 'Dismiss Service Desk promotion' } + = icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true') + .svg-container + = custom_icon('icon_service_desk') + .user-callout-copy + -# haml-lint:disable NoPlainNodes + %h4 + Improve customer support with GitLab Service Desk. + %p + GitLab Service Desk is a simple way to allow people to create issues in your GitLab instance without needing their own user account. It provides a unique email address for end users to create issues in a project, and replies can be sent either through the GitLab interface or by email. End users will only see the thread through email. + = link_to 'Read more', help_page_path('user/project/service_desk.md'), target: '_blank' diff --git a/app/views/shared/runners/_runner_description.html.haml b/app/views/shared/runners/_runner_description.html.haml index a47bbd55325..d3e50cfe92f 100644 --- a/app/views/shared/runners/_runner_description.html.haml +++ b/app/views/shared/runners/_runner_description.html.haml @@ -1,4 +1,4 @@ -.light.prepend-top-default +.light.gl-mt-3 %p = _("You can set up as many Runners as you need to run your jobs.") %br diff --git a/app/views/shared/runners/show.html.haml b/app/views/shared/runners/show.html.haml index f62eed694d2..8a78f12bdd8 100644 --- a/app/views/shared/runners/show.html.haml +++ b/app/views/shared/runners/show.html.haml @@ -1,4 +1,4 @@ -- page_title "#{@runner.description} ##{@runner.id}", "Runners" +- page_title "#{@runner.description} ##{@runner.id}", _("Runners") %h3.page-title Runner ##{@runner.id} diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml index 7f213c50de2..36b6bfd061f 100644 --- a/app/views/shared/snippets/_header.html.haml +++ b/app/views/shared/snippets/_header.html.haml @@ -1,6 +1,6 @@ .detail-page-header .detail-page-header-body - .snippet-box.has-tooltip.inline.append-right-5{ title: snippet_visibility_level_description(@snippet.visibility_level, @snippet), data: { container: "body" } } + .snippet-box.has-tooltip.inline.gl-mr-2{ title: snippet_visibility_level_description(@snippet.visibility_level, @snippet), data: { container: "body" } } %span.sr-only = visibility_level_label(@snippet.visibility_level) = visibility_level_icon(@snippet.visibility_level, fw: false) diff --git a/app/views/shared/snippets/_snippet.html.haml b/app/views/shared/snippets/_snippet.html.haml index 128ddbb8e8b..b2c9a74b177 100644 --- a/app/views/shared/snippets/_snippet.html.haml +++ b/app/views/shared/snippets/_snippet.html.haml @@ -11,7 +11,7 @@ %ul.controls %li = link_to gitlab_snippet_path(snippet, anchor: 'notes'), class: ('no-comments' if notes_count.zero?) do - = icon('comments') + = sprite_icon('comments', size: 16, css_class: 'gl-vertical-align-text-bottom') = notes_count %li %span.sr-only diff --git a/app/views/shared/web_hooks/_hook.html.haml b/app/views/shared/web_hooks/_hook.html.haml index 470e2f6b904..a957f9f6dfa 100644 --- a/app/views/shared/web_hooks/_hook.html.haml +++ b/app/views/shared/web_hooks/_hook.html.haml @@ -10,7 +10,7 @@ = _('SSL Verification:') = hook.enable_ssl_verification ? _('enabled') : _('disabled') - .col-md-4.col-lg-5.text-right-md.prepend-top-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' diff --git a/app/views/shared/web_hooks/_index.html.haml b/app/views/shared/web_hooks/_index.html.haml index 149f4baeb21..794418b8336 100644 --- a/app/views/shared/web_hooks/_index.html.haml +++ b/app/views/shared/web_hooks/_index.html.haml @@ -10,5 +10,5 @@ - hooks.each do |hook| = render 'shared/web_hooks/hook', hook: hook - else - %p.text-center.prepend-top-default.append-bottom-default + %p.text-center.gl-mt-3.gl-mb-3 = _('No webhooks found, add one in the form above.') diff --git a/app/views/shared/wikis/_form.html.haml b/app/views/shared/wikis/_form.html.haml index 8ea06d4d6c3..92b9207aaa4 100644 --- a/app/views/shared/wikis/_form.html.haml +++ b/app/views/shared/wikis/_form.html.haml @@ -1,4 +1,4 @@ -- form_classes = %w[wiki-form common-note-form prepend-top-default js-quick-submit] +- form_classes = %w[wiki-form common-note-form gl-mt-3 js-quick-submit] - if @page.persisted? - form_action = wiki_page_path(@wiki, @page) @@ -20,7 +20,7 @@ .col-sm-12= f.label :title, class: 'control-label-full-width' .col-sm-12 = f.text_field :title, class: 'form-control qa-wiki-title-textbox', value: @page.title, required: true, autofocus: !@page.persisted?, placeholder: s_('Wiki|Page title') - %span.d-inline-block.mw-100.prepend-top-5 + %span.d-inline-block.mw-100.gl-mt-2 = icon('lightbulb-o') - if @page.persisted? = s_("WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title.") diff --git a/app/views/shared/wikis/_pages_wiki_page.html.haml b/app/views/shared/wikis/_pages_wiki_page.html.haml index 534884eb848..b56ae2bf9b1 100644 --- a/app/views/shared/wikis/_pages_wiki_page.html.haml +++ b/app/views/shared/wikis/_pages_wiki_page.html.haml @@ -1,5 +1,5 @@ %li - = link_to wiki_page.title, wiki_page_path(@wiki, wiki_page) + = link_to wiki_page.title, wiki_page_path(@wiki, wiki_page), data: { qa_selector: 'wiki_page_link', qa_page_name: wiki_page.slug } %small (#{wiki_page.format}) .float-right - if wiki_page.last_version diff --git a/app/views/shared/wikis/_sidebar.html.haml b/app/views/shared/wikis/_sidebar.html.haml index 8cfb95cdcf5..cddf19fbc8e 100644 --- a/app/views/shared/wikis/_sidebar.html.haml +++ b/app/views/shared/wikis/_sidebar.html.haml @@ -1,12 +1,12 @@ %aside.right-sidebar.right-sidebar-expanded.wiki-sidebar.js-wiki-sidebar.js-right-sidebar{ data: { "offset-top" => "50", "spy" => "affix" } } .sidebar-container - .block.wiki-sidebar-header.append-bottom-default.w-100 + .block.wiki-sidebar-header.gl-mb-3.w-100 %a.gutter-toggle.float-right.d-block.d-sm-block.d-md-none.js-sidebar-wiki-toggle{ href: "#" } - = icon('angle-double-right') + = sprite_icon('chevron-double-lg-right', size: 16, css_class: 'gl-icon') - git_access_url = wiki_path(@wiki, action: :git_access) = link_to git_access_url, class: active_nav_link?(path: 'wikis#git_access') ? 'active' : '', data: { qa_selector: 'clone_repository_link' } do - = icon('cloud-download', class: 'append-right-5') + = sprite_icon('download', size: 16, css_class: 'gl-mr-2') %span= _("Clone repository") .blocks-container @@ -18,5 +18,5 @@ = render @sidebar_wiki_entries, context: 'sidebar' .block.w-100 - if @sidebar_limited - = link_to wiki_path(@wiki, action: :pages), class: 'btn btn-block' do + = link_to wiki_path(@wiki, action: :pages), class: 'btn btn-block', data: { qa_selector: 'view_all_pages_button' } do = s_("Wiki|View All Pages") diff --git a/app/views/shared/wikis/_sidebar_wiki_page.html.haml b/app/views/shared/wikis/_sidebar_wiki_page.html.haml index 2573471f9f9..4259633280a 100644 --- a/app/views/shared/wikis/_sidebar_wiki_page.html.haml +++ b/app/views/shared/wikis/_sidebar_wiki_page.html.haml @@ -1,3 +1,3 @@ %li{ class: active_when(params[:id] == wiki_page.slug) } - = link_to wiki_page_path(@wiki, wiki_page) do + = link_to wiki_page_path(@wiki, wiki_page), data: { qa_selector: 'wiki_page_link', qa_page_name: wiki_page.slug } do = wiki_page.human_title diff --git a/app/views/shared/wikis/diff.html.haml b/app/views/shared/wikis/diff.html.haml new file mode 100644 index 00000000000..6fce3f5894e --- /dev/null +++ b/app/views/shared/wikis/diff.html.haml @@ -0,0 +1,32 @@ +- wiki_page_title @page, _('Changes') +- commit = @diffs.diffable + +.wiki-page-header.top-area.has-sidebar-toggle.flex-column.flex-lg-row + = wiki_sidebar_toggle_button + + .nav-text + %h2.wiki-page-title + = link_to_wiki_page @page + %span.light + · + = _('Changes') + + .nav-controls.pb-md-3.pb-lg-0 + = link_to wiki_page_path(@wiki, @page, action: :history), class: 'btn', role: 'button', data: { qa_selector: 'page_history_button' } do + = s_('Wiki|Page history') + +.page-content-header + .header-main-content + %strong= markdown_field(commit, :title) + %span.d-none.d-sm-inline= _('authored') + #{time_ago_with_tooltip(commit.authored_date)} + %span= s_('ByAuthor|by') + = author_avatar(commit, size: 24, has_tooltip: false) + %strong + = commit_author_link(commit, avatar: true, size: 24) + - if commit.description.present? + %pre.commit-description< + = preserve(markdown_field(commit, :description)) + += render 'projects/diffs/diffs', diffs: @diffs += render 'shared/wikis/sidebar' diff --git a/app/views/shared/wikis/edit.html.haml b/app/views/shared/wikis/edit.html.haml index 5bda8d85627..64a4816def6 100644 --- a/app/views/shared/wikis/edit.html.haml +++ b/app/views/shared/wikis/edit.html.haml @@ -1,18 +1,14 @@ -- @content_class = "limit-container-width" unless fluid_layout -- add_to_breadcrumbs _("Wiki"), wiki_page_path(@wiki, @page) -- breadcrumb_title @page.persisted? ? _("Edit") : _("New") -- page_title @page.persisted? ? _("Edit") : _("New"), @page.human_title, _("Wiki") +- wiki_page_title @page, @page.persisted? ? _('Edit') : _('New') = wiki_page_errors(@error) .wiki-page-header.top-area.has-sidebar-toggle.flex-column.flex-lg-row - %button.btn.btn-default.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" } - = icon('angle-double-left') + = wiki_sidebar_toggle_button .nav-text %h2.wiki-page-title - if @page.persisted? - = link_to @page.human_title, wiki_page_path(@wiki, @page) + = link_to_wiki_page @page %span.light · = s_("Wiki|Edit Page") diff --git a/app/views/shared/wikis/history.html.haml b/app/views/shared/wikis/history.html.haml index ec07082bd02..f9d21c8fb57 100644 --- a/app/views/shared/wikis/history.html.haml +++ b/app/views/shared/wikis/history.html.haml @@ -1,41 +1,38 @@ -- page_title _("History"), @page.human_title, _("Wiki") +- wiki_page_title @page, _('History') .wiki-page-header.top-area.has-sidebar-toggle.flex-column.flex-lg-row - %button.btn.btn-default.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" } - = icon('angle-double-left') + = wiki_sidebar_toggle_button .nav-text %h2.wiki-page-title - = link_to @page.human_title, wiki_page_path(@wiki, @page) + = link_to_wiki_page @page %span.light · - = _("History") + = _('History') -.table-holder - %table.table - %thead - %tr - %th= s_("Wiki|Page version") - %th= _("Author") - %th= _("Commit Message") - %th= _("Last updated") - %th= _("Format") - %tbody - - @page_versions.each_with_index do |version, index| - - commit = version +.prepend-top-default.gl-mb-3 + .table-holder + %table.table.wiki-history + %thead %tr - %td - = link_to wiki_page_path(@wiki, @page, version_id: index == 0 ? nil : commit.id) do - = truncate_sha(commit.id) - %td - = commit.author_name - %td - = commit.message - %td - #{time_ago_with_tooltip(version.authored_date)} - %td - %strong - = version.format -= paginate @page_versions, theme: 'gitlab' + %th= s_('Wiki|Page version') + %th= _('Author') + %th= _('Changes') + %th= _('Last updated') + %tbody + - @page_versions.each do |commit| + %tr + %td + = link_to wiki_page_path(@wiki, @page, version_id: commit.id) do + = truncate_sha(commit.id) + %td + = commit.author_name + %td + %span.str-truncated-60 + = link_to wiki_page_path(@wiki, @page, action: :diff, version_id: commit.id), { title: commit.message } do + = commit.message + %td + = time_ago_with_tooltip(commit.authored_date) + = paginate @page_versions, theme: 'gitlab' = render 'shared/wikis/sidebar' diff --git a/app/views/shared/wikis/pages.html.haml b/app/views/shared/wikis/pages.html.haml index 987c696cdfe..35a62ec2bb4 100644 --- a/app/views/shared/wikis/pages.html.haml +++ b/app/views/shared/wikis/pages.html.haml @@ -11,7 +11,7 @@ .nav-controls.pb-md-3.pb-lg-0 = link_to wiki_path(@wiki, action: :git_access), class: 'btn' do - = icon('cloud-download') + = sprite_icon('download') = _("Clone repository") .dropdown.inline.wiki-sort-dropdown @@ -19,7 +19,7 @@ .btn-group{ role: 'group' } %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' } = sort_title - = icon('chevron-down') + = sprite_icon('chevron-down') %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort %li = sortable_item(s_("Wiki|Title"), wiki_path(@wiki, action: :pages, sort: Wiki::TITLE_ORDER), sort_title) diff --git a/app/views/shared/wikis/show.html.haml b/app/views/shared/wikis/show.html.haml index a4f3996e5de..a7c734f5af4 100644 --- a/app/views/shared/wikis/show.html.haml +++ b/app/views/shared/wikis/show.html.haml @@ -1,19 +1,14 @@ -- @content_class = "limit-container-width" unless fluid_layout -- breadcrumb_title @page.human_title -- wiki_breadcrumb_dropdown_links(@page.slug) -- page_title @page.human_title, _("Wiki") -- add_to_breadcrumbs _("Wiki"), wiki_path(@wiki) +- wiki_page_title @page .wiki-page-header.top-area.has-sidebar-toggle.flex-column.flex-lg-row - %button.btn.btn-default.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" } - = icon('angle-double-left') + = wiki_sidebar_toggle_button .nav-text.flex-fill %h2.wiki-page-title{ data: { qa_selector: 'wiki_page_title' } }= @page.human_title %span.wiki-last-edit-by - if @page.last_version = (_("Last edited by %{name}") % { name: "<strong>#{@page.last_version.author_name}</strong>" }).html_safe - #{time_ago_with_tooltip(@page.last_version.authored_date)} + = time_ago_with_tooltip(@page.last_version.authored_date) .nav-controls.pb-md-3.pb-lg-0 = render 'shared/wikis/main_links' @@ -25,8 +20,8 @@ - history_link = link_to s_("WikiHistoricalPage|history"), wiki_page_path(@wiki, @page, action: :history) = (s_("WikiHistoricalPage|You can view the %{most_recent_link} or browse the %{history_link}.") % { most_recent_link: most_recent_link, history_link: history_link }).html_safe -.prepend-top-default.append-bottom-default - .md{ data: { qa_selector: 'wiki_page_content' } } +.gl-mt-3.gl-mb-3 + .js-wiki-page-content.md{ data: { qa_selector: 'wiki_page_content', tracking_context: wiki_page_tracking_context(@page).to_json } } = render_wiki_content(@page) = render 'shared/wikis/sidebar' diff --git a/app/views/sherlock/file_samples/show.html.haml b/app/views/sherlock/file_samples/show.html.haml index 7255d352775..cc8bdbae55d 100644 --- a/app/views/sherlock/file_samples/show.html.haml +++ b/app/views/sherlock/file_samples/show.html.haml @@ -12,7 +12,7 @@ = t('sherlock.file_sample') = @file_sample.id -.prepend-top-default +.gl-mt-3 %p %span.light #{t('sherlock.time')}: @@ -32,7 +32,7 @@ = @file_sample.file .code.file-content.js-syntax-highlight .line-numbers - %table.sherlock-line-samples-table + %table.sherlock-line-samples-table.gl-mb-0 %thead %tr %th= t('sherlock.line_capitalized') diff --git a/app/views/sherlock/queries/_backtrace.html.haml b/app/views/sherlock/queries/_backtrace.html.haml index 38b4d2c6102..ff5a6b73e47 100644 --- a/app/views/sherlock/queries/_backtrace.html.haml +++ b/app/views/sherlock/queries/_backtrace.html.haml @@ -1,4 +1,4 @@ -.prepend-top-default +.gl-mt-3 .card .card-header %strong diff --git a/app/views/sherlock/queries/_general.html.haml b/app/views/sherlock/queries/_general.html.haml index 1514ad55d71..92f4f16f453 100644 --- a/app/views/sherlock/queries/_general.html.haml +++ b/app/views/sherlock/queries/_general.html.haml @@ -1,4 +1,4 @@ -.prepend-top-default +.gl-mt-3 .card .card-header %strong diff --git a/app/views/sherlock/transactions/_general.html.haml b/app/views/sherlock/transactions/_general.html.haml index 9c028b5c741..7cf6f27e1af 100644 --- a/app/views/sherlock/transactions/_general.html.haml +++ b/app/views/sherlock/transactions/_general.html.haml @@ -1,4 +1,4 @@ -.prepend-top-default +.gl-mt-3 .card .card-header %strong diff --git a/app/views/snippets/_actions.html.haml b/app/views/snippets/_actions.html.haml index 2ff174971cc..566395133a1 100644 --- a/app/views/snippets/_actions.html.haml +++ b/app/views/snippets/_actions.html.haml @@ -13,7 +13,7 @@ - if @snippet.submittable_as_spam_by?(current_user) = link_to _('Submit as spam'), mark_as_spam_snippet_path(@snippet), method: :post, class: 'btn btn-grouped btn-spam', title: _('Submit as spam') .d-block.d-sm-none.dropdown - %button.btn.btn-default.btn-block.gl-mb-0.prepend-top-5{ data: { toggle: "dropdown" } } + %button.btn.btn-default.btn-block.gl-mb-0.gl-mt-2{ data: { toggle: "dropdown" } } = _("Options") = icon('caret-down') .dropdown-menu.dropdown-menu-full-width diff --git a/app/views/snippets/new.html.haml b/app/views/snippets/new.html.haml index acc0ce0fff3..2669754cc3a 100644 --- a/app/views/snippets/new.html.haml +++ b/app/views/snippets/new.html.haml @@ -6,5 +6,5 @@ .page-title-holder.d-flex.align-items-center %h1.page-title= _('New Snippet') -.prepend-top-default +.gl-mt-3 = render "shared/snippets/form", url: snippets_path(@snippet) diff --git a/app/views/snippets/notes/_actions.html.haml b/app/views/snippets/notes/_actions.html.haml index 28fbeaa25f0..310d9946663 100644 --- a/app/views/snippets/notes/_actions.html.haml +++ b/app/views/snippets/notes/_actions.html.haml @@ -8,7 +8,7 @@ - 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' } do + = 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 %span.link-highlight = custom_icon('icon_pencil') diff --git a/app/views/users/_overview.html.haml b/app/views/users/_overview.html.haml index 7bd2d30a35c..5b6d1169b4b 100644 --- a/app/views/users/_overview.html.haml +++ b/app/views/users/_overview.html.haml @@ -1,6 +1,6 @@ .row .col-12 - .calendar-block.prepend-top-default.append-bottom-default + .calendar-block.gl-mt-3.gl-mb-3 .user-calendar.d-none.d-sm-block{ data: { calendar_path: user_calendar_path(@user, :json), calendar_activities_path: user_calendar_activities_path, utc_offset: Time.zone.utc_offset } } %h4.center.light .spinner.spinner-md @@ -9,7 +9,7 @@ .col-md-12.col-lg-6 - if can?(current_user, :read_cross_project) .activities-block - .prepend-top-16 + .gl-mt-5 .d-flex.align-items-center.border-bottom %h4.flex-grow = s_('UserProfile|Activity') @@ -20,7 +20,7 @@ .col-md-12.col-lg-6 .projects-block - .prepend-top-16 + .gl-mt-5 .d-flex.align-items-center.border-bottom %h4.flex-grow = s_('UserProfile|Personal projects') diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index dc151a61ee1..d2f7ff91f0d 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -85,12 +85,13 @@ - if @user.bio.present? .cover-desc.cgray %p.profile-user-bio - = @user.bio + = markdown(@user.bio_html) + - unless profile_tabs.empty? .scrolling-tabs-container - .fade-left= icon('angle-left') - .fade-right= icon('angle-right') + .fade-left= sprite_icon('chevron-lg-left', size: 12) + .fade-right= sprite_icon('chevron-lg-right', size: 12) %ul.nav-links.user-profile-nav.scrolling-tabs.nav.nav-tabs - if profile_tab?(:overview) %li.js-overview-tab diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 3baa2166812..5148772c881 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -11,6 +11,14 @@ :weight: 1 :idempotent: true :tags: [] +- :name: authorized_project_update:authorized_project_update_project_group_link_create + :feature_category: :authentication_and_authorization + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: authorized_project_update:authorized_project_update_user_refresh_over_user_range :feature_category: :authentication_and_authorization :has_external_dependencies: @@ -227,6 +235,14 @@ :weight: 1 :idempotent: :tags: [] +- :name: cronjob:partition_creation + :feature_category: :database + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: cronjob:personal_access_tokens_expiring :feature_category: :authentication_and_authorization :has_external_dependencies: @@ -331,15 +347,15 @@ :weight: 1 :idempotent: :tags: [] -- :name: cronjob:stuck_import_jobs - :feature_category: :importers +- :name: cronjob:stuck_merge_jobs + :feature_category: :source_code_management :has_external_dependencies: :urgency: :low - :resource_boundary: :cpu + :resource_boundary: :unknown :weight: 1 :idempotent: :tags: [] -- :name: cronjob:stuck_merge_jobs +- :name: cronjob:trending_projects :feature_category: :source_code_management :has_external_dependencies: :urgency: :low @@ -347,13 +363,13 @@ :weight: 1 :idempotent: :tags: [] -- :name: cronjob:trending_projects - :feature_category: :source_code_management +- :name: cronjob:update_container_registry_info + :feature_category: :container_registry :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: true :tags: [] - :name: cronjob:users_create_statistics :feature_category: :users @@ -675,6 +691,14 @@ :weight: 2 :idempotent: true :tags: [] +- :name: incident_management:incident_management_pager_duty_process_incident + :feature_category: :incident_management + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 2 + :idempotent: + :tags: [] - :name: incident_management:incident_management_process_alert :feature_category: :incident_management :has_external_dependencies: @@ -771,14 +795,6 @@ :weight: 2 :idempotent: :tags: [] -- :name: notifications:new_release - :feature_category: :release_orchestration - :has_external_dependencies: - :urgency: :low - :resource_boundary: :unknown - :weight: 2 - :idempotent: - :tags: [] - :name: object_pool:object_pool_create :feature_category: :gitaly :has_external_dependencies: @@ -827,6 +843,14 @@ :weight: 1 :idempotent: :tags: [] +- :name: package_repositories:packages_nuget_extraction + :feature_category: :package_registry + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: + :tags: [] - :name: pipeline_background:archive_trace :feature_category: :continuous_integration :has_external_dependencies: @@ -859,6 +883,22 @@ :weight: 1 :idempotent: true :tags: [] +- :name: pipeline_background:ci_pipeline_success_unlock_artifacts + :feature_category: :continuous_integration + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] +- :name: pipeline_background:ci_ref_delete_unlock_artifacts + :feature_category: :continuous_integration + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: pipeline_cache:expire_job_cache :feature_category: :continuous_integration :has_external_dependencies: @@ -970,7 +1010,8 @@ :resource_boundary: :cpu :weight: 5 :idempotent: - :tags: [] + :tags: + - :requires_disk_io - :name: pipeline_processing:build_queue :feature_category: :continuous_integration :has_external_dependencies: @@ -1025,7 +1066,7 @@ :urgency: :high :resource_boundary: :unknown :weight: 5 - :idempotent: + :idempotent: true :tags: [] - :name: pipeline_processing:stage_update :feature_category: :continuous_integration @@ -1107,6 +1148,14 @@ :weight: 1 :idempotent: :tags: [] +- :name: unassign_issuables:members_destroyer_unassign_issuables + :feature_category: :authentication_and_authorization + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: update_namespace_statistics:namespaces_root_statistics :feature_category: :source_code_management :has_external_dependencies: @@ -1526,10 +1575,10 @@ - :name: project_update_repository_storage :feature_category: :gitaly :has_external_dependencies: - :urgency: :low + :urgency: :throttled :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: true :tags: [] - :name: prometheus_create_default_alerts :feature_category: :incident_management @@ -1635,6 +1684,14 @@ :weight: 2 :idempotent: :tags: [] +- :name: service_desk_email_receiver + :feature_category: :issue_tracking + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: + :tags: [] - :name: system_hook_push :feature_category: :source_code_management :has_external_dependencies: diff --git a/app/workers/authorized_project_update/project_group_link_create_worker.rb b/app/workers/authorized_project_update/project_group_link_create_worker.rb new file mode 100644 index 00000000000..5fb59efaacb --- /dev/null +++ b/app/workers/authorized_project_update/project_group_link_create_worker.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module AuthorizedProjectUpdate + class ProjectGroupLinkCreateWorker + include ApplicationWorker + + feature_category :authentication_and_authorization + urgency :low + queue_namespace :authorized_project_update + + idempotent! + + def perform(project_id, group_id) + project = Project.find(project_id) + group = Group.find(group_id) + + AuthorizedProjectUpdate::ProjectGroupLinkCreateService.new(project, group) + .execute + end + end +end diff --git a/app/workers/build_finished_worker.rb b/app/workers/build_finished_worker.rb index d38780dd08d..d0f7d65aed6 100644 --- a/app/workers/build_finished_worker.rb +++ b/app/workers/build_finished_worker.rb @@ -7,6 +7,7 @@ class BuildFinishedWorker # rubocop:disable Scalability/IdempotentWorker queue_namespace :pipeline_processing urgency :high worker_resource_boundary :cpu + tags :requires_disk_io # rubocop: disable CodeReuse/ActiveRecord def perform(build_id) diff --git a/app/workers/ci/pipeline_success_unlock_artifacts_worker.rb b/app/workers/ci/pipeline_success_unlock_artifacts_worker.rb new file mode 100644 index 00000000000..bc31876aa1d --- /dev/null +++ b/app/workers/ci/pipeline_success_unlock_artifacts_worker.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Ci + class PipelineSuccessUnlockArtifactsWorker + include ApplicationWorker + include PipelineBackgroundQueue + + idempotent! + + def perform(pipeline_id) + ::Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline| + break unless pipeline.has_archive_artifacts? + + ::Ci::UnlockArtifactsService + .new(pipeline.project, pipeline.user) + .execute(pipeline.ci_ref, pipeline) + end + end + end +end diff --git a/app/workers/ci/ref_delete_unlock_artifacts_worker.rb b/app/workers/ci/ref_delete_unlock_artifacts_worker.rb new file mode 100644 index 00000000000..3b4a6fcf630 --- /dev/null +++ b/app/workers/ci/ref_delete_unlock_artifacts_worker.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Ci + class RefDeleteUnlockArtifactsWorker + include ApplicationWorker + include PipelineBackgroundQueue + + idempotent! + + def perform(project_id, user_id, ref_path) + ::Project.find_by_id(project_id).try do |project| + ::User.find_by_id(user_id).try do |user| + ::Ci::Ref.find_by_ref_path(ref_path).try do |ci_ref| + ::Ci::UnlockArtifactsService + .new(project, user) + .execute(ci_ref) + end + end + end + end + end +end diff --git a/app/workers/concerns/project_export_options.rb b/app/workers/concerns/project_export_options.rb deleted file mode 100644 index e9318c1ba43..00000000000 --- a/app/workers/concerns/project_export_options.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -module ProjectExportOptions - extend ActiveSupport::Concern - - EXPORT_RETRY_COUNT = 3 - - included do - sidekiq_options retry: EXPORT_RETRY_COUNT, status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION - - # We mark the project export as failed once we have exhausted all retries - sidekiq_retries_exhausted do |job| - project = Project.find(job['args'][1]) - # rubocop: disable CodeReuse/ActiveRecord - job = project.export_jobs.find_by(jid: job["jid"]) - # rubocop: enable CodeReuse/ActiveRecord - - if job&.fail_op - Sidekiq.logger.info "Job #{job['jid']} for project #{project.id} has been set to failed state" - else - Sidekiq.logger.error "Failed to set Job #{job['jid']} for project #{project.id} to failed state" - end - end - end -end diff --git a/app/workers/concerns/reenqueuer.rb b/app/workers/concerns/reenqueuer.rb index 5cc13e490d8..bf6f6546c03 100644 --- a/app/workers/concerns/reenqueuer.rb +++ b/app/workers/concerns/reenqueuer.rb @@ -60,8 +60,6 @@ module Reenqueuer 5.seconds end - # We intend to get rid of sleep: - # https://gitlab.com/gitlab-org/gitlab/issues/121697 module ReenqueuerSleeper # The block will run, and then sleep until the minimum duration. Returns the # block's return value. @@ -73,7 +71,7 @@ module Reenqueuer # end # def ensure_minimum_duration(minimum_duration) - start_time = Time.now + start_time = Time.current result = yield @@ -95,7 +93,7 @@ module Reenqueuer end def elapsed_time(start_time) - Time.now - start_time + Time.current - start_time end end end diff --git a/app/workers/concerns/worker_attributes.rb b/app/workers/concerns/worker_attributes.rb index b19217b15de..bb6192166b4 100644 --- a/app/workers/concerns/worker_attributes.rb +++ b/app/workers/concerns/worker_attributes.rb @@ -2,6 +2,7 @@ module WorkerAttributes extend ActiveSupport::Concern + include Gitlab::ClassAttributes # Resource boundaries that workers can declare through the # `resource_boundary` attribute @@ -30,24 +31,24 @@ module WorkerAttributes }.stringify_keys.freeze class_methods do - def feature_category(value) + def feature_category(value, *extras) raise "Invalid category. Use `feature_category_not_owned!` to mark a worker as not owned" if value == :not_owned - worker_attributes[:feature_category] = value + class_attributes[:feature_category] = value end # Special case: mark this work as not associated with a feature category # this should be used for cross-cutting concerns, such as mailer workers. def feature_category_not_owned! - worker_attributes[:feature_category] = :not_owned + class_attributes[:feature_category] = :not_owned end def get_feature_category - get_worker_attribute(:feature_category) + get_class_attribute(:feature_category) end def feature_category_not_owned? - get_worker_attribute(:feature_category) == :not_owned + get_feature_category == :not_owned end # This should be set to :high for jobs that need to be run @@ -61,97 +62,76 @@ module WorkerAttributes def urgency(urgency) raise "Invalid urgency: #{urgency}" unless VALID_URGENCIES.include?(urgency) - worker_attributes[:urgency] = urgency + class_attributes[:urgency] = urgency end def get_urgency - worker_attributes[:urgency] || :low + class_attributes[:urgency] || :low end # Set this attribute on a job when it will call to services outside of the # application, such as 3rd party applications, other k8s clusters etc See - # doc/development/sidekiq_style_guide.md#Jobs-with-External-Dependencies for + # doc/development/sidekiq_style_guide.md#jobs-with-external-dependencies for # details def worker_has_external_dependencies! - worker_attributes[:external_dependencies] = true + class_attributes[:external_dependencies] = true end # Returns a truthy value if the worker has external dependencies. - # See doc/development/sidekiq_style_guide.md#Jobs-with-External-Dependencies + # See doc/development/sidekiq_style_guide.md#jobs-with-external-dependencies # for details def worker_has_external_dependencies? - worker_attributes[:external_dependencies] + class_attributes[:external_dependencies] end def worker_resource_boundary(boundary) raise "Invalid boundary" unless VALID_RESOURCE_BOUNDARIES.include? boundary - worker_attributes[:resource_boundary] = boundary + class_attributes[:resource_boundary] = boundary end def get_worker_resource_boundary - worker_attributes[:resource_boundary] || :unknown + class_attributes[:resource_boundary] || :unknown end def idempotent! - worker_attributes[:idempotent] = true + class_attributes[:idempotent] = true end def idempotent? - worker_attributes[:idempotent] + class_attributes[:idempotent] end def weight(value) - worker_attributes[:weight] = value + class_attributes[:weight] = value end def get_weight - worker_attributes[:weight] || + class_attributes[:weight] || NAMESPACE_WEIGHTS[queue_namespace] || 1 end def tags(*values) - worker_attributes[:tags] = values + class_attributes[:tags] = values end def get_tags - Array(worker_attributes[:tags]) + Array(class_attributes[:tags]) end def deduplicate(strategy, options = {}) - worker_attributes[:deduplication_strategy] = strategy - worker_attributes[:deduplication_options] = options + class_attributes[:deduplication_strategy] = strategy + class_attributes[:deduplication_options] = options end def get_deduplicate_strategy - worker_attributes[:deduplication_strategy] || + class_attributes[:deduplication_strategy] || Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob::DEFAULT_STRATEGY end def get_deduplication_options - worker_attributes[:deduplication_options] || {} - end - - protected - - # Returns a worker attribute declared on this class or its parent class. - # This approach allows declared attributes to be inherited by - # child classes. - def get_worker_attribute(name) - worker_attributes[name] || superclass_worker_attributes(name) - end - - private - - def worker_attributes - @attributes ||= {} - end - - def superclass_worker_attributes(name) - return unless superclass.include? WorkerAttributes - - superclass.get_worker_attribute(name) + class_attributes[:deduplication_options] || {} end end end diff --git a/app/workers/delete_merged_branches_worker.rb b/app/workers/delete_merged_branches_worker.rb index ab3d42e5384..8d7026e2d1e 100644 --- a/app/workers/delete_merged_branches_worker.rb +++ b/app/workers/delete_merged_branches_worker.rb @@ -17,7 +17,6 @@ class DeleteMergedBranchesWorker # rubocop:disable Scalability/IdempotentWorker begin ::Branches::DeleteMergedService.new(project, user).execute rescue Gitlab::Access::AccessDeniedError - return end end end diff --git a/app/workers/gitlab/jira_import/import_issue_worker.rb b/app/workers/gitlab/jira_import/import_issue_worker.rb index 7709d2ec31b..d1ceda4fd6a 100644 --- a/app/workers/gitlab/jira_import/import_issue_worker.rb +++ b/app/workers/gitlab/jira_import/import_issue_worker.rb @@ -62,7 +62,7 @@ module Gitlab end def build_label_attrs(issue_id, label_id) - time = Time.now + time = Time.current { label_id: label_id, target_id: issue_id, diff --git a/app/workers/group_export_worker.rb b/app/workers/group_export_worker.rb index 6fd977e43d8..e22b691d35e 100644 --- a/app/workers/group_export_worker.rb +++ b/app/workers/group_export_worker.rb @@ -6,6 +6,7 @@ class GroupExportWorker # rubocop:disable Scalability/IdempotentWorker feature_category :importers loggable_arguments 2 + sidekiq_options retry: false def perform(current_user_id, group_id, params = {}) current_user = User.find(current_user_id) diff --git a/app/workers/incident_management/pager_duty/process_incident_worker.rb b/app/workers/incident_management/pager_duty/process_incident_worker.rb new file mode 100644 index 00000000000..3f378b012a1 --- /dev/null +++ b/app/workers/incident_management/pager_duty/process_incident_worker.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module IncidentManagement + module PagerDuty + class ProcessIncidentWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + + queue_namespace :incident_management + feature_category :incident_management + + def perform(project_id, incident_payload) + return unless project_id + + project = find_project(project_id) + return unless project + + result = create_issue(project, incident_payload) + + log_error(result) if result.error? + end + + private + + def find_project(project_id) + Project.find_by_id(project_id) + end + + def create_issue(project, incident_payload) + ::IncidentManagement::PagerDuty::CreateIncidentIssueService + .new(project, incident_payload) + .execute + end + + def log_error(result) + Gitlab::AppLogger.warn( + message: 'Cannot create issue for PagerDuty incident', + issue_errors: result.message + ) + end + end + end +end diff --git a/app/workers/incident_management/process_alert_worker.rb b/app/workers/incident_management/process_alert_worker.rb index 0af34fa35d5..bc23dbda693 100644 --- a/app/workers/incident_management/process_alert_worker.rb +++ b/app/workers/incident_management/process_alert_worker.rb @@ -7,39 +7,45 @@ module IncidentManagement queue_namespace :incident_management feature_category :incident_management - def perform(project_id, alert_payload, am_alert_id = nil) - project = find_project(project_id) - return unless project + # `project_id` and `alert_payload` are deprecated and can be removed + # starting from 14.0 release + # https://gitlab.com/gitlab-org/gitlab/-/issues/224500 + def perform(_project_id = nil, _alert_payload = nil, alert_id = nil) + return unless alert_id - new_issue = create_issue(project, alert_payload) - return unless am_alert_id && new_issue&.persisted? + alert = find_alert(alert_id) + return unless alert + + new_issue = create_issue_for(alert) + return unless new_issue&.persisted? - link_issue_with_alert(am_alert_id, new_issue.id) + link_issue_with_alert(alert, new_issue.id) end private - def find_project(project_id) - Project.find_by_id(project_id) + def find_alert(alert_id) + AlertManagement::Alert.find_by_id(alert_id) + end + + def parsed_payload(alert) + Gitlab::Alerting::NotificationPayloadParser.call(alert.payload.to_h, alert.project) end - def create_issue(project, alert_payload) + def create_issue_for(alert) IncidentManagement::CreateIssueService - .new(project, alert_payload) + .new(alert.project, parsed_payload(alert)) .execute .dig(:issue) end - def link_issue_with_alert(alert_id, issue_id) - alert = AlertManagement::Alert.find_by_id(alert_id) - return unless alert - + def link_issue_with_alert(alert, issue_id) return if alert.update(issue_id: issue_id) Gitlab::AppLogger.warn( message: 'Cannot link an Issue with Alert', issue_id: issue_id, - alert_id: alert_id, + alert_id: alert.id, alert_errors: alert.errors.messages ) end diff --git a/app/workers/incident_management/process_prometheus_alert_worker.rb b/app/workers/incident_management/process_prometheus_alert_worker.rb index e405bc2c2d2..4b778f6a621 100644 --- a/app/workers/incident_management/process_prometheus_alert_worker.rb +++ b/app/workers/incident_management/process_prometheus_alert_worker.rb @@ -9,68 +9,13 @@ module IncidentManagement worker_resource_boundary :cpu def perform(project_id, alert_hash) - project = find_project(project_id) - return unless project - - parsed_alert = Gitlab::Alerting::Alert.new(project: project, payload: alert_hash) - event = find_prometheus_alert_event(parsed_alert) - - if event&.resolved? - issue = event.related_issues.order_created_at_desc.detect(&:opened?) - - close_issue(project, issue) - else - issue = create_issue(project, alert_hash) - - relate_issue_to_event(event, issue) - end - end - - private - - def find_project(project_id) - Project.find_by_id(project_id) - end - - def find_prometheus_alert_event(alert) - if alert.gitlab_managed? - find_gitlab_managed_event(alert) - else - find_self_managed_event(alert) - end - end - - def find_gitlab_managed_event(alert) - PrometheusAlertEvent.find_by_payload_key(alert.gitlab_fingerprint) - end - - def find_self_managed_event(alert) - SelfManagedPrometheusAlertEvent.find_by_payload_key(alert.gitlab_fingerprint) - end - - def create_issue(project, alert) - IncidentManagement::CreateIssueService - .new(project, alert) - .execute - .dig(:issue) - end - - def close_issue(project, issue) - return if issue.blank? || issue.closed? - - processed_issue = Issues::CloseService - .new(project, User.alert_bot) - .execute(issue, system_note: false) - - SystemNoteService.auto_resolve_prometheus_alert(issue, project, User.alert_bot) if processed_issue.reset.closed? - end - - def relate_issue_to_event(event, issue) - return unless event && issue - - if event.related_issues.exclude?(issue) - event.related_issues << issue - end + # no-op + # + # This worker is not scheduled anymore since + # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/35943 + # and will be removed completely via + # https://gitlab.com/gitlab-org/gitlab/-/issues/227146 + # in 14.0. end end end diff --git a/app/workers/members_destroyer/unassign_issuables_worker.rb b/app/workers/members_destroyer/unassign_issuables_worker.rb new file mode 100644 index 00000000000..2c17120bf48 --- /dev/null +++ b/app/workers/members_destroyer/unassign_issuables_worker.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module MembersDestroyer + class UnassignIssuablesWorker + include ApplicationWorker + + ENTITY_TYPES = %w(Group Project).freeze + + queue_namespace :unassign_issuables + feature_category :authentication_and_authorization + + idempotent! + + def perform(user_id, entity_id, entity_type) + unless ENTITY_TYPES.include?(entity_type) + logger.error( + message: "#{entity_type} is not a supported entity.", + entity_type: entity_type, + entity_id: entity_id, + user_id: user_id + ) + + return + end + + user = User.find(user_id) + entity = entity_type.constantize.find(entity_id) + + ::Members::UnassignIssuablesService.new(user, entity).execute + end + end +end diff --git a/app/workers/new_release_worker.rb b/app/workers/new_release_worker.rb deleted file mode 100644 index fa4703d10f2..00000000000 --- a/app/workers/new_release_worker.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -# TODO: Worker can be removed in 13.2: -# https://gitlab.com/gitlab-org/gitlab/-/issues/218231 -class NewReleaseWorker # rubocop:disable Scalability/IdempotentWorker - include ApplicationWorker - - queue_namespace :notifications - feature_category :release_orchestration - weight 2 - - def perform(release_id) - release = Release.preloaded.find_by_id(release_id) - return unless release - - NotificationService.new.send_new_release_notifications(release) - end -end diff --git a/app/workers/packages/nuget/extraction_worker.rb b/app/workers/packages/nuget/extraction_worker.rb new file mode 100644 index 00000000000..820304a9f3b --- /dev/null +++ b/app/workers/packages/nuget/extraction_worker.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Packages + module Nuget + class ExtractionWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + + queue_namespace :package_repositories + feature_category :package_registry + + def perform(package_file_id) + package_file = ::Packages::PackageFile.find_by_id(package_file_id) + + return unless package_file + + ::Packages::Nuget::UpdatePackageFromMetadataService.new(package_file).execute + + rescue ::Packages::Nuget::MetadataExtractionService::ExtractionError, + ::Packages::Nuget::UpdatePackageFromMetadataService::InvalidMetadataError => e + Gitlab::ErrorTracking.log_exception(e, project_id: package_file.project_id) + package_file.package.destroy! + end + end + end +end diff --git a/app/workers/partition_creation_worker.rb b/app/workers/partition_creation_worker.rb new file mode 100644 index 00000000000..9101623d93a --- /dev/null +++ b/app/workers/partition_creation_worker.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class PartitionCreationWorker + include ApplicationWorker + include CronjobQueue # rubocop:disable Scalability/CronWorkerContext + + feature_category :database + idempotent! + + def perform + Gitlab::AppLogger.info("Checking state of dynamic postgres partitions") + + Gitlab::Database::Partitioning::PartitionCreator.new.create_partitions + end +end diff --git a/app/workers/pipeline_update_worker.rb b/app/workers/pipeline_update_worker.rb index 7f667057af6..267caa5bedd 100644 --- a/app/workers/pipeline_update_worker.rb +++ b/app/workers/pipeline_update_worker.rb @@ -1,12 +1,14 @@ # frozen_string_literal: true -class PipelineUpdateWorker # rubocop:disable Scalability/IdempotentWorker +class PipelineUpdateWorker include ApplicationWorker include PipelineQueue queue_namespace :pipeline_processing urgency :high + idempotent! + def perform(pipeline_id) Ci::Pipeline.find_by_id(pipeline_id)&.update_legacy_status end diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index 62d76294bc0..8f844bd0b47 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -79,7 +79,7 @@ class PostReceive # rubocop:disable Scalability/IdempotentWorker return false unless user expire_caches(post_received, snippet.repository) - snippet.repository.expire_statistics_caches + Snippets::UpdateStatisticsService.new(snippet).execute end # Expire the repository status, branch, and tag cache once per push. diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb index 5756ebb8358..3c7af641f16 100644 --- a/app/workers/process_commit_worker.rb +++ b/app/workers/process_commit_worker.rb @@ -80,7 +80,7 @@ class ProcessCommitWorker # manually parse these values. hash.each do |key, value| if key.to_s.end_with?(date_suffix) && value.is_a?(String) - hash[key] = Time.parse(value) + hash[key] = Time.zone.parse(value) end end diff --git a/app/workers/project_export_worker.rb b/app/workers/project_export_worker.rb index d29348e85bc..6c8640138a1 100644 --- a/app/workers/project_export_worker.rb +++ b/app/workers/project_export_worker.rb @@ -3,12 +3,13 @@ class ProjectExportWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker include ExceptionBacktrace - include ProjectExportOptions feature_category :importers worker_resource_boundary :memory urgency :throttled loggable_arguments 2, 3 + sidekiq_options retry: false + sidekiq_options status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION def perform(current_user_id, project_id, after_export_strategy = {}, params = {}) current_user = User.find(current_user_id) diff --git a/app/workers/project_update_repository_storage_worker.rb b/app/workers/project_update_repository_storage_worker.rb index 5c1a8062f12..7c0b1ae07fa 100644 --- a/app/workers/project_update_repository_storage_worker.rb +++ b/app/workers/project_update_repository_storage_worker.rb @@ -1,9 +1,11 @@ # frozen_string_literal: true -class ProjectUpdateRepositoryStorageWorker # rubocop:disable Scalability/IdempotentWorker +class ProjectUpdateRepositoryStorageWorker include ApplicationWorker + idempotent! feature_category :gitaly + urgency :throttled def perform(project_id, new_repository_storage_key, repository_storage_move_id = nil) repository_storage_move = diff --git a/app/workers/repository_check/batch_worker.rb b/app/workers/repository_check/batch_worker.rb index 1e2cb912598..d47f738ccb0 100644 --- a/app/workers/repository_check/batch_worker.rb +++ b/app/workers/repository_check/batch_worker.rb @@ -34,7 +34,7 @@ module RepositoryCheck end def perform_repository_checks - start = Time.now + start = Time.current # This loop will break after a little more than one hour ('a little # more' because `git fsck` may take a few minutes), or if it runs out of @@ -42,7 +42,7 @@ module RepositoryCheck # RepositoryCheckWorker each hour so that as long as there are repositories to # check, only one (or two) will be checked at a time. project_ids.each do |project_id| - break if Time.now - start >= RUN_TIME + break if Time.current - start >= RUN_TIME next unless try_obtain_lease_for_project(project_id) diff --git a/app/workers/repository_check/single_repository_worker.rb b/app/workers/repository_check/single_repository_worker.rb index edff7fc31df..d757b87c23a 100644 --- a/app/workers/repository_check/single_repository_worker.rb +++ b/app/workers/repository_check/single_repository_worker.rb @@ -17,7 +17,7 @@ module RepositoryCheck def update_repository_check_status(project, healthy) project.update_columns( last_repository_check_failed: !healthy, - last_repository_check_at: Time.now + last_repository_check_at: Time.current ) end diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb index 30570a2227e..54052bda675 100644 --- a/app/workers/repository_import_worker.rb +++ b/app/workers/repository_import_worker.rb @@ -4,10 +4,11 @@ class RepositoryImportWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker include ExceptionBacktrace include ProjectStartImport - include ProjectImportOptions feature_category :importers worker_has_external_dependencies! + sidekiq_options retry: false + sidekiq_options status_expiration: Gitlab::Import::StuckImportJob::IMPORT_JOBS_EXPIRATION # technical debt: https://gitlab.com/gitlab-org/gitlab/issues/33991 sidekiq_options memory_killer_memory_growth_kb: ENV.fetch('MEMORY_KILLER_REPOSITORY_IMPORT_WORKER_MEMORY_GROWTH_KB', 50).to_i diff --git a/app/workers/service_desk_email_receiver_worker.rb b/app/workers/service_desk_email_receiver_worker.rb new file mode 100644 index 00000000000..8649034445c --- /dev/null +++ b/app/workers/service_desk_email_receiver_worker.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class ServiceDeskEmailReceiverWorker < EmailReceiverWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + + def perform(raw) + return unless ::Gitlab::ServiceDeskEmail.enabled? + + begin + Gitlab::Email::ServiceDeskReceiver.new(raw).execute + rescue => e + handle_failure(raw, e) + end + end +end diff --git a/app/workers/stuck_import_jobs_worker.rb b/app/workers/stuck_import_jobs_worker.rb deleted file mode 100644 index ce8d5bf0219..00000000000 --- a/app/workers/stuck_import_jobs_worker.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -class StuckImportJobsWorker # rubocop:disable Scalability/IdempotentWorker - include Gitlab::Import::StuckImportJob - - private - - def track_metrics(with_jid_count, without_jid_count) - Gitlab::Metrics.add_event( - :stuck_import_jobs, - projects_without_jid_count: without_jid_count, - projects_with_jid_count: with_jid_count - ) - end - - def enqueued_import_states - ProjectImportState.with_status([:scheduled, :started]) - end -end diff --git a/app/workers/update_container_registry_info_worker.rb b/app/workers/update_container_registry_info_worker.rb new file mode 100644 index 00000000000..14a816f25ef --- /dev/null +++ b/app/workers/update_container_registry_info_worker.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class UpdateContainerRegistryInfoWorker + include ApplicationWorker + include CronjobQueue # rubocop:disable Scalability/CronWorkerContext + + feature_category :container_registry + urgency :low + + idempotent! + + def perform + UpdateContainerRegistryInfoService.new.execute + end +end |