diff options
Diffstat (limited to 'app')
1166 files changed, 17633 insertions, 7465 deletions
diff --git a/app/assets/javascripts/admin/cohorts/components/usage_ping_disabled.vue b/app/assets/javascripts/admin/cohorts/components/usage_ping_disabled.vue index 2ea55d44420..bc2d96832fa 100644 --- a/app/assets/javascripts/admin/cohorts/components/usage_ping_disabled.vue +++ b/app/assets/javascripts/admin/cohorts/components/usage_ping_disabled.vue @@ -9,13 +9,13 @@ export default { }, inject: { svgPath: { - type: String, + default: '', }, docsLink: { - type: String, + default: '', }, primaryButtonPath: { - type: String, + default: '', }, }, }; diff --git a/app/assets/javascripts/admin/dev_ops_report/components/usage_ping_disabled.vue b/app/assets/javascripts/admin/dev_ops_report/components/usage_ping_disabled.vue index 5429ec403d3..316827e1b07 100644 --- a/app/assets/javascripts/admin/dev_ops_report/components/usage_ping_disabled.vue +++ b/app/assets/javascripts/admin/dev_ops_report/components/usage_ping_disabled.vue @@ -10,16 +10,16 @@ export default { }, inject: { isAdmin: { - type: Boolean, + default: false, }, svgPath: { - type: String, + default: '', }, docsLink: { - type: String, + default: '', }, primaryButtonPath: { - type: String, + default: '', }, }, }; diff --git a/app/assets/javascripts/alert_handler.js b/app/assets/javascripts/alert_handler.js index 8fffb61d1dd..26b0142f6a2 100644 --- a/app/assets/javascripts/alert_handler.js +++ b/app/assets/javascripts/alert_handler.js @@ -1,13 +1,21 @@ -// This allows us to dismiss alerts that we've migrated from bootstrap -// Note: This ONLY works on alerts that are created on page load +// This allows us to dismiss alerts and banners that we've migrated from bootstrap +// Note: This ONLY works on elements that are created on page load // You can follow this effort in the following epic // https://gitlab.com/groups/gitlab-org/-/epics/4070 export default function initAlertHandler() { - const ALERT_SELECTOR = '.gl-alert'; - const CLOSE_SELECTOR = '.gl-alert-dismiss'; + const DISMISSIBLE_SELECTORS = ['.gl-alert', '.gl-banner']; + const DISMISS_LABEL = '[aria-label="Dismiss"]'; + const DISMISS_CLASS = '.gl-alert-dismiss'; - const dismissAlert = ({ target }) => target.closest(ALERT_SELECTOR).remove(); - const closeButtons = document.querySelectorAll(`${ALERT_SELECTOR} ${CLOSE_SELECTOR}`); - closeButtons.forEach(alert => alert.addEventListener('click', dismissAlert)); + DISMISSIBLE_SELECTORS.forEach(selector => { + const elements = document.querySelectorAll(selector); + elements.forEach(element => { + const button = element.querySelector(DISMISS_LABEL) || element.querySelector(DISMISS_CLASS); + if (!button) { + return; + } + button.addEventListener('click', () => element.remove()); + }); + }); } diff --git a/app/assets/javascripts/alert_management/components/alert_details.vue b/app/assets/javascripts/alert_management/components/alert_details.vue index c6605452616..072ed2fa663 100644 --- a/app/assets/javascripts/alert_management/components/alert_details.vue +++ b/app/assets/javascripts/alert_management/components/alert_details.vue @@ -1,15 +1,16 @@ <script> -/* eslint-disable vue/no-v-html */ import * as Sentry from '@sentry/browser'; import { GlAlert, GlBadge, GlIcon, + GlLink, GlLoadingIcon, GlSprintf, GlTabs, GlTab, GlButton, + GlSafeHtmlDirective, } from '@gitlab/ui'; import { s__ } from '~/locale'; import alertQuery from '../graphql/queries/details.query.graphql'; @@ -28,6 +29,7 @@ import SystemNote from './system_notes/system_note.vue'; import AlertSidebar from './alert_sidebar.vue'; import AlertMetrics from './alert_metrics.vue'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; +import AlertSummaryRow from './alert_summary_row.vue'; const containerEl = document.querySelector('.page-with-contextual-sidebar'); @@ -39,6 +41,9 @@ export default { reportedAt: s__('AlertManagement|Reported %{when}'), reportedAtWithTool: s__('AlertManagement|Reported %{when} by %{tool}'), }, + directives: { + SafeHtml: GlSafeHtmlDirective, + }, severityLabels: ALERTS_SEVERITY_LABELS, tabsConfig: [ { @@ -56,9 +61,11 @@ export default { ], components: { AlertDetailsTable, + AlertSummaryRow, GlBadge, GlAlert, GlIcon, + GlLink, GlLoadingIcon, GlSprintf, GlTab, @@ -74,15 +81,12 @@ export default { default: '', }, alertId: { - type: String, default: '', }, projectId: { - type: String, default: '', }, projectIssuesPath: { - type: String, default: '', }, }, @@ -211,7 +215,7 @@ export default { <template> <div> <gl-alert v-if="showErrorMsg" variant="danger" @dismiss="dismissError"> - <p v-html="sidebarErrorMessage || $options.i18n.errorMsg"></p> + <p v-safe-html="sidebarErrorMessage || $options.i18n.errorMsg"></p> </gl-alert> <gl-alert v-if="createIncidentError" @@ -283,54 +287,66 @@ export default { </div> <gl-tabs v-if="alert" v-model="currentTabIndex" data-testid="alertDetailsTabs"> <gl-tab :data-testid="$options.tabsConfig[0].id" :title="$options.tabsConfig[0].title"> - <div v-if="alert.severity" class="gl-mt-3 gl-mb-5 gl-display-flex"> - <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3"> - {{ s__('AlertManagement|Severity') }}: - </div> - <div class="gl-pl-2" data-testid="severity"> - <span> - <gl-icon - class="gl-vertical-align-middle" - :size="12" - :name="`severity-${alert.severity.toLowerCase()}`" - :class="`icon-${alert.severity.toLowerCase()}`" - /> - </span> + <alert-summary-row v-if="alert.severity" :label="`${s__('AlertManagement|Severity')}:`"> + <span data-testid="severity"> + <gl-icon + class="gl-vertical-align-middle" + :size="12" + :name="`severity-${alert.severity.toLowerCase()}`" + :class="`icon-${alert.severity.toLowerCase()}`" + /> {{ $options.severityLabels[alert.severity] }} - </div> - </div> - <div v-if="alert.startedAt" class="gl-my-5 gl-display-flex"> - <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3"> - {{ s__('AlertManagement|Start time') }}: - </div> - <div class="gl-pl-2"> - <time-ago-tooltip data-testid="startTimeItem" :time="alert.startedAt" /> - </div> - </div> - <div v-if="alert.eventCount" class="gl-my-5 gl-display-flex"> - <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3"> - {{ s__('AlertManagement|Events') }}: - </div> - <div class="gl-pl-2" data-testid="eventCount">{{ alert.eventCount }}</div> - </div> - <div v-if="alert.monitoringTool" class="gl-my-5 gl-display-flex"> - <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3"> - {{ s__('AlertManagement|Tool') }}: - </div> - <div class="gl-pl-2" data-testid="monitoringTool">{{ alert.monitoringTool }}</div> - </div> - <div v-if="alert.service" class="gl-my-5 gl-display-flex"> - <div class="bold gl-w-13 gl-text-right gl-pr-3"> - {{ s__('AlertManagement|Service') }}: - </div> - <div class="gl-pl-2" data-testid="service">{{ alert.service }}</div> - </div> - <div v-if="alert.runbook" class="gl-my-5 gl-display-flex"> - <div class="bold gl-w-13 gl-text-right gl-pr-3"> - {{ s__('AlertManagement|Runbook') }}: - </div> - <div class="gl-pl-2" data-testid="runbook">{{ alert.runbook }}</div> - </div> + </span> + </alert-summary-row> + <alert-summary-row + v-if="alert.environment" + :label="`${s__('AlertManagement|Environment')}:`" + > + <gl-link + v-if="alert.environmentUrl" + class="gl-display-inline-block" + data-testid="environmentUrl" + :href="alert.environmentUrl" + target="_blank" + > + {{ alert.environment }} + </gl-link> + <span v-else data-testid="environment">{{ alert.environment }}</span> + </alert-summary-row> + <alert-summary-row + v-if="alert.startedAt" + :label="`${s__('AlertManagement|Start time')}:`" + > + <time-ago-tooltip data-testid="startTimeItem" :time="alert.startedAt" /> + </alert-summary-row> + <alert-summary-row + v-if="alert.eventCount" + :label="`${s__('AlertManagement|Events')}:`" + data-testid="eventCount" + > + {{ alert.eventCount }} + </alert-summary-row> + <alert-summary-row + v-if="alert.monitoringTool" + :label="`${s__('AlertManagement|Tool')}:`" + data-testid="monitoringTool" + > + {{ alert.monitoringTool }} + </alert-summary-row> + <alert-summary-row + v-if="alert.service" + :label="`${s__('AlertManagement|Service')}:`" + data-testid="service" + > + {{ alert.service }} + </alert-summary-row> + <alert-summary-row + v-if="alert.runbook" + :label="`${s__('AlertManagement|Runbook')}:`" + data-testid="runbook" + > + {{ alert.runbook }} + </alert-summary-row> <alert-details-table :alert="alert" :loading="loading" /> </gl-tab> <gl-tab :data-testid="$options.tabsConfig[1].id" :title="$options.tabsConfig[1].title"> diff --git a/app/assets/javascripts/alert_management/components/alert_management_table.vue b/app/assets/javascripts/alert_management/components/alert_management_table.vue index 0fd00fe90eb..fc87252f772 100644 --- a/app/assets/javascripts/alert_management/components/alert_management_table.vue +++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue @@ -267,8 +267,8 @@ export default { this.searchTerm = trimmedInput; } }, 500), - navigateToAlertDetails({ iid }) { - return visitUrl(joinPaths(window.location.pathname, iid, 'details')); + navigateToAlertDetails({ iid }, index, { metaKey }) { + return visitUrl(joinPaths(window.location.pathname, iid, 'details'), metaKey); }, trackPageViews() { const { category, action } = trackAlertListViewsOptions; diff --git a/app/assets/javascripts/alert_management/components/alert_sidebar.vue b/app/assets/javascripts/alert_management/components/alert_sidebar.vue index 64e4089c85a..41d77716592 100644 --- a/app/assets/javascripts/alert_management/components/alert_sidebar.vue +++ b/app/assets/javascripts/alert_management/components/alert_sidebar.vue @@ -18,7 +18,6 @@ export default { default: '', }, projectId: { - type: String, default: '', }, }, diff --git a/app/assets/javascripts/alert_management/components/alert_status.vue b/app/assets/javascripts/alert_management/components/alert_status.vue index ff71b348cc9..c505ef6c15b 100644 --- a/app/assets/javascripts/alert_management/components/alert_status.vue +++ b/app/assets/javascripts/alert_management/components/alert_status.vue @@ -1,5 +1,5 @@ <script> -import { GlDeprecatedDropdown, GlDeprecatedDropdownItem, GlButton } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { s__ } from '~/locale'; import Tracking from '~/tracking'; import { trackAlertStatusUpdateOptions } from '../constants'; @@ -18,9 +18,8 @@ export default { RESOLVED: s__('AlertManagement|Resolved'), }, components: { - GlDeprecatedDropdown, - GlDeprecatedDropdownItem, - GlButton, + GlDropdown, + GlDropdownItem, }, props: { projectPath: { @@ -91,39 +90,30 @@ export default { <template> <div class="dropdown dropdown-menu-selectable" :class="dropdownClass"> - <gl-deprecated-dropdown + <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 gl-display-flex"> - <span class="alert-title gl-ml-auto">{{ s__('AlertManagement|Assign status') }}</span> - <gl-button - :aria-label="__('Close')" - variant="link" - class="dropdown-title-button dropdown-menu-close gl-ml-auto gl-text-black-normal!" - icon="close" - @click="$emit('hide-dropdown')" - /> - </div> + <p v-if="isSidebar" class="gl-new-dropdown-header-top" data-testid="dropdown-header"> + {{ s__('AlertManagement|Assign status') }} + </p> <div class="dropdown-content dropdown-body"> - <gl-deprecated-dropdown-item + <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-deprecated-dropdown-item> + </gl-dropdown-item> </div> - </gl-deprecated-dropdown> + </gl-dropdown> </div> </template> diff --git a/app/assets/javascripts/alert_management/components/alert_summary_row.vue b/app/assets/javascripts/alert_management/components/alert_summary_row.vue new file mode 100644 index 00000000000..13835b7e2fa --- /dev/null +++ b/app/assets/javascripts/alert_management/components/alert_summary_row.vue @@ -0,0 +1,18 @@ +<script> +export default { + props: { + label: { + type: String, + required: true, + }, + }, +}; +</script> +<template> + <div class="gl-my-5 gl-display-flex"> + <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3">{{ label }}</div> + <div class="gl-pl-2"> + <slot></slot> + </div> + </div> +</template> diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue index 0a1478ef5fe..df07038151e 100644 --- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue +++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue @@ -1,9 +1,9 @@ <script> -import { GlDeprecatedDropdownItem } from '@gitlab/ui'; +import { GlDropdownItem } from '@gitlab/ui'; export default { components: { - GlDeprecatedDropdownItem, + GlDropdownItem, }, props: { user: { @@ -24,7 +24,7 @@ export default { </script> <template> - <gl-deprecated-dropdown-item + <gl-dropdown-item :key="user.username" data-testid="assigneeDropdownItem" class="assignee-dropdown-item gl-vertical-align-middle" @@ -47,5 +47,5 @@ export default { </strong> <span class="dropdown-menu-user-username"> {{ user.username }}</span> </span> - </gl-deprecated-dropdown-item> + </gl-dropdown-item> </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 0f354e85e96..2e667bf99a8 100644 --- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue +++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue @@ -1,10 +1,11 @@ <script> import { GlIcon, - GlDeprecatedDropdown, - GlDeprecatedDropdownDivider, - GlDeprecatedDropdownHeader, - GlDeprecatedDropdownItem, + GlDropdown, + GlDropdownDivider, + GlDropdownSectionHeader, + GlDropdownItem, + GlSearchBoxByType, GlLoadingIcon, GlTooltip, GlButton, @@ -33,10 +34,11 @@ export default { }, components: { GlIcon, - GlDeprecatedDropdown, - GlDeprecatedDropdownItem, - GlDeprecatedDropdownDivider, - GlDeprecatedDropdownHeader, + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + GlDropdownSectionHeader, + GlSearchBoxByType, GlLoadingIcon, GlTooltip, GlButton, @@ -216,48 +218,36 @@ export default { </p> <div class="dropdown dropdown-menu-selectable" :class="dropdownClass"> - <gl-deprecated-dropdown + <gl-dropdown ref="dropdown" :text="userName" class="w-100" toggle-class="dropdown-menu-toggle" - variant="outline-default" @keydown.esc.native="hideDropdown" @hide="hideDropdown" > - <div class="dropdown-title gl-display-flex"> - <span class="alert-title gl-ml-auto">{{ __('Assign To') }}</span> - <gl-button - :aria-label="__('Close')" - variant="link" - class="dropdown-title-button dropdown-menu-close gl-ml-auto gl-text-black-normal!" - icon="close" - @click="hideDropdown" - /> - </div> - <div class="dropdown-input"> - <input - v-model.trim="search" - class="dropdown-input-field" - type="search" - :placeholder="__('Search users')" - /> - <gl-icon name="search" class="dropdown-input-search ic-search" data-hidden="true" /> - </div> + <p class="gl-new-dropdown-header-top"> + {{ __('Assign To') }} + </p> + <gl-search-box-by-type + v-model.trim="search" + class="m-2" + :placeholder="__('Search users')" + /> <div class="dropdown-content dropdown-body"> <template v-if="userListValid"> - <gl-deprecated-dropdown-item + <gl-dropdown-item :active="!userName" active-class="is-active" @click="updateAlertAssignees('')" > {{ __('Unassigned') }} - </gl-deprecated-dropdown-item> - <gl-deprecated-dropdown-divider /> + </gl-dropdown-item> + <gl-dropdown-divider /> - <gl-deprecated-dropdown-header class="mt-0"> + <gl-dropdown-section-header> {{ __('Assignee') }} - </gl-deprecated-dropdown-header> + </gl-dropdown-section-header> <sidebar-assignee v-for="user in sortedUsers" :key="user.username" @@ -266,12 +256,12 @@ export default { @update-alert-assignees="updateAlertAssignees" /> </template> - <gl-deprecated-dropdown-item v-else-if="userListEmpty"> + <p v-else-if="userListEmpty" class="mx-3 my-2"> {{ __('No Matching Results') }} - </gl-deprecated-dropdown-item> + </p> <gl-loading-icon v-else /> </div> - </gl-deprecated-dropdown> + </gl-dropdown> </div> <gl-loading-icon v-if="isUpdating" :inline="true" /> 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 0b206ce42f4..3705e36a579 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 @@ -1,11 +1,12 @@ <script> /* eslint-disable vue/no-v-html */ +import { GlIcon } from '@gitlab/ui'; import NoteHeader from '~/notes/components/note_header.vue'; -import { spriteIcon } from '~/lib/utils/common_utils'; export default { components: { NoteHeader, + GlIcon, }, props: { note: { @@ -24,23 +25,23 @@ export default { } = this.note; return { ...author, id: id?.split('/').pop() }; }, - iconHtml() { - return spriteIcon(this.note?.systemNoteIconName); - }, }, }; </script> <template> - <li :id="noteAnchorId" class="timeline-entry note system-note note-wrapper gl-px-0!"> - <div class="timeline-entry-inner"> - <div class="timeline-icon" v-html="iconHtml"></div> - <div class="timeline-content"> - <div class="note-header"> - <note-header :author="noteAuthor" :created-at="note.createdAt" :note-id="note.id"> - <span v-html="note.bodyHtml"></span> - </note-header> - </div> + <li :id="noteAnchorId" class="timeline-entry note system-note note-wrapper gl-p-0!"> + <div class="gl-display-inline-flex gl-align-items-center"> + <div + class="gl-display-inline gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-box-sizing-content-box gl-p-3 gl-mt-n2 gl-mr-6" + > + <gl-icon :name="note.systemNoteIconName" /> + </div> + + <div class="note-header"> + <note-header :author="noteAuthor" :created-at="note.createdAt" :note-id="note.id"> + <span v-html="note.bodyHtml"></span> + </note-header> </div> </div> </li> 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 c5e213d7dc9..f2394ce385f 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 @@ -180,11 +180,9 @@ export default { /> </span> </div> - <span class="gl-display-flex gl-justify-content-end"> - <gl-button v-gl-modal.authKeyModal class="gl-mt-2" :disabled="isDisabled">{{ - $options.RESET_KEY - }}</gl-button> - </span> + <gl-button v-gl-modal.authKeyModal class="gl-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_settings/components/alerts_settings_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue index f0bb8b0a90f..225cdbcdab0 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue @@ -511,16 +511,11 @@ export default { max-rows="10" /> </gl-form-group> - <div class="gl-display-flex gl-justify-content-end"> - <gl-button :disabled="!canTestAlert" @click="validateTestAlert">{{ - $options.i18n.testAlertInfo - }}</gl-button> - </div> + <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 category="primary" :disabled="!canSaveConfig" @click="onReset"> - {{ __('Cancel') }} - </gl-button> <gl-button variant="success" category="primary" @@ -529,6 +524,9 @@ export default { > {{ __('Save changes') }} </gl-button> + <gl-button category="primary" :disabled="!canSaveConfig" @click="onReset"> + {{ __('Cancel') }} + </gl-button> </div> </gl-form> </div> diff --git a/app/assets/javascripts/analytics/instance_statistics/components/app.vue b/app/assets/javascripts/analytics/instance_statistics/components/app.vue new file mode 100644 index 00000000000..eb0b67a1629 --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/components/app.vue @@ -0,0 +1,14 @@ +<script> +import InstanceCounts from './instance_counts.vue'; + +export default { + name: 'InstanceStatisticsApp', + components: { + InstanceCounts, + }, +}; +</script> + +<template> + <instance-counts /> +</template> diff --git a/app/assets/javascripts/analytics/instance_statistics/components/instance_counts.vue b/app/assets/javascripts/analytics/instance_statistics/components/instance_counts.vue new file mode 100644 index 00000000000..1147ce9af73 --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/components/instance_counts.vue @@ -0,0 +1,64 @@ +<script> +import * as Sentry from '@sentry/browser'; +import { s__ } from '~/locale'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; +import { SUPPORTED_FORMATS, getFormatter } from '~/lib/utils/unit_format'; +import MetricCard from '~/analytics/shared/components/metric_card.vue'; +import instanceStatisticsCountQuery from '../graphql/queries/instance_statistics_count.query.graphql'; + +const defaultPrecision = 0; + +export default { + name: 'InstanceCounts', + components: { + MetricCard, + }, + data() { + return { + counts: [], + }; + }, + apollo: { + counts: { + query: instanceStatisticsCountQuery, + update(data) { + return Object.entries(data).map(([key, obj]) => { + const label = this.$options.i18n.labels[key]; + const formatter = getFormatter(SUPPORTED_FORMATS.number); + const value = obj.nodes?.length ? formatter(obj.nodes[0].count, defaultPrecision) : null; + + return { + key, + value, + label, + }; + }); + }, + error(error) { + createFlash(this.$options.i18n.loadCountsError); + Sentry.captureException(error); + }, + }, + }, + i18n: { + labels: { + users: s__('InstanceStatistics|Users'), + projects: s__('InstanceStatistics|Projects'), + groups: s__('InstanceStatistics|Groups'), + issues: s__('InstanceStatistics|Issues'), + mergeRequests: s__('InstanceStatistics|Merge Requests'), + pipelines: s__('InstanceStatistics|Pipelines'), + }, + loadCountsError: s__('Could not load instance counts. Please refresh the page to try again.'), + }, +}; +</script> + +<template> + <metric-card + :title="__('Instance Statistics')" + :metrics="counts" + :is-loading="$apollo.queries.counts.loading" + class="gl-mt-4" + /> +</template> diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_statistics_count.query.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_statistics_count.query.graphql new file mode 100644 index 00000000000..fd8282683d9 --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_statistics_count.query.graphql @@ -0,0 +1,32 @@ +query getInstanceCounts { + projects: instanceStatisticsMeasurements(identifier: PROJECTS, first: 1) { + nodes { + count + } + } + groups: instanceStatisticsMeasurements(identifier: GROUPS, first: 1) { + nodes { + count + } + } + users: instanceStatisticsMeasurements(identifier: USERS, first: 1) { + nodes { + count + } + } + issues: instanceStatisticsMeasurements(identifier: ISSUES, first: 1) { + nodes { + count + } + } + mergeRequests: instanceStatisticsMeasurements(identifier: MERGE_REQUESTS, first: 1) { + nodes { + count + } + } + pipelines: instanceStatisticsMeasurements(identifier: PIPELINES, first: 1) { + nodes { + count + } + } +} diff --git a/app/assets/javascripts/analytics/instance_statistics/index.js b/app/assets/javascripts/analytics/instance_statistics/index.js new file mode 100644 index 00000000000..0d7dcf6ace8 --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/index.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import InstanceStatisticsApp from './components/app.vue'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +export default () => { + const el = document.getElementById('js-instance-statistics-app'); + + if (!el) return false; + + return new Vue({ + el, + apolloProvider, + render(h) { + return h(InstanceStatisticsApp); + }, + }); +}; diff --git a/app/assets/javascripts/analytics/shared/components/metric_card.vue b/app/assets/javascripts/analytics/shared/components/metric_card.vue new file mode 100644 index 00000000000..cee186c057c --- /dev/null +++ b/app/assets/javascripts/analytics/shared/components/metric_card.vue @@ -0,0 +1,80 @@ +<script> +import { + GlCard, + GlDeprecatedSkeletonLoading as GlSkeletonLoading, + GlLink, + GlIcon, + GlTooltipDirective, +} from '@gitlab/ui'; + +export default { + name: 'MetricCard', + components: { + GlCard, + GlSkeletonLoading, + GlLink, + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + title: { + type: String, + required: true, + }, + metrics: { + type: Array, + required: true, + }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, + }, + methods: { + valueText(metric) { + const { value = null, unit = null } = metric; + if (!value || value === '-') return '-'; + return unit && value ? `${value} ${unit}` : value; + }, + }, +}; +</script> +<template> + <gl-card> + <template #header> + <strong ref="title">{{ title }}</strong> + </template> + <template #default> + <gl-skeleton-loading v-if="isLoading" class="gl-h-auto gl-py-3" /> + <div v-else ref="metricsWrapper" class="gl-display-flex"> + <div + v-for="metric in metrics" + :key="metric.key" + ref="metricItem" + class="js-metric-card-item gl-flex-grow-1 gl-text-center" + > + <gl-link v-if="metric.link" :href="metric.link"> + <h3 class="gl-my-2 gl-text-blue-700">{{ valueText(metric) }}</h3> + </gl-link> + <h3 v-else class="gl-my-2">{{ valueText(metric) }}</h3> + <p class="text-secondary gl-font-sm gl-mb-2"> + {{ metric.label }} + <span v-if="metric.tooltipText"> + + <gl-icon + v-gl-tooltip="{ title: metric.tooltipText }" + :size="14" + class="gl-vertical-align-middle" + name="question" + data-testid="tooltip" + /> + </span> + </p> + </div> + </div> + </template> + </gl-card> +</template> diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index dbc7ff67d9d..a87f89efd70 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -64,6 +64,9 @@ const Api = { issuePath: '/api/:version/projects/:id/issues/:issue_iid', tagsPath: '/api/:version/projects/:id/repository/tags', freezePeriodsPath: '/api/:version/projects/:id/freeze_periods', + usageDataIncrementUniqueUsersPath: '/api/:version/usage_data/increment_unique_users', + featureFlagUserLists: '/api/:version/projects/:id/feature_flags_user_lists', + featureFlagUserList: '/api/:version/projects/:id/feature_flags_user_lists/:list_iid', group(groupId, callback = () => {}) { const url = Api.buildUrl(Api.groupPath).replace(':id', groupId); @@ -111,6 +114,12 @@ const Api = { }); }, + inviteGroupMember(id, data) { + const url = Api.buildUrl(this.groupMembersPath).replace(':id', encodeURIComponent(id)); + + return axios.post(url, data); + }, + groupMilestones(id, options) { const url = Api.buildUrl(this.groupMilestonesPath).replace(':id', encodeURIComponent(id)); @@ -686,9 +695,58 @@ const Api = { return axios.post(url, freezePeriod); }, + trackRedisHllUserEvent(event) { + if (!gon.features?.usageDataApi) { + return null; + } + + const url = Api.buildUrl(this.usageDataIncrementUniqueUsersPath); + const headers = { + 'Content-Type': 'application/json', + }; + + return axios.post(url, { event }, { headers }); + }, + buildUrl(url) { return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version)); }, + + fetchFeatureFlagUserLists(id, page) { + const url = Api.buildUrl(this.featureFlagUserLists).replace(':id', id); + + return axios.get(url, { params: { page } }); + }, + + createFeatureFlagUserList(id, list) { + const url = Api.buildUrl(this.featureFlagUserLists).replace(':id', id); + + return axios.post(url, list); + }, + + fetchFeatureFlagUserList(id, listIid) { + const url = Api.buildUrl(this.featureFlagUserList) + .replace(':id', id) + .replace(':list_iid', listIid); + + return axios.get(url); + }, + + updateFeatureFlagUserList(id, list) { + const url = Api.buildUrl(this.featureFlagUserList) + .replace(':id', id) + .replace(':list_iid', list.iid); + + return axios.put(url, list); + }, + + deleteFeatureFlagUserList(id, listIid) { + const url = Api.buildUrl(this.featureFlagUserList) + .replace(':id', id) + .replace(':list_iid', listIid); + + return axios.delete(url); + }, }; export default Api; diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index cb71047e00c..bc69c02e21e 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -572,7 +572,7 @@ export class AwardsHandler { } findMatchingEmojiElements(query) { - const emojiMatches = this.emoji.filterEmojiNamesByAlias(query); + const emojiMatches = this.emoji.searchEmoji(query).map(({ name }) => name); const $emojiElements = $('.emoji-menu-list:not(.frequent-emojis) [data-name]'); const $matchingElements = $emojiElements.filter( (i, elm) => emojiMatches.indexOf(elm.dataset.name) >= 0, diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue index 6afb10dd2ad..0a8479519f1 100644 --- a/app/assets/javascripts/badges/components/badge_form.vue +++ b/app/assets/javascripts/badges/components/badge_form.vue @@ -218,7 +218,7 @@ export default { </p> </div> - <div v-if="isEditing" class="row-content-block gl-display-flex gl-justify-content-end"> + <div v-if="isEditing" class="row-content-block"> <gl-button class="btn-cancel gl-mr-4" data-testid="cancelEditing" @click="onCancel"> {{ __('Cancel') }} </gl-button> @@ -232,7 +232,7 @@ export default { {{ s__('Badges|Save changes') }} </gl-button> </div> - <div v-else class="gl-display-flex gl-justify-content-end form-group"> + <div v-else class="form-group"> <gl-button :loading="isSaving" type="submit" variant="success" category="primary"> {{ s__('Badges|Add badge') }} </gl-button> diff --git a/app/assets/javascripts/badges/components/badge_list_row.vue b/app/assets/javascripts/badges/components/badge_list_row.vue index 3343634ecad..bf950d525bd 100644 --- a/app/assets/javascripts/badges/components/badge_list_row.vue +++ b/app/assets/javascripts/badges/components/badge_list_row.vue @@ -1,6 +1,6 @@ <script> import { mapActions, mapState } from 'vuex'; -import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlButton } from '@gitlab/ui'; import { s__ } from '~/locale'; import { PROJECT_BADGE } from '../constants'; import Badge from './badge.vue'; @@ -9,8 +9,8 @@ export default { name: 'BadgeListRow', components: { Badge, - GlIcon, GlLoadingIcon, + GlButton, }, props: { badge: { @@ -51,24 +51,25 @@ export default { <span class="table-section section-30 str-truncated">{{ badge.linkUrl }}</span> <div class="table-section section-10 table-button-footer"> <div v-if="canEditBadge" class="table-action-buttons"> - <button + <gl-button :disabled="badge.isDeleting" - class="btn btn-default gl-mr-3" - type="button" + class="gl-mr-3" + variant="default" + icon="pencil" + size="medium" + :aria-label="__('Edit')" @click="editBadge(badge)" - > - <gl-icon :size="16" :aria-label="__('Edit')" name="pencil" /> - </button> - <button + /> + <gl-button :disabled="badge.isDeleting" - class="btn btn-danger" - type="button" + variant="danger" data-toggle="modal" data-target="#delete-badge-modal" + icon="remove" + size="medium" + :aria-label="__('Delete')" @click="updateBadgeInModal(badge)" - > - <gl-icon :size="16" :aria-label="__('Delete')" name="remove" /> - </button> + /> <gl-loading-icon v-show="badge.isDeleting" :inline="true" /> </div> </div> diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue index a6cd36caede..74069b61f07 100644 --- a/app/assets/javascripts/batch_comments/components/draft_note.vue +++ b/app/assets/javascripts/batch_comments/components/draft_note.vue @@ -18,11 +18,6 @@ export default { type: Object, required: true, }, - diffFile: { - type: Object, - required: false, - default: () => ({}), - }, line: { type: Object, required: false, diff --git a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue index 2b37ed19176..e18dc344cd7 100644 --- a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue +++ b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue @@ -1,114 +1,43 @@ <script> -import { mapActions, mapGetters, mapState } from 'vuex'; -import { GlButton, GlLoadingIcon, GlIcon } from '@gitlab/ui'; -import { sprintf, n__ } from '~/locale'; -import DraftsCount from './drafts_count.vue'; -import PublishButton from './publish_button.vue'; +import { mapActions, mapGetters } from 'vuex'; +import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui'; import PreviewItem from './preview_item.vue'; export default { components: { - GlButton, - GlLoadingIcon, + GlDropdown, + GlDropdownItem, GlIcon, - DraftsCount, - PublishButton, PreviewItem, }, computed: { - ...mapGetters(['isNotesFetched']), ...mapGetters('batchComments', ['draftsCount', 'sortedDrafts']), - ...mapState('batchComments', ['showPreviewDropdown']), - dropdownTitle() { - return sprintf( - n__('%{count} pending comment', '%{count} pending comments', this.draftsCount), - { count: this.draftsCount }, - ); - }, - }, - watch: { - showPreviewDropdown() { - if (this.showPreviewDropdown && this.$refs.dropdown) { - this.$nextTick(() => this.$refs.dropdown.$el.focus()); - } - }, - }, - mounted() { - document.addEventListener('click', this.onClickDocument); - }, - beforeDestroy() { - document.removeEventListener('click', this.onClickDocument); }, methods: { - ...mapActions('batchComments', ['toggleReviewDropdown']), + ...mapActions('batchComments', ['scrollToDraft']), isLast(index) { return index === this.sortedDrafts.length - 1; }, - onClickDocument({ target }) { - if ( - this.showPreviewDropdown && - !target.closest('.review-preview-dropdown, .js-publish-draft-button') - ) { - this.toggleReviewDropdown(); - } - }, }, }; </script> <template> - <div - class="dropdown float-right review-preview-dropdown" - :class="{ - show: showPreviewDropdown, - }" + <gl-dropdown + :header-text="n__('%d pending comment', '%d pending comments', draftsCount)" + dropup + toggle-class="qa-review-preview-toggle" > - <gl-button - ref="dropdown" - type="button" - category="primary" - variant="success" - class="review-preview-dropdown-toggle qa-review-preview-toggle" - @click="toggleReviewDropdown" - > - {{ __('Finish review') }} - <drafts-count /> - <gl-icon name="angle-up" /> - </gl-button> - <div - class="dropdown-menu dropdown-menu-large dropdown-menu-right dropdown-open-top" - :class="{ - show: showPreviewDropdown, - }" + <template #button-content> + {{ __('Pending comments') }} + <gl-icon class="dropdown-chevron" name="chevron-up" /> + </template> + <gl-dropdown-item + v-for="(draft, index) in sortedDrafts" + :key="draft.id" + @click="scrollToDraft(draft)" > - <div class="dropdown-title gl-display-flex gl-align-items-center"> - <span class="gl-ml-auto">{{ dropdownTitle }}</span> - <gl-button - :aria-label="__('Close')" - type="button" - category="tertiary" - size="small" - class="dropdown-title-button gl-ml-auto gl-p-0!" - icon="close" - @click="toggleReviewDropdown" - /> - </div> - <div class="dropdown-content"> - <ul v-if="isNotesFetched"> - <li v-for="(draft, index) in sortedDrafts" :key="draft.id"> - <preview-item :draft="draft" :is-last="isLast(index)" /> - </li> - </ul> - <gl-loading-icon v-else size="lg" class="gl-mt-3 gl-mb-3" /> - </div> - <div class="dropdown-footer"> - <publish-button - :show-count="false" - :should-publish="true" - :label="__('Submit review')" - class="float-right gl-mr-3" - /> - </div> - </div> - </div> + <preview-item :draft="draft" :is-last="isLast(index)" /> + </gl-dropdown-item> + </gl-dropdown> </template> diff --git a/app/assets/javascripts/batch_comments/components/preview_item.vue b/app/assets/javascripts/batch_comments/components/preview_item.vue index c89a6b537ef..dca6d90fbcb 100644 --- a/app/assets/javascripts/batch_comments/components/preview_item.vue +++ b/app/assets/javascripts/batch_comments/components/preview_item.vue @@ -1,5 +1,5 @@ <script> -import { mapActions, mapGetters } from 'vuex'; +import { mapGetters } from 'vuex'; import { GlSprintf, GlIcon } from '@gitlab/ui'; import { IMAGE_DIFF_POSITION_TYPE } from '~/diffs/constants'; import { sprintf, __ } from '~/locale'; @@ -78,7 +78,6 @@ export default { }, }, methods: { - ...mapActions('batchComments', ['scrollToDraft']), getLineClasses(lineNumber) { return getLineClasses(lineNumber); }, @@ -88,17 +87,7 @@ export default { </script> <template> - <button - type="button" - class="review-preview-item menu-item" - :class="[ - componentClasses, - { - 'is-last': isLast, - }, - ]" - @click="scrollToDraft(draft)" - > + <span> <span class="review-preview-item-header"> <gl-icon class="flex-shrink-0" :name="iconName" /> <span @@ -139,5 +128,5 @@ export default { > <gl-icon class="gl-mr-3" name="status_success" /> {{ resolvedStatusMessage }} </span> - </button> + </span> </template> diff --git a/app/assets/javascripts/batch_comments/components/publish_button.vue b/app/assets/javascripts/batch_comments/components/publish_button.vue index 0c79e185f06..ecced36771e 100644 --- a/app/assets/javascripts/batch_comments/components/publish_button.vue +++ b/app/assets/javascripts/batch_comments/components/publish_button.vue @@ -1,7 +1,6 @@ <script> import { mapActions, mapState } from 'vuex'; import { GlButton } from '@gitlab/ui'; -import { __ } from '~/locale'; import DraftsCount from './drafts_count.vue'; export default { @@ -15,11 +14,6 @@ export default { required: false, default: false, }, - label: { - type: String, - required: false, - default: __('Finish review'), - }, category: { type: String, required: false, @@ -30,22 +24,14 @@ export default { required: false, default: 'success', }, - shouldPublish: { - type: Boolean, - required: true, - }, }, computed: { ...mapState('batchComments', ['isPublishing']), }, methods: { - ...mapActions('batchComments', ['publishReview', 'toggleReviewDropdown']), + ...mapActions('batchComments', ['publishReview']), onClick() { - if (this.shouldPublish) { - this.publishReview(); - } else { - this.toggleReviewDropdown(); - } + this.publishReview(); }, }, }; @@ -59,7 +45,7 @@ export default { :variant="variant" @click="onClick" > - {{ label }} + {{ __('Submit review') }} <drafts-count v-if="showCount" /> </gl-button> </template> diff --git a/app/assets/javascripts/batch_comments/components/review_bar.vue b/app/assets/javascripts/batch_comments/components/review_bar.vue index e51888eabc1..035d6f4e0ab 100644 --- a/app/assets/javascripts/batch_comments/components/review_bar.vue +++ b/app/assets/javascripts/batch_comments/components/review_bar.vue @@ -1,22 +1,15 @@ <script> -/* eslint-disable vue/no-v-html */ -import { mapActions, mapState, mapGetters } from 'vuex'; -import { GlModal, GlModalDirective, GlButton } from '@gitlab/ui'; -import { sprintf, s__ } from '~/locale'; +import { mapActions, mapGetters } from 'vuex'; import PreviewDropdown from './preview_dropdown.vue'; +import PublishButton from './publish_button.vue'; export default { components: { - GlButton, - GlModal, PreviewDropdown, - }, - directives: { - 'gl-modal': GlModalDirective, + PublishButton, }, computed: { ...mapGetters(['isNotesFetched']), - ...mapState('batchComments', ['isDiscarding']), ...mapGetters('batchComments', ['draftsCount']), }, watch: { @@ -27,45 +20,17 @@ export default { }, }, methods: { - ...mapActions('batchComments', ['discardReview', 'expandAllDiscussions']), + ...mapActions('batchComments', ['expandAllDiscussions']), }, - modalId: 'discard-draft-review', - text: sprintf( - s__( - `BatchComments|You're about to discard your review which will delete all of your pending comments. - The deleted comments %{strong_start}cannot%{strong_end} be restored.`, - ), - { - strong_start: '<strong>', - strong_end: '</strong>', - }, - false, - ), }; </script> <template> <div v-show="draftsCount > 0"> <nav class="review-bar-component"> - <div class="review-bar-content qa-review-bar"> + <div class="review-bar-content qa-review-bar d-flex gl-justify-content-end"> <preview-dropdown /> - <gl-button - v-gl-modal="$options.modalId" - :loading="isDiscarding" - class="qa-discard-review float-right" - > - {{ __('Discard review') }} - </gl-button> + <publish-button class="gl-ml-3" show-count /> </div> </nav> - <gl-modal - :title="s__('BatchComments|Discard review?')" - :ok-title="s__('BatchComments|Delete all pending comments')" - :modal-id="$options.modalId" - title-tag="h4" - ok-variant="danger qa-modal-delete-pending-comments" - @ok="discardReview" - > - <p v-html="$options.text"></p> - </gl-modal> </div> </template> diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js index d9b92113103..ebd821125fb 100644 --- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js @@ -75,15 +75,6 @@ export const updateDiscussionsAfterPublish = ({ dispatch, getters, rootGetters } }), ); -export const discardReview = ({ commit, getters }) => { - commit(types.REQUEST_DISCARD_REVIEW); - - return service - .discard(getters.getNotesData.draftsDiscardPath) - .then(() => commit(types.RECEIVE_DISCARD_REVIEW_SUCCESS)) - .catch(() => commit(types.RECEIVE_DISCARD_REVIEW_ERROR)); -}; - export const updateDraft = ( { commit, getters }, { note, noteText, resolveDiscussion, position, callback }, @@ -108,8 +99,6 @@ export const scrollToDraft = ({ dispatch, rootGetters }, draft) => { const draftID = `note_${draft.id}`; const el = document.querySelector(`#${tabEl} #${draftID}`); - dispatch('closeReviewDropdown'); - window.location.hash = draftID; if (window.mrTabs.currentAction !== tab) { @@ -125,17 +114,6 @@ export const scrollToDraft = ({ dispatch, rootGetters }, draft) => { } }; -export const toggleReviewDropdown = ({ dispatch, state }) => { - if (state.showPreviewDropdown) { - dispatch('closeReviewDropdown'); - } else { - dispatch('openReviewDropdown'); - } -}; - -export const openReviewDropdown = ({ commit }) => commit(types.OPEN_REVIEW_DROPDOWN); -export const closeReviewDropdown = ({ commit }) => commit(types.CLOSE_REVIEW_DROPDOWN); - export const expandAllDiscussions = ({ dispatch, state }) => state.drafts .filter(draft => draft.discussion_id) diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js index c8f0658c21c..df523a692d3 100644 --- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js @@ -11,13 +11,6 @@ export const REQUEST_PUBLISH_REVIEW = 'REQUEST_PUBLISH_REVIEW'; export const RECEIVE_PUBLISH_REVIEW_SUCCESS = 'RECEIVE_PUBLISH_REVIEW_SUCCESS'; export const RECEIVE_PUBLISH_REVIEW_ERROR = 'RECEIVE_PUBLISH_REVIEW_ERROR'; -export const REQUEST_DISCARD_REVIEW = 'REQUEST_DISCARD_REVIEW'; -export const RECEIVE_DISCARD_REVIEW_SUCCESS = 'RECEIVE_DISCARD_REVIEW_SUCCESS'; -export const RECEIVE_DISCARD_REVIEW_ERROR = 'RECEIVE_DISCARD_REVIEW_ERROR'; - export const RECEIVE_DRAFT_UPDATE_SUCCESS = 'RECEIVE_DRAFT_UPDATE_SUCCESS'; -export const OPEN_REVIEW_DROPDOWN = 'OPEN_REVIEW_DROPDOWN'; -export const CLOSE_REVIEW_DROPDOWN = 'CLOSE_REVIEW_DROPDOWN'; - export const TOGGLE_RESOLVE_DISCUSSION = 'TOGGLE_RESOLVE_DISCUSSION'; diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js index 81ceef7b160..731f4b6d12a 100644 --- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js @@ -43,16 +43,6 @@ export default { [types.RECEIVE_PUBLISH_REVIEW_ERROR](state) { state.isPublishing = false; }, - [types.REQUEST_DISCARD_REVIEW](state) { - state.isDiscarding = true; - }, - [types.RECEIVE_DISCARD_REVIEW_SUCCESS](state) { - state.isDiscarding = false; - state.drafts = []; - }, - [types.RECEIVE_DISCARD_REVIEW_ERROR](state) { - state.isDiscarding = false; - }, [types.RECEIVE_DRAFT_UPDATE_SUCCESS](state, data) { const index = state.drafts.findIndex(draft => draft.id === data.id); @@ -60,12 +50,6 @@ export default { state.drafts.splice(index, 1, processDraft(data)); } }, - [types.OPEN_REVIEW_DROPDOWN](state) { - state.showPreviewDropdown = true; - }, - [types.CLOSE_REVIEW_DROPDOWN](state) { - state.showPreviewDropdown = false; - }, [types.TOGGLE_RESOLVE_DISCUSSION](state, draftId) { state.drafts = state.drafts.map(draft => { if (draft.id === draftId) { diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js index 80c710deab0..6b97fc242c8 100644 --- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js @@ -4,6 +4,4 @@ export default () => ({ drafts: [], isPublishing: false, currentlyPublishingDrafts: [], - isDiscarding: false, - showPreviewDropdown: false, }); diff --git a/app/assets/javascripts/behaviors/collapse_sidebar_on_window_resize.js b/app/assets/javascripts/behaviors/collapse_sidebar_on_window_resize.js index d9164f6204a..719d76fef8f 100644 --- a/app/assets/javascripts/behaviors/collapse_sidebar_on_window_resize.js +++ b/app/assets/javascripts/behaviors/collapse_sidebar_on_window_resize.js @@ -8,7 +8,6 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; * @sentrify */ export default () => { - const $sidebarGutterToggle = $('.js-sidebar-toggle'); let bootstrapBreakpoint = bp.getBreakpointSize(); $(window).on('resize.app', () => { @@ -19,8 +18,13 @@ export default () => { const breakpointSizes = ['md', 'sm', 'xs']; if (breakpointSizes.includes(bootstrapBreakpoint)) { - const $gutterIcon = $sidebarGutterToggle.find('i'); - if ($gutterIcon.hasClass('fa-angle-double-right')) { + const $toggleContainer = $('.js-sidebar-toggle-container'); + const isExpanded = $toggleContainer.data('is-expanded'); + const $expandIcon = $('.js-sidebar-expand'); + + if (isExpanded) { + const $sidebarGutterToggle = $expandIcon.closest('.js-sidebar-toggle'); + $sidebarGutterToggle.trigger('click'); } diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js index fd12c282b62..613309a1c5a 100644 --- a/app/assets/javascripts/behaviors/index.js +++ b/app/assets/javascripts/behaviors/index.js @@ -13,6 +13,9 @@ import './toggler_behavior'; import './preview_markdown'; import initCollapseSidebarOnWindowResize from './collapse_sidebar_on_window_resize'; import initSelect2Dropdowns from './select2'; +import { loadStartupCSS } from './load_startup_css'; + +loadStartupCSS(); installGlEmojiElement(); diff --git a/app/assets/javascripts/behaviors/load_startup_css.js b/app/assets/javascripts/behaviors/load_startup_css.js new file mode 100644 index 00000000000..1d7bf716475 --- /dev/null +++ b/app/assets/javascripts/behaviors/load_startup_css.js @@ -0,0 +1,15 @@ +export const loadStartupCSS = () => { + // We need to fallback to dispatching `load` in case our event listener was added too late + // or the browser environment doesn't load media=print. + // Do this on `window.load` so that the default deferred behavior takes precedence. + // https://gitlab.com/gitlab-org/gitlab/-/issues/239357 + window.addEventListener( + 'load', + () => { + document + .querySelectorAll('link[media=print]') + .forEach(x => x.dispatchEvent(new Event('load'))); + }, + { once: true }, + ); +}; diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js index 8a8b61a57cd..3cb2d6719c8 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js @@ -117,9 +117,9 @@ export default class Shortcuts { e.preventDefault(); const performanceBarCookieName = 'perf_bar_enabled'; if (parseBoolean(Cookies.get(performanceBarCookieName))) { - Cookies.set(performanceBarCookieName, 'false', { path: '/' }); + Cookies.set(performanceBarCookieName, 'false', { expires: 365, path: '/' }); } else { - Cookies.set(performanceBarCookieName, 'true', { path: '/' }); + Cookies.set(performanceBarCookieName, 'true', { expires: 365, path: '/' }); } refreshCurrentPage(); } diff --git a/app/assets/javascripts/blob/components/blob_header_filepath.vue b/app/assets/javascripts/blob/components/blob_header_filepath.vue index 601b694db87..f99ecba2324 100644 --- a/app/assets/javascripts/blob/components/blob_header_filepath.vue +++ b/app/assets/javascripts/blob/components/blob_header_filepath.vue @@ -43,6 +43,7 @@ export default { :text="blob.path" :gfm="gfmCopyText" :title="__('Copy file path')" + category="tertiary" css-class="btn-clipboard btn-transparent lh-100 position-static" /> </div> diff --git a/app/assets/javascripts/blob/suggest_web_ide_ci/components/web_ide_alert.vue b/app/assets/javascripts/blob/suggest_web_ide_ci/components/web_ide_alert.vue deleted file mode 100644 index 1308ca53e74..00000000000 --- a/app/assets/javascripts/blob/suggest_web_ide_ci/components/web_ide_alert.vue +++ /dev/null @@ -1,50 +0,0 @@ -<script> -import { GlAlert, GlButton } from '@gitlab/ui'; -import axios from '~/lib/utils/axios_utils'; - -export default { - components: { - GlAlert, - GlButton, - }, - props: { - dismissEndpoint: { - type: String, - required: true, - }, - featureId: { - type: String, - required: true, - }, - editPath: { - type: String, - required: true, - }, - }, - data() { - return { - showAlert: true, - }; - }, - methods: { - dismissAlert() { - this.showAlert = false; - - return axios.post(this.dismissEndpoint, { - feature_name: this.featureId, - }); - }, - }, -}; -</script> - -<template> - <gl-alert v-if="showAlert" class="gl-mt-5" @dismiss="dismissAlert"> - {{ __('The Web IDE offers advanced syntax highlighting capabilities and more.') }} - <div class="gl-mt-5"> - <gl-button :href="editPath" category="primary" variant="info">{{ - __('Open Web IDE') - }}</gl-button> - </div> - </gl-alert> -</template> diff --git a/app/assets/javascripts/blob/suggest_web_ide_ci/index.js b/app/assets/javascripts/blob/suggest_web_ide_ci/index.js deleted file mode 100644 index eadf3cd6216..00000000000 --- a/app/assets/javascripts/blob/suggest_web_ide_ci/index.js +++ /dev/null @@ -1,20 +0,0 @@ -import Vue from 'vue'; -import WebIdeAlert from './components/web_ide_alert.vue'; - -export default el => { - const { dismissEndpoint, featureId, editPath } = el.dataset; - - // eslint-disable-next-line no-new - new Vue({ - el, - render(createElement) { - return createElement(WebIdeAlert, { - props: { - dismissEndpoint, - featureId, - editPath, - }, - }); - }, - }); -}; diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js index 05ee8e49eb1..0fb803cdfec 100644 --- a/app/assets/javascripts/blob/viewer/index.js +++ b/app/assets/javascripts/blob/viewer/index.js @@ -179,9 +179,7 @@ export default class BlobViewer { viewer.innerHTML = data.html; viewer.setAttribute('data-loaded', 'true'); - if (window.gon?.features?.codeNavigation) { - eventHub.$emit('showBlobInteractionZones', viewer.dataset.path); - } + 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 c9972f0b43c..d1e5dad7971 100644 --- a/app/assets/javascripts/blob_edit/blob_bundle.js +++ b/app/assets/javascripts/blob_edit/blob_bundle.js @@ -7,14 +7,12 @@ import BlobFileDropzone from '../blob/blob_file_dropzone'; import initPopover from '~/blob/suggest_gitlab_ci_yml'; import { disableButtonIfEmptyField, setCookie } from '~/lib/utils/common_utils'; import Tracking from '~/tracking'; -import initWebIdeAlert from '~/blob/suggest_web_ide_ci'; export default () => { const editBlobForm = $('.js-edit-blob-form'); const uploadBlobForm = $('.js-upload-blob-form'); const deleteBlobForm = $('.js-delete-blob-form'); const suggestEl = document.querySelector('.js-suggest-gitlab-ci-yml'); - const alertEl = document.getElementById('js-suggest-web-ide-ci'); if (editBlobForm.length) { const urlRoot = editBlobForm.data('relativeUrlRoot'); @@ -85,8 +83,4 @@ export default () => { }); } } - - if (alertEl) { - initWebIdeAlert(alertEl); - } }; diff --git a/app/assets/javascripts/boards/components/board_blank_state.vue b/app/assets/javascripts/boards/components/board_blank_state.vue deleted file mode 100644 index 55e3e4a6329..00000000000 --- a/app/assets/javascripts/boards/components/board_blank_state.vue +++ /dev/null @@ -1,104 +0,0 @@ -<script> -import { GlButton } from '@gitlab/ui'; -import Cookies from 'js-cookie'; -import { __ } from '~/locale'; -import ListLabel from '~/boards/models/label'; -import boardsStore from '../stores/boards_store'; - -export default { - components: { - GlButton, - }, - data() { - return { - predefinedLabels: [ - new ListLabel({ title: __('To Do'), color: '#F0AD4E' }), - new ListLabel({ title: __('Doing'), color: '#5CB85C' }), - ], - }; - }, - methods: { - addDefaultLists() { - this.clearBlankState(); - - this.predefinedLabels.forEach((label, i) => { - boardsStore.addList({ - title: label.title, - position: i, - list_type: 'label', - label: { - title: label.title, - color: label.color, - }, - }); - }); - - const loadListIssues = listObj => { - const list = boardsStore.findList('title', listObj.title); - - if (!list) { - return null; - } - - list.id = listObj.id; - list.label.id = listObj.label.id; - return list.getIssues().catch(() => { - // TODO: handle request error - }); - }; - - // Save the labels - boardsStore - .generateDefaultLists() - .then(res => res.data) - .then(data => Promise.all(data.map(loadListIssues))) - .catch(() => { - boardsStore.removeList(undefined, 'label'); - Cookies.remove('issue_board_welcome_hidden', { - path: '', - }); - boardsStore.addBlankState(); - }); - }, - clearBlankState: boardsStore.removeBlankState.bind(boardsStore), - }, -}; -</script> - -<template> - <div class="board-blank-state p-3"> - <p> - {{ - s__('BoardBlankState|Add the following default lists to your Issue Board with one click:') - }} - </p> - <ul class="list-unstyled board-blank-state-list"> - <li v-for="(label, index) in predefinedLabels" :key="index"> - <span - :style="{ backgroundColor: label.color }" - class="label-color position-relative d-inline-block rounded" - ></span> - {{ label.title }} - </li> - </ul> - <p> - {{ - s__( - 'BoardBlankState|Starting out with the default set of lists will get you right on the way to making the most of your board.', - ) - }} - </p> - <gl-button - category="secondary" - variant="success" - block="block" - class="gl-mb-0" - @click.stop="addDefaultLists" - > - {{ s__('BoardBlankState|Add default lists') }} - </gl-button> - <gl-button category="secondary" variant="default" block="block" @click.stop="clearBlankState"> - {{ s__("BoardBlankState|Nevermind, I'll use my own") }} - </gl-button> - </div> -</template> diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index 6d216911798..6aff5f0c3c3 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -6,7 +6,6 @@ import BoardListHeader from 'ee_else_ce/boards/components/board_list_header.vue' import Tooltip from '~/vue_shared/directives/tooltip'; import EmptyComponent from '~/vue_shared/components/empty_component'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import BoardBlankState from './board_blank_state.vue'; import BoardList from './board_list.vue'; import boardsStore from '../stores/boards_store'; import eventHub from '../eventhub'; @@ -16,7 +15,6 @@ import { ListType } from '../constants'; export default { components: { BoardPromotionState: EmptyComponent, - BoardBlankState, BoardListHeader, BoardList, }, @@ -54,7 +52,7 @@ export default { computed: { ...mapGetters(['getIssues']), showBoardListAndBoardInfo() { - return this.list.type !== ListType.blank && this.list.type !== ListType.promotion; + return this.list.type !== ListType.promotion; }, uniqueKey() { // eslint-disable-next-line @gitlab/require-i18n-strings @@ -148,7 +146,6 @@ export default { :list="list" :loading="list.loading" /> - <board-blank-state v-if="canAdminList && list.id === 'blank'" /> <!-- Will be only available in EE --> <board-promotion-state v-if="list.id === 'promotion'" /> diff --git a/app/assets/javascripts/boards/components/board_configuration_options.vue b/app/assets/javascripts/boards/components/board_configuration_options.vue new file mode 100644 index 00000000000..ad3d653b905 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_configuration_options.vue @@ -0,0 +1,65 @@ +<script> +import { GlFormCheckbox } from '@gitlab/ui'; + +export default { + components: { + GlFormCheckbox, + }, + props: { + currentBoard: { + type: Object, + required: true, + }, + board: { + type: Object, + required: true, + }, + isNewForm: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + const { hide_backlog_list: hideBacklogList, hide_closed_list: hideClosedList } = this.isNewForm + ? this.board + : this.currentBoard; + + return { + hideClosedList, + hideBacklogList, + }; + }, + methods: { + changeClosedList(checked) { + this.board.hideClosedList = !checked; + }, + changeBacklogList(checked) { + this.board.hideBacklogList = !checked; + }, + }, +}; +</script> + +<template> + <div class="append-bottom-20"> + <label class="form-section-title label-bold" for="board-new-name"> + {{ __('List options') }} + </label> + <p class="text-secondary gl-mb-3"> + {{ __('Configure which lists are shown for anyone who visits this board') }} + </p> + <gl-form-checkbox + :checked="!hideBacklogList" + data-testid="backlog-list-checkbox" + @change="changeBacklogList" + >{{ __('Show the Open list') }} + </gl-form-checkbox> + <gl-form-checkbox + :checked="!hideClosedList" + data-testid="closed-list-checkbox" + @change="changeClosedList" + >{{ __('Show the Closed list') }} + </gl-form-checkbox> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index 385dd5fdc71..793c594cf16 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -5,6 +5,8 @@ import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; import { visitUrl } from '~/lib/utils/url_utility'; import boardsStore from '~/boards/stores/boards_store'; +import BoardConfigurationOptions from './board_configuration_options.vue'; + const boardDefaults = { id: false, name: '', @@ -13,12 +15,15 @@ const boardDefaults = { assignee: {}, assignee_id: undefined, weight: null, + hide_backlog_list: false, + hide_closed_list: false, }; export default { components: { BoardScope: () => import('ee_component/boards/components/board_scope.vue'), DeprecatedModal, + BoardConfigurationOptions, }, props: { canAdminBoard: { @@ -140,7 +145,17 @@ export default { } else { boardsStore .createBoard(this.board) - .then(resp => resp.data) + .then(resp => { + // This handles 2 use cases + // - In create call we only get one parameter, the new board + // - In update call, due to Promise.all, we get REST response in + // array index 0 + + if (Array.isArray(resp)) { + return resp[0].data; + } + return resp.data ? resp.data : resp; + }) .then(data => { visitUrl(data.board_path); }) @@ -182,7 +197,7 @@ export default { <form v-else class="js-board-config-modal" @submit.prevent> <div v-if="!readonly" class="append-bottom-20"> <label class="form-section-title label-bold" for="board-new-name">{{ - __('Board name') + __('Title') }}</label> <input id="board-new-name" @@ -196,6 +211,12 @@ export default { /> </div> + <board-configuration-options + :is-new-form="isNewForm" + :board="board" + :current-board="currentBoard" + /> + <board-scope v-if="scopedIssueBoardFeatureEnabled" :collapse-scope="isNewForm" diff --git a/app/assets/javascripts/boards/components/modal/tabs.vue b/app/assets/javascripts/boards/components/modal/tabs.vue index a71fda9d7c5..b066fb25360 100644 --- a/app/assets/javascripts/boards/components/modal/tabs.vue +++ b/app/assets/javascripts/boards/components/modal/tabs.vue @@ -1,9 +1,15 @@ <script> /* eslint-disable @gitlab/vue-require-i18n-strings */ +import { GlTabs, GlTab, GlBadge } from '@gitlab/ui'; import ModalStore from '../../stores/modal_store'; import modalMixin from '../../mixins/modal_mixins'; export default { + components: { + GlTabs, + GlTab, + GlBadge, + }, mixins: [modalMixin], data() { return ModalStore.store; @@ -19,18 +25,18 @@ export default { }; </script> <template> - <div class="top-area gl-mt-3 gl-mb-3"> - <ul class="nav-links issues-state-filters"> - <li :class="{ active: activeTab == 'all' }"> - <a href="#" role="button" @click.prevent="changeTab('all')"> - Open issues <span class="badge badge-pill"> {{ issuesCount }} </span> - </a> - </li> - <li :class="{ active: activeTab == 'selected' }"> - <a href="#" role="button" @click.prevent="changeTab('selected')"> - Selected issues <span class="badge badge-pill"> {{ selectedCount }} </span> - </a> - </li> - </ul> - </div> + <gl-tabs class="gl-mt-3"> + <gl-tab @click.prevent="changeTab('all')"> + <template slot="title"> + <span>Open issues</span> + <gl-badge size="sm" class="gl-tab-counter-badge">{{ issuesCount }}</gl-badge> + </template> + </gl-tab> + <gl-tab @click.prevent="changeTab('selected')"> + <template slot="title"> + <span>Selected issues</span> + <gl-badge size="sm" class="gl-tab-counter-badge">{{ selectedCount }}</gl-badge> + </template> + </gl-tab> + </gl-tabs> </template> diff --git a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue index 8df03ea581f..ec3c4e309b6 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue @@ -36,7 +36,7 @@ export default { } this.edit = true; - this.$emit('changed', this.edit); + this.$emit('open'); window.addEventListener('click', this.collapseWhenOffClick); }, collapse() { @@ -45,7 +45,7 @@ export default { } this.edit = false; - this.$emit('changed', this.edit); + this.$emit('close'); window.removeEventListener('click', this.collapseWhenOffClick); }, }, diff --git a/app/assets/javascripts/boards/ee_functions.js b/app/assets/javascripts/boards/ee_functions.js index 583270fcae5..419a640d5c5 100644 --- a/app/assets/javascripts/boards/ee_functions.js +++ b/app/assets/javascripts/boards/ee_functions.js @@ -1,6 +1,6 @@ export const setPromotionState = () => {}; -export const setWeigthFetchingState = () => {}; +export const setWeightFetchingState = () => {}; export const setEpicFetchingState = () => {}; export const getMilestoneTitle = () => ({}); diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 1173c6d0578..2af96e94d32 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -11,7 +11,7 @@ import toggleLabels from 'ee_else_ce/boards/toggle_labels'; import toggleEpicsSwimlanes from 'ee_else_ce/boards/toggle_epics_swimlanes'; import { setPromotionState, - setWeigthFetchingState, + setWeightFetchingState, setEpicFetchingState, getMilestoneTitle, getBoardsModalData, @@ -84,8 +84,9 @@ export default () => { }, provide: { boardId: $boardApp.dataset.boardId, - groupId: Number($boardApp.dataset.groupId) || null, + groupId: Number($boardApp.dataset.groupId), rootPath: $boardApp.dataset.rootPath, + canUpdate: $boardApp.dataset.canUpdate, }, store, apolloProvider, @@ -131,6 +132,7 @@ export default () => { eventHub.$on('clearDetailIssue', this.clearDetailIssue); sidebarEventHub.$on('toggleSubscription', this.toggleSubscription); eventHub.$on('performSearch', this.performSearch); + eventHub.$on('initialBoardLoad', this.initialBoardLoad); }, beforeDestroy() { eventHub.$off('updateTokens', this.updateTokens); @@ -138,6 +140,7 @@ export default () => { eventHub.$off('clearDetailIssue', this.clearDetailIssue); sidebarEventHub.$off('toggleSubscription', this.toggleSubscription); eventHub.$off('performSearch', this.performSearch); + eventHub.$off('initialBoardLoad', this.initialBoardLoad); }, mounted() { this.filterManager = new FilteredSearchBoards(boardsStore.filter, true, boardsStore.cantEdit); @@ -148,6 +151,18 @@ export default () => { boardsStore.disabled = this.disabled; if (!gon.features.graphqlBoardLists) { + this.initialBoardLoad(); + } + }, + methods: { + ...mapActions([ + 'setInitialBoardData', + 'setFilters', + 'fetchEpicsSwimlanes', + 'resetIssues', + 'resetEpics', + ]), + initialBoardLoad() { boardsStore .all() .then(res => res.data) @@ -160,30 +175,23 @@ export default () => { .catch(() => { Flash(__('An error occurred while fetching the board lists. Please try again.')); }); - } - }, - methods: { - ...mapActions([ - 'setInitialBoardData', - 'setFilters', - 'fetchEpicsSwimlanes', - 'fetchIssuesForAllLists', - ]), + }, updateTokens() { this.filterManager.updateTokens(); }, performSearch() { this.setFilters(convertObjectPropsToCamelCase(urlParamsToObject(window.location.search))); if (gon.features.boardsWithSwimlanes && this.isShowingEpicsSwimlanes) { - this.fetchEpicsSwimlanes(false); - this.fetchIssuesForAllLists(); + this.resetEpics(); + this.fetchEpicsSwimlanes({ withLists: false }); + this.resetIssues(); } }, updateDetailIssue(newIssue, multiSelect = false) { const { sidebarInfoEndpoint } = newIssue; if (sidebarInfoEndpoint && newIssue.subscribed === undefined) { newIssue.setFetchingState('subscriptions', true); - setWeigthFetchingState(newIssue, true); + setWeightFetchingState(newIssue, true); setEpicFetchingState(newIssue, true); boardsStore .getIssueInfo(sidebarInfoEndpoint) @@ -201,7 +209,7 @@ export default () => { } = convertObjectPropsToCamelCase(data); newIssue.setFetchingState('subscriptions', false); - setWeigthFetchingState(newIssue, false); + setWeightFetchingState(newIssue, false); setEpicFetchingState(newIssue, false); newIssue.updateData({ humanTimeSpent: humanTotalTimeSpent, @@ -216,7 +224,7 @@ export default () => { }) .catch(() => { newIssue.setFetchingState('subscriptions', false); - setWeigthFetchingState(newIssue, false); + setWeightFetchingState(newIssue, false); Flash(__('An error occurred while fetching sidebar data')); }); } diff --git a/app/assets/javascripts/boards/queries/board.mutation.graphql b/app/assets/javascripts/boards/queries/board.mutation.graphql new file mode 100644 index 00000000000..ef2b81a7939 --- /dev/null +++ b/app/assets/javascripts/boards/queries/board.mutation.graphql @@ -0,0 +1,11 @@ +mutation UpdateBoard($id: ID!, $hideClosedList: Boolean, $hideBacklogList: Boolean) { + updateBoard( + input: { id: $id, hideClosedList: $hideClosedList, hideBacklogList: $hideBacklogList } + ) { + board { + id + hideClosedList + hideBacklogList + } + } +} diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index 4b81d9c73ef..a513e02e0ca 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -226,31 +226,8 @@ export default { .catch(() => commit(types.RECEIVE_ISSUES_FOR_LIST_FAILURE, listId)); }, - fetchIssuesForAllLists: ({ state, commit }) => { - commit(types.REQUEST_ISSUES_FOR_ALL_LISTS); - - const { endpoints, boardType, filterParams } = state; - const { fullPath, boardId } = endpoints; - - const variables = { - fullPath, - boardId: fullBoardId(boardId), - filters: filterParams, - isGroup: boardType === BoardType.group, - isProject: boardType === BoardType.project, - }; - - return gqlClient - .query({ - query: listsIssuesQuery, - variables, - }) - .then(({ data }) => { - const { lists } = data[boardType]?.board; - const listIssues = formatListIssues(lists); - commit(types.RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS, listIssues); - }) - .catch(() => commit(types.RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE)); + resetIssues: ({ commit }) => { + commit(types.RESET_ISSUES); }, moveIssue: ( diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index faf4f9ebfd3..d1a5db1bcc5 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -2,7 +2,7 @@ /* global List */ /* global ListIssue */ import $ from 'jquery'; -import { sortBy } from 'lodash'; +import { sortBy, pick } from 'lodash'; import Vue from 'vue'; import Cookies from 'js-cookie'; import BoardsStoreEE from 'ee_else_ce/boards/stores/boards_store_ee'; @@ -12,7 +12,7 @@ import { parseBoolean, convertObjectPropsToCamelCase, } from '~/lib/utils/common_utils'; -import { __ } from '~/locale'; +import createDefaultClient from '~/lib/graphql'; import axios from '~/lib/utils/axios_utils'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; @@ -23,7 +23,11 @@ import ListLabel from '../models/label'; import ListAssignee from '../models/assignee'; import ListMilestone from '../models/milestone'; +import createBoardMutation from '../queries/board.mutation.graphql'; + const PER_PAGE = 20; +export const gqlClient = createDefaultClient(); + const boardsStore = { disabled: false, timeTracking: { @@ -114,7 +118,6 @@ const boardsStore = { .catch(() => { // https://gitlab.com/gitlab-org/gitlab-foss/issues/30821 }); - this.removeBlankState(); }, updateNewListDropdown(listId) { $(`.js-board-list-${listId}`).removeClass('is-active'); @@ -124,22 +127,14 @@ const boardsStore = { return !this.state.lists.filter(list => list.type !== 'backlog' && list.type !== 'closed')[0]; }, addBlankState() { - if (!this.shouldAddBlankState() || this.welcomeIsHidden() || this.disabled) return; - - this.addList({ - id: 'blank', - list_type: 'blank', - title: __('Welcome to your Issue Board!'), - position: 0, - }); - }, - removeBlankState() { - this.removeList('blank'); + if (!this.shouldAddBlankState() || this.welcomeIsHidden()) return; - Cookies.set('issue_board_welcome_hidden', 'true', { - expires: 365 * 10, - path: '', - }); + this.generateDefaultLists() + .then(res => res.data) + .then(data => Promise.all(data.map(list => this.addList(list)))) + .catch(() => { + this.removeList(undefined, 'label'); + }); }, findIssueLabel(issue, findLabel) { @@ -542,6 +537,10 @@ const boardsStore = { this.timeTracking.limitToHours = parseBoolean(limitToHours); }, + generateBoardGid(boardId) { + return `gid://gitlab/Board/${boardId}`; + }, + generateBoardsPath(id) { return `${this.state.endpoints.boardsEndpoint}${id ? `/${id}` : ''}.json`; }, @@ -800,9 +799,33 @@ const boardsStore = { } if (boardPayload.id) { - return axios.put(this.generateBoardsPath(boardPayload.id), { board: boardPayload }); + const input = { + ...pick(boardPayload, ['hideClosedList', 'hideBacklogList']), + id: this.generateBoardGid(boardPayload.id), + }; + + return Promise.all([ + axios.put(this.generateBoardsPath(boardPayload.id), { board: boardPayload }), + gqlClient.mutate({ + mutation: createBoardMutation, + variables: input, + }), + ]); } - return axios.post(this.generateBoardsPath(), { board: boardPayload }); + + return axios + .post(this.generateBoardsPath(), { board: boardPayload }) + .then(resp => resp.data) + .then(data => { + gqlClient.mutate({ + mutation: createBoardMutation, + variables: { + ...pick(boardPayload, ['hideClosedList', 'hideBacklogList']), + id: this.generateBoardGid(data.id), + }, + }); + return data; + }); }, deleteBoard({ id }) { diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js index f0a283f6161..7e0597f5332 100644 --- a/app/assets/javascripts/boards/stores/mutation_types.js +++ b/app/assets/javascripts/boards/stores/mutation_types.js @@ -12,11 +12,8 @@ export const UPDATE_LIST_FAILURE = 'UPDATE_LIST_FAILURE'; export const REQUEST_REMOVE_LIST = 'REQUEST_REMOVE_LIST'; export const RECEIVE_REMOVE_LIST_SUCCESS = 'RECEIVE_REMOVE_LIST_SUCCESS'; export const RECEIVE_REMOVE_LIST_ERROR = 'RECEIVE_REMOVE_LIST_ERROR'; -export const REQUEST_ISSUES_FOR_ALL_LISTS = 'REQUEST_ISSUES_FOR_ALL_LISTS'; export const RECEIVE_ISSUES_FOR_LIST_FAILURE = 'RECEIVE_ISSUES_FOR_LIST_FAILURE'; export const RECEIVE_ISSUES_FOR_LIST_SUCCESS = 'RECEIVE_ISSUES_FOR_LIST_SUCCESS'; -export const RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS = 'RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS'; -export const RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE = 'RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE'; export const REQUEST_ADD_ISSUE = 'REQUEST_ADD_ISSUE'; export const RECEIVE_ADD_ISSUE_SUCCESS = 'RECEIVE_ADD_ISSUE_SUCCESS'; export const RECEIVE_ADD_ISSUE_ERROR = 'RECEIVE_ADD_ISSUE_ERROR'; @@ -32,3 +29,4 @@ export const SET_CURRENT_PAGE = 'SET_CURRENT_PAGE'; export const TOGGLE_EMPTY_STATE = 'TOGGLE_EMPTY_STATE'; export const SET_ACTIVE_ID = 'SET_ACTIVE_ID'; export const UPDATE_ISSUE_BY_ID = 'UPDATE_ISSUE_BY_ID'; +export const RESET_ISSUES = 'RESET_ISSUES'; diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index faeb3e25a71..de18ec4b4f3 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import { sortBy, pull } from 'lodash'; import { formatIssue, moveIssueListHelper } from '../boards_util'; import * as mutationTypes from './mutation_types'; -import { __ } from '~/locale'; +import { s__ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; const notImplemented = () => { @@ -49,7 +49,7 @@ export default { }, [mutationTypes.CREATE_LIST_FAILURE]: state => { - state.error = __('An error occurred while creating the list. Please try again.'); + state.error = s__('Boards|An error occurred while creating the list. Please try again.'); }, [mutationTypes.REQUEST_ADD_LIST]: () => { @@ -73,7 +73,7 @@ export default { }, [mutationTypes.UPDATE_LIST_FAILURE]: (state, backupList) => { - state.error = __('An error occurred while updating the list. Please try again.'); + state.error = s__('Boards|An error occurred while updating the list. Please try again.'); Vue.set(state, 'boardLists', backupList); }, @@ -98,19 +98,17 @@ export default { }, [mutationTypes.RECEIVE_ISSUES_FOR_LIST_FAILURE]: (state, listId) => { - state.error = __('An error occurred while fetching the board issues. Please reload the page.'); + state.error = s__( + 'Boards|An error occurred while fetching the board issues. Please reload the page.', + ); const listIndex = state.boardLists.findIndex(l => l.id === listId); Vue.set(state.boardLists[listIndex], 'loading', false); }, - [mutationTypes.REQUEST_ISSUES_FOR_ALL_LISTS]: state => { - state.isLoadingIssues = true; - }, - - [mutationTypes.RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS]: (state, { listData, issues }) => { - state.issuesByListId = listData; - state.issues = issues; - state.isLoadingIssues = false; + [mutationTypes.RESET_ISSUES]: state => { + Object.keys(state.issuesByListId).forEach(listId => { + Vue.set(state.issuesByListId, listId, []); + }); }, [mutationTypes.UPDATE_ISSUE_BY_ID]: (state, { issueId, prop, value }) => { @@ -122,11 +120,6 @@ export default { Vue.set(state.issues[issueId], prop, value); }, - [mutationTypes.RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE]: state => { - state.error = __('An error occurred while fetching the board issues. Please reload the page.'); - state.isLoadingIssues = false; - }, - [mutationTypes.REQUEST_ADD_ISSUE]: () => { notImplemented(); }, @@ -162,7 +155,7 @@ export default { state, { originalIssue, fromListId, toListId, originalIndex }, ) => { - state.error = __('An error occurred while moving the issue. Please try again.'); + state.error = s__('Boards|An error occurred while moving the issue. Please try again.'); Vue.set(state.issues, originalIssue.id, originalIssue); removeIssueFromList(state, toListId, originalIssue.id); addIssueToList({ @@ -193,7 +186,7 @@ export default { }, [mutationTypes.ADD_ISSUE_TO_LIST_FAILURE]: (state, { list, issue }) => { - state.error = __('An error occurred while creating the issue. Please try again.'); + state.error = s__('Boards|An error occurred while creating the issue. Please try again.'); removeIssueFromList(state, list.id, issue.id); }, diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js index be937d68c6c..2d388739586 100644 --- a/app/assets/javascripts/boards/stores/state.js +++ b/app/assets/javascripts/boards/stores/state.js @@ -11,7 +11,6 @@ export default () => ({ boardLists: [], issuesByListId: {}, issues: {}, - isLoadingIssues: false, filterParams: {}, error: undefined, // TODO: remove after ce/ee split of board_content.vue diff --git a/app/assets/javascripts/build_artifacts.js b/app/assets/javascripts/build_artifacts.js index 2955f0f014b..8324c649538 100644 --- a/app/assets/javascripts/build_artifacts.js +++ b/app/assets/javascripts/build_artifacts.js @@ -3,6 +3,7 @@ import $ from 'jquery'; import { visitUrl } from './lib/utils/url_utility'; import { parseBoolean } from './lib/utils/common_utils'; +import { hide, initTooltips, show } from '~/tooltips'; export default class BuildArtifacts { constructor() { @@ -10,6 +11,7 @@ export default class BuildArtifacts { this.setupEntryClick(); this.setupTooltips(); } + // eslint-disable-next-line class-methods-use-this disablePropagation() { $('.top-block').on('click', '.download', e => { @@ -19,15 +21,17 @@ export default class BuildArtifacts { e.stopImmediatePropagation(); }); } + // eslint-disable-next-line class-methods-use-this setupEntryClick() { return $('.tree-holder').on('click', 'tr[data-link]', function() { visitUrl(this.dataset.link, parseBoolean(this.dataset.externalLink)); }); } + // eslint-disable-next-line class-methods-use-this setupTooltips() { - $('.js-artifact-tree-tooltip').tooltip({ + initTooltips({ placement: 'bottom', // Stop the tooltip from hiding when we stop hovering the element directly // We handle all the showing/hiding below @@ -38,14 +42,14 @@ export default class BuildArtifacts { // But be placed below and in the middle of the file name $('.js-artifact-tree-row') .on('mouseenter', e => { - $(e.currentTarget) - .find('.js-artifact-tree-tooltip') - .tooltip('show'); + const $el = $(e.currentTarget).find('.js-artifact-tree-tooltip'); + + show($el); }) .on('mouseleave', e => { - $(e.currentTarget) - .find('.js-artifact-tree-tooltip') - .tooltip('hide'); + const $el = $(e.currentTarget).find('.js-artifact-tree-tooltip'); + + hide($el); }); } } diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue new file mode 100644 index 00000000000..ad07052a298 --- /dev/null +++ b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue @@ -0,0 +1,143 @@ +<script> +import { GlTable, GlButton, GlBadge, GlTooltipDirective } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + components: { + GlTable, + GlButton, + GlBadge, + ClipboardButton, + TooltipOnTruncate, + UserAvatarLink, + TimeAgoTooltip, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + triggers: { + type: Array, + required: false, + default: () => [], + }, + }, + fields: [ + { + key: 'token', + label: s__('Pipelines|Token'), + }, + { + key: 'description', + label: s__('Pipelines|Description'), + }, + { + key: 'owner', + label: s__('Pipelines|Owner'), + }, + { + key: 'lastUsed', + label: s__('Pipelines|Last Used'), + }, + { + key: 'actions', + label: '', + tdClass: 'gl-text-right gl-white-space-nowrap', + }, + ], +}; +</script> + +<template> + <div> + <gl-table + v-if="triggers.length" + :fields="$options.fields" + :items="triggers" + class="triggers-list" + responsive + > + <template #cell(token)="{item}"> + {{ item.token }} + <clipboard-button + v-if="item.hasTokenExposed" + :text="item.token" + data-testid="clipboard-btn" + data-qa-selector="clipboard_button" + :title="s__('Pipelines|Copy trigger token')" + css-class="gl-border-none gl-py-0 gl-px-2" + /> + <div class="label-container"> + <gl-badge v-if="!item.canAccessProject" variant="danger"> + <span + v-gl-tooltip.viewport + boundary="viewport" + :title="s__('Pipelines|Trigger user has insufficient permissions to project')" + >{{ s__('Pipelines|invalid') }}</span + > + </gl-badge> + </div> + </template> + <template #cell(description)="{item}"> + <tooltip-on-truncate + :title="item.description" + truncate-target="child" + placement="top" + class="trigger-description gl-display-flex" + > + <div class="gl-flex-fill-1 gl-text-truncate">{{ item.description }}</div> + </tooltip-on-truncate> + </template> + <template #cell(owner)="{item}"> + <span class="trigger-owner sr-only">{{ item.owner.name }}</span> + <user-avatar-link + v-if="item.owner" + :link-href="item.owner.path" + :img-src="item.owner.avatarUrl" + :tooltip-text="item.owner.name" + :img-alt="item.owner.name" + /> + </template> + <template #cell(lastUsed)="{item}"> + <time-ago-tooltip v-if="item.lastUsed" :time="item.lastUsed" /> + <span v-else>{{ __('Never') }}</span> + </template> + <template #cell(actions)="{item}"> + <gl-button + :title="s__('Pipelines|Edit')" + icon="pencil" + data-testid="edit-btn" + :href="item.editProjectTriggerPath" + /> + <gl-button + :title="s__('Pipelines|Revoke')" + icon="remove" + variant="warning" + :data-confirm=" + s__( + 'Pipelines|By revoking a trigger you will break any processes making use of it. Are you sure?', + ) + " + data-method="delete" + rel="nofollow" + class="gl-ml-3" + data-testid="trigger_revoke_button" + data-qa-selector="trigger_revoke_button" + :href="item.projectTriggerPath" + /> + </template> + </gl-table> + <div + v-else + data-testid="no_triggers_content" + data-qa-selector="no_triggers_content" + class="settings-message gl-text-center gl-mb-3" + > + {{ s__('Pipelines|No triggers have been created yet. Add one using the form above.') }} + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/index.js b/app/assets/javascripts/ci_settings_pipeline_triggers/index.js new file mode 100644 index 00000000000..182d5ca5ffb --- /dev/null +++ b/app/assets/javascripts/ci_settings_pipeline_triggers/index.js @@ -0,0 +1,36 @@ +import Vue from 'vue'; +import TriggersList from './components/triggers_list.vue'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; + +const parseJsonArray = triggers => { + try { + return convertObjectPropsToCamelCase(JSON.parse(triggers), { deep: true }); + } catch { + return []; + } +}; + +export default (containerId = 'js-ci-pipeline-triggers-list') => { + const containerEl = document.getElementById(containerId); + + // Note: Remove this check when FF `ci_pipeline_triggers_settings_vue_ui` is removed. + if (!containerEl) { + return null; + } + + const triggers = parseJsonArray(containerEl.dataset.triggers); + + return new Vue({ + el: containerEl, + components: { + TriggersList, + }, + render(h) { + return h(TriggersList, { + props: { + triggers, + }, + }); + }, + }); +}; 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 fbf19847e9d..a2f4bea2f61 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 @@ -6,7 +6,6 @@ import { GlFormCheckbox, GlFormCombobox, GlFormGroup, - GlFormInput, GlFormSelect, GlFormTextarea, GlIcon, @@ -41,7 +40,6 @@ export default { GlFormCheckbox, GlFormCombobox, GlFormGroup, - GlFormInput, GlFormSelect, GlFormTextarea, GlIcon, @@ -122,11 +120,6 @@ export default { return ''; }, tokenValidationState() { - // If the feature flag is off, do not validate. Remove when flag is removed. - if (!this.glFeatures.ciKeyAutocomplete) { - return true; - } - const validator = this.$options.tokens?.[this.variable.key]?.validation; if (validator) { @@ -204,21 +197,12 @@ export default { > <form> <gl-form-combobox - v-if="glFeatures.ciKeyAutocomplete" v-model="key" :token-list="$options.tokenList" :label-text="__('Key')" data-qa-selector="ci_variable_key_field" /> - <gl-form-group v-else :label="__('Key')" label-for="ci-variable-key"> - <gl-form-input - id="ci-variable-key" - v-model="key" - data-qa-selector="ci_variable_key_field" - /> - </gl-form-group> - <gl-form-group :label="__('Value')" label-for="ci-variable-value" diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue index 501c82b419e..07278bb442c 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue @@ -163,10 +163,7 @@ export default { </p> </template> </gl-table> - <div - class="ci-variable-actions d-flex justify-content-end" - :class="{ 'justify-content-center': !tableIsNotEmpty }" - > + <div class="ci-variable-actions" :class="{ 'justify-content-center': !tableIsNotEmpty }"> <gl-button v-if="tableIsNotEmpty" ref="secret-value-reveal-button" diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index 039237042ea..7add8d16912 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -481,7 +481,7 @@ export default { type="text" class="form-control js-hostname" /> - <span class="input-group-btn"> + <span class="input-group-append"> <clipboard-button :text="jupyterHostname" :title="s__('ClusterIntegration|Copy Jupyter Hostname')" diff --git a/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue b/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue index c816fc56d7a..6b99bb09504 100644 --- a/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue +++ b/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue @@ -1,12 +1,12 @@ <script> -import { GlDeprecatedDropdown, GlDeprecatedDropdownItem, GlIcon } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui'; import { s__ } from '../../locale'; export default { name: 'CrossplaneProviderStack', components: { - GlDeprecatedDropdown, - GlDeprecatedDropdownItem, + GlDropdown, + GlDropdownItem, GlIcon, }, props: { @@ -67,21 +67,17 @@ export default { <label> {{ s__('ClusterIntegration|Enabled stack') }} </label> - <gl-deprecated-dropdown + <gl-dropdown :disabled="crossplane.installed" :text="dropdownText" toggle-class="dropdown-menu-toggle gl-field-error-outline" class="w-100" :class="{ 'gl-show-field-errors': validationError }" > - <gl-deprecated-dropdown-item - v-for="stack in stacks" - :key="stack.code" - @click="selectStack(stack)" - > + <gl-dropdown-item v-for="stack in stacks" :key="stack.code" @click="selectStack(stack)"> <span class="ml-1">{{ stack.name }}</span> - </gl-deprecated-dropdown-item> - </gl-deprecated-dropdown> + </gl-dropdown-item> + </gl-dropdown> <span v-if="validationError" class="gl-field-error">{{ validationError }}</span> <p class="form-text text-muted"> {{ s__(`You must select a stack for configuring your cloud provider. Learn more about`) }} diff --git a/app/assets/javascripts/clusters/components/fluentd_output_settings.vue b/app/assets/javascripts/clusters/components/fluentd_output_settings.vue index e6001b11296..b37fc3894f8 100644 --- a/app/assets/javascripts/clusters/components/fluentd_output_settings.vue +++ b/app/assets/javascripts/clusters/components/fluentd_output_settings.vue @@ -1,11 +1,5 @@ <script> -import { - GlAlert, - GlDeprecatedButton, - GlDeprecatedDropdown, - GlDeprecatedDropdownItem, - GlFormCheckbox, -} from '@gitlab/ui'; +import { GlAlert, GlButton, GlDropdown, GlDropdownItem, GlFormCheckbox } from '@gitlab/ui'; import { mapValues } from 'lodash'; import { __ } from '~/locale'; import { APPLICATION_STATUS, FLUENTD } from '~/clusters/constants'; @@ -16,9 +10,9 @@ const { UPDATING, UNINSTALLING, INSTALLING, INSTALLED, UPDATED } = APPLICATION_S export default { components: { GlAlert, - GlDeprecatedButton, - GlDeprecatedDropdown, - GlDeprecatedDropdownItem, + GlButton, + GlDropdown, + GlDropdownItem, GlFormCheckbox, }, props: { @@ -203,15 +197,15 @@ export default { <label for="fluentd-protocol"> <strong>{{ s__('ClusterIntegration|SIEM Protocol') }}</strong> </label> - <gl-deprecated-dropdown :text="protocolName" class="w-100"> - <gl-deprecated-dropdown-item + <gl-dropdown :text="protocolName" class="w-100"> + <gl-dropdown-item v-for="(value, index) in protocols" :key="index" @click="selectProtocol(value.toLowerCase())" > {{ value }} - </gl-deprecated-dropdown-item> - </gl-deprecated-dropdown> + </gl-dropdown-item> + </gl-dropdown> </div> <div class="form-group flex flex-wrap"> <gl-form-checkbox :checked="wafLogEnabled" @input="wafLogChanged"> @@ -221,20 +215,21 @@ export default { <strong>{{ s__('ClusterIntegration|Send Container Network Policies Logs') }}</strong> </gl-form-checkbox> </div> - <div v-if="showButtons" class="mt-3"> - <gl-deprecated-button + <div v-if="showButtons" class="gl-mt-5 gl-display-flex"> + <gl-button ref="saveBtn" - class="mr-1" + class="gl-mr-3" variant="success" + category="primary" :loading="isSaving" :disabled="saveButtonDisabled" @click="updateApplication" > {{ saveButtonLabel }} - </gl-deprecated-button> - <gl-deprecated-button ref="cancelBtn" :disabled="saveButtonDisabled" @click="resetStatus"> + </gl-button> + <gl-button ref="cancelBtn" :disabled="saveButtonDisabled" @click="resetStatus"> {{ __('Cancel') }} - </gl-deprecated-button> + </gl-button> </div> </div> </div> diff --git a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue index 5e8e1a76182..f05c8db5d56 100644 --- a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue +++ b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue @@ -5,9 +5,9 @@ import { GlSprintf, GlLink, GlToggle, - GlDeprecatedButton, - GlDeprecatedDropdown, - GlDeprecatedDropdownItem, + GlButton, + GlDropdown, + GlDropdownItem, GlIcon, } from '@gitlab/ui'; import modSecurityLogo from 'images/cluster_app_logos/gitlab.png'; @@ -25,9 +25,9 @@ export default { GlSprintf, GlLink, GlToggle, - GlDeprecatedButton, - GlDeprecatedDropdown, - GlDeprecatedDropdownItem, + GlButton, + GlDropdown, + GlDropdownItem, GlIcon, }, props: { @@ -221,29 +221,31 @@ export default { </strong> </p> </div> - <gl-deprecated-dropdown :text="modSecurityModeName" :disabled="saveButtonDisabled"> - <gl-deprecated-dropdown-item - v-for="(mode, key) in modes" - :key="key" - @click="selectMode(key)" - > + <gl-dropdown :text="modSecurityModeName" :disabled="saveButtonDisabled"> + <gl-dropdown-item v-for="(mode, key) in modes" :key="key" @click="selectMode(key)"> {{ mode.name }} - </gl-deprecated-dropdown-item> - </gl-deprecated-dropdown> + </gl-dropdown-item> + </gl-dropdown> </div> </div> - <div v-if="showButtons" class="mt-3"> - <gl-deprecated-button - class="btn-success inline mr-1" + <div v-if="showButtons" class="gl-mt-5 gl-display-flex"> + <gl-button + variant="success" + category="primary" + data-qa-selector="save_ingress_modsecurity_settings" :loading="saving" :disabled="saveButtonDisabled" @click="updateApplication" > {{ saveButtonLabel }} - </gl-deprecated-button> - <gl-deprecated-button :disabled="saveButtonDisabled" @click="resetStatus"> + </gl-button> + <gl-button + data-qa-selector="cancel_ingress_modsecurity_settings" + :disabled="saveButtonDisabled" + @click="resetStatus" + > {{ __('Cancel') }} - </gl-deprecated-button> + </gl-button> </div> </div> </div> diff --git a/app/assets/javascripts/clusters/components/knative_domain_editor.vue b/app/assets/javascripts/clusters/components/knative_domain_editor.vue index 2617ea0bdea..19ce3e36cd7 100644 --- a/app/assets/javascripts/clusters/components/knative_domain_editor.vue +++ b/app/assets/javascripts/clusters/components/knative_domain_editor.vue @@ -1,8 +1,8 @@ <script> import { - GlDeprecatedDropdown, - GlDeprecatedDropdownDivider, - GlDeprecatedDropdownItem, + GlDropdown, + GlDropdownDivider, + GlDropdownItem, GlLoadingIcon, GlSearchBoxByType, GlSprintf, @@ -20,9 +20,9 @@ export default { GlButton, ClipboardButton, GlLoadingIcon, - GlDeprecatedDropdown, - GlDeprecatedDropdownDivider, - GlDeprecatedDropdownItem, + GlDropdown, + GlDropdownDivider, + GlDropdownItem, GlSearchBoxByType, GlSprintf, }, @@ -121,7 +121,7 @@ export default { <strong>{{ s__('ClusterIntegration|Knative Domain Name:') }}</strong> </label> - <gl-deprecated-dropdown + <gl-dropdown v-if="showDomainsDropdown" :text="domainDropdownText" toggle-class="dropdown-menu-toggle" @@ -132,16 +132,16 @@ export default { :placeholder="s__('ClusterIntegration|Search domains')" class="gl-m-3" /> - <gl-deprecated-dropdown-item + <gl-dropdown-item v-for="domain in filteredDomains" :key="domain.id" @click="selectDomain(domain)" > <span class="ml-1">{{ domain.domain }}</span> - </gl-deprecated-dropdown-item> + </gl-dropdown-item> <template v-if="searchQuery"> - <gl-deprecated-dropdown-divider /> - <gl-deprecated-dropdown-item key="custom-domain" @click="selectCustomDomain(searchQuery)"> + <gl-dropdown-divider /> + <gl-dropdown-item key="custom-domain" @click="selectCustomDomain(searchQuery)"> <span class="ml-1"> <gl-sprintf :message="s__('ClusterIntegration|Use %{query}')"> <template #query> @@ -149,9 +149,9 @@ export default { </template> </gl-sprintf> </span> - </gl-deprecated-dropdown-item> + </gl-dropdown-item> </template> - </gl-deprecated-dropdown> + </gl-dropdown> <input v-else diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue index 7b53020fc49..f8fb58cdca2 100644 --- a/app/assets/javascripts/clusters_list/components/clusters.vue +++ b/app/assets/javascripts/clusters_list/components/clusters.vue @@ -10,6 +10,7 @@ import { GlTable, } from '@gitlab/ui'; import AncestorNotice from './ancestor_notice.vue'; +import NodeErrorHelpText from './node_error_help_text.vue'; import tooltip from '~/vue_shared/directives/tooltip'; import { CLUSTER_TYPES, STATUSES } from '../constants'; import { __, sprintf } from '~/locale'; @@ -26,6 +27,7 @@ export default { GlSkeletonLoading, GlSprintf, GlTable, + NodeErrorHelpText, }, directives: { tooltip, @@ -199,7 +201,13 @@ export default { <section v-else> <ancestor-notice /> - <gl-table :items="clusters" :fields="fields" stacked="md" class="qa-clusters-table"> + <gl-table + :items="clusters" + :fields="fields" + stacked="md" + class="qa-clusters-table" + data-testid="cluster_list_table" + > <template #cell(name)="{ item }"> <div :class="[contentAlignClasses, 'js-status']"> <img @@ -231,9 +239,12 @@ export default { <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-200">{{ - __('Unknown') - }}</small> + <NodeErrorHelpText + v-else-if="item.kubernetes_errors" + :class="contentAlignClasses" + :error-type="item.kubernetes_errors.connection_error" + :popover-id="`nodeSizeError${item.id}`" + /> </template> <template #cell(total_cpu)="{ item }"> @@ -250,6 +261,13 @@ export default { </span> <gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" /> + + <NodeErrorHelpText + v-else-if="item.kubernetes_errors" + :class="contentAlignClasses" + :error-type="item.kubernetes_errors.node_connection_error" + :popover-id="`nodeCpuError${item.id}`" + /> </template> <template #cell(total_memory)="{ item }"> @@ -266,6 +284,13 @@ export default { </span> <gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" /> + + <NodeErrorHelpText + v-else-if="item.kubernetes_errors" + :class="contentAlignClasses" + :error-type="item.kubernetes_errors.metrics_connection_error" + :popover-id="`nodeMemoryError${item.id}`" + /> </template> <template #cell(cluster_type)="{value}"> diff --git a/app/assets/javascripts/clusters_list/components/node_error_help_text.vue b/app/assets/javascripts/clusters_list/components/node_error_help_text.vue new file mode 100644 index 00000000000..1a396694bc8 --- /dev/null +++ b/app/assets/javascripts/clusters_list/components/node_error_help_text.vue @@ -0,0 +1,53 @@ +<script> +import { GlIcon, GlPopover } from '@gitlab/ui'; +import { CLUSTER_ERRORS } from '../constants'; + +export default { + components: { + GlIcon, + GlPopover, + }, + props: { + errorType: { + type: String, + required: false, + default: '', + }, + popoverId: { + type: String, + required: true, + }, + }, + computed: { + errorContent() { + return CLUSTER_ERRORS[this.errorType] || CLUSTER_ERRORS.default; + }, + }, +}; +</script> + +<template> + <div :id="popoverId"> + <span class="gl-font-style-italic"> + {{ errorContent.tableText }} + </span> + + <gl-icon name="status_warning" :size="24" class="gl-p-2" /> + + <gl-popover :container="popoverId" :target="popoverId" placement="top" triggers="hover focus"> + <template #title> + <span class="gl-display-block gl-text-left">{{ errorContent.title }}</span> + </template> + + <p class="gl-text-left">{{ errorContent.description }}</p> + + <p class="gl-text-left">{{ s__('ClusterIntegration|Troubleshooting tips:') }}</p> + + <ul class="gl-text-left"> + <li v-for="tip in errorContent.troubleshootingTips" :key="tip"> + {{ tip }} + </li> + </ul> + </gl-popover> + </div> +</template> diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js index 3e8ef3151a6..f39678b73dc 100644 --- a/app/assets/javascripts/clusters_list/constants.js +++ b/app/assets/javascripts/clusters_list/constants.js @@ -1,4 +1,45 @@ -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; + +export const CLUSTER_ERRORS = { + default: { + tableText: s__('ClusterIntegration|Unknown Error'), + title: s__('ClusterIntegration|Unknown Error'), + description: s__( + 'ClusterIntegration|An unknown error occurred while attempting to connect to Kubernetes.', + ), + troubleshootingTips: [ + s__('ClusterIntegration|Check your cluster status'), + s__('ClusterIntegration|Make sure your API endpoint is correct'), + s__( + 'ClusterIntegration|Node calculations use the Kubernetes Metrics API. Make sure your cluster has metrics installed', + ), + ], + }, + authentication_error: { + tableText: s__('ClusterIntegration|Unable to Authenticate'), + title: s__('ClusterIntegration|Authentication Error'), + description: s__('ClusterIntegration|GitLab failed to authenticate.'), + troubleshootingTips: [ + s__('ClusterIntegration|Check your token'), + s__('ClusterIntegration|Check your CA certificate'), + ], + }, + connection_error: { + tableText: s__('ClusterIntegration|Unable to Connect'), + title: s__('ClusterIntegration|Connection Error'), + description: s__('ClusterIntegration|GitLab failed to connect to the cluster.'), + troubleshootingTips: [ + s__('ClusterIntegration|Check your cluster status'), + s__('ClusterIntegration|Make sure your API endpoint is correct'), + ], + }, + http_error: { + tableText: s__('ClusterIntegration|Unable to Connect'), + title: s__('ClusterIntegration|HTTP Error'), + description: s__('ClusterIntegration|There was an HTTP error when connecting to your cluster.'), + troubleshootingTips: [s__('ClusterIntegration|Check your cluster status')], + }, +}; export const CLUSTER_TYPES = { project_type: __('Project'), diff --git a/app/assets/javascripts/clusters_list/index.js b/app/assets/javascripts/clusters_list/index.js index 51ad8769250..daa82892773 100644 --- a/app/assets/javascripts/clusters_list/index.js +++ b/app/assets/javascripts/clusters_list/index.js @@ -1,20 +1,6 @@ import Vue from 'vue'; -import Clusters from './components/clusters.vue'; -import { createStore } from './store'; +import loadClusters from './load_clusters'; export default () => { - const entryPoint = document.querySelector('#js-clusters-list-app'); - - if (!entryPoint) { - return; - } - - // eslint-disable-next-line no-new - new Vue({ - el: '#js-clusters-list-app', - store: createStore(entryPoint.dataset), - render(createElement) { - return createElement(Clusters); - }, - }); + loadClusters(Vue); }; diff --git a/app/assets/javascripts/clusters_list/load_clusters.js b/app/assets/javascripts/clusters_list/load_clusters.js new file mode 100644 index 00000000000..98bc5880898 --- /dev/null +++ b/app/assets/javascripts/clusters_list/load_clusters.js @@ -0,0 +1,18 @@ +import Clusters from './components/clusters.vue'; +import { createStore } from './store'; + +export default Vue => { + const el = document.querySelector('#js-clusters-list-app'); + + if (!el) { + return null; + } + + return new Vue({ + el, + store: createStore(el.dataset), + render(createElement) { + return createElement(Clusters); + }, + }); +}; diff --git a/app/assets/javascripts/code_navigation/index.js b/app/assets/javascripts/code_navigation/index.js index 362c26ae065..fa5835245bc 100644 --- a/app/assets/javascripts/code_navigation/index.js +++ b/app/assets/javascripts/code_navigation/index.js @@ -1,13 +1,17 @@ import Vue from 'vue'; import Vuex from 'vuex'; -import store from './store'; +import createStore from './store'; import App from './components/app.vue'; -Vue.use(Vuex); - export default initialData => { const el = document.getElementById('js-code-navigation'); + if (!el) return null; + + Vue.use(Vuex); + + const store = createStore(); + store.dispatch('setInitialData', initialData); return new Vue({ diff --git a/app/assets/javascripts/code_navigation/store/index.js b/app/assets/javascripts/code_navigation/store/index.js index fe48f3ac7f5..9b60fc337fe 100644 --- a/app/assets/javascripts/code_navigation/store/index.js +++ b/app/assets/javascripts/code_navigation/store/index.js @@ -3,8 +3,9 @@ import createState from './state'; import actions from './actions'; import mutations from './mutations'; -export default new Vuex.Store({ - actions, - mutations, - state: createState(), -}); +export default () => + new Vuex.Store({ + actions, + mutations, + state: createState(), + }); diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue index 2f4118c1717..188d958ba86 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue @@ -1,6 +1,5 @@ <script> import { GlButton, GlLoadingIcon, GlModal, GlLink } from '@gitlab/ui'; -import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import PipelinesService from '~/pipelines/services/pipelines_service'; import PipelineStore from '~/pipelines/stores/pipelines_store'; import pipelinesMixin from '~/pipelines/mixins/pipelines'; @@ -126,16 +125,6 @@ export default { (latest.flags.detached_merge_request_pipeline || latest.flags.merge_request_pipeline) ); }, - /** - * When we are on Desktop and the button is visible - * we need to add a negative margin to the table - * to make it inline with the button - * - * @returns {Boolean} - */ - shouldAddNegativeMargin() { - return this.canRenderPipelineButton && bp.isDesktop(); - }, }, created() { this.service = new PipelinesService(this.endpoint); @@ -205,65 +194,76 @@ export default { /> <div v-else-if="shouldRenderTable" class="table-holder"> - <div v-if="canRenderPipelineButton" class="nav justify-content-end"> - <gl-button - variant="success" - class="js-run-mr-pipeline gl-mt-3 btn-wide-on-xs" - :disabled="state.isRunningMergeRequestPipeline" - @click="tryRunPipeline" - > - <gl-loading-icon v-if="state.isRunningMergeRequestPipeline" inline /> - {{ s__('Pipelines|Run Pipeline') }} - </gl-button> - - <gl-modal - :id="modalId" - ref="modal" - :modal-id="modalId" - :title="s__('Pipelines|Are you sure you want to run this pipeline?')" - :ok-title="s__('Pipelines|Run Pipeline')" - ok-variant="danger" - @ok="onClickRunPipeline" - > - <p> - {{ - s__( - 'Pipelines|This pipeline will run code originating from a forked project merge request. This means that the code can potentially have security considerations like exposing CI variables.', - ) - }} - </p> - <p> - {{ - s__( - "Pipelines|It is recommended the code is reviewed thoroughly before running this pipeline with the parent project's CI resource.", - ) - }} - </p> - <p> - {{ - s__( - 'Pipelines|If you are unsure, please ask a project maintainer to review it for you.', - ) - }} - </p> - <gl-link - href="/help/ci/merge_request_pipelines/index.html#run-pipelines-in-the-parent-project-for-merge-requests-from-a-forked-project" - target="_blank" - > - {{ s__('Pipelines|More Information') }} - </gl-link> - </gl-modal> - </div> + <gl-button + v-if="canRenderPipelineButton" + block + class="gl-mt-3 gl-mb-0 gl-display-md-none" + variant="success" + data-testid="run_pipeline_button_mobile" + :loading="state.isRunningMergeRequestPipeline" + @click="tryRunPipeline" + > + {{ s__('Pipelines|Run Pipeline') }} + </gl-button> <pipelines-table-component :pipelines="state.pipelines" :update-graph-dropdown="updateGraphDropdown" :auto-devops-help-path="autoDevopsHelpPath" :view-type="viewType" - :class="{ 'negative-margin-top': shouldAddNegativeMargin }" - /> + > + <template #table-header-actions> + <div v-if="canRenderPipelineButton" class="gl-text-right"> + <gl-button + variant="success" + data-testid="run_pipeline_button" + :loading="state.isRunningMergeRequestPipeline" + @click="tryRunPipeline" + > + {{ s__('Pipelines|Run Pipeline') }} + </gl-button> + </div> + </template> + </pipelines-table-component> </div> + <gl-modal + v-if="canRenderPipelineButton" + :id="modalId" + ref="modal" + :modal-id="modalId" + :title="s__('Pipelines|Are you sure you want to run this pipeline?')" + :ok-title="s__('Pipelines|Run Pipeline')" + ok-variant="danger" + @ok="onClickRunPipeline" + > + <p> + {{ + s__( + 'Pipelines|This pipeline will run code originating from a forked project merge request. This means that the code can potentially have security considerations like exposing CI variables.', + ) + }} + </p> + <p> + {{ + s__( + "Pipelines|It is recommended the code is reviewed thoroughly before running this pipeline with the parent project's CI resource.", + ) + }} + </p> + <p> + {{ + s__('Pipelines|If you are unsure, please ask a project maintainer to review it for you.') + }} + </p> + <gl-link + href="/help/ci/merge_request_pipelines/index.html#run-pipelines-in-the-parent-project-for-merge-requests-from-a-forked-project" + target="_blank" + > + {{ s__('Pipelines|More Information') }} + </gl-link> + </gl-modal> + <table-pagination v-if="shouldRenderPagination" :change="onChangePage" diff --git a/app/assets/javascripts/commons/index.js b/app/assets/javascripts/commons/index.js index e0d012cef23..77c85d85e27 100644 --- a/app/assets/javascripts/commons/index.js +++ b/app/assets/javascripts/commons/index.js @@ -1,5 +1,4 @@ import './polyfills'; -import './jquery'; import './bootstrap'; import './vue'; import '../lib/utils/axios_utils'; diff --git a/app/assets/javascripts/commons/jquery.js b/app/assets/javascripts/commons/jquery.js deleted file mode 100644 index 334f95bb27f..00000000000 --- a/app/assets/javascripts/commons/jquery.js +++ /dev/null @@ -1,4 +0,0 @@ -import 'jquery'; - -// common jQuery plugins -import 'jquery-ujs'; diff --git a/app/assets/javascripts/confirm_danger_modal.js b/app/assets/javascripts/confirm_danger_modal.js index 262d501bfba..7321e4d18cc 100644 --- a/app/assets/javascripts/confirm_danger_modal.js +++ b/app/assets/javascripts/confirm_danger_modal.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { Rails } from '~/lib/utils/rails_ujs'; import { rstrip } from './lib/utils/common_utils'; function openConfirmDangerModal($form, $modal, text) { @@ -21,9 +22,16 @@ function openConfirmDangerModal($form, $modal, text) { $submit.disable(); } }); + $('.js-confirm-danger-submit', $modal) .off('click') - .on('click', () => $form.submit()); + .on('click', () => { + if ($form.data('remote')) { + Rails.fire($form[0], 'submit'); + } else { + $form.submit(); + } + }); } function getModal($btn) { diff --git a/app/assets/javascripts/confirm_modal.js b/app/assets/javascripts/confirm_modal.js index 4b4fdf03873..bf2ea3ce38a 100644 --- a/app/assets/javascripts/confirm_modal.js +++ b/app/assets/javascripts/confirm_modal.js @@ -1,14 +1,16 @@ import Vue from 'vue'; import ConfirmModal from '~/vue_shared/components/confirm_modal.vue'; -const mountConfirmModal = () => { - return new Vue({ +const mountConfirmModal = optionalProps => + new Vue({ render(h) { return h(ConfirmModal, { - props: { selector: '.js-confirm-modal-button' }, + props: { + selector: '.js-confirm-modal-button', + ...optionalProps, + }, }); }, }).$mount(); -}; -export default () => mountConfirmModal(); +export default (optionalProps = {}) => mountConfirmModal(optionalProps); diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue index 3f7c2204b9f..eb195ad2b30 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue @@ -13,6 +13,10 @@ export default { type: String, required: true, }, + namespacePerEnvironmentHelpPath: { + type: String, + required: true, + }, kubernetesIntegrationHelpPath: { type: String, required: true, @@ -40,6 +44,7 @@ export default { <eks-cluster-configuration-form v-if="hasCredentials" :gitlab-managed-cluster-help-path="gitlabManagedClusterHelpPath" + :namespace-per-environment-help-path="namespacePerEnvironmentHelpPath" :kubernetes-integration-help-path="kubernetesIntegrationHelpPath" :external-link-icon="externalLinkIcon" /> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue index a653e228e3f..d403f370f9d 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue @@ -1,9 +1,7 @@ <script> -/* eslint-disable vue/no-v-html */ import { createNamespacedHelpers, mapState, mapActions, mapGetters } from 'vuex'; -import { escape } from 'lodash'; -import { GlFormInput, GlFormCheckbox } from '@gitlab/ui'; -import { sprintf, s__ } from '~/locale'; +import { GlFormInput, GlFormCheckbox, GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; import ClusterFormDropdown from '~/create_cluster/components/cluster_form_dropdown.vue'; import { KUBERNETES_VERSIONS } from '../constants'; import LoadingButton from '~/vue_shared/components/loading_button.vue'; @@ -28,8 +26,11 @@ const { mapState: mapInstanceTypesState } = createNamespacedHelpers('instanceTyp export default { components: { ClusterFormDropdown, - GlFormInput, GlFormCheckbox, + GlFormInput, + GlIcon, + GlLink, + GlSprintf, LoadingButton, }, props: { @@ -37,6 +38,10 @@ export default { type: String, required: true, }, + namespacePerEnvironmentHelpPath: { + type: String, + required: true, + }, kubernetesIntegrationHelpPath: { type: String, required: true, @@ -46,6 +51,49 @@ export default { required: true, }, }, + i18n: { + kubernetesIntegrationHelpText: s__( + 'ClusterIntegration|Read our %{linkStart}help page%{linkEnd} on Kubernetes cluster integration.', + ), + roleDropdownHelpText: s__( + 'ClusterIntegration|Your service role is distinct from the provision role used when authenticating. It will allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. To use a new role, first create one on %{linkStart}Amazon Web Services%{linkEnd}.', + ), + roleDropdownHelpPath: + 'https://docs.aws.amazon.com/eks/latest/userguide/service_IAM_role.html#create-service-role', + regionsDropdownHelpText: s__( + 'ClusterIntegration|Learn more about %{linkStart}Regions%{linkEnd}.', + ), + regionsDropdownHelpPath: + 'https://aws.amazon.com/about-aws/global-infrastructure/regional-product-services/', + keyPairDropdownHelpText: s__( + 'ClusterIntegration|Select the key pair name that will be used to create EC2 nodes. To use a new key pair name, first create one on %{linkStart}Amazon Web Services%{linkEnd}.', + ), + keyPairDropdownHelpPath: + 'https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-key-pairs.html#having-ec2-create-your-key-pair', + vpcDropdownHelpText: s__( + 'ClusterIntegration|Select a VPC to use for your EKS Cluster resources. To use a new VPC, first create one on %{linkStart}Amazon Web Services %{linkEnd}.', + ), + vpcDropdownHelpPath: + 'https://docs.aws.amazon.com/eks/latest/userguide/getting-started-console.html#vpc-create', + subnetDropdownHelpText: s__( + 'ClusterIntegration|Choose the %{linkStart}subnets %{linkEnd} in your VPC where your worker nodes will run.', + ), + subnetDropdownHelpPath: 'https://console.aws.amazon.com/vpc/home?#subnets', + securityGroupDropdownHelpText: s__( + 'ClusterIntegration|Choose the %{linkStart}security group %{linkEnd} to apply to the EKS-managed Elastic Network Interfaces that are created in your worker node subnets.', + ), + securityGroupDropdownHelpPath: 'https://console.aws.amazon.com/vpc/home?#securityGroups', + instanceTypesDropdownHelpText: s__( + 'ClusterIntegration|Choose the worker node %{linkStart}instance type%{linkEnd}.', + ), + instanceTypesDropdownHelpPath: 'https://aws.amazon.com/ec2/instance-types', + gitlabManagedClusterHelpText: s__( + 'ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster. %{linkStart}More information%{linkEnd}', + ), + namespacePerEnvironmentHelpText: s__( + 'ClusterIntegration|Deploy each environment to its own namespace. Otherwise, environments within a project share a project-wide namespace. Note that anyone who can trigger a deployment of a namespace can read its secrets. If modified, existing environments will use their current namespaces until the cluster cache is cleared. %{linkStart}More information%{linkEnd}', + ), + }, computed: { ...mapState([ 'clusterName', @@ -60,6 +108,7 @@ export default { 'selectedInstanceType', 'nodeCount', 'gitlabManagedCluster', + 'namespacePerEnvironment', 'isCreatingCluster', ]), ...mapGetters(['subnetValid']), @@ -137,90 +186,6 @@ export default { ? s__('ClusterIntegration|Creating Kubernetes cluster') : s__('ClusterIntegration|Create Kubernetes cluster'); }, - kubernetesIntegrationHelpText() { - const escapedUrl = escape(this.kubernetesIntegrationHelpPath); - - return sprintf( - s__( - 'ClusterIntegration|Read our %{link_start}help page%{link_end} on Kubernetes cluster integration.', - ), - { - link_start: `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer">`, - link_end: '</a>', - }, - false, - ); - }, - roleDropdownHelpText() { - return sprintf( - s__( - 'ClusterIntegration|Your service role is distinct from the provision role used when authenticating. It will allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. To use a new role, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}.', - ), - { - startLink: - '<a href="https://docs.aws.amazon.com/eks/latest/userguide/service_IAM_role.html#create-service-role" target="_blank" rel="noopener noreferrer">', - externalLinkIcon: this.externalLinkIcon, - endLink: '</a>', - }, - false, - ); - }, - regionsDropdownHelpText() { - return sprintf( - s__( - 'ClusterIntegration|Learn more about %{startLink}Regions %{externalLinkIcon}%{endLink}.', - ), - { - startLink: - '<a href="https://aws.amazon.com/about-aws/global-infrastructure/regional-product-services/" target="_blank" rel="noopener noreferrer">', - externalLinkIcon: this.externalLinkIcon, - endLink: '</a>', - }, - false, - ); - }, - keyPairDropdownHelpText() { - return sprintf( - s__( - 'ClusterIntegration|Select the key pair name that will be used to create EC2 nodes. To use a new key pair name, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}.', - ), - { - startLink: - '<a href="https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-key-pairs.html#having-ec2-create-your-key-pair" target="_blank" rel="noopener noreferrer">', - externalLinkIcon: this.externalLinkIcon, - endLink: '</a>', - }, - false, - ); - }, - vpcDropdownHelpText() { - return sprintf( - s__( - 'ClusterIntegration|Select a VPC to use for your EKS Cluster resources. To use a new VPC, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}.', - ), - { - startLink: - '<a href="https://docs.aws.amazon.com/eks/latest/userguide/getting-started-console.html#vpc-create" target="_blank" rel="noopener noreferrer">', - externalLinkIcon: this.externalLinkIcon, - endLink: '</a>', - }, - false, - ); - }, - subnetDropdownHelpText() { - return sprintf( - s__( - 'ClusterIntegration|Choose the %{startLink}subnets %{externalLinkIcon} %{endLink} in your VPC where your worker nodes will run.', - ), - { - startLink: - '<a href="https://console.aws.amazon.com/vpc/home?#subnets" target="_blank" rel="noopener noreferrer">', - externalLinkIcon: this.externalLinkIcon, - endLink: '</a>', - }, - false, - ); - }, subnetValidationErrorText() { if (this.loadingSubnetsError) { return s__('ClusterIntegration|Could not load subnets for the selected VPC'); @@ -228,48 +193,6 @@ export default { return s__('ClusterIntegration|You should select at least two subnets'); }, - securityGroupDropdownHelpText() { - return sprintf( - s__( - 'ClusterIntegration|Choose the %{startLink}security group %{externalLinkIcon} %{endLink} to apply to the EKS-managed Elastic Network Interfaces that are created in your worker node subnets.', - ), - { - startLink: - '<a href="https://console.aws.amazon.com/vpc/home?#securityGroups" target="_blank" rel="noopener noreferrer">', - externalLinkIcon: this.externalLinkIcon, - endLink: '</a>', - }, - false, - ); - }, - instanceTypesDropdownHelpText() { - return sprintf( - s__( - 'ClusterIntegration|Choose the worker node %{startLink}instance type %{externalLinkIcon} %{endLink}.', - ), - { - startLink: - '<a href="https://aws.amazon.com/ec2/instance-types" target="_blank" rel="noopener noreferrer">', - externalLinkIcon: this.externalLinkIcon, - endLink: '</a>', - }, - false, - ); - }, - gitlabManagedHelpText() { - const escapedUrl = escape(this.gitlabManagedClusterHelpPath); - - return sprintf( - s__( - 'ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster. %{startLink}More information%{endLink}', - ), - { - startLink: `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer">`, - endLink: '</a>', - }, - false, - ); - }, }, mounted() { this.fetchRegions(); @@ -290,6 +213,7 @@ export default { 'setInstanceType', 'setNodeCount', 'setGitlabManagedCluster', + 'setNamespacePerEnvironment', ]), ...mapRegionsActions({ fetchRegions: 'fetchItems' }), ...mapVpcActions({ fetchVpcs: 'fetchItems' }), @@ -321,7 +245,15 @@ export default { <h4> {{ s__('ClusterIntegration|Enter the details for your Amazon EKS Kubernetes cluster') }} </h4> - <div class="mb-3" v-html="kubernetesIntegrationHelpText"></div> + <div class="mb-3"> + <gl-sprintf :message="$options.i18n.kubernetesIntegrationHelpText"> + <template #link="{ content }"> + <gl-link :href="kubernetesIntegrationHelpPath"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </div> <div class="form-group"> <label class="label-bold" for="eks-cluster-name">{{ s__('ClusterIntegration|Kubernetes cluster name') @@ -371,7 +303,16 @@ export default { :error-message="s__('ClusterIntegration|Could not load IAM roles')" @input="setRole({ role: $event })" /> - <p class="form-text text-muted" v-html="roleDropdownHelpText"></p> + <p class="form-text text-muted"> + <gl-sprintf :message="$options.i18n.roleDropdownHelpText"> + <template #link="{ content }"> + <gl-link :href="$options.i18n.roleDropdownHelpPath" target="_blank"> + {{ content }} + <gl-icon name="external-link" class="gl-vertical-align-middle" /> + </gl-link> + </template> + </gl-sprintf> + </p> </div> <div class="form-group"> <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Region') }}</label> @@ -389,7 +330,16 @@ export default { :error-message="s__('ClusterIntegration|Could not load regions from your AWS account')" @input="setRegionAndFetchVpcsAndKeyPairs($event)" /> - <p class="form-text text-muted" v-html="regionsDropdownHelpText"></p> + <p class="form-text text-muted"> + <gl-sprintf :message="$options.i18n.regionsDropdownHelpText"> + <template #link="{ content }"> + <gl-link :href="$options.i18n.regionsDropdownHelpPath" target="_blank"> + {{ content }} + <gl-icon name="external-link" class="gl-vertical-align-middle" /> + </gl-link> + </template> + </gl-sprintf> + </p> </div> <div class="form-group"> <label class="label-bold" for="eks-key-pair">{{ @@ -411,7 +361,16 @@ export default { :error-message="s__('ClusterIntegration|Could not load Key Pairs')" @input="setKeyPair({ keyPair: $event })" /> - <p class="form-text text-muted" v-html="keyPairDropdownHelpText"></p> + <p class="form-text text-muted"> + <gl-sprintf :message="$options.i18n.keyPairDropdownHelpText"> + <template #link="{ content }"> + <gl-link :href="$options.i18n.keyPairDropdownHelpPath" target="_blank"> + {{ content }} + <gl-icon name="external-link" class="gl-vertical-align-middle" /> + </gl-link> + </template> + </gl-sprintf> + </p> </div> <div class="form-group"> <label class="label-bold" for="eks-vpc">{{ s__('ClusterIntegration|VPC') }}</label> @@ -431,7 +390,16 @@ export default { :error-message="s__('ClusterIntegration|Could not load VPCs for the selected region')" @input="setVpcAndFetchSubnets($event)" /> - <p class="form-text text-muted" v-html="vpcDropdownHelpText"></p> + <p class="form-text text-muted"> + <gl-sprintf :message="$options.i18n.vpcDropdownHelpText"> + <template #link="{ content }"> + <gl-link :href="$options.i18n.vpcDropdownHelpPath" target="_blank"> + {{ content }} + <gl-icon name="external-link" class="gl-vertical-align-middle" /> + </gl-link> + </template> + </gl-sprintf> + </p> </div> <div class="form-group"> <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Subnets') }}</label> @@ -452,7 +420,16 @@ export default { :error-message="subnetValidationErrorText" @input="setSubnet({ subnet: $event })" /> - <p class="form-text text-muted" v-html="subnetDropdownHelpText"></p> + <p class="form-text text-muted"> + <gl-sprintf :message="$options.i18n.subnetDropdownHelpText"> + <template #link="{ content }"> + <gl-link :href="$options.i18n.subnetDropdownHelpPath" target="_blank"> + {{ content }} + <gl-icon name="external-link" class="gl-vertical-align-middle" /> + </gl-link> + </template> + </gl-sprintf> + </p> </div> <div class="form-group"> <label class="label-bold" for="eks-security-group">{{ @@ -476,7 +453,16 @@ export default { " @input="setSecurityGroup({ securityGroup: $event })" /> - <p class="form-text text-muted" v-html="securityGroupDropdownHelpText"></p> + <p class="form-text text-muted"> + <gl-sprintf :message="$options.i18n.securityGroupDropdownHelpText"> + <template #link="{ content }"> + <gl-link :href="$options.i18n.securityGroupDropdownHelpPath" target="_blank"> + {{ content }} + <gl-icon name="external-link" class="gl-vertical-align-middle" /> + </gl-link> + </template> + </gl-sprintf> + </p> </div> <div class="form-group"> <label class="label-bold" for="eks-instance-type">{{ @@ -496,7 +482,16 @@ export default { :error-message="s__('ClusterIntegration|Could not load instance types')" @input="setInstanceType({ instanceType: $event })" /> - <p class="form-text text-muted" v-html="instanceTypesDropdownHelpText"></p> + <p class="form-text text-muted"> + <gl-sprintf :message="$options.i18n.instanceTypesDropdownHelpText"> + <template #link="{ content }"> + <gl-link :href="$options.i18n.instanceTypesDropdownHelpPath" target="_blank"> + {{ content }} + <gl-icon name="external-link" class="gl-vertical-align-middle" /> + </gl-link> + </template> + </gl-sprintf> + </p> </div> <div class="form-group"> <label class="label-bold" for="eks-node-count">{{ @@ -517,7 +512,31 @@ export default { @input="setGitlabManagedCluster({ gitlabManagedCluster: $event })" >{{ s__('ClusterIntegration|GitLab-managed cluster') }}</gl-form-checkbox > - <p class="form-text text-muted" v-html="gitlabManagedHelpText"></p> + <p class="form text text-muted"> + <gl-sprintf :message="$options.i18n.gitlabManagedClusterHelpText"> + <template #link="{ content }"> + <gl-link :href="gitlabManagedClusterHelpPath" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </p> + </div> + <div class="form-group"> + <gl-form-checkbox + :checked="namespacePerEnvironment" + @input="setNamespacePerEnvironment({ namespacePerEnvironment: $event })" + >{{ s__('ClusterIntegration|Namespace per environment') }}</gl-form-checkbox + > + <p class="form text text-muted"> + <gl-sprintf :message="$options.i18n.namespacePerEnvironmentHelpText"> + <template #link="{ content }"> + <gl-link :href="namespacePerEnvironmentHelpPath" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </p> </div> <div class="form-group"> <loading-button diff --git a/app/assets/javascripts/create_cluster/eks_cluster/index.js b/app/assets/javascripts/create_cluster/eks_cluster/index.js index fb993a7aa59..6d1034b4a72 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/index.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/index.js @@ -9,6 +9,7 @@ Vue.use(Vuex); export default el => { const { gitlabManagedClusterHelpPath, + namespacePerEnvironmentHelpPath, kubernetesIntegrationHelpPath, accountAndExternalIdsHelpPath, createRoleArnHelpPath, @@ -42,6 +43,7 @@ export default el => { return createElement('create-eks-cluster', { props: { gitlabManagedClusterHelpPath, + namespacePerEnvironmentHelpPath, kubernetesIntegrationHelpPath, accountAndExternalIdsHelpPath, createRoleArnHelpPath, diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js index 5abff3c7831..48c85ff627f 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js @@ -55,6 +55,7 @@ export const createCluster = ({ dispatch, state }) => { name: state.clusterName, environment_scope: state.environmentScope, managed: state.gitlabManagedCluster, + namespace_per_environment: state.namespacePerEnvironment, provider_aws_attributes: { kubernetes_version: state.kubernetesVersion, region: state.selectedRegion, @@ -114,6 +115,10 @@ export const setGitlabManagedCluster = ({ commit }, payload) => { commit(types.SET_GITLAB_MANAGED_CLUSTER, payload); }; +export const setNamespacePerEnvironment = ({ commit }, payload) => { + commit(types.SET_NAMESPACE_PER_ENVIRONMENT, payload); +}; + export const setInstanceType = ({ commit }, payload) => { commit(types.SET_INSTANCE_TYPE, payload); }; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js b/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js index 9dee6abae5f..4a48195a27b 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js @@ -10,6 +10,7 @@ export const SET_SECURITY_GROUP = 'SET_SECURITY_GROUP'; export const SET_INSTANCE_TYPE = 'SET_INSTANCE_TYPE'; export const SET_NODE_COUNT = 'SET_NODE_COUNT'; export const SET_GITLAB_MANAGED_CLUSTER = 'SET_GITLAB_MANAGED_CLUSTER'; +export const SET_NAMESPACE_PER_ENVIRONMENT = 'SET_NAMESPACE_PER_ENVIRONMENT'; export const REQUEST_CREATE_ROLE = 'REQUEST_CREATE_ROLE'; export const CREATE_ROLE_SUCCESS = 'CREATE_ROLE_SUCCESS'; export const CREATE_ROLE_ERROR = 'CREATE_ROLE_ERROR'; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js b/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js index c331d27d255..f57236e0e31 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js @@ -37,6 +37,9 @@ export default { [types.SET_GITLAB_MANAGED_CLUSTER](state, { gitlabManagedCluster }) { state.gitlabManagedCluster = gitlabManagedCluster; }, + [types.SET_NAMESPACE_PER_ENVIRONMENT](state, { namespacePerEnvironment }) { + state.namespacePerEnvironment = namespacePerEnvironment; + }, [types.REQUEST_CREATE_ROLE](state) { state.isCreatingRole = true; state.createRoleError = null; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/state.js b/app/assets/javascripts/create_cluster/eks_cluster/store/state.js index ed51e95e434..c957eca1f7a 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/state.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/state.js @@ -30,4 +30,5 @@ export default () => ({ createClusterError: false, gitlabManagedCluster: true, + namespacePerEnvironment: true, }); diff --git a/app/assets/javascripts/custom_metrics/components/delete_custom_metric_modal.vue b/app/assets/javascripts/custom_metrics/components/delete_custom_metric_modal.vue index 34e4aeb290f..7c4117d7e8b 100644 --- a/app/assets/javascripts/custom_metrics/components/delete_custom_metric_modal.vue +++ b/app/assets/javascripts/custom_metrics/components/delete_custom_metric_modal.vue @@ -1,11 +1,11 @@ <script> -import { GlModal, GlModalDirective, GlDeprecatedButton } from '@gitlab/ui'; +import { GlModal, GlModalDirective, GlButton } from '@gitlab/ui'; import { s__ } from '~/locale'; export default { components: { GlModal, - GlDeprecatedButton, + GlButton, }, directives: { 'gl-modal': GlModalDirective, @@ -33,9 +33,9 @@ export default { </script> <template> <div class="d-inline-block float-right mr-3"> - <gl-deprecated-button v-gl-modal="$options.modalId" variant="danger"> + <gl-button v-gl-modal="$options.modalId" variant="danger" category="primary"> {{ __('Delete') }} - </gl-deprecated-button> + </gl-button> <gl-modal :title="s__('Metrics|Delete metric?')" :ok-title="s__('Metrics|Delete metric')" diff --git a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue index ff0f352b333..b2c9cd4e597 100644 --- a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue +++ b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue @@ -1,7 +1,10 @@ <script> -import { GlTooltipDirective } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; export default { + components: { + GlIcon, + }, directives: { GlTooltip: GlTooltipDirective, }, @@ -15,15 +18,17 @@ export default { </script> <template> <span v-if="count === 50" class="events-info float-right"> - <i - v-gl-tooltip - :title=" - n__('Limited to showing %d event at most', 'Limited to showing %d events at most', 50) - " - class="fa fa-warning" + <gl-icon + v-gl-tooltip="{ + title: n__( + 'Limited to showing %d event at most', + 'Limited to showing %d events at most', + 50, + ), + }" + name="warning" aria-hidden="true" - > - </i> + /> {{ n__('Showing %d event', 'Showing %d events', 50) }} </span> </template> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue index ba2be2e5167..6530bfd72a9 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue +++ b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue @@ -1,6 +1,5 @@ <script> -/* eslint-disable vue/no-v-html */ -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue'; import iconBranch from '../svg/icon_branch.svg'; import limitWarning from './limit_warning_component.vue'; @@ -13,6 +12,9 @@ export default { limitWarning, GlIcon, }, + directives: { + SafeHtml, + }, props: { items: { type: Array, @@ -47,7 +49,7 @@ export default { <a :href="build.url" class="pipeline-id"> #{{ build.id }} </a> <gl-icon :size="16" name="fork" /> <a :href="build.branch.url" class="ref-name"> {{ build.branch.name }} </a> - <span class="icon-branch" v-html="iconBranch"> </span> + <span v-safe-html="iconBranch" class="icon-branch"> </span> <a :href="build.commitUrl" class="commit-sha"> {{ build.shortSha }} </a> </h5> <span> diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js index f6bad5dce41..4cccabca28b 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js @@ -36,12 +36,6 @@ export default () => { 'stage-review-component': stageReviewComponent, 'stage-staging-component': stageStagingComponent, 'stage-production-component': stageComponent, - GroupsDropdownFilter: () => - import('ee_component/analytics/shared/components/groups_dropdown_filter.vue'), - ProjectsDropdownFilter: () => - import('ee_component/analytics/shared/components/projects_dropdown_filter.vue'), - DateRangeDropdown: () => - import('ee_component/analytics/shared/components/date_range_dropdown.vue'), 'stage-nav-item': stageNavItem, }, data() { diff --git a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue index 159f5ddd755..0d6657973c3 100644 --- a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue +++ b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue @@ -69,15 +69,13 @@ export default { </p> </template> </gl-table> - <div class="gl-display-flex gl-justify-content-center"> - <gl-button - v-gl-modal.deploy-freeze-modal - data-testid="add-deploy-freeze" - category="primary" - variant="success" - > - {{ $options.translations.addDeployFreeze }} - </gl-button> - </div> + <gl-button + v-gl-modal.deploy-freeze-modal + data-testid="add-deploy-freeze" + category="primary" + variant="success" + > + {{ $options.translations.addDeployFreeze }} + </gl-button> </div> </template> diff --git a/app/assets/javascripts/design_management/components/delete_button.vue b/app/assets/javascripts/design_management/components/delete_button.vue index 970197ef41b..273fa3f6be2 100644 --- a/app/assets/javascripts/design_management/components/delete_button.vue +++ b/app/assets/javascripts/design_management/components/delete_button.vue @@ -63,7 +63,7 @@ export default { title: s__('DesignManagement|Are you sure you want to archive the selected designs?'), actionPrimary: { text: s__('DesignManagement|Archive designs'), - attributes: { variant: 'warning' }, + attributes: { variant: 'warning', 'data-qa-selector': 'confirm_archiving_button' }, }, actionCancel: { text: __('Cancel'), diff --git a/app/assets/javascripts/design_management/components/design_sidebar.vue b/app/assets/javascripts/design_management/components/design_sidebar.vue index df425e3b96d..fecedceef32 100644 --- a/app/assets/javascripts/design_management/components/design_sidebar.vue +++ b/app/assets/javascripts/design_management/components/design_sidebar.vue @@ -126,7 +126,7 @@ export default { v-if="showTodoButton" class="gl-py-4 gl-mb-4 gl-display-flex gl-justify-content-space-between gl-align-items-center gl-border-b-1 gl-border-b-solid gl-border-b-gray-100" > - <span>{{ __('To-Do') }}</span> + <span>{{ __('To Do') }}</span> <design-todo-button :design="design" @error="$emit('todoError', $event)" /> </div> <h2 class="gl-font-weight-bold gl-mt-0"> diff --git a/app/assets/javascripts/design_management/components/list/item.vue b/app/assets/javascripts/design_management/components/list/item.vue index 36ea812d92e..b179b1b5e79 100644 --- a/app/assets/javascripts/design_management/components/list/item.vue +++ b/app/assets/javascripts/design_management/components/list/item.vue @@ -149,6 +149,7 @@ export default { :alt="filename" class="gl-display-block gl-mx-auto gl-max-w-full mh-100 design-img" data-qa-selector="design_image" + :data-qa-filename="filename" @load="onImageLoad" @error="onImageError" /> diff --git a/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue b/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue index afca8ed2c6f..2719d701c12 100644 --- a/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue +++ b/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue @@ -64,9 +64,9 @@ export default { </script> <template> - <div v-if="designsCount" class="d-flex align-items-center"> + <div v-if="designsCount" class="gl-display-flex gl-align-items-center"> {{ paginationText }} - <gl-button-group class="ml-3 mr-3"> + <gl-button-group class="gl-mx-5"> <gl-button :disabled="!previousDesign" :title="s__('DesignManagement|Go to previous design')" diff --git a/app/assets/javascripts/design_management/components/toolbar/index.vue b/app/assets/javascripts/design_management/components/toolbar/index.vue index a1cb57123ab..8d25d467d59 100644 --- a/app/assets/javascripts/design_management/components/toolbar/index.vue +++ b/app/assets/javascripts/design_management/components/toolbar/index.vue @@ -106,12 +106,12 @@ export default { > <gl-icon 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 class="gl-overflow-hidden gl-display-flex gl-align-items-center"> + <h2 class="gl-m-0 str-truncated-100 gl-font-base">{{ filename }}</h2> + <small v-if="updatedAt" class="gl-text-gray-500">{{ updatedText }}</small> </div> </div> - <design-navigation :id="id" class="ml-auto flex-shrink-0" /> + <design-navigation :id="id" class="gl-ml-auto gl-flex-shrink-0" /> <gl-button :href="image" icon="download" /> <delete-button v-if="isLatestVersion && canDeleteDesign" 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 96efa8e8242..efa61edf51a 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 @@ -6,6 +6,7 @@ query getDesignList($fullPath: ID!, $iid: String!, $atVersion: ID) { id issue(iid: $iid) { designCollection { + copyState designs(atVersion: $atVersion) { nodes { ...DesignListItem diff --git a/app/assets/javascripts/design_management/mixins/all_designs.js b/app/assets/javascripts/design_management/mixins/all_designs.js index 0c2858bb14b..62bcf216add 100644 --- a/app/assets/javascripts/design_management/mixins/all_designs.js +++ b/app/assets/javascripts/design_management/mixins/all_designs.js @@ -8,7 +8,7 @@ import { DESIGNS_ROUTE_NAME } from '../router/constants'; export default { mixins: [allVersionsMixin], apollo: { - designs: { + designCollection: { query: getDesignListQuery, variables() { return { @@ -25,10 +25,11 @@ export default { 'designs', 'nodes', ]); - if (designNodes) { - return designNodes; - } - return []; + const copyState = propertyOf(data)(['project', 'issue', 'designCollection', 'copyState']); + return { + designs: designNodes, + copyState, + }; }, error() { this.error = true; @@ -42,13 +43,26 @@ export default { ); this.$router.replace({ name: DESIGNS_ROUTE_NAME, query: { version: undefined } }); } + if (this.designCollection.copyState === 'ERROR') { + createFlash( + s__( + 'DesignManagement|There was an error moving your designs. Please upload your designs below.', + ), + 'warning', + ); + } }, }, }, data() { return { - designs: [], + designCollection: null, error: false, }; }, + computed: { + designs() { + return this.designCollection?.designs || []; + }, + }, }; diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue index 6c4c8c75054..fb6a91abcdc 100644 --- a/app/assets/javascripts/design_management/pages/index.vue +++ b/app/assets/javascripts/design_management/pages/index.vue @@ -71,11 +71,14 @@ export default { selectedDesigns: [], isDraggingDesign: false, reorderedDesigns: null, + isReorderingInProgress: false, }; }, computed: { isLoading() { - return this.$apollo.queries.designs.loading || this.$apollo.queries.permissions.loading; + return ( + this.$apollo.queries.designCollection.loading || this.$apollo.queries.permissions.loading + ); }, isSaving() { return this.filesToBeSaved.length > 0; @@ -109,6 +112,9 @@ export default { isDesignListEmpty() { return !this.isSaving && !this.hasDesigns; }, + isDesignCollectionCopying() { + return this.designCollection && this.designCollection.copyState === 'IN_PROGRESS'; + }, designDropzoneWrapperClass() { return this.isDesignListEmpty ? 'col-12' @@ -277,6 +283,7 @@ export default { return variables; }, reorderDesigns({ moved: { newIndex, element } }) { + this.isReorderingInProgress = true; this.$apollo .mutate({ mutation: moveDesignMutation, @@ -287,6 +294,9 @@ export default { }) .catch(() => { createFlash(MOVE_DESIGN_ERROR); + }) + .finally(() => { + this.isReorderingInProgress = false; }); }, onDesignMove(designs) { @@ -339,6 +349,7 @@ export default { button-category="secondary" button-class="gl-mr-3" button-size="small" + data-qa-selector="archive_button" :loading="loading" :has-selected-designs="hasSelectedDesigns" @deleteSelectedDesigns="mutate()" @@ -355,10 +366,25 @@ export default { <gl-alert v-else-if="error" variant="danger" :dismissible="false"> {{ __('An error occurred while loading designs. Please try again.') }} </gl-alert> + <header + v-else-if="isDesignCollectionCopying" + class="card" + data-testid="design-collection-is-copying" + > + <div class="card-header design-card-header border-bottom-0"> + <div class="card-title gl-display-flex gl-align-items-center gl-my-0 gl-h-7"> + {{ + s__( + 'DesignManagement|Your designs are being copied and are on their way… Please refresh to update.', + ) + }} + </div> + </div> + </header> <vue-draggable v-else :value="designs" - :disabled="!isLatestVersion" + :disabled="!isLatestVersion || isReorderingInProgress" v-bind="$options.dragOptions" tag="ol" draggable=".js-design-tile" @@ -390,6 +416,8 @@ export default { :checked="isDesignSelected(design.filename)" type="checkbox" class="design-checkbox" + data-qa-selector="design_checkbox" + :data-qa-design="design.filename" @change="changeSelectedDesigns(design.filename)" /> </li> @@ -399,6 +427,7 @@ export default { :is-dragging-design="isDraggingDesign" :class="{ 'design-list-item design-list-item-new': !isDesignListEmpty }" :has-designs="hasDesigns" + data-qa-selector="design_dropzone_content" @change="onUploadDesign" /> </li> diff --git a/app/assets/javascripts/design_management/utils/cache_update.js b/app/assets/javascripts/design_management/utils/cache_update.js index ff41136fd54..6c64f05c973 100644 --- a/app/assets/javascripts/design_management/utils/cache_update.js +++ b/app/assets/javascripts/design_management/utils/cache_update.js @@ -155,6 +155,7 @@ const addNewDesignToStore = (store, designManagementUpload, query) => { const updatedDesigns = { __typename: 'DesignCollection', + copyState: 'READY', designs: { __typename: 'DesignConnection', nodes: newDesigns, diff --git a/app/assets/javascripts/design_management/utils/design_management_utils.js b/app/assets/javascripts/design_management/utils/design_management_utils.js index 93e4d6060c3..687e793d3df 100644 --- a/app/assets/javascripts/design_management/utils/design_management_utils.js +++ b/app/assets/javascripts/design_management/utils/design_management_utils.js @@ -65,6 +65,10 @@ export const designUploadOptimisticResponse = files => { fullPath: '', notesCount: 0, event: 'NONE', + currentUserTodos: { + __typename: 'TodoConnection', + nodes: [], + }, diffRefs: { __typename: 'DiffRefs', baseSha: '', diff --git a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js deleted file mode 100644 index dd60e2c7684..00000000000 --- a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js +++ /dev/null @@ -1,65 +0,0 @@ -/* global CommentsStore */ - -import $ from 'jquery'; -import Vue from 'vue'; -import { __ } from '~/locale'; - -const CommentAndResolveBtn = Vue.extend({ - props: { - discussionId: { - type: String, - required: true, - }, - }, - data() { - return { - textareaIsEmpty: true, - discussion: {}, - }; - }, - computed: { - showButton() { - if (this.discussion) { - return this.discussion.isResolvable(); - } - return false; - }, - isDiscussionResolved() { - return this.discussion.isResolved(); - }, - buttonText() { - if (this.textareaIsEmpty) { - return this.isDiscussionResolved ? __('Unresolve thread') : __('Resolve thread'); - } - return this.isDiscussionResolved - ? __('Comment & unresolve thread') - : __('Comment & resolve thread'); - }, - }, - created() { - if (this.discussionId) { - this.discussion = CommentsStore.state[this.discussionId]; - } - }, - mounted() { - if (!this.discussionId) return; - - const $textarea = $( - `.js-discussion-note-form[data-discussion-id=${this.discussionId}] .note-textarea`, - ); - this.textareaIsEmpty = $textarea.val() === ''; - - $textarea.on('input.comment-and-resolve-btn', () => { - this.textareaIsEmpty = $textarea.val() === ''; - }); - }, - destroyed() { - if (!this.discussionId) return; - - $(`.js-discussion-note-form[data-discussion-id=${this.discussionId}] .note-textarea`).off( - 'input.comment-and-resolve-btn', - ); - }, -}); - -Vue.component('comment-and-resolve-btn', CommentAndResolveBtn); diff --git a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js deleted file mode 100644 index b5a781cbc92..00000000000 --- a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js +++ /dev/null @@ -1,189 +0,0 @@ -/* global CommentsStore */ - -import $ from 'jquery'; -import Vue from 'vue'; -import collapseIcon from '../icons/collapse_icon.svg'; -import Notes from '../../notes'; -import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue'; -import { n__ } from '~/locale'; - -const DiffNoteAvatars = Vue.extend({ - components: { - userAvatarImage, - }, - props: { - discussionId: { - type: String, - required: true, - }, - }, - data() { - return { - isVisible: false, - lineType: '', - storeState: CommentsStore.state, - shownAvatars: 3, - collapseIcon, - }; - }, - computed: { - discussionClassName() { - return `js-diff-avatars-${this.discussionId}`; - }, - notesSubset() { - let notes = []; - - if (this.discussion) { - notes = Object.keys(this.discussion.notes) - .slice(0, this.shownAvatars) - .map(noteId => this.discussion.notes[noteId]); - } - - return notes; - }, - extraNotesTitle() { - if (this.discussion) { - const extra = this.discussion.notesCount() - this.shownAvatars; - - return n__('%d more comment', '%d more comments', extra); - } - - return ''; - }, - discussion() { - return this.storeState[this.discussionId]; - }, - notesCount() { - if (this.discussion) { - return this.discussion.notesCount(); - } - - return 0; - }, - moreText() { - const plusSign = this.notesCount < 100 ? '+' : ''; - - return `${plusSign}${this.notesCount - this.shownAvatars}`; - }, - }, - watch: { - storeState: { - handler() { - this.$nextTick(() => { - $('.has-tooltip', this.$el).tooltip('_fixTitle'); - - // We need to add/remove a class to an element that is outside the Vue instance - this.addNoCommentClass(); - }); - }, - deep: true, - }, - }, - mounted() { - this.$nextTick(() => { - this.addNoCommentClass(); - this.setDiscussionVisible(); - - this.lineType = $(this.$el) - .closest('.diff-line-num') - .hasClass('old_line') - ? 'old' - : 'new'; - }); - - $(document).on('toggle.comments', () => { - this.$nextTick(() => { - this.setDiscussionVisible(); - }); - }); - }, - beforeDestroy() { - this.addNoCommentClass(); - $(document).off('toggle.comments'); - }, - methods: { - clickedAvatar(e) { - Notes.instance.onAddDiffNote(e); - - // Toggle the active state of the toggle all button - this.toggleDiscussionsToggleState(); - - this.$nextTick(() => { - this.setDiscussionVisible(); - - $('.has-tooltip', this.$el).tooltip('_fixTitle'); - $('.has-tooltip', this.$el).tooltip('hide'); - }); - }, - addNoCommentClass() { - const { notesCount } = this; - - $(this.$el) - .closest('.js-avatar-container') - .toggleClass('no-comment-btn', notesCount > 0) - .nextUntil('.js-avatar-container') - .toggleClass('no-comment-btn', notesCount > 0); - }, - toggleDiscussionsToggleState() { - const $notesHolders = $(this.$el) - .closest('.code') - .find('.notes_holder'); - const $visibleNotesHolders = $notesHolders.filter(':visible'); - const $toggleDiffCommentsBtn = $(this.$el) - .closest('.diff-file') - .find('.js-toggle-diff-comments'); - - $toggleDiffCommentsBtn.toggleClass( - 'active', - $notesHolders.length === $visibleNotesHolders.length, - ); - }, - setDiscussionVisible() { - this.isVisible = $(`.diffs .notes[data-discussion-id="${this.discussion.id}"]`).is( - ':visible', - ); - }, - getTooltipText(note) { - return `${note.authorName}: ${note.noteTruncated}`; - }, - }, - template: ` - <div class="diff-comment-avatar-holders" - :class="discussionClassName" - v-show="notesCount !== 0"> - <div v-if="!isVisible"> - <!-- FIXME: Pass an alt attribute here for accessibility --> - <user-avatar-image - v-for="note in notesSubset" - :key="note.id" - class="diff-comment-avatar js-diff-comment-avatar" - @click.native="clickedAvatar($event)" - :img-src="note.authorAvatar" - :tooltip-text="getTooltipText(note)" - :data-line-type="lineType" - :size="19" - data-html="true" - /> - <span v-if="notesCount > shownAvatars" - class="diff-comments-more-count has-tooltip js-diff-comment-avatar" - data-container="body" - data-placement="top" - ref="extraComments" - role="button" - :data-line-type="lineType" - :title="extraNotesTitle" - @click="clickedAvatar($event)">{{ moreText }}</span> - </div> - <button class="diff-notes-collapse js-diff-comment-avatar" - type="button" - aria-label="Show comments" - :data-line-type="lineType" - @click="clickedAvatar($event)" - v-if="isVisible" - v-html="collapseIcon"> - </button> - </div> - `, -}); - -Vue.component('diff-note-avatars', DiffNoteAvatars); diff --git a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js deleted file mode 100644 index 1de00c9f08b..00000000000 --- a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js +++ /dev/null @@ -1,210 +0,0 @@ -/* eslint-disable func-names, no-continue */ -/* global CommentsStore */ - -import $ from 'jquery'; -import 'vendor/jquery.scrollTo'; -import Vue from 'vue'; -import { __ } from '~/locale'; - -import DiscussionMixins from '../mixins/discussion'; - -const JumpToDiscussion = Vue.extend({ - mixins: [DiscussionMixins], - props: { - discussionId: { - type: String, - required: true, - }, - }, - data() { - return { - discussions: CommentsStore.state, - discussion: {}, - }; - }, - computed: { - buttonText() { - if (this.discussionId) { - return __('Jump to next unresolved thread'); - } - return __('Jump to first unresolved thread'); - }, - allResolved() { - return this.unresolvedDiscussionCount === 0; - }, - showButton() { - if (this.discussionId) { - if (this.unresolvedDiscussionCount > 1) { - return true; - } - return this.discussionId !== this.lastResolvedId; - } - return this.unresolvedDiscussionCount >= 1; - }, - lastResolvedId() { - let lastId; - Object.keys(this.discussions).forEach(discussionId => { - const discussion = this.discussions[discussionId]; - - if (!discussion.isResolved()) { - lastId = discussion.id; - } - }); - return lastId; - }, - }, - created() { - this.discussion = this.discussions[this.discussionId]; - }, - methods: { - jumpToNextUnresolvedDiscussion() { - let discussionsSelector; - let discussionIdsInScope; - let firstUnresolvedDiscussionId; - let nextUnresolvedDiscussionId; - let activeTab = window.mrTabs.currentAction; - let hasDiscussionsToJumpTo = true; - let jumpToFirstDiscussion = !this.discussionId; - - const discussionIdsForElements = function(elements) { - return elements - .map(function() { - return $(this).attr('data-discussion-id'); - }) - .toArray(); - }; - - const { discussions } = this; - - if (activeTab === 'diffs') { - discussionsSelector = '.diffs .notes[data-discussion-id]'; - discussionIdsInScope = discussionIdsForElements($(discussionsSelector)); - - let unresolvedDiscussionCount = 0; - - for (let i = 0; i < discussionIdsInScope.length; i += 1) { - const discussionId = discussionIdsInScope[i]; - const discussion = discussions[discussionId]; - if (discussion && !discussion.isResolved()) { - unresolvedDiscussionCount += 1; - } - } - - if (this.discussionId && !this.discussion.isResolved()) { - // If this is the last unresolved discussion on the diffs tab, - // there are no discussions to jump to. - if (unresolvedDiscussionCount === 1) { - hasDiscussionsToJumpTo = false; - } - } else if (unresolvedDiscussionCount === 0) { - // If there are no unresolved discussions on the diffs tab at all, - // there are no discussions to jump to. - hasDiscussionsToJumpTo = false; - } - } else if (activeTab !== 'show') { - // If we are on the commits or builds tabs, - // there are no discussions to jump to. - hasDiscussionsToJumpTo = false; - } - - if (!hasDiscussionsToJumpTo) { - // If there are no discussions to jump to on the current page, - // switch to the notes tab and jump to the first discussion there. - window.mrTabs.activateTab('show'); - activeTab = 'show'; - jumpToFirstDiscussion = true; - } - - if (activeTab === 'show') { - discussionsSelector = '.discussion[data-discussion-id]'; - discussionIdsInScope = discussionIdsForElements($(discussionsSelector)); - } - - let currentDiscussionFound = false; - for (let i = 0; i < discussionIdsInScope.length; i += 1) { - const discussionId = discussionIdsInScope[i]; - const discussion = discussions[discussionId]; - - if (!discussion) { - // Discussions for comments on commits in this MR don't have a resolved status. - continue; - } - - if (!firstUnresolvedDiscussionId && !discussion.isResolved()) { - firstUnresolvedDiscussionId = discussionId; - - if (jumpToFirstDiscussion) { - break; - } - } - - if (!jumpToFirstDiscussion) { - if (currentDiscussionFound) { - if (!discussion.isResolved()) { - nextUnresolvedDiscussionId = discussionId; - break; - } else { - continue; - } - } - - if (discussionId === this.discussionId) { - currentDiscussionFound = true; - } - } - } - - nextUnresolvedDiscussionId = nextUnresolvedDiscussionId || firstUnresolvedDiscussionId; - - if (!nextUnresolvedDiscussionId) { - return; - } - - let $target = $(`${discussionsSelector}[data-discussion-id="${nextUnresolvedDiscussionId}"]`); - - if (activeTab === 'show') { - $target = $target.closest('.note-discussion'); - - // If the next discussion is closed, toggle it open. - if ($target.find('.js-toggle-content').is(':hidden')) { - $target.find('.js-toggle-button i').trigger('click'); - } - } else if (activeTab === 'diffs') { - // Resolved discussions are hidden in the diffs tab by default. - // If they are marked unresolved on the notes tab, they will still be hidden on the diffs tab. - // When jumping between unresolved discussions on the diffs tab, we show them. - $target.closest('.content').show(); - - const $notesHolder = $target.closest('tr.notes_holder'); - - // Image diff discussions does not use notes_holder - // so we should keep original $target value in those cases - if ($notesHolder.length > 0) { - $target = $notesHolder; - } - - $target.show(); - - // If we are on the diffs tab, we don't scroll to the discussion itself, but to - // 4 diff lines above it: the line the discussion was in response to + 3 context - let prevEl; - for (let i = 0; i < 4; i += 1) { - prevEl = $target.prev(); - - // If the discussion doesn't have 4 lines above it, we'll have to do with fewer. - if (!prevEl.hasClass('line_holder')) { - break; - } - - $target = prevEl; - } - } - - $.scrollTo($target, { - offset: -150, - }); - }, - }, -}); - -Vue.component('jump-to-discussion', JumpToDiscussion); diff --git a/app/assets/javascripts/diff_notes/components/new_issue_for_discussion.js b/app/assets/javascripts/diff_notes/components/new_issue_for_discussion.js deleted file mode 100644 index e0c09aa0eee..00000000000 --- a/app/assets/javascripts/diff_notes/components/new_issue_for_discussion.js +++ /dev/null @@ -1,28 +0,0 @@ -/* global CommentsStore */ - -import Vue from 'vue'; - -const NewIssueForDiscussion = Vue.extend({ - props: { - discussionId: { - type: String, - required: true, - }, - }, - data() { - return { - discussions: CommentsStore.state, - }; - }, - computed: { - discussion() { - return this.discussions[this.discussionId]; - }, - showButton() { - if (this.discussion) return !this.discussion.isResolved(); - return false; - }, - }, -}); - -Vue.component('new-issue-for-discussion-btn', NewIssueForDiscussion); diff --git a/app/assets/javascripts/diff_notes/components/resolve_btn.js b/app/assets/javascripts/diff_notes/components/resolve_btn.js deleted file mode 100644 index 0943712d0c5..00000000000 --- a/app/assets/javascripts/diff_notes/components/resolve_btn.js +++ /dev/null @@ -1,145 +0,0 @@ -/* global CommentsStore */ -/* global ResolveService */ - -import $ from 'jquery'; -import Vue from 'vue'; -import { deprecatedCreateFlash as Flash } from '../../flash'; -import { sprintf, __ } from '~/locale'; - -const ResolveBtn = Vue.extend({ - props: { - noteId: { - type: Number, - required: true, - }, - discussionId: { - type: String, - required: true, - }, - resolved: { - type: Boolean, - required: true, - }, - canResolve: { - type: Boolean, - required: true, - }, - resolvedBy: { - type: String, - required: true, - }, - authorName: { - type: String, - required: true, - }, - authorAvatar: { - type: String, - required: true, - }, - noteTruncated: { - type: String, - required: true, - }, - }, - data() { - return { - discussions: CommentsStore.state, - loading: false, - }; - }, - computed: { - discussion() { - return this.discussions[this.discussionId]; - }, - note() { - return this.discussion ? this.discussion.getNote(this.noteId) : {}; - }, - buttonText() { - if (this.isResolved) { - return sprintf(__('Resolved by %{resolvedByName}'), { - resolvedByName: this.resolvedByName, - }); - } else if (this.canResolve) { - return __('Mark as resolved'); - } - - return __('Unable to resolve'); - }, - isResolved() { - if (this.note) { - return this.note.resolved; - } - - return false; - }, - resolvedByName() { - return this.note.resolved_by; - }, - }, - watch: { - discussions: { - handler: 'updateTooltip', - deep: true, - }, - }, - mounted() { - $(this.$refs.button).tooltip({ - container: 'body', - }); - }, - beforeDestroy() { - CommentsStore.delete(this.discussionId, this.noteId); - }, - created() { - CommentsStore.create({ - discussionId: this.discussionId, - noteId: this.noteId, - canResolve: this.canResolve, - resolved: this.resolved, - resolvedBy: this.resolvedBy, - authorName: this.authorName, - authorAvatar: this.authorAvatar, - noteTruncated: this.noteTruncated, - }); - }, - methods: { - updateTooltip() { - this.$nextTick(() => { - $(this.$refs.button) - .tooltip('hide') - .tooltip('_fixTitle'); - }); - }, - resolve() { - if (!this.canResolve) return; - - let promise; - this.loading = true; - - if (this.isResolved) { - promise = ResolveService.unresolve(this.noteId); - } else { - promise = ResolveService.resolve(this.noteId); - } - - promise - .then(resp => resp.json()) - .then(data => { - this.loading = false; - - const resolvedBy = data ? data.resolved_by : null; - - CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolvedBy); - this.discussion.updateHeadline(data); - gl.mrWidget.checkStatus(); - this.updateTooltip(); - }) - .catch( - () => - new Flash(__('An error occurred when trying to resolve a comment. Please try again.')), - ); - }, - }, -}); - -Vue.component('resolve-btn', ResolveBtn); diff --git a/app/assets/javascripts/diff_notes/components/resolve_count.js b/app/assets/javascripts/diff_notes/components/resolve_count.js deleted file mode 100644 index f960853b25b..00000000000 --- a/app/assets/javascripts/diff_notes/components/resolve_count.js +++ /dev/null @@ -1,28 +0,0 @@ -/* global CommentsStore */ - -import Vue from 'vue'; - -import DiscussionMixins from '../mixins/discussion'; - -window.ResolveCount = Vue.extend({ - mixins: [DiscussionMixins], - props: { - loggedOut: { - type: Boolean, - required: true, - }, - }, - data() { - return { - discussions: CommentsStore.state, - }; - }, - computed: { - allResolved() { - return this.resolvedDiscussionCount === this.discussionCount; - }, - resolvedCountText() { - return this.discussionCount === 1 ? 'discussion' : 'discussions'; - }, - }, -}); diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js b/app/assets/javascripts/diff_notes/diff_notes_bundle.js deleted file mode 100644 index 92862d4c933..00000000000 --- a/app/assets/javascripts/diff_notes/diff_notes_bundle.js +++ /dev/null @@ -1,72 +0,0 @@ -/* eslint-disable func-names, new-cap */ - -import $ from 'jquery'; -import Vue from 'vue'; -import './models/discussion'; -import './models/note'; -import './stores/comments'; -import './services/resolve'; -import './mixins/discussion'; -import './components/comment_resolve_btn'; -import './components/jump_to_discussion'; -import './components/resolve_btn'; -import './components/resolve_count'; -import './components/diff_note_avatars'; -import './components/new_issue_for_discussion'; - -export default () => { - const projectPathHolder = - document.querySelector('.merge-request') || document.querySelector('.commit-box'); - const { projectPath } = projectPathHolder.dataset; - const COMPONENT_SELECTOR = - 'resolve-btn, jump-to-discussion, comment-and-resolve-btn, new-issue-for-discussion-btn'; - - window.gl = window.gl || {}; - window.gl.diffNoteApps = {}; - - window.ResolveService = new gl.DiffNotesResolveServiceClass(projectPath); - - gl.diffNotesCompileComponents = () => { - $('diff-note-avatars').each(function() { - const tmp = Vue.extend({ - template: $(this).get(0).outerHTML, - }); - const tmpApp = new tmp().$mount(); - - $(this).replaceWith(tmpApp.$el); - $(tmpApp.$el).one('remove.vue', () => { - tmpApp.$destroy(); - tmpApp.$el.remove(); - }); - }); - - const $components = $(COMPONENT_SELECTOR).filter(function() { - return $(this).closest('resolve-count').length !== 1; - }); - - if ($components) { - $components.each(function() { - const $this = $(this); - const noteId = $this.attr(':note-id'); - const discussionId = $this.attr(':discussion-id'); - - if ($this.is('comment-and-resolve-btn') && !discussionId) return; - - const tmp = Vue.extend({ - template: $this.get(0).outerHTML, - }); - const tmpApp = new tmp().$mount(); - - if (noteId) { - gl.diffNoteApps[`note_${noteId}`] = tmpApp; - } - - $this.replaceWith(tmpApp.$el); - }); - } - }; - - gl.diffNotesCompileComponents(); - - $(window).trigger('resize.nav'); -}; diff --git a/app/assets/javascripts/diff_notes/icons/collapse_icon.svg b/app/assets/javascripts/diff_notes/icons/collapse_icon.svg deleted file mode 100644 index bd4b393cfaa..00000000000 --- a/app/assets/javascripts/diff_notes/icons/collapse_icon.svg +++ /dev/null @@ -1 +0,0 @@ -<svg width="11" height="11" viewBox="0 0 9 13"><path d="M2.57568253,6.49866948 C2.50548852,6.57199715 2.44637866,6.59708255 2.39835118,6.57392645 C2.3503237,6.55077034 2.32631032,6.48902165 2.32631032,6.38867852 L2.32631032,-2.13272614 C2.32631032,-2.23306927 2.3503237,-2.29481796 2.39835118,-2.31797406 C2.44637866,-2.34113017 2.50548852,-2.31604477 2.57568253,-2.24271709 L6.51022184,1.86747129 C6.53977721,1.8983461 6.56379059,1.93500939 6.5822627,1.97746225 L6.5822627,2.27849013 C6.56379059,2.31708364 6.53977721,2.35374693 6.51022184,2.38848109 L2.57568253,6.49866948 Z" transform="translate(4.454287, 2.127976) rotate(90.000000) translate(-4.454287, -2.127976) "></path><path d="M3.74312342,2.09553332 C3.74312342,1.99519019 3.77821989,1.9083561 3.8484139,1.83502843 C3.91860791,1.76170075 4.00173115,1.72503747 4.09778611,1.72503747 L4.80711151,1.72503747 C4.90316647,1.72503747 4.98628971,1.76170075 5.05648372,1.83502843 C5.12667773,1.9083561 5.16177421,1.99519019 5.16177421,2.09553332 L5.16177421,10.2464421 C5.16177421,10.3467853 5.12667773,10.4336194 5.05648372,10.506947 C4.98628971,10.5802747 4.90316647,10.616938 4.80711151,10.616938 L4.09778611,10.616938 C4.00173115,10.616938 3.91860791,10.5802747 3.8484139,10.506947 C3.77821989,10.4336194 3.74312342,10.3467853 3.74312342,10.2464421 L3.74312342,2.09553332 Z" transform="translate(4.452449, 6.170988) rotate(-90.000000) translate(-4.452449, -6.170988) "></path><path d="M2.57568253,14.6236695 C2.50548852,14.6969971 2.44637866,14.7220826 2.39835118,14.6989264 C2.3503237,14.6757703 2.32631032,14.6140216 2.32631032,14.5136785 L2.32631032,5.99227386 C2.32631032,5.89193073 2.3503237,5.83018204 2.39835118,5.80702594 C2.44637866,5.78386983 2.50548852,5.80895523 2.57568253,5.88228291 L6.51022184,9.99247129 C6.53977721,10.0233461 6.56379059,10.0600094 6.5822627,10.1024622 L6.5822627,10.4034901 C6.56379059,10.4420836 6.53977721,10.4787469 6.51022184,10.5134811 L2.57568253,14.6236695 Z" transform="translate(4.454287, 10.252976) scale(1, -1) rotate(90.000000) translate(-4.454287, -10.252976) "></path></svg> diff --git a/app/assets/javascripts/diff_notes/mixins/discussion.js b/app/assets/javascripts/diff_notes/mixins/discussion.js deleted file mode 100644 index ef3001393cf..00000000000 --- a/app/assets/javascripts/diff_notes/mixins/discussion.js +++ /dev/null @@ -1,37 +0,0 @@ -/* eslint-disable guard-for-in, no-restricted-syntax, */ - -const DiscussionMixins = { - computed: { - discussionCount() { - return Object.keys(this.discussions).length; - }, - resolvedDiscussionCount() { - let resolvedCount = 0; - - for (const discussionId in this.discussions) { - const discussion = this.discussions[discussionId]; - - if (discussion.isResolved()) { - resolvedCount += 1; - } - } - - return resolvedCount; - }, - unresolvedDiscussionCount() { - let unresolvedCount = 0; - - for (const discussionId in this.discussions) { - const discussion = this.discussions[discussionId]; - - if (!discussion.isResolved()) { - unresolvedCount += 1; - } - } - - return unresolvedCount; - }, - }, -}; - -export default DiscussionMixins; diff --git a/app/assets/javascripts/diff_notes/models/discussion.js b/app/assets/javascripts/diff_notes/models/discussion.js deleted file mode 100644 index 97296a40d6e..00000000000 --- a/app/assets/javascripts/diff_notes/models/discussion.js +++ /dev/null @@ -1,99 +0,0 @@ -/* eslint-disable guard-for-in, no-restricted-syntax */ -/* global NoteModel */ - -import $ from 'jquery'; -import Vue from 'vue'; -import { localTimeAgo } from '../../lib/utils/datetime_utility'; - -class DiscussionModel { - constructor(discussionId) { - this.id = discussionId; - this.notes = {}; - this.loading = false; - this.canResolve = false; - } - - createNote(noteObj) { - Vue.set(this.notes, noteObj.noteId, new NoteModel(this.id, noteObj)); - } - - deleteNote(noteId) { - Vue.delete(this.notes, noteId); - } - - getNote(noteId) { - return this.notes[noteId]; - } - - notesCount() { - return Object.keys(this.notes).length; - } - - isResolved() { - for (const noteId in this.notes) { - const note = this.notes[noteId]; - - if (!note.resolved) { - return false; - } - } - return true; - } - - resolveAllNotes(resolvedBy) { - for (const noteId in this.notes) { - const note = this.notes[noteId]; - - if (!note.resolved) { - note.resolved = true; - note.resolved_by = resolvedBy; - } - } - } - - unResolveAllNotes() { - for (const noteId in this.notes) { - const note = this.notes[noteId]; - - if (note.resolved) { - note.resolved = false; - note.resolved_by = null; - } - } - } - - updateHeadline(data) { - const discussionSelector = `.discussion[data-discussion-id="${this.id}"]`; - const $discussionHeadline = $(`${discussionSelector} .js-discussion-headline`); - - if (data.discussion_headline_html) { - if ($discussionHeadline.length) { - $discussionHeadline.replaceWith(data.discussion_headline_html); - } else { - $(`${discussionSelector} .discussion-header`).append(data.discussion_headline_html); - } - - localTimeAgo($('.js-timeago', `${discussionSelector}`)); - } else { - $discussionHeadline.remove(); - } - } - - isResolvable() { - if (!this.canResolve) { - return false; - } - - for (const noteId in this.notes) { - const note = this.notes[noteId]; - - if (note.canResolve) { - return true; - } - } - - return false; - } -} - -window.DiscussionModel = DiscussionModel; diff --git a/app/assets/javascripts/diff_notes/models/note.js b/app/assets/javascripts/diff_notes/models/note.js deleted file mode 100644 index 825a69deeec..00000000000 --- a/app/assets/javascripts/diff_notes/models/note.js +++ /dev/null @@ -1,14 +0,0 @@ -class NoteModel { - constructor(discussionId, noteObj) { - this.discussionId = discussionId; - this.id = noteObj.noteId; - this.canResolve = noteObj.canResolve; - this.resolved = noteObj.resolved; - this.resolved_by = noteObj.resolvedBy; - this.authorName = noteObj.authorName; - this.authorAvatar = noteObj.authorAvatar; - this.noteTruncated = noteObj.noteTruncated; - } -} - -window.NoteModel = NoteModel; diff --git a/app/assets/javascripts/diff_notes/services/resolve.js b/app/assets/javascripts/diff_notes/services/resolve.js deleted file mode 100644 index d6975963977..00000000000 --- a/app/assets/javascripts/diff_notes/services/resolve.js +++ /dev/null @@ -1,86 +0,0 @@ -/* global CommentsStore */ - -import Vue from 'vue'; -import { deprecatedCreateFlash as Flash } from '../../flash'; -import { __ } from '~/locale'; - -window.gl = window.gl || {}; - -class ResolveServiceClass { - constructor(root) { - this.noteResource = Vue.resource(`${root}/notes{/noteId}/resolve?html=true`); - this.discussionResource = Vue.resource( - `${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve?html=true`, - ); - } - - resolve(noteId) { - return this.noteResource.save({ noteId }, {}); - } - - unresolve(noteId) { - return this.noteResource.delete({ noteId }, {}); - } - - toggleResolveForDiscussion(mergeRequestId, discussionId) { - const discussion = CommentsStore.state[discussionId]; - const isResolved = discussion.isResolved(); - let promise; - - if (isResolved) { - promise = this.unResolveAll(mergeRequestId, discussionId); - } else { - promise = this.resolveAll(mergeRequestId, discussionId); - } - - promise - .then(resp => resp.json()) - .then(data => { - discussion.loading = false; - const resolvedBy = data ? data.resolved_by : null; - - if (isResolved) { - discussion.unResolveAllNotes(); - } else { - discussion.resolveAllNotes(resolvedBy); - } - - if (gl.mrWidget) gl.mrWidget.checkStatus(); - discussion.updateHeadline(data); - }) - .catch( - () => - new Flash(__('An error occurred when trying to resolve a discussion. Please try again.')), - ); - } - - resolveAll(mergeRequestId, discussionId) { - const discussion = CommentsStore.state[discussionId]; - - discussion.loading = true; - - return this.discussionResource.save( - { - mergeRequestId, - discussionId, - }, - {}, - ); - } - - unResolveAll(mergeRequestId, discussionId) { - const discussion = CommentsStore.state[discussionId]; - - discussion.loading = true; - - return this.discussionResource.delete( - { - mergeRequestId, - discussionId, - }, - {}, - ); - } -} - -gl.DiffNotesResolveServiceClass = ResolveServiceClass; diff --git a/app/assets/javascripts/diff_notes/stores/comments.js b/app/assets/javascripts/diff_notes/stores/comments.js deleted file mode 100644 index 9bde18c4edf..00000000000 --- a/app/assets/javascripts/diff_notes/stores/comments.js +++ /dev/null @@ -1,56 +0,0 @@ -/* eslint-disable no-restricted-syntax, guard-for-in */ -/* global DiscussionModel */ - -import Vue from 'vue'; - -window.CommentsStore = { - state: {}, - get(discussionId, noteId) { - return this.state[discussionId].getNote(noteId); - }, - createDiscussion(discussionId, canResolve) { - let discussion = this.state[discussionId]; - if (!this.state[discussionId]) { - discussion = new DiscussionModel(discussionId); - Vue.set(this.state, discussionId, discussion); - } - - if (canResolve !== undefined) { - discussion.canResolve = canResolve; - } - - return discussion; - }, - create(noteObj) { - const discussion = this.createDiscussion(noteObj.discussionId); - - discussion.createNote(noteObj); - }, - update(discussionId, noteId, resolved, resolvedBy) { - const discussion = this.state[discussionId]; - const note = discussion.getNote(noteId); - note.resolved = resolved; - note.resolved_by = resolvedBy; - }, - delete(discussionId, noteId) { - const discussion = this.state[discussionId]; - discussion.deleteNote(noteId); - - if (discussion.notesCount() === 0) { - Vue.delete(this.state, discussionId); - } - }, - unresolvedDiscussionIds() { - const ids = []; - - for (const discussionId in this.state) { - const discussion = this.state[discussionId]; - - if (!discussion.isResolved()) { - ids.push(discussion.id); - } - } - - return ids; - }, -}; diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index dd5addbf1e3..085f951147f 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -474,7 +474,7 @@ export default { <div v-if="showTreeList" :style="{ width: `${treeWidth}px` }" - class="diff-tree-list js-diff-tree-list mr-3" + class="diff-tree-list js-diff-tree-list px-3 pr-md-0" > <panel-resizer :size.sync="treeWidth" @@ -487,7 +487,7 @@ export default { <tree-list :hide-file-stats="hideFileStats" /> </div> <div - class="diff-files-holder" + class="col-12 col-md-auto diff-files-holder" :class="{ [CENTERED_LIMITED_CONTAINER_CLASSES]: isLimitedContainer, }" diff --git a/app/assets/javascripts/diffs/components/collapsed_files_warning.vue b/app/assets/javascripts/diffs/components/collapsed_files_warning.vue index dded3643115..270bbfb99b7 100644 --- a/app/assets/javascripts/diffs/components/collapsed_files_warning.vue +++ b/app/assets/javascripts/diffs/components/collapsed_files_warning.vue @@ -50,7 +50,7 @@ export default { </script> <template> - <div v-if="!isDismissed" data-testid="root" :class="containerClasses"> + <div v-if="!isDismissed" data-testid="root" :class="containerClasses" class="col-12"> <gl-alert :dismissible="true" :title="__('Some changes are not shown')" diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue index 23669eecce2..09abdbe25d7 100644 --- a/app/assets/javascripts/diffs/components/commit_item.vue +++ b/app/assets/javascripts/diffs/components/commit_item.vue @@ -1,7 +1,7 @@ <script> /* eslint-disable vue/no-v-html */ import { mapActions } from 'vuex'; -import { GlButtonGroup, GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlButtonGroup, GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -182,14 +182,14 @@ export default { :endpoint="commit.pipeline_status_path" class="d-inline-flex" /> - <div class="commit-sha-group"> - <div class="label label-monospace monospace" v-text="commit.short_id"></div> + <gl-button-group class="gl-ml-4" data-testid="commit-sha-group"> + <gl-button label class="gl-font-monospace" v-text="commit.short_id" /> <clipboard-button :text="commit.id" :title="__('Copy commit SHA')" - class="btn btn-default" + class="input-group-text" /> - </div> + </gl-button-group> <div v-if="hasNeighborCommits && glFeatures.mrCommitNeighborNav" class="commit-nav-buttons ml-3" diff --git a/app/assets/javascripts/diffs/components/commit_widget.vue b/app/assets/javascripts/diffs/components/commit_widget.vue index 5c7e84bd87c..b1a2b2a72ea 100644 --- a/app/assets/javascripts/diffs/components/commit_widget.vue +++ b/app/assets/javascripts/diffs/components/commit_widget.vue @@ -1,19 +1,6 @@ <script> import CommitItem from './commit_item.vue'; -/** - * CommitWidget - * - * ----------------------------------------------------------------- - * WARNING: Please keep changes up-to-date with the following files: - * - `views/projects/merge_requests/diffs/_commit_widget.html.haml` - * ----------------------------------------------------------------- - * - * This Component was cloned from a HAML view. For the time being, - * they coexist, but there is an issue to remove the duplication. - * https://gitlab.com/gitlab-org/gitlab-foss/issues/51613 - * - */ export default { components: { CommitItem, diff --git a/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue b/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue index 8263e938e69..adef5d94624 100644 --- a/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue +++ b/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue @@ -32,7 +32,7 @@ export default { <gl-icon :size="12" name="angle-down" class="position-absolute" /> </a> <div class="dropdown-menu dropdown-select dropdown-menu-selectable"> - <div class="dropdown-content"> + <div class="dropdown-content" data-qa-selector="dropdown_content"> <ul> <li v-for="version in versions" :key="version.id"> <a :class="{ 'is-active': version.selected }" :href="version.href"> diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue index b94874c5644..b1ebd8e6ebc 100644 --- a/app/assets/javascripts/diffs/components/compare_versions.vue +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -100,6 +100,7 @@ export default { <compare-dropdown-layout :versions="diffCompareDropdownTargetVersions" class="mr-version-compare-dropdown" + data-qa-selector="target_version_dropdown" /> </template> <template #source> diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue index 9ecb9a44443..e68260b3e62 100644 --- a/app/assets/javascripts/diffs/components/diff_content.vue +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -85,11 +85,9 @@ export default { }, }, updated() { - if (window.gon?.features?.codeNavigation) { - this.$nextTick(() => { - eventHub.$emit('showBlobInteractionZones', this.diffFile.new_path); - }); - } + this.$nextTick(() => { + eventHub.$emit('showBlobInteractionZones', this.diffFile.new_path); + }); }, methods: { ...mapActions('diffs', ['saveDiffDiscussion', 'closeDiffFileCommentForm']), diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index 9a7ed76bad3..529723a349d 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -1,32 +1,26 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; import { escape } from 'lodash'; -import { GlButton, GlLoadingIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlLoadingIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { sprintf } from '~/locale'; +import { __, sprintf } from '~/locale'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { hasDiff } from '~/helpers/diffs_helper'; import eventHub from '../../notes/event_hub'; import DiffFileHeader from './diff_file_header.vue'; import DiffContent from './diff_content.vue'; import { diffViewerErrors } from '~/ide/constants'; -import { GENERIC_ERROR, DIFF_FILE } from '../i18n'; export default { components: { DiffFileHeader, DiffContent, - GlButton, GlLoadingIcon, }, directives: { SafeHtml, }, mixins: [glFeatureFlagsMixin()], - i18n: { - genericError: GENERIC_ERROR, - ...DIFF_FILE, - }, props: { file: { type: Object, @@ -50,7 +44,7 @@ export default { return { isLoadingCollapsedDiff: false, forkMessageVisible: false, - isCollapsed: this.file.viewer.collapsed || false, + isCollapsed: this.file.viewer.automaticallyCollapsed || false, }; }, computed: { @@ -59,7 +53,7 @@ export default { ...mapGetters('diffs', ['getDiffFileDiscussions']), viewBlobLink() { return sprintf( - this.$options.i18n.blobView, + __('You can %{linkStart}view the blob%{linkEnd} instead.'), { linkStart: `<a href="${escape(this.file.view_path)}">`, linkEnd: '</a>', @@ -81,7 +75,9 @@ export default { }, forkMessage() { return sprintf( - this.$options.i18n.editInFork, + __( + "You're not allowed to %{tag_start}edit%{tag_end} files in this project directly. Please fork this project, make your changes there, and submit a merge request.", + ), { tag_start: '<span class="js-file-fork-suggestion-section-action">', tag_end: '</span>', @@ -100,16 +96,16 @@ export default { }, 'file.file_hash': { handler: function watchFileHash() { - if (this.viewDiffsFileByFile && this.file.viewer.collapsed) { + if (this.viewDiffsFileByFile && this.file.viewer.automaticallyCollapsed) { this.isCollapsed = false; this.handleLoadCollapsedDiff(); } else { - this.isCollapsed = this.file.viewer.collapsed || false; + this.isCollapsed = this.file.viewer.automaticallyCollapsed || false; } }, immediate: true, }, - 'file.viewer.collapsed': function setIsCollapsed(newVal) { + 'file.viewer.automaticallyCollapsed': function setIsCollapsed(newVal) { if (!this.viewDiffsFileByFile) { this.isCollapsed = newVal; } @@ -152,7 +148,7 @@ export default { }) .catch(() => { this.isLoadingCollapsedDiff = false; - createFlash(this.$options.i18n.genericError); + createFlash(__('Something went wrong on our end. Please try again!')); }); }, showForkMessage() { @@ -192,14 +188,14 @@ export default { <a :href="file.fork_path" class="js-fork-suggestion-button btn btn-grouped btn-inverted btn-success" - >{{ $options.i18n.fork }}</a + >{{ __('Fork') }}</a > <button class="js-cancel-fork-suggestion-button btn btn-grouped" type="button" @click="hideForkMessage" > - {{ $options.i18n.cancel }} + {{ __('Cancel') }} </button> </div> <gl-loading-icon v-if="showLoadingIcon" class="diff-content loading" /> @@ -209,17 +205,11 @@ export default { <div v-safe-html="errorMessage" class="nothing-here-block"></div> </div> <template v-else> - <div v-show="isCollapsed" class="gl-p-7 gl-text-center collapsed-file-warning"> - <p class="gl-mb-8 gl-mt-5"> - {{ $options.i18n.collapsed }} - </p> - <gl-button - class="gl-alert-action gl-mb-5" - data-testid="expandButton" - @click="handleToggle" - > - {{ $options.i18n.expand }} - </gl-button> + <div v-show="isCollapsed" class="nothing-here-block diff-collapsed"> + {{ __('This diff is collapsed.') }} + <a class="click-to-expand js-click-to-expand" href="#" @click.prevent="handleToggle">{{ + __('Click to expand it.') + }}</a> </div> <diff-content v-show="!isCollapsed && !isFileTooLarge" diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index fded391cc84..ee8a8737f44 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -1,34 +1,37 @@ <script> -/* eslint-disable vue/no-v-html */ import { escape } from 'lodash'; import { mapActions, mapGetters } from 'vuex'; import { - GlDeprecatedButton, GlTooltipDirective, GlSafeHtmlDirective, - GlLoadingIcon, GlIcon, GlButton, + GlButtonGroup, + GlDropdown, + GlDropdownItem, + GlDropdownSectionHeader, + GlDropdownDivider, } from '@gitlab/ui'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import { truncateSha } from '~/lib/utils/text_utility'; import { __, s__, sprintf } from '~/locale'; import { diffViewerModes } from '~/ide/constants'; -import EditButton from './edit_button.vue'; import DiffStats from './diff_stats.vue'; import { scrollToElement } from '~/lib/utils/common_utils'; export default { components: { - GlLoadingIcon, - GlDeprecatedButton, ClipboardButton, - EditButton, GlIcon, FileIcon, DiffStats, GlButton, + GlButtonGroup, + GlDropdown, + GlDropdownItem, + GlDropdownSectionHeader, + GlDropdownDivider, }, directives: { GlTooltip: GlTooltipDirective, @@ -69,6 +72,11 @@ export default { default: false, }, }, + data() { + return { + moreActionsShown: false, + }; + }, computed: { ...mapGetters('diffs', ['diffHasExpandedDiscussions', 'diffHasDiscussions']), diffContentIDSelector() { @@ -151,6 +159,13 @@ export default { } return s__('MRDiff|Show full file'); }, + showEditButton() { + return ( + this.diffFile.blob?.readable_text && + !this.diffFile.deleted_file && + (this.diffFile.edit_path || this.diffFile.ide_edit_path) + ); + }, }, methods: { ...mapActions('diffs', [ @@ -162,8 +177,11 @@ export default { handleToggleFile() { this.$emit('toggleFile'); }, - showForkMessage() { - this.$emit('showForkMessage'); + showForkMessage(e) { + if (this.canCurrentUserFork && !this.diffFile.can_modify_blob) { + e.preventDefault(); + this.$emit('showForkMessage'); + } }, handleFileNameClick(e) { const isLinkToOtherPage = @@ -179,6 +197,9 @@ export default { } } }, + setMoreActionsShown(val) { + this.moreActionsShown = val; + }, }, }; </script> @@ -186,10 +207,11 @@ export default { <template> <div ref="header" + :class="{ 'gl-z-dropdown-menu!': moreActionsShown }" class="js-file-title file-title file-title-flex-parent" @click.self="handleToggleFile" > - <div class="file-header-content"> + <div class="file-header-content gl-display-flex gl-align-items-center gl-pr-0!"> <gl-icon v-if="collapsible" ref="collapseIcon" @@ -202,7 +224,7 @@ export default { <a ref="titleWrapper" :v-once="!viewDiffsFileByFile" - class="gl-mr-2" + class="gl-mr-2 gl-text-decoration-none!" :href="titleLink" @click="handleFileNameClick" > @@ -210,20 +232,27 @@ export default { <span v-if="isFileRenamed"> <strong v-gl-tooltip + v-safe-html="diffFile.old_path_html" :title="diffFile.old_path" class="file-title-name" - v-html="diffFile.old_path_html" ></strong> → <strong v-gl-tooltip + v-safe-html="diffFile.new_path_html" :title="diffFile.new_path" class="file-title-name" - v-html="diffFile.new_path_html" ></strong> </span> - <strong v-else v-gl-tooltip :title="filePath" class="file-title-name" data-container="body"> + <strong + v-else + v-gl-tooltip + :title="filePath" + class="file-title-name" + data-container="body" + data-qa-selector="file_name_content" + > {{ filePath }} </strong> </a> @@ -232,7 +261,8 @@ export default { :title="__('Copy file path')" :text="diffFile.file_path" :gfm="gfmCopyText" - css-class="btn-default btn-transparent btn-clipboard" + data-testid="diff-file-copy-clipboard" + category="tertiary" data-track-event="click_copy_file_button" data-track-label="diff_copy_file_path_button" data-track-property="diff_copy_file" @@ -247,93 +277,95 @@ export default { <div v-if="!diffFile.submodule && addMergeRequestButtons" - class="file-actions d-none d-sm-flex align-items-center flex-wrap" + class="file-actions d-flex align-items-center flex-wrap" > <diff-stats :added-lines="diffFile.added_lines" :removed-lines="diffFile.removed_lines" /> - <div class="btn-group" role="group"> - <template v-if="diffFile.blob && diffFile.blob.readable_text"> - <span v-gl-tooltip.hover :title="s__('MergeRequests|Toggle comments for this file')"> - <gl-deprecated-button - ref="toggleDiscussionsButton" - :disabled="!diffHasDiscussions(diffFile)" - :class="{ active: diffHasExpandedDiscussions(diffFile) }" - class="js-btn-vue-toggle-comments btn" - data-qa-selector="toggle_comments_button" - data-track-event="click_toggle_comments_button" - data-track-label="diff_toggle_comments_button" - data-track-property="diff_toggle_comments" - type="button" - @click="toggleFileDiscussionWrappers(diffFile)" - > - <gl-icon name="comment" /> - </gl-deprecated-button> - </span> - - <edit-button - v-if="!diffFile.deleted_file" - :can-current-user-fork="canCurrentUserFork" - :edit-path="diffFile.edit_path" - :can-modify-blob="diffFile.can_modify_blob" - data-track-event="click_toggle_edit_button" - data-track-label="diff_toggle_edit_button" - data-track-property="diff_toggle_edit" - @showForkMessage="showForkMessage" - /> - </template> - - <a - v-if="diffFile.replaced_view_path" - ref="replacedFileButton" - :href="diffFile.replaced_view_path" - class="btn view-file" - v-html="viewReplacedFileButtonText" - > - </a> - <gl-deprecated-button - v-if="!diffFile.is_fully_expanded" - ref="expandDiffToFullFileButton" - v-gl-tooltip.hover - :title="expandDiffToFullFileTitle" - class="expand-file" - data-track-event="click_toggle_view_full_button" - data-track-label="diff_toggle_view_full_button" - data-track-property="diff_toggle_view_full" - @click="toggleFullDiff(diffFile.file_path)" - > - <gl-loading-icon v-if="diffFile.isLoadingFullFile" color="dark" inline /> - <gl-icon v-else-if="diffFile.isShowingFullFile" name="doc-changes" /> - <gl-icon v-else name="doc-expand" /> - </gl-deprecated-button> - <gl-deprecated-button - ref="viewButton" - v-gl-tooltip.hover - :href="diffFile.view_path" - target="_blank" - class="view-file" - data-track-event="click_toggle_view_sha_button" - data-track-label="diff_toggle_view_sha_button" - data-track-property="diff_toggle_view_sha" - :title="viewFileButtonText" - > - <gl-icon name="doc-text" /> - </gl-deprecated-button> - - <a + <gl-button-group class="gl-pt-0!"> + <gl-button v-if="diffFile.external_url" ref="externalLink" v-gl-tooltip.hover :href="diffFile.external_url" :title="`View on ${diffFile.formatted_external_url}`" target="_blank" - rel="noopener noreferrer" data-track-event="click_toggle_external_button" data-track-label="diff_toggle_external_button" data-track-property="diff_toggle_external" - class="btn btn-file-option" + icon="external-link" + /> + <gl-dropdown + v-gl-tooltip.hover.focus="__('More actions')" + right + toggle-class="btn-icon js-diff-more-actions" + class="gl-pt-0!" + @show="setMoreActionsShown(true)" + @hidden="setMoreActionsShown(false)" > - <gl-icon name="external-link" /> - </a> - </div> + <template #button-content> + <gl-icon name="ellipsis_v" class="mr-0" /> + <span class="sr-only">{{ __('More actions') }}</span> + </template> + <gl-dropdown-section-header> + {{ __('More actions') }} + </gl-dropdown-section-header> + <gl-dropdown-item + v-if="diffFile.replaced_view_path" + ref="replacedFileButton" + v-safe-html="viewReplacedFileButtonText" + :href="diffFile.replaced_view_path" + target="_blank" + /> + <gl-dropdown-item ref="viewButton" :href="diffFile.view_path" target="_blank"> + {{ viewFileButtonText }} + </gl-dropdown-item> + <template v-if="showEditButton"> + <gl-dropdown-item + v-if="diffFile.edit_path" + ref="editButton" + :href="diffFile.edit_path" + class="js-edit-blob" + @click="showForkMessage" + > + {{ __('Edit in single-file editor') }} + </gl-dropdown-item> + <gl-dropdown-item + v-if="diffFile.edit_path" + ref="ideEditButton" + :href="diffFile.ide_edit_path" + class="js-ide-edit-blob" + > + {{ __('Edit in Web IDE') }} + </gl-dropdown-item> + </template> + + <template v-if="!diffFile.viewer.automaticallyCollapsed"> + <gl-dropdown-divider + v-if="!diffFile.is_fully_expanded || diffHasDiscussions(diffFile)" + /> + + <gl-dropdown-item + v-if="diffHasDiscussions(diffFile)" + ref="toggleDiscussionsButton" + data-qa-selector="toggle_comments_button" + @click="toggleFileDiscussionWrappers(diffFile)" + > + <template v-if="diffHasExpandedDiscussions(diffFile)"> + {{ __('Hide comments on this file') }} + </template> + <template v-else> + {{ __('Show comments on this file') }} + </template> + </gl-dropdown-item> + <gl-dropdown-item + v-if="!diffFile.is_fully_expanded" + ref="expandDiffToFullFileButton" + @click="toggleFullDiff(diffFile.file_path)" + > + {{ expandDiffToFullFileTitle }} + </gl-dropdown-item> + </template> + </gl-dropdown> + </gl-button-group> </div> <div diff --git a/app/assets/javascripts/diffs/components/diff_row_utils.js b/app/assets/javascripts/diffs/components/diff_row_utils.js new file mode 100644 index 00000000000..998320c3245 --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_row_utils.js @@ -0,0 +1,99 @@ +import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; +import { + MATCH_LINE_TYPE, + CONTEXT_LINE_TYPE, + LINE_HOVER_CLASS_NAME, + OLD_NO_NEW_LINE_TYPE, + NEW_NO_NEW_LINE_TYPE, + EMPTY_CELL_TYPE, +} from '../constants'; + +export const isHighlighted = (state, line, isCommented) => { + if (isCommented) return true; + + const lineCode = line?.line_code; + return lineCode ? lineCode === state.diffs.highlightedRow : false; +}; + +export const isContextLine = type => type === CONTEXT_LINE_TYPE; + +export const isMatchLine = type => type === MATCH_LINE_TYPE; + +export const isMetaLine = type => + [OLD_NO_NEW_LINE_TYPE, NEW_NO_NEW_LINE_TYPE, EMPTY_CELL_TYPE].includes(type); + +export const shouldRenderCommentButton = ( + isLoggedIn, + isCommentButtonRendered, + featureMergeRefHeadComments = false, +) => { + if (!isCommentButtonRendered) { + return false; + } + + if (isLoggedIn) { + const isDiffHead = parseBoolean(getParameterByName('diff_head')); + return !isDiffHead || featureMergeRefHeadComments; + } + + return false; +}; + +export const hasDiscussions = line => line?.discussions?.length > 0; + +export const lineHref = line => `#${line?.line_code || ''}`; + +export const lineCode = line => { + if (!line) return undefined; + return line.line_code || line.left?.line_code || line.right?.line_code; +}; + +export const classNameMapCell = (line, hll, isLoggedIn, isHover) => { + if (!line) return []; + const { type } = line; + + return [ + type, + { + hll, + [LINE_HOVER_CLASS_NAME]: isLoggedIn && isHover && !isContextLine(type) && !isMetaLine(type), + }, + ]; +}; + +export const addCommentTooltip = line => { + let tooltip; + if (!line) return tooltip; + + tooltip = __('Add a comment to this line'); + const brokenSymlinks = line.commentsDisabled; + + if (brokenSymlinks) { + if (brokenSymlinks.wasSymbolic || brokenSymlinks.isSymbolic) { + tooltip = __( + 'Commenting on symbolic links that replace or are replaced by files is currently not supported.', + ); + } else if (brokenSymlinks.wasReal || brokenSymlinks.isReal) { + tooltip = __( + 'Commenting on files that replace or are replaced by symbolic links is currently not supported.', + ); + } + } + + return tooltip; +}; + +export const parallelViewLeftLineType = (line, hll) => { + if (line?.right?.type === NEW_NO_NEW_LINE_TYPE) { + return OLD_NO_NEW_LINE_TYPE; + } + + const lineTypeClass = line?.left ? line.left.type : EMPTY_CELL_TYPE; + + return [lineTypeClass, { hll }]; +}; + +export const shouldShowCommentButton = (hover, context, meta, discussions) => { + return hover && !context && !meta && !discussions; +}; diff --git a/app/assets/javascripts/diffs/components/diff_stats.vue b/app/assets/javascripts/diffs/components/diff_stats.vue index 05fbbd39fae..f229fc4cf60 100644 --- a/app/assets/javascripts/diffs/components/diff_stats.vue +++ b/app/assets/javascripts/diffs/components/diff_stats.vue @@ -42,7 +42,7 @@ export default { class="diff-stats" :class="{ 'is-compare-versions-header d-none d-lg-inline-flex': isCompareVersionsHeader, - 'd-inline-flex': !isCompareVersionsHeader, + 'd-none d-sm-inline-flex': !isCompareVersionsHeader, }" > <div v-if="hasDiffFiles" class="diff-stats-group"> diff --git a/app/assets/javascripts/diffs/components/diff_table_cell.vue b/app/assets/javascripts/diffs/components/diff_table_cell.vue deleted file mode 100644 index 49982a81372..00000000000 --- a/app/assets/javascripts/diffs/components/diff_table_cell.vue +++ /dev/null @@ -1,206 +0,0 @@ -<script> -import { mapGetters, mapActions } from 'vuex'; -import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils'; -import DiffGutterAvatars from './diff_gutter_avatars.vue'; -import { __ } from '~/locale'; -import { - CONTEXT_LINE_TYPE, - LINE_POSITION_RIGHT, - EMPTY_CELL_TYPE, - OLD_NO_NEW_LINE_TYPE, - OLD_LINE_TYPE, - NEW_NO_NEW_LINE_TYPE, - LINE_HOVER_CLASS_NAME, -} from '../constants'; - -export default { - components: { - DiffGutterAvatars, - GlIcon, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - line: { - type: Object, - required: true, - }, - fileHash: { - type: String, - required: true, - }, - isHighlighted: { - type: Boolean, - required: true, - }, - showCommentButton: { - type: Boolean, - required: false, - default: false, - }, - linePosition: { - type: String, - required: false, - default: '', - }, - lineType: { - type: String, - required: false, - default: '', - }, - isBottom: { - type: Boolean, - required: false, - default: false, - }, - isHover: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - isCommentButtonRendered: false, - }; - }, - computed: { - ...mapGetters(['isLoggedIn']), - lineCode() { - return ( - this.line.line_code || - (this.line.left && this.line.left.line_code) || - (this.line.right && this.line.right.line_code) - ); - }, - lineHref() { - return `#${this.line.line_code || ''}`; - }, - shouldShowCommentButton() { - return this.isHover && !this.isContextLine && !this.isMetaLine && !this.hasDiscussions; - }, - hasDiscussions() { - return this.line.discussions && this.line.discussions.length > 0; - }, - shouldShowAvatarsOnGutter() { - if (!this.line.type && this.linePosition === LINE_POSITION_RIGHT) { - return false; - } - 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; - } - - return false; - }, - isContextLine() { - return this.line.type === CONTEXT_LINE_TYPE; - }, - isMetaLine() { - const { type } = this.line; - - return ( - type === OLD_NO_NEW_LINE_TYPE || type === NEW_NO_NEW_LINE_TYPE || type === EMPTY_CELL_TYPE - ); - }, - classNameMap() { - const { type } = this.line; - - return [ - type, - { - hll: this.isHighlighted, - [LINE_HOVER_CLASS_NAME]: - this.isLoggedIn && this.isHover && !this.isContextLine && !this.isMetaLine, - }, - ]; - }, - lineNumber() { - return this.lineType === OLD_LINE_TYPE ? this.line.old_line : this.line.new_line; - }, - addCommentTooltip() { - const brokenSymlinks = this.line.commentsDisabled; - let tooltip = __('Add a comment to this line'); - - if (brokenSymlinks) { - if (brokenSymlinks.wasSymbolic || brokenSymlinks.isSymbolic) { - tooltip = __( - 'Commenting on symbolic links that replace or are replaced by files is currently not supported.', - ); - } else if (brokenSymlinks.wasReal || brokenSymlinks.isReal) { - tooltip = __( - 'Commenting on files that replace or are replaced by symbolic links is currently not supported.', - ); - } - } - - return tooltip; - }, - }, - mounted() { - this.unwatchShouldShowCommentButton = this.$watch('shouldShowCommentButton', newVal => { - if (newVal) { - this.isCommentButtonRendered = true; - this.unwatchShouldShowCommentButton(); - } - }); - }, - beforeDestroy() { - this.unwatchShouldShowCommentButton(); - }, - methods: { - ...mapActions('diffs', ['showCommentForm', 'setHighlightedRow', 'toggleLineDiscussions']), - handleCommentButton() { - this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.fileHash }); - }, - }, -}; -</script> - -<template> - <td ref="td" :class="classNameMap"> - <span - ref="addNoteTooltip" - v-gl-tooltip - class="add-diff-note tooltip-wrapper" - :title="addCommentTooltip" - > - <button - v-if="shouldRenderCommentButton" - v-show="shouldShowCommentButton" - ref="addDiffNoteButton" - type="button" - class="add-diff-note note-button js-add-diff-note-button qa-diff-comment" - :disabled="line.commentsDisabled" - @click="handleCommentButton" - > - <gl-icon :size="12" name="comment" /> - </button> - </span> - <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/edit_button.vue b/app/assets/javascripts/diffs/components/edit_button.vue deleted file mode 100644 index ff1af5569dc..00000000000 --- a/app/assets/javascripts/diffs/components/edit_button.vue +++ /dev/null @@ -1,64 +0,0 @@ -<script> -import { GlTooltipDirective, GlDeprecatedButton, GlIcon } from '@gitlab/ui'; -import { __ } from '~/locale'; - -export default { - components: { - GlDeprecatedButton, - GlIcon, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - editPath: { - type: String, - required: false, - default: '', - }, - canCurrentUserFork: { - type: Boolean, - required: true, - }, - canModifyBlob: { - type: Boolean, - required: false, - default: false, - }, - }, - computed: { - tooltipTitle() { - if (this.isDisabled) { - return __("Can't edit as source branch was deleted"); - } - - return __('Edit file'); - }, - isDisabled() { - return !this.editPath; - }, - }, - methods: { - handleEditClick(evt) { - if (this.canCurrentUserFork && !this.canModifyBlob) { - evt.preventDefault(); - this.$emit('showForkMessage'); - } - }, - }, -}; -</script> - -<template> - <span v-gl-tooltip.top :title="tooltipTitle"> - <gl-deprecated-button - :href="editPath" - :disabled="isDisabled" - :class="{ 'cursor-not-allowed': isDisabled }" - class="rounded-0 js-edit-blob" - @click.native="handleEditClick" - > - <gl-icon name="pencil" /> - </gl-deprecated-button> - </span> -</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 7fab750089e..f9d491603cb 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue @@ -1,22 +1,9 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; import { GlTooltipDirective, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; -import { - MATCH_LINE_TYPE, - NEW_LINE_TYPE, - OLD_LINE_TYPE, - CONTEXT_LINE_TYPE, - CONTEXT_LINE_CLASS_NAME, - LINE_POSITION_LEFT, - LINE_POSITION_RIGHT, - LINE_HOVER_CLASS_NAME, - OLD_NO_NEW_LINE_TYPE, - NEW_NO_NEW_LINE_TYPE, - EMPTY_CELL_TYPE, -} from '../constants'; -import { __ } from '~/locale'; -import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils'; +import { CONTEXT_LINE_CLASS_NAME } from '../constants'; import DiffGutterAvatars from './diff_gutter_avatars.vue'; +import * as utils from './diff_row_utils'; export default { components: { @@ -61,14 +48,11 @@ export default { ...mapGetters('diffs', ['fileLineCoverage']), ...mapState({ isHighlighted(state) { - if (this.isCommented) return true; - - const lineCode = this.line.line_code; - return lineCode ? lineCode === state.diffs.highlightedRow : false; + return utils.isHighlighted(state, this.line, this.isCommented); }, }), isContextLine() { - return this.line.type === CONTEXT_LINE_TYPE; + return utils.isContextLine(this.line.type); }, classNameMap() { return [ @@ -82,82 +66,48 @@ export default { return this.line.line_code || `${this.fileHash}_${this.line.old_line}_${this.line.new_line}`; }, isMatchLine() { - return this.line.type === MATCH_LINE_TYPE; + return utils.isMatchLine(this.line.type); }, coverageState() { return this.fileLineCoverage(this.filePath, this.line.new_line); }, isMetaLine() { - const { type } = this.line; - - return ( - type === OLD_NO_NEW_LINE_TYPE || type === NEW_NO_NEW_LINE_TYPE || type === EMPTY_CELL_TYPE - ); + return utils.isMetaLine(this.line.type); }, classNameMapCell() { - const { type } = this.line; - - return [ - type, - { - hll: this.isHighlighted, - [LINE_HOVER_CLASS_NAME]: - this.isLoggedIn && this.isHover && !this.isContextLine && !this.isMetaLine, - }, - ]; + return utils.classNameMapCell(this.line, this.isHighlighted, this.isLoggedIn, this.isHover); }, addCommentTooltip() { - const brokenSymlinks = this.line.commentsDisabled; - let tooltip = __('Add a comment to this line'); - - if (brokenSymlinks) { - if (brokenSymlinks.wasSymbolic || brokenSymlinks.isSymbolic) { - tooltip = __( - 'Commenting on symbolic links that replace or are replaced by files is currently not supported.', - ); - } else if (brokenSymlinks.wasReal || brokenSymlinks.isReal) { - tooltip = __( - 'Commenting on files that replace or are replaced by symbolic links is currently not supported.', - ); - } - } - - return tooltip; + return utils.addCommentTooltip(this.line); }, shouldRenderCommentButton() { - if (this.isLoggedIn) { - const isDiffHead = parseBoolean(getParameterByName('diff_head')); - return !isDiffHead || gon.features?.mergeRefHeadComments; - } - - return false; + return utils.shouldRenderCommentButton( + this.isLoggedIn, + true, + gon.features?.mergeRefHeadComments, + ); }, shouldShowCommentButton() { - return this.isHover && !this.isContextLine && !this.isMetaLine && !this.hasDiscussions; + return utils.shouldShowCommentButton( + this.isHover, + this.isContextLine, + this.isMetaLine, + this.hasDiscussions, + ); }, hasDiscussions() { - return this.line.discussions && this.line.discussions.length > 0; + return utils.hasDiscussions(this.line); }, lineHref() { - return `#${this.line.line_code || ''}`; + return utils.lineHref(this.line); }, lineCode() { - return ( - this.line.line_code || - (this.line.left && this.line.left.line_code) || - (this.line.right && this.line.right.line_code) - ); + return utils.lineCode(this.line); }, shouldShowAvatarsOnGutter() { return this.hasDiscussions; }, }, - created() { - this.newLineType = NEW_LINE_TYPE; - this.oldLineType = OLD_LINE_TYPE; - this.linePositionLeft = LINE_POSITION_LEFT; - this.linePositionRight = LINE_POSITION_RIGHT; - }, mounted() { this.scrollToLineIfNeededInline(this.line); }, @@ -242,6 +192,7 @@ export default { class="line-coverage" ></td> <td + :key="line.line_code" v-safe-html="line.rich_text" :class="[ line.type, diff --git a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue index b525490f7cc..127e3f214cf 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue @@ -113,8 +113,8 @@ export default { }, methods: { ...mapActions('diffs', ['showCommentForm']), - showNewDiscussionForm() { - this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.diffFileHash }); + showNewDiscussionForm(lineCode) { + this.showCommentForm({ lineCode, fileHash: this.diffFileHash }); }, }, }; @@ -134,7 +134,7 @@ export default { v-if="!hasDraftLeft" :has-form="showLeftSideCommentForm" :render-reply-placeholder="shouldRenderReplyPlaceholderOnLeft" - @showNewDiscussionForm="showNewDiscussionForm" + @showNewDiscussionForm="showNewDiscussionForm(line.left.line_code)" > <template #form> <diff-line-note-form @@ -159,7 +159,7 @@ export default { v-if="!hasDraftRight" :has-form="showRightSideCommentForm" :render-reply-placeholder="shouldRenderReplyPlaceholderOnRight" - @showNewDiscussionForm="showNewDiscussionForm" + @showNewDiscussionForm="showNewDiscussionForm(line.right.line_code)" > <template #form> <diff-line-note-form 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 0bf47dc77a6..06dcadb2dc1 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue @@ -2,21 +2,9 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import $ from 'jquery'; import { GlTooltipDirective, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; -import { - MATCH_LINE_TYPE, - NEW_LINE_TYPE, - OLD_LINE_TYPE, - CONTEXT_LINE_TYPE, - CONTEXT_LINE_CLASS_NAME, - OLD_NO_NEW_LINE_TYPE, - PARALLEL_DIFF_VIEW_TYPE, - NEW_NO_NEW_LINE_TYPE, - EMPTY_CELL_TYPE, - LINE_HOVER_CLASS_NAME, -} from '../constants'; -import { __ } from '~/locale'; -import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils'; +import { CONTEXT_LINE_CLASS_NAME, PARALLEL_DIFF_VIEW_TYPE } from '../constants'; import DiffGutterAvatars from './diff_gutter_avatars.vue'; +import * as utils from './diff_row_utils'; export default { components: { @@ -63,20 +51,15 @@ export default { ...mapGetters(['isLoggedIn']), ...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); - - return lineCode ? lineCode === state.diffs.highlightedRow : false; + const line = this.line.left?.line_code ? this.line.left : this.line.right; + return utils.isHighlighted(state, line, this.isCommented); }, }), isContextLineLeft() { - return this.line.left && this.line.left.type === CONTEXT_LINE_TYPE; + return utils.isContextLine(this.line.left?.type); }, isContextLineRight() { - return this.line.right && this.line.right.type === CONTEXT_LINE_TYPE; + return utils.isContextLine(this.line.right?.type); }, classNameMap() { return { @@ -85,157 +68,84 @@ export default { }; }, parallelViewLeftLineType() { - if (this.line.right && this.line.right.type === NEW_NO_NEW_LINE_TYPE) { - return OLD_NO_NEW_LINE_TYPE; - } - - const lineTypeClass = this.line.left ? this.line.left.type : EMPTY_CELL_TYPE; - - return [ - lineTypeClass, - { - hll: this.isHighlighted, - }, - ]; + return utils.parallelViewLeftLineType(this.line, this.isHighlighted); }, isMatchLineLeft() { - return this.line.left && this.line.left.type === MATCH_LINE_TYPE; + return utils.isMatchLine(this.line.left?.type); }, isMatchLineRight() { - return this.line.right && this.line.right.type === MATCH_LINE_TYPE; + return utils.isMatchLine(this.line.right?.type); }, coverageState() { return this.fileLineCoverage(this.filePath, this.line.right.new_line); }, classNameMapCellLeft() { - const { type } = this.line.left; - - return [ - type, - { - hll: this.isHighlighted, - [LINE_HOVER_CLASS_NAME]: - this.isLoggedIn && this.isLeftHover && !this.isContextLineLeft && !this.isMetaLineLeft, - }, - ]; + return utils.classNameMapCell( + this.line.left, + this.isHighlighted, + this.isLoggedIn, + this.isLeftHover, + ); }, classNameMapCellRight() { - const { type } = this.line.right; - - return [ - type, - { - hll: this.isHighlighted, - [LINE_HOVER_CLASS_NAME]: - this.isLoggedIn && - this.isRightHover && - !this.isContextLineRight && - !this.isMetaLineRight, - }, - ]; + return utils.classNameMapCell( + this.line.right, + this.isHighlighted, + this.isLoggedIn, + this.isRightHover, + ); }, addCommentTooltipLeft() { - const brokenSymlinks = this.line.left.commentsDisabled; - let tooltip = __('Add a comment to this line'); - - if (brokenSymlinks) { - if (brokenSymlinks.wasSymbolic || brokenSymlinks.isSymbolic) { - tooltip = __( - 'Commenting on symbolic links that replace or are replaced by files is currently not supported.', - ); - } else if (brokenSymlinks.wasReal || brokenSymlinks.isReal) { - tooltip = __( - 'Commenting on files that replace or are replaced by symbolic links is currently not supported.', - ); - } - } - - return tooltip; + return utils.addCommentTooltip(this.line.left); }, addCommentTooltipRight() { - const brokenSymlinks = this.line.right.commentsDisabled; - let tooltip = __('Add a comment to this line'); - - if (brokenSymlinks) { - if (brokenSymlinks.wasSymbolic || brokenSymlinks.isSymbolic) { - tooltip = __( - 'Commenting on symbolic links that replace or are replaced by files is currently not supported.', - ); - } else if (brokenSymlinks.wasReal || brokenSymlinks.isReal) { - tooltip = __( - 'Commenting on files that replace or are replaced by symbolic links is currently not supported.', - ); - } - } - - return tooltip; + return utils.addCommentTooltip(this.line.right); }, shouldRenderCommentButton() { - if (!this.isCommentButtonRendered) { - return false; - } - - if (this.isLoggedIn) { - const isDiffHead = parseBoolean(getParameterByName('diff_head')); - return !isDiffHead || gon.features?.mergeRefHeadComments; - } - - return false; + return utils.shouldRenderCommentButton( + this.isLoggedIn, + this.isCommentButtonRendered, + gon.features?.mergeRefHeadComments, + ); }, shouldShowCommentButtonLeft() { - return ( - this.isLeftHover && - !this.isContextLineLeft && - !this.isMetaLineLeft && - !this.hasDiscussionsLeft + return utils.shouldShowCommentButton( + this.isLeftHover, + this.isContextLineLeft, + this.isMetaLineLeft, + this.hasDiscussionsLeft, ); }, shouldShowCommentButtonRight() { - return ( - this.isRightHover && - !this.isContextLineRight && - !this.isMetaLineRight && - !this.hasDiscussionsRight + return utils.shouldShowCommentButton( + this.isRightHover, + this.isContextLineRight, + this.isMetaLineRight, + this.hasDiscussionsRight, ); }, hasDiscussionsLeft() { - return this.line.left?.discussions?.length > 0; + return utils.hasDiscussions(this.line.left); }, hasDiscussionsRight() { - return this.line.right?.discussions?.length > 0; + return utils.hasDiscussions(this.line.right); }, lineHrefOld() { - return `#${this.line.left.line_code || ''}`; + return utils.lineHref(this.line.left); }, lineHrefNew() { - return `#${this.line.right.line_code || ''}`; + return utils.lineHref(this.line.right); }, lineCode() { - return ( - (this.line.left && this.line.left.line_code) || - (this.line.right && this.line.right.line_code) - ); + return utils.lineCode(this.line); }, isMetaLineLeft() { - const type = this.line.left?.type; - - return ( - type === OLD_NO_NEW_LINE_TYPE || type === NEW_NO_NEW_LINE_TYPE || type === EMPTY_CELL_TYPE - ); + return utils.isMetaLine(this.line.left?.type); }, isMetaLineRight() { - const type = this.line.right?.type; - - return ( - type === OLD_NO_NEW_LINE_TYPE || type === NEW_NO_NEW_LINE_TYPE || type === EMPTY_CELL_TYPE - ); + return utils.isMetaLine(this.line.right?.type); }, }, - created() { - this.newLineType = NEW_LINE_TYPE; - this.oldLineType = OLD_LINE_TYPE; - this.parallelDiffViewType = PARALLEL_DIFF_VIEW_TYPE; - }, mounted() { this.scrollToLineIfNeededParallel(this.line); this.unwatchShouldShowCommentButton = this.$watch( @@ -341,6 +251,7 @@ export default { <td :class="parallelViewLeftLineType" class="line-coverage left-side"></td> <td :id="line.left.line_code" + :key="line.left.line_code" v-safe-html="line.left.rich_text" :class="parallelViewLeftLineType" class="line_content with-coverage parallel left-side" @@ -401,6 +312,7 @@ export default { ></td> <td :id="line.right.line_code" + :key="line.right.rich_text" v-safe-html="line.right.rich_text" :class="[ line.right.type, diff --git a/app/assets/javascripts/diffs/diff_file.js b/app/assets/javascripts/diffs/diff_file.js index 610b71235d9..933197a2c7f 100644 --- a/app/assets/javascripts/diffs/diff_file.js +++ b/app/assets/javascripts/diffs/diff_file.js @@ -18,9 +18,21 @@ function fileSymlinkInformation(file, fileList) { ); } +function collapsed(file) { + const viewer = file.viewer || {}; + + return { + automaticallyCollapsed: viewer.automaticallyCollapsed || viewer.collapsed || false, + }; +} + export function prepareRawDiffFile({ file, allFiles }) { Object.assign(file, { brokenSymlink: fileSymlinkInformation(file, allFiles), + viewer: { + ...file.viewer, + ...collapsed(file), + }, }); return file; diff --git a/app/assets/javascripts/diffs/i18n.js b/app/assets/javascripts/diffs/i18n.js deleted file mode 100644 index 8b91543587c..00000000000 --- a/app/assets/javascripts/diffs/i18n.js +++ /dev/null @@ -1,14 +0,0 @@ -import { __ } from '~/locale'; - -export const GENERIC_ERROR = __('Something went wrong on our end. Please try again!'); - -export const DIFF_FILE = { - blobView: __('You can %{linkStart}view the blob%{linkEnd} instead.'), - editInFork: __( - "You're not allowed to %{tag_start}edit%{tag_end} files in this project directly. Please fork this project, make your changes there, and submit a merge request.", - ), - fork: __('Fork'), - cancel: __('Cancel'), - collapsed: __('This file is collapsed.'), - expand: __('Expand file'), -}; diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 0f275f1cb3e..966b706fc31 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -103,7 +103,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { commit(types.VIEW_DIFF_FILE, state.diffFiles[0].file_hash); } - if (gon.features?.codeNavigation) { + if (state.diffFiles?.length) { // eslint-disable-next-line promise/catch-or-return,promise/no-nesting import('~/code_navigation').then(m => m.default({ @@ -236,7 +236,7 @@ export const renderFileForDiscussionId = ({ commit, rootState, state }, discussi commit(types.RENDER_FILE, file); } - if (file.viewer.collapsed) { + if (file.viewer.automaticallyCollapsed) { eventHub.$emit(`loadCollapsedDiff/${file.file_hash}`); scrollToElement(document.getElementById(file.file_hash)); } else { @@ -252,7 +252,8 @@ export const startRenderDiffsQueue = ({ state, commit }) => { const nextFile = state.diffFiles.find( file => !file.renderIt && - (file.viewer && (!file.viewer.collapsed || file.viewer.name !== diffViewerModes.text)), + (file.viewer && + (!file.viewer.automaticallyCollapsed || file.viewer.name !== diffViewerModes.text)), ); if (nextFile) { @@ -631,7 +632,7 @@ export function switchToFullDiffFromRenamedFile({ commit, dispatch, state }, { d filePath: diffFile.file_path, viewer: { ...diffFile.alternate_viewer, - collapsed: false, + automaticallyCollapsed: false, }, }); commit(types.SET_CURRENT_VIEW_DIFF_FILE_LINES, { filePath: diffFile.file_path, lines }); diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js index 42df5873a41..91425c7825b 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -9,7 +9,7 @@ export const isParallelView = state => state.diffViewType === PARALLEL_DIFF_VIEW export const isInlineView = state => state.diffViewType === INLINE_DIFF_VIEW_TYPE; export const hasCollapsedFile = state => - state.diffFiles.some(file => file.viewer && file.viewer.collapsed); + state.diffFiles.some(file => file.viewer && file.viewer.automaticallyCollapsed); export const commitId = state => (state.commit && state.commit.id ? state.commit.id : null); @@ -46,15 +46,24 @@ export const diffHasAllCollapsedDiscussions = (state, getters) => diff => { * @param {Object} diff * @returns {Boolean} */ -export const diffHasExpandedDiscussions = (state, getters) => diff => { - const discussions = getters.getDiffFileDiscussions(diff); - - return ( - (discussions && - discussions.length && - discussions.find(discussion => discussion.expanded) !== undefined) || - false - ); +export const diffHasExpandedDiscussions = state => diff => { + const lines = { + [INLINE_DIFF_VIEW_TYPE]: diff.highlighted_diff_lines || [], + [PARALLEL_DIFF_VIEW_TYPE]: (diff.parallel_diff_lines || []).reduce((acc, line) => { + if (line.left) { + acc.push(line.left); + } + + if (line.right) { + acc.push(line.right); + } + + return acc; + }, []), + }; + return lines[window.gon?.features?.unifiedDiffLines ? 'inline' : state.diffViewType] + .filter(l => l.discussions.length >= 1) + .some(l => l.discussionsExpanded); }; /** @@ -62,8 +71,25 @@ export const diffHasExpandedDiscussions = (state, getters) => diff => { * @param {Boolean} diff * @returns {Boolean} */ -export const diffHasDiscussions = (state, getters) => diff => - getters.getDiffFileDiscussions(diff).length > 0; +export const diffHasDiscussions = state => diff => { + const lines = { + [INLINE_DIFF_VIEW_TYPE]: diff.highlighted_diff_lines || [], + [PARALLEL_DIFF_VIEW_TYPE]: (diff.parallel_diff_lines || []).reduce((acc, line) => { + if (line.left) { + acc.push(line.left); + } + + if (line.right) { + acc.push(line.right); + } + + return acc; + }, []), + }; + return lines[window.gon?.features?.unifiedDiffLines ? 'inline' : state.diffViewType].some( + l => l.discussions.length >= 1, + ); +}; /** * Returns an array with the discussions of the given diff diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 7925c620c4e..13ecf6a997d 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -172,7 +172,7 @@ export default { state.diffFiles.forEach(file => { Object.assign(file, { viewer: Object.assign(file.viewer, { - collapsed: false, + automaticallyCollapsed: false, }), }); }); @@ -355,7 +355,7 @@ export default { const file = state.diffFiles.find(f => f.file_path === filePath); if (file && file.viewer) { - file.viewer.collapsed = collapsed; + file.viewer.automaticallyCollapsed = collapsed; } }, [types.SET_HIDDEN_VIEW_DIFF_FILE_LINES](state, { filePath, lines }) { diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js index 4567c807c40..1bdc7b3a8b5 100644 --- a/app/assets/javascripts/emoji/index.js +++ b/app/assets/javascripts/emoji/index.js @@ -1,53 +1,58 @@ import { uniq } from 'lodash'; +import fuzzaldrinPlus from 'fuzzaldrin-plus'; import emojiAliases from 'emojis/aliases.json'; import axios from '../lib/utils/axios_utils'; - 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); - }); - } - }); +async function loadEmoji() { + if ( + isLocalStorageAvailable && + window.localStorage.getItem('gl-emoji-map-version') === EMOJI_VERSION && + window.localStorage.getItem('gl-emoji-map') + ) { + return JSON.parse(window.localStorage.getItem('gl-emoji-map')); + } - return emojiPromise; + // 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 + const { data } = await axios.get( + `${gon.relative_url_root || ''}/-/emojis/${EMOJI_VERSION}/emojis.json`, + ); + window.localStorage.setItem('gl-emoji-map-version', EMOJI_VERSION); + window.localStorage.setItem('gl-emoji-map', JSON.stringify(data)); + return data; +} + +async function prepareEmojiMap() { + emojiMap = await loadEmoji(); + + validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)]; + + Object.keys(emojiMap).forEach(name => { + emojiMap[name].aliases = []; + emojiMap[name].name = name; + }); + Object.entries(emojiAliases).forEach(([alias, name]) => { + // This check, `if (name in emojiMap)` is necessary during testing. In + // production, it shouldn't be necessary, because at no point should there + // be an entry in aliases.json with no corresponding entry in emojis.json. + // However, during testing, the endpoint for emojis.json is mocked with a + // small dataset, whereas aliases.json is always `import`ed directly. + if (name in emojiMap) emojiMap[name].aliases.push(alias); + }); +} + +export function initEmojiMap() { + initEmojiMap.promise = initEmojiMap.promise || prepareEmojiMap(); + return initEmojiMap.promise; } export function normalizeEmojiName(name) { @@ -62,13 +67,49 @@ export function isEmojiNameValid(name) { return validEmojiNames.indexOf(name) >= 0; } -export function filterEmojiNames(filter) { - const match = filter.toLowerCase(); - return validEmojiNames.filter(name => name.indexOf(match) >= 0); +/** + * Search emoji by name or alias. Returns a normalized, deduplicated list of + * names. + * + * Calling with an empty filter returns an empty array. + * + * @param {String} + * @returns {Array} + */ +export function queryEmojiNames(filter) { + const matches = fuzzaldrinPlus.filter(validEmojiNames, filter); + return uniq(matches.map(name => normalizeEmojiName(name))); } -export function filterEmojiNamesByAlias(filter) { - return uniq(filterEmojiNames(filter).map(name => normalizeEmojiName(name))); +/** + * Searches emoji by name, alias, description, and unicode value and returns an + * array of matches. + * + * Note: `initEmojiMap` must have been called and completed before this method + * can safely be called. + * + * @param {String} query The search query + * @returns {Object[]} A list of emoji that match the query + */ +export function searchEmoji(query) { + if (!emojiMap) + // eslint-disable-next-line @gitlab/require-i18n-strings + throw new Error('The emoji map is uninitialized or initialization has not completed'); + + const matches = s => fuzzaldrinPlus.score(s, query) > 0; + + // Search emoji + return Object.values(emojiMap).filter( + emoji => + // by name + matches(emoji.name) || + // by alias + emoji.aliases.some(matches) || + // by description + matches(emoji.d) || + // by unicode value + query === emoji.e, + ); } let emojiCategoryMap; diff --git a/app/assets/javascripts/environments/components/enable_review_app_button.vue b/app/assets/javascripts/environments/components/enable_review_app_button.vue index 8fbbc5189bf..554875b7ce3 100644 --- a/app/assets/javascripts/environments/components/enable_review_app_button.vue +++ b/app/assets/javascripts/environments/components/enable_review_app_button.vue @@ -49,7 +49,7 @@ export default { variant="info" category="secondary" type="button" - class="js-enable-review-app-button" + class="gl-w-full js-enable-review-app-button" > {{ s__('Environments|Enable review app') }} </gl-button> diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index f0e74d96f09..18f69855349 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -1,5 +1,5 @@ <script> -import { GlDeprecatedButton } from '@gitlab/ui'; +import { GlBadge, GlButton, GlTab, GlTabs } from '@gitlab/ui'; import { deprecatedCreateFlash as Flash } from '~/flash'; import { s__ } from '~/locale'; import emptyState from './empty_state.vue'; @@ -16,7 +16,10 @@ export default { ConfirmRollbackModal, emptyState, EnableReviewAppButton, - GlDeprecatedButton, + GlBadge, + GlButton, + GlTab, + GlTabs, StopEnvironmentModal, DeleteEnvironmentModal, }, @@ -124,43 +127,87 @@ export default { }; </script> <template> - <div> + <div class="environments-section"> <stop-environment-modal :environment="environmentInStopModal" /> <delete-environment-modal :environment="environmentInDeleteModal" /> <confirm-rollback-modal :environment="environmentInRollbackModal" /> - <div class="top-area"> - <tabs :tabs="tabs" scope="environments" @onChangeTab="onChangeTab" /> - - <div class="nav-controls"> - <enable-review-app-button v-if="state.reviewAppDetails.can_setup_review_app" class="mr-2" /> - <gl-deprecated-button + <div class="gl-w-full"> + <div + class=" + gl-display-flex + gl-flex-direction-column + gl-mt-3 + gl-display-md-none!" + > + <enable-review-app-button + v-if="state.reviewAppDetails.can_setup_review_app" + class="gl-mb-3 gl-flex-fill-1" + /> + <gl-button v-if="canCreateEnvironment && !isLoading" :href="newEnvironmentPath" category="primary" variant="success" > {{ s__('Environments|New environment') }} - </gl-deprecated-button> + </gl-button> </div> + <gl-tabs content-class="gl-display-none"> + <gl-tab + v-for="(tab, idx) in tabs" + :key="idx" + :title-item-class="`js-environments-tab-${tab.scope}`" + @click="onChangeTab(tab.scope)" + > + <template #title> + <span>{{ tab.name }}</span> + <gl-badge size="sm" class="gl-tab-counter-badge">{{ tab.count }}</gl-badge> + </template> + </gl-tab> + <template #tabs-end> + <div + class=" + gl-display-none + gl-display-md-flex + gl-lg-align-items-center + gl-lg-flex-direction-row + gl-lg-flex-fill-1 + gl-lg-justify-content-end + gl-lg-mt-0" + > + <enable-review-app-button + v-if="state.reviewAppDetails.can_setup_review_app" + class="gl-mb-3 gl-lg-mr-3 gl-lg-mb-0" + /> + <gl-button + v-if="canCreateEnvironment && !isLoading" + :href="newEnvironmentPath" + category="primary" + variant="success" + > + {{ s__('Environments|New environment') }} + </gl-button> + </div> + </template> + </gl-tabs> + <container + :is-loading="isLoading" + :environments="state.environments" + :pagination="state.paginationInformation" + :can-read-environment="canReadEnvironment" + :canary-deployment-feature-id="canaryDeploymentFeatureId" + :show-canary-deployment-callout="showCanaryDeploymentCallout" + :user-callouts-path="userCalloutsPath" + :lock-promotion-svg-path="lockPromotionSvgPath" + :help-canary-deployments-path="helpCanaryDeploymentsPath" + :deploy-boards-help-path="deployBoardsHelpPath" + @onChangePage="onChangePage" + > + <template v-if="!isLoading && state.environments.length === 0" #emptyState> + <empty-state :help-path="helpPagePath" /> + </template> + </container> </div> - - <container - :is-loading="isLoading" - :environments="state.environments" - :pagination="state.paginationInformation" - :can-read-environment="canReadEnvironment" - :canary-deployment-feature-id="canaryDeploymentFeatureId" - :show-canary-deployment-callout="showCanaryDeploymentCallout" - :user-callouts-path="userCalloutsPath" - :lock-promotion-svg-path="lockPromotionSvgPath" - :help-canary-deployments-path="helpCanaryDeploymentsPath" - :deploy-boards-help-path="deployBoardsHelpPath" - @onChangePage="onChangePage" - > - <template v-if="!isLoading && state.environments.length === 0" #emptyState> - <empty-state :help-path="helpPagePath" /> - </template> - </container> </div> </template> diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index c06ab265915..c1b3eabec16 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -184,7 +184,6 @@ export default { :deploy-boards-help-path="deployBoardsHelpPath" :is-loading="model.isLoadingDeployBoard" :is-empty="model.isEmptyDeployBoard" - :has-legacy-app-label="model.hasLegacyAppLabel" :logs-path="model.logs_path" /> </div> diff --git a/app/assets/javascripts/environments/components/stop_environment_modal.vue b/app/assets/javascripts/environments/components/stop_environment_modal.vue index 88612376b6e..892d0b96da1 100644 --- a/app/assets/javascripts/environments/components/stop_environment_modal.vue +++ b/app/assets/javascripts/environments/components/stop_environment_modal.vue @@ -1,8 +1,7 @@ <script> -/* eslint-disable @gitlab/vue-require-i18n-strings, vue/no-v-html */ -import { GlTooltipDirective } from '@gitlab/ui'; +/* eslint-disable @gitlab/vue-require-i18n-strings */ +import { GlSprintf, GlTooltipDirective } from '@gitlab/ui'; import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; -import { s__, sprintf } from '~/locale'; import eventHub from '../event_hub'; export default { @@ -11,6 +10,7 @@ export default { components: { GlModal: DeprecatedModal2, + GlSprintf, }, directives: { @@ -24,27 +24,6 @@ export default { }, }, - computed: { - noStopActionMessage() { - return sprintf( - s__( - `Environments|Note that this action will stop the environment, - but it will %{emphasisStart}not%{emphasisEnd} have an effect on any existing deployment - due to no “stop environment action” being defined - in the %{ciConfigLinkStart}.gitlab-ci.yml%{ciConfigLinkEnd} file.`, - ), - { - emphasisStart: '<strong>', - emphasisEnd: '</strong>', - ciConfigLinkStart: - '<a href="https://docs.gitlab.com/ee/ci/yaml/" target="_blank" rel="noopener noreferrer">', - ciConfigLinkEnd: '</a>', - }, - false, - ); - }, - }, - methods: { onSubmit() { eventHub.$emit('stopEnvironment', this.environment); @@ -72,7 +51,25 @@ export default { <p>{{ s__('Environments|Are you sure you want to stop this environment?') }}</p> <div v-if="!environment.has_stop_action" class="warning_message"> - <p v-html="noStopActionMessage"></p> + <p> + <gl-sprintf + :message=" + s__(`Environments|Note that this action will stop the environment, + but it will %{emphasisStart}not%{emphasisEnd} have an effect on any existing deployment + due to no “stop environment action” being defined + in the %{ciConfigLinkStart}.gitlab-ci.yml%{ciConfigLinkEnd} file.`) + " + > + <template #emphasis="{ content }"> + <strong>{{ content }}</strong> + </template> + <template #ciConfigLink="{ content }"> + <a href="https://docs.gitlab.com/ee/ci/yaml/" target="_blank" rel="noopener noreferrer"> + {{ content }}</a + > + </template> + </gl-sprintf> + </p> <a href="https://docs.gitlab.com/ee/ci/environments.html#stopping-an-environment" target="_blank" diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue index 16d25615779..061c9ffe8d4 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_view.vue +++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue @@ -1,4 +1,5 @@ <script> +import { GlBadge, GlTab, GlTabs } from '@gitlab/ui'; import environmentsMixin from '../mixins/environments_mixin'; import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin'; import StopEnvironmentModal from '../components/stop_environment_modal.vue'; @@ -6,8 +7,11 @@ import DeleteEnvironmentModal from '../components/delete_environment_modal.vue'; export default { components: { - StopEnvironmentModal, DeleteEnvironmentModal, + GlBadge, + GlTab, + GlTabs, + StopEnvironmentModal, }, mixins: [environmentsMixin, CIPaginationMixin], @@ -73,9 +77,21 @@ export default { <b>{{ folderName }}</b> </h4> - <div class="top-area"> - <tabs v-if="!isLoading" :tabs="tabs" scope="environments" @onChangeTab="onChangeTab" /> - </div> + <gl-tabs v-if="!isLoading" scope="environments" content-class="gl-display-none"> + <gl-tab + v-for="(tab, i) in tabs" + :key="`${tab.name}-${i}`" + :active="tab.isActive" + :title-item-class="tab.isActive ? 'gl-outline-none' : ''" + :title-link-attributes="{ 'data-testid': `environments-tab-${tab.scope}` }" + @click="onChangeTab(tab.scope)" + > + <template #title> + <span>{{ tab.name }}</span> + <gl-badge size="sm" class="gl-tab-counter-badge">{{ tab.count }}</gl-badge> + </template> + </gl-tab> + </gl-tabs> <container :is-loading="isLoading" diff --git a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue index a4938fe13ed..cd4bb476b6e 100644 --- a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue +++ b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue @@ -94,7 +94,9 @@ export default { <clipboard-button :title="__('Copy file path')" :text="filePath" - css-class="btn-default btn-transparent btn-clipboard position-static" + category="tertiary" + size="small" + css-class="gl-mr-1" /> <gl-sprintf v-if="errorFn" :message="__('%{spanStart}in%{spanEnd} %{errorFn}')"> diff --git a/app/assets/javascripts/error_tracking_settings/components/app.vue b/app/assets/javascripts/error_tracking_settings/components/app.vue index db90ac1c740..786abc8ce49 100644 --- a/app/assets/javascripts/error_tracking_settings/components/app.vue +++ b/app/assets/javascripts/error_tracking_settings/components/app.vue @@ -92,15 +92,13 @@ export default { @select-project="updateSelectedProject" /> </div> - <div class="gl-display-flex gl-justify-content-end"> - <gl-button - :disabled="settingsLoading" - class="js-error-tracking-button" - variant="success" - @click="handleSubmit" - > - {{ __('Save changes') }} - </gl-button> - </div> + <gl-button + :disabled="settingsLoading" + class="js-error-tracking-button" + variant="success" + @click="handleSubmit" + > + {{ __('Save changes') }} + </gl-button> </div> </template> 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 f1fb1a44758..b1b699d2e2a 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 @@ -1,10 +1,9 @@ <script> import { mapActions, mapState } from 'vuex'; -import { GlFormInput, GlIcon } from '@gitlab/ui'; -import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import { GlFormInput, GlIcon, GlButton } from '@gitlab/ui'; export default { - components: { GlFormInput, GlIcon, LoadingButton }, + components: { GlFormInput, GlIcon, GlButton }, computed: { ...mapState(['apiHost', 'connectError', 'connectSuccessful', 'isLoadingProjects', 'token']), tokenInputState() { @@ -57,12 +56,16 @@ export default { /> </div> <div class="col-4 col-md-3 gl-pl-0"> - <loading-button + <gl-button class="js-error-tracking-connect gl-ml-2 d-inline-flex" - :label="isLoadingProjects ? __('Connecting') : __('Connect')" + category="secondary" + variant="default" :loading="isLoadingProjects" @click="fetchProjects" - /> + > + {{ isLoadingProjects ? __('Connecting') : __('Connect') }} + </gl-button> + <gl-icon v-show="connectSuccessful" class="js-error-tracking-connect-success gl-ml-2 text-success align-middle" diff --git a/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue b/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue new file mode 100644 index 00000000000..b652cb329d7 --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue @@ -0,0 +1,254 @@ +<script> +import { + GlFormGroup, + GlFormInput, + GlModal, + GlTooltipDirective, + GlLoadingIcon, + GlSprintf, + GlLink, + GlIcon, +} from '@gitlab/ui'; +import { s__, __ } from '~/locale'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; +import Callout from '~/vue_shared/components/callout.vue'; + +export default { + cancelActionLabel: __('Close'), + modalTitle: s__('FeatureFlags|Configure feature flags'), + apiUrlLabelText: s__('FeatureFlags|API URL'), + apiUrlCopyText: __('Copy URL'), + instanceIdLabelText: s__('FeatureFlags|Instance ID'), + instanceIdCopyText: __('Copy ID'), + instanceIdRegenerateError: __('Unable to generate new instance ID'), + instanceIdRegenerateText: __( + 'Regenerating the instance ID can break integration depending on the client you are using.', + ), + instanceIdRegenerateActionLabel: __('Regenerate instance ID'), + components: { + GlFormGroup, + GlFormInput, + GlModal, + ModalCopyButton, + GlIcon, + Callout, + GlLoadingIcon, + GlSprintf, + GlLink, + }, + + directives: { + GlTooltip: GlTooltipDirective, + }, + + props: { + helpClientLibrariesPath: { + type: String, + required: true, + }, + helpClientExamplePath: { + type: String, + required: true, + }, + apiUrl: { + type: String, + required: true, + }, + instanceId: { + type: String, + required: true, + }, + modalId: { + type: String, + required: false, + default: 'configure-feature-flags', + }, + isRotating: { + type: Boolean, + required: true, + }, + hasRotateError: { + type: Boolean, + required: true, + }, + canUserRotateToken: { + type: Boolean, + required: true, + }, + }, + inject: ['projectName', 'featureFlagsHelpPagePath'], + data() { + return { + enteredProjectName: '', + }; + }, + computed: { + cancelActionProps() { + return { + text: this.$options.cancelActionLabel, + }; + }, + canRegenerateInstanceId() { + return this.canUserRotateToken && this.enteredProjectName === this.projectName; + }, + regenerateInstanceIdActionProps() { + return this.canUserRotateToken + ? { + text: this.$options.instanceIdRegenerateActionLabel, + attributes: [ + { + category: 'secondary', + disabled: !this.canRegenerateInstanceId, + loading: this.isRotating, + variant: 'danger', + }, + ], + } + : null; + }, + }, + + methods: { + clearState() { + this.enteredProjectName = ''; + }, + rotateToken() { + this.$emit('token'); + this.clearState(); + }, + }, +}; +</script> +<template> + <gl-modal + :modal-id="modalId" + :action-cancel="cancelActionProps" + :action-primary="regenerateInstanceIdActionProps" + @canceled="clearState" + @hide="clearState" + @primary.prevent="rotateToken" + > + <template #modal-title> + {{ $options.modalTitle }} + </template> + <p> + <gl-sprintf + :message=" + s__( + 'FeatureFlags|Install a %{docsLinkAnchoredStart}compatible client library%{docsLinkAnchoredEnd} and specify the API URL, application name, and instance ID during the configuration setup. %{docsLinkStart}More Information%{docsLinkEnd}', + ) + " + > + <template #docsLinkAnchored="{ content }"> + <gl-link :href="helpClientLibrariesPath" target="_blank" data-testid="help-client-link"> + {{ content }} + </gl-link> + </template> + <template #docsLink="{ content }"> + <gl-link :href="featureFlagsHelpPagePath" target="_blank" data-testid="help-link">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </p> + + <callout category="warning"> + <gl-sprintf + :message=" + s__( + 'FeatureFlags|Set the Unleash client application name to the name of the environment your application runs in. This value is used to match environment scopes. See the %{linkStart}example client configuration%{linkEnd}.', + ) + " + > + <template #link="{ content }"> + <gl-link :href="helpClientExamplePath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </callout> + <div class="form-group"> + <label for="api_url" class="label-bold">{{ $options.apiUrlLabelText }}</label> + <div class="input-group"> + <input + id="api_url" + :value="apiUrl" + readonly + class="form-control" + type="text" + name="api_url" + /> + <span class="input-group-append"> + <modal-copy-button + :text="apiUrl" + :title="$options.apiUrlCopyText" + :modal-id="modalId" + class="input-group-text" + /> + </span> + </div> + </div> + <div class="form-group"> + <label for="instance_id" class="label-bold">{{ $options.instanceIdLabelText }}</label> + <div class="input-group"> + <input + id="instance_id" + :value="instanceId" + class="form-control" + type="text" + name="instance_id" + readonly + :disabled="isRotating" + /> + + <gl-loading-icon + v-if="isRotating" + class="position-absolute align-self-center instance-id-loading-icon" + /> + + <div class="input-group-append"> + <modal-copy-button + :text="instanceId" + :title="$options.instanceIdCopyText" + :modal-id="modalId" + :disabled="isRotating" + class="input-group-text" + /> + </div> + </div> + </div> + <div + v-if="hasRotateError" + class="text-danger d-flex align-items-center font-weight-normal mb-2" + > + <gl-icon name="warning" class="mr-1" /> + <span>{{ $options.instanceIdRegenerateError }}</span> + </div> + <callout + v-if="canUserRotateToken" + category="danger" + :message="$options.instanceIdRegenerateText" + /> + <p v-if="canUserRotateToken" data-testid="prevent-accident-text"> + <gl-sprintf + :message=" + s__( + 'FeatureFlags|To prevent accidental actions we ask you to confirm your intention. Please type %{projectName} to proceed or close this modal to cancel.', + ) + " + > + <template #projectName> + <span class="gl-font-weight-bold gl-text-red-500">{{ projectName }}</span> + </template> + </gl-sprintf> + </p> + <gl-form-group> + <gl-form-input + v-if="canUserRotateToken" + id="project_name_verification" + v-model="enteredProjectName" + name="project_name" + type="text" + :disabled="isRotating" + /> + </gl-form-group> + </gl-modal> +</template> diff --git a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue new file mode 100644 index 00000000000..7c9744da0e8 --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue @@ -0,0 +1,184 @@ +<script> +import { GlAlert, GlLoadingIcon, GlToggle } from '@gitlab/ui'; +import { createNamespacedHelpers } from 'vuex'; +import axios from '~/lib/utils/axios_utils'; +import { sprintf, s__ } from '~/locale'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { LEGACY_FLAG, NEW_FLAG_ALERT } from '../constants'; +import store from '../store/index'; +import FeatureFlagForm from './form.vue'; + +const { mapState, mapActions } = createNamespacedHelpers('edit'); + +export default { + store, + components: { + GlAlert, + GlLoadingIcon, + GlToggle, + FeatureFlagForm, + }, + mixins: [glFeatureFlagMixin()], + props: { + endpoint: { + type: String, + required: true, + }, + path: { + type: String, + required: true, + }, + environmentsEndpoint: { + type: String, + required: true, + }, + projectId: { + type: String, + required: true, + }, + featureFlagIssuesEndpoint: { + type: String, + required: true, + }, + showUserCallout: { + type: Boolean, + required: true, + }, + userCalloutId: { + default: '', + type: String, + required: false, + }, + userCalloutsPath: { + default: '', + type: String, + required: false, + }, + }, + data() { + return { + userShouldSeeNewFlagAlert: this.showUserCallout, + }; + }, + translations: { + legacyFlagAlert: s__( + 'FeatureFlags|GitLab is moving to a new way of managing feature flags, and in 13.4, this feature flag will become read-only. Please create a new feature flag.', + ), + legacyReadOnlyFlagAlert: s__( + 'FeatureFlags|GitLab is moving to a new way of managing feature flags. This feature flag is read-only, and it will be removed in 14.0. Please create a new feature flag.', + ), + newFlagAlert: NEW_FLAG_ALERT, + }, + computed: { + ...mapState([ + 'error', + 'name', + 'description', + 'scopes', + 'strategies', + 'isLoading', + 'hasError', + 'iid', + 'active', + 'version', + ]), + title() { + return this.iid + ? `^${this.iid} ${this.name}` + : sprintf(s__('Edit %{name}'), { name: this.name }); + }, + deprecated() { + return this.hasNewVersionFlags && this.version === LEGACY_FLAG; + }, + deprecatedAndEditable() { + return this.deprecated && !this.hasLegacyReadOnlyFlags; + }, + deprecatedAndReadOnly() { + return this.deprecated && this.hasLegacyReadOnlyFlags; + }, + hasNewVersionFlags() { + return this.glFeatures.featureFlagsNewVersion; + }, + hasLegacyReadOnlyFlags() { + return ( + this.glFeatures.featureFlagsLegacyReadOnly && + !this.glFeatures.featureFlagsLegacyReadOnlyOverride + ); + }, + shouldShowNewFlagAlert() { + return !this.hasNewVersionFlags && this.userShouldSeeNewFlagAlert; + }, + }, + created() { + this.setPath(this.path); + return this.setEndpoint(this.endpoint).then(() => this.fetchFeatureFlag()); + }, + methods: { + ...mapActions([ + 'updateFeatureFlag', + 'setEndpoint', + 'setPath', + 'fetchFeatureFlag', + 'toggleActive', + ]), + dismissNewVersionFlagAlert() { + this.userShouldSeeNewFlagAlert = false; + axios.post(this.userCalloutsPath, { + feature_name: this.userCalloutId, + }); + }, + }, +}; +</script> +<template> + <div> + <gl-alert + v-if="shouldShowNewFlagAlert" + variant="warning" + class="gl-my-5" + @dismiss="dismissNewVersionFlagAlert" + > + {{ $options.translations.newFlagAlert }} + </gl-alert> + <gl-loading-icon v-if="isLoading" /> + + <template v-else-if="!isLoading && !hasError"> + <gl-alert v-if="deprecatedAndEditable" variant="warning" :dismissible="false" class="gl-my-5"> + {{ $options.translations.legacyFlagAlert }} + </gl-alert> + <gl-alert v-if="deprecatedAndReadOnly" variant="warning" :dismissible="false" class="gl-my-5"> + {{ $options.translations.legacyReadOnlyFlagAlert }} + </gl-alert> + <div class="gl-display-flex gl-align-items-center gl-mb-4 gl-mt-4"> + <gl-toggle + :value="active" + data-testid="feature-flag-status-toggle" + data-track-event="click_button" + data-track-label="feature_flag_toggle" + class="gl-mr-4" + @change="toggleActive" + /> + <h3 class="page-title gl-m-0">{{ title }}</h3> + </div> + + <div v-if="error.length" class="alert alert-danger"> + <p v-for="(message, index) in error" :key="index" class="gl-mb-0">{{ message }}</p> + </div> + + <feature-flag-form + :name="name" + :description="description" + :project-id="projectId" + :scopes="scopes" + :strategies="strategies" + :cancel-path="path" + :submit-text="__('Save changes')" + :environments-endpoint="environmentsEndpoint" + :feature-flag-issues-endpoint="featureFlagIssuesEndpoint" + :active="active" + :version="version" + @handleSubmit="data => updateFeatureFlag(data)" + /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/feature_flags/components/environments_dropdown.vue b/app/assets/javascripts/feature_flags/components/environments_dropdown.vue new file mode 100644 index 00000000000..3533771e3ad --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/environments_dropdown.vue @@ -0,0 +1,184 @@ +<script> +import { debounce } from 'lodash'; +import { GlDeprecatedButton, GlSearchBoxByType } from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; + +/** + * Creates a searchable input for environments. + * + * When given a value, it will render it as selected value + * Otherwise it will render a placeholder for the search input. + * It will fetch the available environments on focus. + * + * When the user types, it will trigger an event to allow + * for API queries outside of the component. + * + * When results are returned, it renders a selectable + * list with the suggestions + * + * When no results are returned, it will render a + * button with a `Create` label. When clicked, it will + * emit an event to allow for the creation of a new + * record. + * + */ + +export default { + name: 'EnvironmentsSearchableInput', + components: { + GlDeprecatedButton, + GlSearchBoxByType, + }, + props: { + endpoint: { + type: String, + required: true, + }, + value: { + type: String, + required: false, + default: '', + }, + placeholder: { + type: String, + required: false, + default: __('Search an environment spec'), + }, + createButtonLabel: { + type: String, + required: false, + default: __('Create'), + }, + disabled: { + type: Boolean, + default: false, + required: false, + }, + }, + data() { + return { + environmentSearch: this.value, + results: [], + showSuggestions: false, + isLoading: false, + }; + }, + computed: { + /** + * Creates a label with the value of the filter + * @returns {String} + */ + composedCreateButtonLabel() { + return `${this.createButtonLabel} ${this.environmentSearch}`; + }, + shouldRenderCreateButton() { + return !this.isLoading && !this.results.length; + }, + }, + methods: { + fetchEnvironments: debounce(function debouncedFetchEnvironments() { + this.isLoading = true; + this.openSuggestions(); + axios + .get(this.endpoint, { params: { query: this.environmentSearch } }) + .then(({ data }) => { + this.results = data || []; + this.isLoading = false; + }) + .catch(() => { + this.isLoading = false; + this.closeSuggestions(); + createFlash(__('Something went wrong on our end. Please try again.')); + }); + }, 250), + /** + * Opens the list of suggestions + */ + openSuggestions() { + this.showSuggestions = true; + }, + /** + * Closes the list of suggestions and cleans the results + */ + closeSuggestions() { + this.showSuggestions = false; + this.environmentSearch = ''; + }, + /** + * On click, it will: + * 1. clear the input value + * 2. close the list of suggestions + * 3. emit an event + */ + clearInput() { + this.closeSuggestions(); + this.$emit('clearInput'); + }, + /** + * When the user selects a value from the list of suggestions + * + * It emits an event with the selected value + * Clears the filter + * and closes the list of suggestions + * + * @param {String} selected + */ + selectEnvironment(selected) { + this.$emit('selectEnvironment', selected); + this.results = []; + this.closeSuggestions(); + }, + + /** + * When the user clicks the create button + * it emits an event with the filter value + */ + createClicked() { + this.$emit('createClicked', this.environmentSearch); + this.closeSuggestions(); + }, + }, +}; +</script> +<template> + <div> + <div class="dropdown position-relative"> + <gl-search-box-by-type + v-model.trim="environmentSearch" + class="js-env-search" + :aria-label="placeholder" + :placeholder="placeholder" + :disabled="disabled" + :is-loading="isLoading" + @focus="fetchEnvironments" + @keyup="fetchEnvironments" + /> + <div + v-if="showSuggestions" + class="dropdown-menu d-block dropdown-menu-selectable dropdown-menu-full-width" + > + <div class="dropdown-content"> + <ul v-if="results.length"> + <li v-for="(result, i) in results" :key="i"> + <gl-deprecated-button class="btn-transparent" @click="selectEnvironment(result)">{{ + result + }}</gl-deprecated-button> + </li> + </ul> + <div v-else-if="!results.length" class="text-secondary gl-p-3"> + {{ __('No matching results') }} + </div> + <div v-if="shouldRenderCreateButton" class="dropdown-footer"> + <gl-deprecated-button + class="js-create-button btn-blank dropdown-item" + @click="createClicked" + >{{ composedCreateButtonLabel }}</gl-deprecated-button + > + </div> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/feature_flags/components/feature_flags.vue b/app/assets/javascripts/feature_flags/components/feature_flags.vue new file mode 100644 index 00000000000..18008111a18 --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/feature_flags.vue @@ -0,0 +1,354 @@ +<script> +import { createNamespacedHelpers } from 'vuex'; +import { isEmpty } from 'lodash'; +import { GlButton, GlModalDirective, GlTabs } from '@gitlab/ui'; +import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '../constants'; +import FeatureFlagsTab from './feature_flags_tab.vue'; +import FeatureFlagsTable from './feature_flags_table.vue'; +import UserListsTable from './user_lists_table.vue'; +import store from '../store'; +import { s__ } from '~/locale'; +import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; +import { + getParameterByName, + historyPushState, + buildUrlWithCurrentLocation, +} from '~/lib/utils/common_utils'; + +import ConfigureFeatureFlagsModal from './configure_feature_flags_modal.vue'; + +const { mapState, mapActions } = createNamespacedHelpers('index'); + +const SCOPES = { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE }; + +export default { + store, + components: { + FeatureFlagsTable, + UserListsTable, + TablePagination, + GlButton, + GlTabs, + FeatureFlagsTab, + ConfigureFeatureFlagsModal, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + endpoint: { + type: String, + required: true, + }, + projectId: { + type: String, + required: true, + }, + csrfToken: { + type: String, + required: true, + }, + featureFlagsClientLibrariesHelpPagePath: { + type: String, + required: true, + }, + featureFlagsClientExampleHelpPagePath: { + type: String, + required: true, + }, + rotateInstanceIdPath: { + type: String, + required: false, + default: '', + }, + unleashApiUrl: { + type: String, + required: true, + }, + unleashApiInstanceId: { + type: String, + required: true, + }, + canUserConfigure: { + type: Boolean, + required: true, + }, + newFeatureFlagPath: { + type: String, + required: false, + default: '', + }, + newUserListPath: { + type: String, + required: false, + default: '', + }, + }, + data() { + const scope = getParameterByName('scope') || SCOPES.FEATURE_FLAG_SCOPE; + return { + scope, + page: getParameterByName('page') || '1', + isUserListAlertDismissed: false, + selectedTab: Object.values(SCOPES).indexOf(scope), + }; + }, + computed: { + ...mapState([ + FEATURE_FLAG_SCOPE, + USER_LIST_SCOPE, + 'alerts', + 'count', + 'pageInfo', + 'isLoading', + 'hasError', + 'options', + 'instanceId', + 'isRotating', + 'hasRotateError', + ]), + topAreaBaseClasses() { + return ['gl-display-flex', 'gl-flex-direction-column']; + }, + canUserRotateToken() { + return this.rotateInstanceIdPath !== ''; + }, + currentlyDisplayedData() { + return this.dataForScope(this.scope); + }, + shouldRenderPagination() { + return ( + !this.isLoading && + !this.hasError && + this.currentlyDisplayedData.length > 0 && + this.pageInfo[this.scope].total > this.pageInfo[this.scope].perPage + ); + }, + shouldShowEmptyState() { + return !this.isLoading && !this.hasError && this.currentlyDisplayedData.length === 0; + }, + shouldRenderErrorState() { + return this.hasError && !this.isLoading; + }, + shouldRenderFeatureFlags() { + return this.shouldRenderTable(SCOPES.FEATURE_FLAG_SCOPE); + }, + shouldRenderUserLists() { + return this.shouldRenderTable(SCOPES.USER_LIST_SCOPE); + }, + hasNewPath() { + return !isEmpty(this.newFeatureFlagPath); + }, + emptyStateTitle() { + return s__('FeatureFlags|Get started with feature flags'); + }, + }, + created() { + this.setFeatureFlagsEndpoint(this.endpoint); + this.setFeatureFlagsOptions({ scope: this.scope, page: this.page }); + this.setProjectId(this.projectId); + this.fetchFeatureFlags(); + this.fetchUserLists(); + this.setInstanceId(this.unleashApiInstanceId); + this.setInstanceIdEndpoint(this.rotateInstanceIdPath); + }, + methods: { + ...mapActions([ + 'setFeatureFlagsEndpoint', + 'setFeatureFlagsOptions', + 'fetchFeatureFlags', + 'fetchUserLists', + 'setInstanceIdEndpoint', + 'setInstanceId', + 'setProjectId', + 'rotateInstanceId', + 'toggleFeatureFlag', + 'deleteUserList', + 'clearAlert', + ]), + onChangeTab(scope) { + this.scope = scope; + this.updateFeatureFlagOptions({ + scope, + page: '1', + }); + }, + onFeatureFlagsTab() { + this.onChangeTab(SCOPES.FEATURE_FLAG_SCOPE); + }, + onUserListsTab() { + this.onChangeTab(SCOPES.USER_LIST_SCOPE); + }, + onChangePage(page) { + this.updateFeatureFlagOptions({ + scope: this.scope, + /* URLS parameters are strings, we need to parse to match types */ + page: Number(page).toString(), + }); + }, + updateFeatureFlagOptions(parameters) { + const queryString = Object.keys(parameters) + .map(parameter => { + const value = parameters[parameter]; + return `${parameter}=${encodeURIComponent(value)}`; + }) + .join('&'); + + historyPushState(buildUrlWithCurrentLocation(`?${queryString}`)); + this.setFeatureFlagsOptions(parameters); + if (this.scope === SCOPES.FEATURE_FLAG_SCOPE) { + this.fetchFeatureFlags(); + } else { + this.fetchUserLists(); + } + }, + shouldRenderTable(scope) { + return ( + !this.isLoading && + this.dataForScope(scope).length > 0 && + !this.hasError && + this.scope === scope + ); + }, + dataForScope(scope) { + return this[scope]; + }, + }, +}; +</script> +<template> + <div> + <configure-feature-flags-modal + v-if="canUserConfigure" + :help-client-libraries-path="featureFlagsClientLibrariesHelpPagePath" + :help-client-example-path="featureFlagsClientExampleHelpPagePath" + :api-url="unleashApiUrl" + :instance-id="instanceId" + :is-rotating="isRotating" + :has-rotate-error="hasRotateError" + :can-user-rotate-token="canUserRotateToken" + modal-id="configure-feature-flags" + @token="rotateInstanceId()" + /> + <div :class="topAreaBaseClasses"> + <div class="gl-display-flex gl-flex-direction-column gl-display-md-none!"> + <gl-button + v-if="canUserConfigure" + v-gl-modal="'configure-feature-flags'" + variant="info" + category="secondary" + data-qa-selector="configure_feature_flags_button" + data-testid="ff-configure-button" + class="gl-mb-3" + > + {{ s__('FeatureFlags|Configure') }} + </gl-button> + + <gl-button + v-if="newUserListPath" + :href="newUserListPath" + variant="success" + category="secondary" + class="gl-mb-3" + data-testid="ff-new-list-button" + > + {{ s__('FeatureFlags|New user list') }} + </gl-button> + + <gl-button + v-if="hasNewPath" + :href="newFeatureFlagPath" + variant="success" + data-testid="ff-new-button" + > + {{ s__('FeatureFlags|New feature flag') }} + </gl-button> + </div> + <gl-tabs v-model="selectedTab" class="gl-align-items-center gl-w-full"> + <feature-flags-tab + :title="s__('FeatureFlags|Feature Flags')" + :count="count.featureFlags" + :alerts="alerts" + :is-loading="isLoading" + :loading-label="s__('FeatureFlags|Loading feature flags')" + :error-state="shouldRenderErrorState" + :error-title="s__(`FeatureFlags|There was an error fetching the feature flags.`)" + :empty-state="shouldShowEmptyState" + :empty-title="emptyStateTitle" + data-testid="feature-flags-tab" + @dismissAlert="clearAlert" + @changeTab="onFeatureFlagsTab" + > + <feature-flags-table + v-if="shouldRenderFeatureFlags" + :csrf-token="csrfToken" + :feature-flags="featureFlags" + @toggle-flag="toggleFeatureFlag" + /> + </feature-flags-tab> + <feature-flags-tab + :title="s__('FeatureFlags|User Lists')" + :count="count.userLists" + :alerts="alerts" + :is-loading="isLoading" + :loading-label="s__('FeatureFlags|Loading user lists')" + :error-state="shouldRenderErrorState" + :error-title="s__(`FeatureFlags|There was an error fetching the user lists.`)" + :empty-state="shouldShowEmptyState" + :empty-title="emptyStateTitle" + data-testid="user-lists-tab" + @dismissAlert="clearAlert" + @changeTab="onUserListsTab" + > + <user-lists-table + v-if="shouldRenderUserLists" + :user-lists="userLists" + @delete="deleteUserList" + /> + </feature-flags-tab> + <template #tabs-end> + <div + class="gl-display-none gl-display-md-flex gl-align-items-center gl-flex-fill-1 gl-justify-content-end" + > + <gl-button + v-if="canUserConfigure" + v-gl-modal="'configure-feature-flags'" + variant="info" + category="secondary" + data-qa-selector="configure_feature_flags_button" + data-testid="ff-configure-button" + class="gl-mb-0 gl-mr-4" + > + {{ s__('FeatureFlags|Configure') }} + </gl-button> + + <gl-button + v-if="newUserListPath" + :href="newUserListPath" + variant="success" + category="secondary" + class="gl-mb-0 gl-mr-4" + data-testid="ff-new-list-button" + > + {{ s__('FeatureFlags|New user list') }} + </gl-button> + + <gl-button + v-if="hasNewPath" + :href="newFeatureFlagPath" + variant="success" + data-testid="ff-new-button" + > + {{ s__('FeatureFlags|New feature flag') }} + </gl-button> + </div> + </template> + </gl-tabs> + </div> + <table-pagination + v-if="shouldRenderPagination" + :change="onChangePage" + :page-info="pageInfo[scope]" + /> + </div> +</template> diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_tab.vue b/app/assets/javascripts/feature_flags/components/feature_flags_tab.vue new file mode 100644 index 00000000000..5c35aa33e14 --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/feature_flags_tab.vue @@ -0,0 +1,108 @@ +<script> +import { GlAlert, GlBadge, GlEmptyState, GlLink, GlLoadingIcon, GlTab } from '@gitlab/ui'; + +export default { + components: { GlAlert, GlBadge, GlEmptyState, GlLink, GlLoadingIcon, GlTab }, + props: { + title: { + required: true, + type: String, + }, + count: { + required: false, + type: Number, + default: null, + }, + alerts: { + required: true, + type: Array, + }, + isLoading: { + required: true, + type: Boolean, + }, + loadingLabel: { + required: true, + type: String, + }, + errorState: { + required: true, + type: Boolean, + }, + errorTitle: { + required: true, + type: String, + }, + emptyState: { + required: true, + type: Boolean, + }, + emptyTitle: { + required: true, + type: String, + }, + }, + inject: ['errorStateSvgPath', 'featureFlagsHelpPagePath'], + computed: { + itemCount() { + return this.count ?? 0; + }, + }, + methods: { + clearAlert(index) { + this.$emit('dismissAlert', index); + }, + onClick(event) { + return this.$emit('changeTab', event); + }, + }, +}; +</script> +<template> + <gl-tab @click="onClick"> + <template #title> + <span data-testid="feature-flags-tab-title">{{ title }}</span> + <gl-badge size="sm" class="gl-tab-counter-badge">{{ itemCount }}</gl-badge> + </template> + <template> + <gl-alert + v-for="(message, index) in alerts" + :key="index" + data-testid="serverErrors" + variant="danger" + @dismiss="clearAlert(index)" + > + {{ message }} + </gl-alert> + + <gl-loading-icon v-if="isLoading" :label="loadingLabel" size="md" class="gl-mt-4" /> + + <gl-empty-state + v-else-if="errorState" + :title="errorTitle" + :description="s__(`FeatureFlags|Try again in a few moments or contact your support team.`)" + :svg-path="errorStateSvgPath" + data-testid="error-state" + /> + + <gl-empty-state + v-else-if="emptyState" + :title="emptyTitle" + :svg-path="errorStateSvgPath" + data-testid="empty-state" + > + <template #description> + {{ + s__( + 'FeatureFlags|Feature flags allow you to configure your code into different flavors by dynamically toggling certain functionality.', + ) + }} + <gl-link :href="featureFlagsHelpPagePath" target="_blank"> + {{ s__('FeatureFlags|More information') }} + </gl-link> + </template> + </gl-empty-state> + <slot> </slot> + </template> + </gl-tab> +</template> diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue new file mode 100644 index 00000000000..7881ae523fc --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue @@ -0,0 +1,274 @@ +<script> +import { GlBadge, GlButton, GlTooltipDirective, GlModal, GlToggle, GlIcon } from '@gitlab/ui'; +import { sprintf, s__ } from '~/locale'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { ROLLOUT_STRATEGY_PERCENT_ROLLOUT, NEW_VERSION_FLAG, LEGACY_FLAG } from '../constants'; +import labelForStrategy from '../utils'; + +export default { + components: { + GlBadge, + GlButton, + GlIcon, + GlModal, + GlToggle, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [glFeatureFlagMixin()], + props: { + csrfToken: { + type: String, + required: true, + }, + featureFlags: { + type: Array, + required: true, + }, + }, + data() { + return { + deleteFeatureFlagUrl: null, + deleteFeatureFlagName: null, + }; + }, + translations: { + legacyFlagAlert: s__('FeatureFlags|Flag becomes read only soon'), + legacyFlagReadOnlyAlert: s__('FeatureFlags|Flag is read-only'), + }, + computed: { + permissions() { + return this.glFeatures.featureFlagPermissions; + }, + isNewVersionFlagsEnabled() { + return this.glFeatures.featureFlagsNewVersion; + }, + isLegacyReadOnlyFlagsEnabled() { + return ( + this.glFeatures.featureFlagsLegacyReadOnly && + !this.glFeatures.featureFlagsLegacyReadOnlyOverride + ); + }, + modalTitle() { + return sprintf(s__('FeatureFlags|Delete %{name}?'), { + name: this.deleteFeatureFlagName, + }); + }, + deleteModalMessage() { + return sprintf(s__('FeatureFlags|Feature flag %{name} will be removed. Are you sure?'), { + name: this.deleteFeatureFlagName, + }); + }, + modalId() { + return 'delete-feature-flag'; + }, + legacyFlagToolTipText() { + const { legacyFlagReadOnlyAlert, legacyFlagAlert } = this.$options.translations; + + return this.isLegacyReadOnlyFlagsEnabled ? legacyFlagReadOnlyAlert : legacyFlagAlert; + }, + }, + methods: { + isLegacyFlag(flag) { + return !this.isNewVersionFlagsEnabled || flag.version !== NEW_VERSION_FLAG; + }, + statusToggleDisabled(flag) { + return this.isLegacyReadOnlyFlagsEnabled && flag.version === LEGACY_FLAG; + }, + scopeTooltipText(scope) { + return !scope.active + ? sprintf(s__('FeatureFlags|Inactive flag for %{scope}'), { + scope: scope.environmentScope, + }) + : ''; + }, + badgeText(scope) { + const displayName = + scope.environmentScope === '*' + ? s__('FeatureFlags|* (All environments)') + : scope.environmentScope; + + const displayPercentage = + scope.rolloutStrategy === ROLLOUT_STRATEGY_PERCENT_ROLLOUT + ? `: ${scope.rolloutPercentage}%` + : ''; + + return `${displayName}${displayPercentage}`; + }, + badgeVariant(scope) { + return scope.active ? 'info' : 'muted'; + }, + strategyBadgeText(strategy) { + return labelForStrategy(strategy); + }, + featureFlagIidText(featureFlag) { + return featureFlag.iid ? `^${featureFlag.iid}` : ''; + }, + canDeleteFlag(flag) { + return !this.permissions || (flag.scopes || []).every(scope => scope.can_update); + }, + setDeleteModalData(featureFlag) { + this.deleteFeatureFlagUrl = featureFlag.destroy_path; + this.deleteFeatureFlagName = featureFlag.name; + + this.$refs[this.modalId].show(); + }, + onSubmit() { + this.$refs.form.submit(); + }, + toggleFeatureFlag(flag) { + this.$emit('toggle-flag', { + ...flag, + active: !flag.active, + }); + }, + }, +}; +</script> +<template> + <div class="table-holder js-feature-flag-table"> + <div class="gl-responsive-table-row table-row-header" role="row"> + <div class="table-section section-10"> + {{ s__('FeatureFlags|ID') }} + </div> + <div class="table-section section-10" role="columnheader"> + {{ s__('FeatureFlags|Status') }} + </div> + <div class="table-section section-20" role="columnheader"> + {{ s__('FeatureFlags|Feature Flag') }} + </div> + <div class="table-section section-40" role="columnheader"> + {{ s__('FeatureFlags|Environment Specs') }} + </div> + </div> + + <template v-for="featureFlag in featureFlags"> + <div :key="featureFlag.id" class="gl-responsive-table-row" role="row"> + <div class="table-section section-10" role="gridcell"> + <div class="table-mobile-header" role="rowheader">{{ s__('FeatureFlags|ID') }}</div> + <div class="table-mobile-content js-feature-flag-id"> + {{ featureFlagIidText(featureFlag) }} + </div> + </div> + <div class="table-section section-10" role="gridcell"> + <div class="table-mobile-header" role="rowheader">{{ s__('FeatureFlags|Status') }}</div> + <div class="table-mobile-content"> + <gl-toggle + v-if="featureFlag.update_path" + :value="featureFlag.active" + :disabled="statusToggleDisabled(featureFlag)" + data-testid="feature-flag-status-toggle" + data-track-event="click_button" + data-track-label="feature_flag_toggle" + @change="toggleFeatureFlag(featureFlag)" + /> + <gl-badge + v-else-if="featureFlag.active" + variant="success" + data-testid="feature-flag-status-badge" + > + {{ s__('FeatureFlags|Active') }} + </gl-badge> + <gl-badge v-else variant="danger">{{ s__('FeatureFlags|Inactive') }}</gl-badge> + </div> + </div> + + <div class="table-section section-20" role="gridcell"> + <div class="table-mobile-header" role="rowheader"> + {{ s__('FeatureFlags|Feature Flag') }} + </div> + <div class="table-mobile-content d-flex flex-column js-feature-flag-title"> + <div class="gl-display-flex gl-align-items-center"> + <div class="feature-flag-name text-monospace text-truncate"> + {{ featureFlag.name }} + </div> + <gl-icon + v-if="isLegacyFlag(featureFlag)" + v-gl-tooltip.hover="legacyFlagToolTipText" + class="gl-ml-3" + name="information-o" + /> + </div> + <div class="feature-flag-description text-secondary text-truncate"> + {{ featureFlag.description }} + </div> + </div> + </div> + + <div class="table-section section-40" role="gridcell"> + <div class="table-mobile-header" role="rowheader"> + {{ s__('FeatureFlags|Environment Specs') }} + </div> + <div + class="table-mobile-content d-flex flex-wrap justify-content-end justify-content-md-start js-feature-flag-environments" + > + <template v-if="isLegacyFlag(featureFlag)"> + <gl-badge + v-for="scope in featureFlag.scopes" + :key="scope.id" + v-gl-tooltip.hover="scopeTooltipText(scope)" + :variant="badgeVariant(scope)" + :data-qa-selector="`feature-flag-scope-${badgeVariant(scope)}-badge`" + class="gl-mr-3 gl-mt-2" + > + {{ badgeText(scope) }} + </gl-badge> + </template> + <template v-else> + <gl-badge + v-for="strategy in featureFlag.strategies" + :key="strategy.id" + data-testid="strategy-badge" + variant="info" + class="gl-mr-3 gl-mt-2" + > + {{ strategyBadgeText(strategy) }} + </gl-badge> + </template> + </div> + </div> + + <div class="table-section section-20 table-button-footer" role="gridcell"> + <div class="table-action-buttons btn-group"> + <template v-if="featureFlag.edit_path"> + <gl-button + v-gl-tooltip.hover.bottom="__('Edit')" + class="js-feature-flag-edit-button" + icon="pencil" + :href="featureFlag.edit_path" + /> + </template> + <template v-if="featureFlag.destroy_path"> + <gl-button + v-gl-tooltip.hover.bottom="__('Delete')" + class="js-feature-flag-delete-button" + variant="danger" + icon="remove" + :disabled="!canDeleteFlag(featureFlag)" + @click="setDeleteModalData(featureFlag)" + /> + </template> + </div> + </div> + </div> + </template> + + <gl-modal + :ref="modalId" + :title="modalTitle" + :ok-title="s__('FeatureFlags|Delete feature flag')" + :modal-id="modalId" + title-tag="h4" + ok-variant="danger" + category="primary" + @ok="onSubmit" + > + {{ deleteModalMessage }} + <form ref="form" :action="deleteFeatureFlagUrl" method="post" class="js-requires-input"> + <input ref="method" type="hidden" name="_method" value="delete" /> + <input :value="csrfToken" type="hidden" name="authenticity_token" /> + </form> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/feature_flags/components/form.vue b/app/assets/javascripts/feature_flags/components/form.vue new file mode 100644 index 00000000000..04bea2d80d4 --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/form.vue @@ -0,0 +1,616 @@ +<script> +import Vue from 'vue'; +import { memoize, isString, cloneDeep, isNumber, uniqueId } from 'lodash'; +import { + GlButton, + GlDeprecatedBadge as GlBadge, + GlTooltip, + GlTooltipDirective, + GlFormTextarea, + GlFormCheckbox, + GlSprintf, + GlIcon, +} from '@gitlab/ui'; +import Api from '~/api'; +import RelatedIssuesRoot from '~/related_issues/components/related_issues_root.vue'; +import { s__ } from '~/locale'; +import { deprecatedCreateFlash as flash, FLASH_TYPES } from '~/flash'; +import featureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import ToggleButton from '~/vue_shared/components/toggle_button.vue'; +import EnvironmentsDropdown from './environments_dropdown.vue'; +import Strategy from './strategy.vue'; +import { + ROLLOUT_STRATEGY_ALL_USERS, + ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + ROLLOUT_STRATEGY_USER_ID, + ALL_ENVIRONMENTS_NAME, + INTERNAL_ID_PREFIX, + NEW_VERSION_FLAG, + LEGACY_FLAG, +} from '../constants'; +import { createNewEnvironmentScope } from '../store/modules/helpers'; + +export default { + components: { + GlButton, + GlBadge, + GlFormTextarea, + GlFormCheckbox, + GlTooltip, + GlSprintf, + GlIcon, + ToggleButton, + EnvironmentsDropdown, + Strategy, + RelatedIssuesRoot, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [featureFlagsMixin()], + props: { + active: { + type: Boolean, + required: false, + default: true, + }, + name: { + type: String, + required: false, + default: '', + }, + description: { + type: String, + required: false, + default: '', + }, + projectId: { + type: String, + required: true, + }, + scopes: { + type: Array, + required: false, + default: () => [], + }, + cancelPath: { + type: String, + required: true, + }, + submitText: { + type: String, + required: true, + }, + environmentsEndpoint: { + type: String, + required: true, + }, + featureFlagIssuesEndpoint: { + type: String, + required: false, + default: '', + }, + strategies: { + type: Array, + required: false, + default: () => [], + }, + version: { + type: String, + required: false, + default: LEGACY_FLAG, + }, + }, + translations: { + allEnvironmentsText: s__('FeatureFlags|* (All Environments)'), + + helpText: s__( + 'FeatureFlags|Feature Flag behavior is built up by creating a set of rules to define the status of target environments. A default wildcard rule %{codeStart}*%{codeEnd} for %{boldStart}All Environments%{boldEnd} is set, and you are able to add as many rules as you need by choosing environment specs below. You can toggle the behavior for each of your rules to set them %{boldStart}Active%{boldEnd} or %{boldStart}Inactive%{boldEnd}.', + ), + + newHelpText: s__( + 'FeatureFlags|Enable features for specific users and environments by configuring feature flag strategies.', + ), + noStrategiesText: s__('FeatureFlags|Feature Flag has no strategies'), + }, + + ROLLOUT_STRATEGY_ALL_USERS, + ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + ROLLOUT_STRATEGY_USER_ID, + + // Matches numbers 0 through 100 + rolloutPercentageRegex: /^[0-9]$|^[1-9][0-9]$|^100$/, + + data() { + return { + formName: this.name, + formDescription: this.description, + + // operate on a clone to avoid mutating props + formScopes: this.scopes.map(s => ({ ...s })), + formStrategies: cloneDeep(this.strategies), + + newScope: '', + userLists: [], + }; + }, + computed: { + filteredScopes() { + return this.formScopes.filter(scope => !scope.shouldBeDestroyed); + }, + filteredStrategies() { + return this.formStrategies.filter(s => !s.shouldBeDestroyed); + }, + canUpdateFlag() { + return !this.permissionsFlag || (this.formScopes || []).every(scope => scope.canUpdate); + }, + permissionsFlag() { + return this.glFeatures.featureFlagPermissions; + }, + supportsStrategies() { + return this.glFeatures.featureFlagsNewVersion && this.version === NEW_VERSION_FLAG; + }, + showRelatedIssues() { + return this.featureFlagIssuesEndpoint.length > 0; + }, + readOnly() { + return ( + this.glFeatures.featureFlagsNewVersion && + this.glFeatures.featureFlagsLegacyReadOnly && + !this.glFeatures.featureFlagsLegacyReadOnlyOverride && + this.version === LEGACY_FLAG + ); + }, + }, + mounted() { + if (this.supportsStrategies) { + Api.fetchFeatureFlagUserLists(this.projectId) + .then(({ data }) => { + this.userLists = data; + }) + .catch(() => { + flash(s__('FeatureFlags|There was an error retrieving user lists'), FLASH_TYPES.WARNING); + }); + } + }, + methods: { + keyFor(strategy) { + if (strategy.id) { + return strategy.id; + } + + return uniqueId('strategy_'); + }, + + addStrategy() { + this.formStrategies.push({ name: ROLLOUT_STRATEGY_ALL_USERS, parameters: {}, scopes: [] }); + }, + + deleteStrategy(s) { + if (isNumber(s.id)) { + Vue.set(s, 'shouldBeDestroyed', true); + } else { + this.formStrategies = this.formStrategies.filter(strategy => strategy !== s); + } + }, + + isAllEnvironment(name) { + return name === ALL_ENVIRONMENTS_NAME; + }, + + /** + * When the user clicks the remove button we delete the scope + * + * If the scope has an ID, we need to add the `shouldBeDestroyed` flag. + * If the scope does *not* have an ID, we can just remove it. + * + * This flag will be used when submitting the data to the backend + * to determine which records to delete (via a "_destroy" property). + * + * @param {Object} scope + */ + removeScope(scope) { + if (isString(scope.id) && scope.id.startsWith(INTERNAL_ID_PREFIX)) { + this.formScopes = this.formScopes.filter(s => s !== scope); + } else { + Vue.set(scope, 'shouldBeDestroyed', true); + } + }, + + /** + * Creates a new scope and adds it to the list of scopes + * + * @param overrides An object whose properties will + * be used override the default scope options + */ + createNewScope(overrides) { + this.formScopes.push(createNewEnvironmentScope(overrides, this.permissionsFlag)); + this.newScope = ''; + }, + + /** + * When the user clicks the submit button + * it triggers an event with the form data + */ + handleSubmit() { + const flag = { + name: this.formName, + description: this.formDescription, + active: this.active, + version: this.version, + }; + + if (this.version === LEGACY_FLAG) { + flag.scopes = this.formScopes; + } else { + flag.strategies = this.formStrategies; + } + + this.$emit('handleSubmit', flag); + }, + + canUpdateScope(scope) { + return !this.permissionsFlag || scope.canUpdate; + }, + + isRolloutPercentageInvalid: memoize(function isRolloutPercentageInvalid(percentage) { + return !this.$options.rolloutPercentageRegex.test(percentage); + }), + + /** + * Generates a unique ID for the strategy based on the v-for index + * + * @param index The index of the strategy + */ + rolloutStrategyId(index) { + return `rollout-strategy-${index}`; + }, + + /** + * Generates a unique ID for the percentage based on the v-for index + * + * @param index The index of the percentage + */ + rolloutPercentageId(index) { + return `rollout-percentage-${index}`; + }, + rolloutUserId(index) { + return `rollout-user-id-${index}`; + }, + + shouldDisplayIncludeUserIds(scope) { + return ![ROLLOUT_STRATEGY_ALL_USERS, ROLLOUT_STRATEGY_USER_ID].includes( + scope.rolloutStrategy, + ); + }, + shouldDisplayUserIds(scope) { + return scope.rolloutStrategy === ROLLOUT_STRATEGY_USER_ID || scope.shouldIncludeUserIds; + }, + onStrategyChange(index) { + const scope = this.filteredScopes[index]; + scope.shouldIncludeUserIds = + scope.rolloutUserIds.length > 0 && + scope.rolloutStrategy === ROLLOUT_STRATEGY_PERCENT_ROLLOUT; + }, + onFormStrategyChange(strategy, index) { + Object.assign(this.filteredStrategies[index], strategy); + }, + }, +}; +</script> +<template> + <form class="feature-flags-form"> + <fieldset> + <div class="row"> + <div class="form-group col-md-4"> + <label for="feature-flag-name" class="label-bold">{{ s__('FeatureFlags|Name') }} *</label> + <input + id="feature-flag-name" + v-model="formName" + :disabled="!canUpdateFlag" + class="form-control" + /> + </div> + </div> + + <div class="row"> + <div class="form-group col-md-4"> + <label for="feature-flag-description" class="label-bold"> + {{ s__('FeatureFlags|Description') }} + </label> + <textarea + id="feature-flag-description" + v-model="formDescription" + :disabled="!canUpdateFlag" + class="form-control" + rows="4" + ></textarea> + </div> + </div> + + <related-issues-root + v-if="showRelatedIssues" + :endpoint="featureFlagIssuesEndpoint" + :can-admin="true" + :show-categorized-issues="false" + /> + + <template v-if="supportsStrategies"> + <div class="row"> + <div class="col-md-12"> + <h4>{{ s__('FeatureFlags|Strategies') }}</h4> + <div class="flex align-items-baseline justify-content-between"> + <p class="mr-3">{{ $options.translations.newHelpText }}</p> + <gl-button variant="success" category="secondary" @click="addStrategy"> + {{ s__('FeatureFlags|Add strategy') }} + </gl-button> + </div> + </div> + </div> + <div v-if="filteredStrategies.length > 0" data-testid="feature-flag-strategies"> + <strategy + v-for="(strategy, index) in filteredStrategies" + :key="keyFor(strategy)" + :strategy="strategy" + :index="index" + :endpoint="environmentsEndpoint" + :user-lists="userLists" + @change="onFormStrategyChange($event, index)" + @delete="deleteStrategy(strategy)" + /> + </div> + <div v-else class="flex justify-content-center border-top py-4 w-100"> + <span>{{ $options.translations.noStrategiesText }}</span> + </div> + </template> + + <div v-else class="row"> + <div class="form-group col-md-12"> + <h4>{{ s__('FeatureFlags|Target environments') }}</h4> + <gl-sprintf :message="$options.translations.helpText"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + <template #bold="{ content }"> + <b>{{ content }}</b> + </template> + </gl-sprintf> + + <div class="js-scopes-table gl-mt-3"> + <div class="gl-responsive-table-row table-row-header" role="row"> + <div class="table-section section-30" role="columnheader"> + {{ s__('FeatureFlags|Environment Spec') }} + </div> + <div class="table-section section-20 text-center" role="columnheader"> + {{ s__('FeatureFlags|Status') }} + </div> + <div class="table-section section-40" role="columnheader"> + {{ s__('FeatureFlags|Rollout Strategy') }} + </div> + </div> + + <div + v-for="(scope, index) in filteredScopes" + :key="scope.id" + ref="scopeRow" + class="gl-responsive-table-row" + role="row" + > + <div class="table-section section-30" role="gridcell"> + <div class="table-mobile-header" role="rowheader"> + {{ s__('FeatureFlags|Environment Spec') }} + </div> + <div + class="table-mobile-content js-feature-flag-status d-flex align-items-center justify-content-start" + > + <p v-if="isAllEnvironment(scope.environmentScope)" class="js-scope-all pl-3"> + {{ $options.translations.allEnvironmentsText }} + </p> + + <environments-dropdown + v-else + class="col-12" + :value="scope.environmentScope" + :endpoint="environmentsEndpoint" + :disabled="!canUpdateScope(scope) || scope.environmentScope !== ''" + @selectEnvironment="env => (scope.environmentScope = env)" + @createClicked="env => (scope.environmentScope = env)" + @clearInput="env => (scope.environmentScope = '')" + /> + + <gl-badge v-if="permissionsFlag && scope.protected" variant="success"> + {{ s__('FeatureFlags|Protected') }} + </gl-badge> + </div> + </div> + + <div class="table-section section-20 text-center" role="gridcell"> + <div class="table-mobile-header" role="rowheader"> + {{ s__('FeatureFlags|Status') }} + </div> + <div class="table-mobile-content js-feature-flag-status"> + <toggle-button + :value="scope.active" + :disabled-input="!active || !canUpdateScope(scope)" + @change="status => (scope.active = status)" + /> + </div> + </div> + + <div class="table-section section-40" role="gridcell"> + <div class="table-mobile-header" role="rowheader"> + {{ s__('FeatureFlags|Rollout Strategy') }} + </div> + <div class="table-mobile-content js-rollout-strategy form-inline"> + <label class="sr-only" :for="rolloutStrategyId(index)"> + {{ s__('FeatureFlags|Rollout Strategy') }} + </label> + <div class="select-wrapper col-12 col-md-8 p-0"> + <select + :id="rolloutStrategyId(index)" + v-model="scope.rolloutStrategy" + :disabled="!scope.active" + class="form-control select-control w-100 js-rollout-strategy" + @change="onStrategyChange(index)" + > + <option :value="$options.ROLLOUT_STRATEGY_ALL_USERS"> + {{ s__('FeatureFlags|All users') }} + </option> + <option :value="$options.ROLLOUT_STRATEGY_PERCENT_ROLLOUT"> + {{ s__('FeatureFlags|Percent rollout (logged in users)') }} + </option> + <option :value="$options.ROLLOUT_STRATEGY_USER_ID"> + {{ s__('FeatureFlags|User IDs') }} + </option> + </select> + <gl-icon + name="chevron-down" + class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" + :size="16" + /> + </div> + + <div + v-if="scope.rolloutStrategy === $options.ROLLOUT_STRATEGY_PERCENT_ROLLOUT" + class="d-flex-center mt-2 mt-md-0 ml-md-2" + > + <label class="sr-only" :for="rolloutPercentageId(index)"> + {{ s__('FeatureFlags|Rollout Percentage') }} + </label> + <div class="w-3rem"> + <input + :id="rolloutPercentageId(index)" + v-model="scope.rolloutPercentage" + :disabled="!scope.active" + :class="{ + 'is-invalid': isRolloutPercentageInvalid(scope.rolloutPercentage), + }" + type="number" + min="0" + max="100" + :pattern="$options.rolloutPercentageRegex.source" + class="rollout-percentage js-rollout-percentage form-control text-right w-100" + /> + </div> + <gl-tooltip + v-if="isRolloutPercentageInvalid(scope.rolloutPercentage)" + :target="rolloutPercentageId(index)" + > + {{ + s__('FeatureFlags|Percent rollout must be a whole number between 0 and 100') + }} + </gl-tooltip> + <span class="ml-1">%</span> + </div> + <div class="d-flex flex-column align-items-start mt-2 w-100"> + <gl-form-checkbox + v-if="shouldDisplayIncludeUserIds(scope)" + v-model="scope.shouldIncludeUserIds" + >{{ s__('FeatureFlags|Include additional user IDs') }}</gl-form-checkbox + > + <template v-if="shouldDisplayUserIds(scope)"> + <label :for="rolloutUserId(index)" class="mb-2"> + {{ s__('FeatureFlags|User IDs') }} + </label> + <gl-form-textarea + :id="rolloutUserId(index)" + v-model="scope.rolloutUserIds" + class="w-100" + /> + </template> + </div> + </div> + </div> + + <div class="table-section section-10 text-right" role="gridcell"> + <div class="table-mobile-header" role="rowheader"> + {{ s__('FeatureFlags|Remove') }} + </div> + <div class="table-mobile-content js-feature-flag-delete"> + <gl-button + v-if="!isAllEnvironment(scope.environmentScope) && canUpdateScope(scope)" + v-gl-tooltip + :title="s__('FeatureFlags|Remove')" + class="js-delete-scope btn-transparent pr-3 pl-3" + icon="clear" + @click="removeScope(scope)" + /> + </div> + </div> + </div> + + <div class="js-add-new-scope gl-responsive-table-row" role="row"> + <div class="table-section section-30" role="gridcell"> + <div class="table-mobile-header" role="rowheader"> + {{ s__('FeatureFlags|Environment Spec') }} + </div> + <div class="table-mobile-content js-feature-flag-status"> + <environments-dropdown + class="js-new-scope-name col-12" + :endpoint="environmentsEndpoint" + :value="newScope" + @selectEnvironment="env => createNewScope({ environmentScope: env })" + @createClicked="env => createNewScope({ environmentScope: env })" + /> + </div> + </div> + + <div class="table-section section-20 text-center" role="gridcell"> + <div class="table-mobile-header" role="rowheader"> + {{ s__('FeatureFlags|Status') }} + </div> + <div class="table-mobile-content js-feature-flag-status"> + <toggle-button + :disabled-input="!active" + :value="false" + @change="createNewScope({ active: true })" + /> + </div> + </div> + + <div class="table-section section-40" role="gridcell"> + <div class="table-mobile-header" role="rowheader"> + {{ s__('FeatureFlags|Rollout Strategy') }} + </div> + <div class="table-mobile-content js-rollout-strategy form-inline"> + <label class="sr-only" for="new-rollout-strategy-placeholder">{{ + s__('FeatureFlags|Rollout Strategy') + }}</label> + <div class="select-wrapper col-12 col-md-8 p-0"> + <select + id="new-rollout-strategy-placeholder" + disabled + class="form-control select-control w-100" + > + <option>{{ s__('FeatureFlags|All users') }}</option> + </select> + <gl-icon + name="chevron-down" + class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" + :size="16" + /> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </fieldset> + + <div class="form-actions"> + <gl-button + ref="submitButton" + :disabled="readOnly" + type="button" + variant="success" + class="js-ff-submit col-xs-12" + @click="handleSubmit" + >{{ submitText }}</gl-button + > + <gl-button :href="cancelPath" class="js-ff-cancel col-xs-12 float-right"> + {{ __('Cancel') }} + </gl-button> + </div> + </form> +</template> diff --git a/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue b/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue new file mode 100644 index 00000000000..2888746005e --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue @@ -0,0 +1,106 @@ +<script> +import { debounce } from 'lodash'; +import { + GlDropdown, + GlDropdownDivider, + GlDropdownItem, + GlIcon, + GlLoadingIcon, + GlSearchBoxByType, +} from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import { __, sprintf } from '~/locale'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; + +export default { + components: { + GlDropdown, + GlDropdownDivider, + GlDropdownItem, + GlSearchBoxByType, + GlIcon, + GlLoadingIcon, + }, + props: { + endpoint: { + type: String, + required: true, + }, + }, + data() { + return { + environmentSearch: '', + results: [], + isLoading: false, + }; + }, + translations: { + addEnvironmentsLabel: __('Add environment'), + noResultsLabel: __('No matching results'), + }, + computed: { + createEnvironmentLabel() { + return sprintf(__('Create %{environment}'), { environment: this.environmentSearch }); + }, + }, + methods: { + addEnvironment(newEnvironment) { + this.$emit('add', newEnvironment); + this.environmentSearch = ''; + this.results = []; + }, + fetchEnvironments: debounce(function debouncedFetchEnvironments() { + this.isLoading = true; + axios + .get(this.endpoint, { params: { query: this.environmentSearch } }) + .then(({ data }) => { + this.results = data || []; + }) + .catch(() => { + createFlash(__('Something went wrong on our end. Please try again.')); + }) + .finally(() => { + this.isLoading = false; + }); + }, 250), + setFocus() { + this.$refs.searchBox.focusInput(); + }, + }, +}; +</script> +<template> + <gl-dropdown class="js-new-environments-dropdown" @shown="setFocus"> + <template #button-content> + <span class="d-md-none mr-1"> + {{ $options.translations.addEnvironmentsLabel }} + </span> + <gl-icon class="d-none d-md-inline-flex" name="plus" /> + </template> + <gl-search-box-by-type + ref="searchBox" + v-model.trim="environmentSearch" + class="gl-m-3" + @focus="fetchEnvironments" + @keyup="fetchEnvironments" + /> + <gl-loading-icon v-if="isLoading" /> + <gl-dropdown-item + v-for="environment in results" + v-else-if="results.length" + :key="environment" + @click="addEnvironment(environment)" + > + {{ environment }} + </gl-dropdown-item> + <template v-else-if="environmentSearch.length"> + <span ref="noResults" class="text-secondary gl-p-3"> + {{ $options.translations.noMatchingResults }} + </span> + <gl-dropdown-divider /> + <gl-dropdown-item @click="addEnvironment(environmentSearch)"> + {{ createEnvironmentLabel }} + </gl-dropdown-item> + </template> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/feature_flags/components/new_feature_flag.vue b/app/assets/javascripts/feature_flags/components/new_feature_flag.vue new file mode 100644 index 00000000000..df19667a3ae --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/new_feature_flag.vue @@ -0,0 +1,134 @@ +<script> +import { createNamespacedHelpers } from 'vuex'; +import { GlAlert } from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import store from '../store/index'; +import FeatureFlagForm from './form.vue'; +import { + LEGACY_FLAG, + NEW_VERSION_FLAG, + NEW_FLAG_ALERT, + ROLLOUT_STRATEGY_ALL_USERS, +} from '../constants'; +import { createNewEnvironmentScope } from '../store/modules/helpers'; + +import featureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; + +const { mapState, mapActions } = createNamespacedHelpers('new'); + +export default { + store, + components: { + GlAlert, + FeatureFlagForm, + }, + mixins: [featureFlagsMixin()], + props: { + endpoint: { + type: String, + required: true, + }, + path: { + type: String, + required: true, + }, + environmentsEndpoint: { + type: String, + required: true, + }, + projectId: { + type: String, + required: true, + }, + showUserCallout: { + type: Boolean, + required: true, + }, + userCalloutId: { + default: '', + type: String, + required: false, + }, + userCalloutsPath: { + default: '', + type: String, + required: false, + }, + }, + data() { + return { + userShouldSeeNewFlagAlert: this.showUserCallout, + }; + }, + translations: { + newFlagAlert: NEW_FLAG_ALERT, + }, + computed: { + ...mapState(['error']), + scopes() { + return [ + createNewEnvironmentScope( + { + environmentScope: '*', + active: true, + }, + this.glFeatures.featureFlagsPermissions, + ), + ]; + }, + version() { + return this.hasNewVersionFlags ? NEW_VERSION_FLAG : LEGACY_FLAG; + }, + hasNewVersionFlags() { + return this.glFeatures.featureFlagsNewVersion; + }, + shouldShowNewFlagAlert() { + return !this.hasNewVersionFlags && this.userShouldSeeNewFlagAlert; + }, + strategies() { + return [{ name: ROLLOUT_STRATEGY_ALL_USERS, parameters: {}, scopes: [] }]; + }, + }, + created() { + this.setEndpoint(this.endpoint); + this.setPath(this.path); + }, + methods: { + ...mapActions(['createFeatureFlag', 'setEndpoint', 'setPath']), + dismissNewVersionFlagAlert() { + this.userShouldSeeNewFlagAlert = false; + axios.post(this.userCalloutsPath, { + feature_name: this.userCalloutId, + }); + }, + }, +}; +</script> +<template> + <div> + <gl-alert + v-if="shouldShowNewFlagAlert" + variant="warning" + class="gl-my-5" + @dismiss="dismissNewVersionFlagAlert" + > + {{ $options.translations.newFlagAlert }} + </gl-alert> + <h3 class="page-title">{{ s__('FeatureFlags|New feature flag') }}</h3> + + <div v-if="error.length" class="alert alert-danger"> + <p v-for="(message, index) in error" :key="index" class="mb-0">{{ message }}</p> + </div> + + <feature-flag-form + :project-id="projectId" + :cancel-path="path" + :submit-text="s__('FeatureFlags|Create feature flag')" + :scopes="scopes" + :strategies="strategies" + :environments-endpoint="environmentsEndpoint" + :version="version" + @handleSubmit="data => createFeatureFlag(data)" + /> + </div> +</template> diff --git a/app/assets/javascripts/feature_flags/components/strategy.vue b/app/assets/javascripts/feature_flags/components/strategy.vue new file mode 100644 index 00000000000..3f10ec00aa5 --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/strategy.vue @@ -0,0 +1,327 @@ +<script> +import Vue from 'vue'; +import { isNumber } from 'lodash'; +import { + GlButton, + GlFormSelect, + GlFormInput, + GlFormTextarea, + GlFormGroup, + GlIcon, + GlLink, + GlToken, +} from '@gitlab/ui'; +import { s__, __ } from '~/locale'; +import { + PERCENT_ROLLOUT_GROUP_ID, + ROLLOUT_STRATEGY_ALL_USERS, + ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + ROLLOUT_STRATEGY_USER_ID, + ROLLOUT_STRATEGY_GITLAB_USER_LIST, +} from '../constants'; + +import NewEnvironmentsDropdown from './new_environments_dropdown.vue'; + +export default { + components: { + GlButton, + GlFormGroup, + GlFormInput, + GlFormTextarea, + GlFormSelect, + GlIcon, + GlLink, + GlToken, + NewEnvironmentsDropdown, + }, + model: { + prop: 'strategy', + event: 'change', + }, + inject: { + strategyTypeDocsPagePath: { + type: String, + }, + environmentsScopeDocsPath: { + type: String, + }, + }, + props: { + strategy: { + type: Object, + required: true, + }, + index: { + type: Number, + required: true, + }, + endpoint: { + type: String, + required: false, + default: '', + }, + userLists: { + type: Array, + required: false, + default: () => [], + }, + }, + ROLLOUT_STRATEGY_ALL_USERS, + ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + ROLLOUT_STRATEGY_USER_ID, + ROLLOUT_STRATEGY_GITLAB_USER_LIST, + + i18n: { + allEnvironments: __('All environments'), + environmentsLabel: __('Environments'), + environmentsSelectDescription: __('Select the environment scope for this feature flag.'), + rolloutPercentageDescription: __('Enter a whole number between 0 and 100'), + rolloutPercentageInvalid: s__( + 'FeatureFlags|Percent rollout must be a whole number between 0 and 100', + ), + rolloutPercentageLabel: s__('FeatureFlag|Percentage'), + rolloutUserIdsDescription: __('Enter one or more user ID separated by commas'), + rolloutUserIdsLabel: s__('FeatureFlag|User IDs'), + rolloutUserListLabel: s__('FeatureFlag|List'), + rolloutUserListDescription: s__('FeatureFlag|Select a user list'), + rolloutUserListNoListError: s__('FeatureFlag|There are no configured user lists'), + strategyTypeDescription: __('Select strategy activation method.'), + strategyTypeLabel: s__('FeatureFlag|Type'), + }, + + data() { + return { + environments: this.strategy.scopes || [], + formStrategy: { ...this.strategy }, + formPercentage: + this.strategy.name === ROLLOUT_STRATEGY_PERCENT_ROLLOUT + ? this.strategy.parameters.percentage + : '', + formUserIds: + this.strategy.name === ROLLOUT_STRATEGY_USER_ID ? this.strategy.parameters.userIds : '', + formUserListId: + this.strategy.name === ROLLOUT_STRATEGY_GITLAB_USER_LIST ? this.strategy.userListId : '', + strategies: [ + { + value: ROLLOUT_STRATEGY_ALL_USERS, + text: __('All users'), + }, + { + value: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + text: __('Percent of users'), + }, + { + value: ROLLOUT_STRATEGY_USER_ID, + text: __('User IDs'), + }, + { + value: ROLLOUT_STRATEGY_GITLAB_USER_LIST, + text: __('User List'), + }, + ], + }; + }, + computed: { + strategyTypeId() { + return `strategy-type-${this.index}`; + }, + strategyPercentageId() { + return `strategy-percentage-${this.index}`; + }, + strategyUserIdsId() { + return `strategy-user-ids-${this.index}`; + }, + strategyUserListId() { + return `strategy-user-list-${this.index}`; + }, + environmentsDropdownId() { + return `environments-dropdown-${this.index}`; + }, + isPercentRollout() { + return this.isStrategyType(ROLLOUT_STRATEGY_PERCENT_ROLLOUT); + }, + isUserWithId() { + return this.isStrategyType(ROLLOUT_STRATEGY_USER_ID); + }, + isUserList() { + return this.isStrategyType(ROLLOUT_STRATEGY_GITLAB_USER_LIST); + }, + appliesToAllEnvironments() { + return ( + this.filteredEnvironments.length === 1 && + this.filteredEnvironments[0].environmentScope === '*' + ); + }, + filteredEnvironments() { + return this.environments.filter(e => !e.shouldBeDestroyed); + }, + userListOptions() { + return this.userLists.map(({ name, id }) => ({ value: id, text: name })); + }, + hasUserLists() { + return this.userListOptions.length > 0; + }, + }, + methods: { + addEnvironment(environment) { + const allEnvironmentsScope = this.environments.find(scope => scope.environmentScope === '*'); + if (allEnvironmentsScope) { + allEnvironmentsScope.shouldBeDestroyed = true; + } + this.environments.push({ environmentScope: environment }); + this.onStrategyChange(); + }, + onStrategyChange() { + const parameters = {}; + const strategy = { + ...this.formStrategy, + scopes: this.environments, + }; + switch (this.formStrategy.name) { + case ROLLOUT_STRATEGY_PERCENT_ROLLOUT: + parameters.percentage = this.formPercentage; + parameters.groupId = PERCENT_ROLLOUT_GROUP_ID; + break; + case ROLLOUT_STRATEGY_USER_ID: + parameters.userIds = this.formUserIds; + break; + case ROLLOUT_STRATEGY_GITLAB_USER_LIST: + strategy.userListId = this.formUserListId; + break; + default: + break; + } + this.$emit('change', { + ...strategy, + parameters, + }); + }, + removeScope(environment) { + if (isNumber(environment.id)) { + Vue.set(environment, 'shouldBeDestroyed', true); + } else { + this.environments = this.environments.filter(e => e !== environment); + } + if (this.filteredEnvironments.length === 0) { + this.environments.push({ environmentScope: '*' }); + } + this.onStrategyChange(); + }, + isStrategyType(type) { + return this.formStrategy.name === type; + }, + }, +}; +</script> +<template> + <div class="gl-border-t-solid gl-border-t-1 gl-border-t-gray-100 gl-py-6"> + <div class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row flex-md-wrap"> + <div class="mr-5"> + <gl-form-group :label="$options.i18n.strategyTypeLabel" :label-for="strategyTypeId"> + <p class="gl-display-inline-block ">{{ $options.i18n.strategyTypeDescription }}</p> + <gl-link :href="strategyTypeDocsPagePath" target="_blank"> + <gl-icon name="question" /> + </gl-link> + <gl-form-select + :id="strategyTypeId" + v-model="formStrategy.name" + :options="strategies" + @change="onStrategyChange" + /> + </gl-form-group> + </div> + + <div data-testid="strategy"> + <gl-form-group + v-if="isPercentRollout" + :label="$options.i18n.rolloutPercentageLabel" + :description="$options.i18n.rolloutPercentageDescription" + :label-for="strategyPercentageId" + :invalid-feedback="$options.i18n.rolloutPercentageInvalid" + > + <div class="gl-display-flex gl-align-items-center"> + <gl-form-input + :id="strategyPercentageId" + v-model="formPercentage" + class="rollout-percentage gl-text-right gl-w-9" + type="number" + @input="onStrategyChange" + /> + <span class="gl-ml-2">%</span> + </div> + </gl-form-group> + + <gl-form-group + v-if="isUserWithId" + :label="$options.i18n.rolloutUserIdsLabel" + :description="$options.i18n.rolloutUserIdsDescription" + :label-for="strategyUserIdsId" + > + <gl-form-textarea + :id="strategyUserIdsId" + v-model="formUserIds" + @input="onStrategyChange" + /> + </gl-form-group> + <gl-form-group + v-if="isUserList" + :state="hasUserLists" + :invalid-feedback="$options.i18n.rolloutUserListNoListError" + :label="$options.i18n.rolloutUserListLabel" + :description="$options.i18n.rolloutUserListDescription" + :label-for="strategyUserListId" + > + <gl-form-select + :id="strategyUserListId" + v-model="formUserListId" + :options="userListOptions" + @change="onStrategyChange" + /> + </gl-form-group> + </div> + + <div + class="align-self-end align-self-md-stretch order-first offset-md-0 order-md-0 gl-ml-auto" + > + <gl-button + data-testid="delete-strategy-button" + variant="danger" + icon="remove" + @click="$emit('delete')" + /> + </div> + </div> + <label class="gl-display-block" :for="environmentsDropdownId">{{ + $options.i18n.environmentsLabel + }}</label> + <p class="gl-display-inline-block">{{ $options.i18n.environmentsSelectDescription }}</p> + <gl-link :href="environmentsScopeDocsPath" target="_blank"> + <gl-icon name="question" /> + </gl-link> + <div class="gl-display-flex gl-flex-direction-column"> + <div + class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row align-items-start gl-md-align-items-center" + > + <new-environments-dropdown + :id="environmentsDropdownId" + :endpoint="endpoint" + class="gl-mr-3" + @add="addEnvironment" + /> + <span v-if="appliesToAllEnvironments" class="text-secondary gl-mt-3 mt-md-0 ml-md-3"> + {{ $options.i18n.allEnvironments }} + </span> + <div v-else class="gl-display-flex gl-align-items-center"> + <gl-token + v-for="environment in filteredEnvironments" + :key="environment.id" + class="gl-mt-3 gl-mr-3 mt-md-0 mr-md-0 ml-md-2 rounded-pill" + @close="removeScope(environment)" + > + {{ environment.environmentScope }} + </gl-token> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/feature_flags/components/user_lists_table.vue b/app/assets/javascripts/feature_flags/components/user_lists_table.vue new file mode 100644 index 00000000000..0bfd18f992c --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/user_lists_table.vue @@ -0,0 +1,122 @@ +<script> +import { + GlButton, + GlButtonGroup, + GlModal, + GlSprintf, + GlTooltipDirective, + GlModalDirective, +} from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; + +export default { + components: { GlButton, GlButtonGroup, GlModal, GlSprintf }, + directives: { GlTooltip: GlTooltipDirective, GlModal: GlModalDirective }, + mixins: [timeagoMixin], + props: { + userLists: { + type: Array, + required: true, + }, + }, + translations: { + createdTimeagoLabel: s__('UserList|created %{timeago}'), + deleteListTitle: s__('UserList|Delete %{name}?'), + deleteListMessage: s__('User list %{name} will be removed. Are you sure?'), + }, + modal: { + id: 'deleteListModal', + actionPrimary: { + text: s__('Delete user list'), + attributes: { variant: 'danger', 'data-testid': 'modal-confirm' }, + }, + }, + data() { + return { + deleteUserList: null, + }; + }, + computed: { + deleteListName() { + return this.deleteUserList?.name; + }, + modalTitle() { + return sprintf(this.$options.translations.deleteListTitle, { + name: this.deleteListName, + }); + }, + }, + methods: { + createdTimeago(list) { + return sprintf(this.$options.translations.createdTimeagoLabel, { + timeago: this.timeFormatted(list.created_at), + }); + }, + displayList(list) { + return list.user_xids.replace(/,/g, ', '); + }, + onDelete() { + this.$emit('delete', this.deleteUserList); + }, + confirmDeleteList(list) { + this.deleteUserList = list; + }, + }, +}; +</script> +<template> + <div> + <div + v-for="list in userLists" + :key="list.id" + data-testid="ffUserList" + class="gl-border-b-solid gl-border-gray-100 gl-border-b-1 gl-w-full gl-py-4 gl-display-flex gl-justify-content-space-between" + > + <div class="gl-display-flex gl-flex-direction-column gl-overflow-hidden gl-flex-grow-1"> + <span data-testid="ffUserListName" class="gl-font-weight-bold gl-mb-2"> + {{ list.name }} + </span> + <span + v-gl-tooltip + :title="tooltipTitle(list.created_at)" + data-testid="ffUserListTimestamp" + class="gl-text-gray-300 gl-mb-2" + > + {{ createdTimeago(list) }} + </span> + <span data-testid="ffUserListIds" class="gl-str-truncated">{{ displayList(list) }}</span> + </div> + + <gl-button-group class="gl-align-self-start gl-mt-2"> + <gl-button + :href="list.path" + category="secondary" + icon="pencil" + data-testid="edit-user-list" + /> + <gl-button + v-gl-modal="$options.modal.id" + category="secondary" + variant="danger" + icon="remove" + data-testid="delete-user-list" + @click="confirmDeleteList(list)" + /> + </gl-button-group> + </div> + <gl-modal + :title="modalTitle" + :modal-id="$options.modal.id" + :action-primary="$options.modal.actionPrimary" + static + @primary="onDelete" + > + <gl-sprintf :message="$options.translations.deleteListMessage"> + <template #name> + <b>{{ deleteListName }}</b> + </template> + </gl-sprintf> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/feature_flags/constants.js b/app/assets/javascripts/feature_flags/constants.js new file mode 100644 index 00000000000..f59414ab1a7 --- /dev/null +++ b/app/assets/javascripts/feature_flags/constants.js @@ -0,0 +1,28 @@ +import { property } from 'lodash'; +import { s__ } from '~/locale'; + +export const ROLLOUT_STRATEGY_ALL_USERS = 'default'; +export const ROLLOUT_STRATEGY_PERCENT_ROLLOUT = 'gradualRolloutUserId'; +export const ROLLOUT_STRATEGY_USER_ID = 'userWithId'; +export const ROLLOUT_STRATEGY_GITLAB_USER_LIST = 'gitlabUserList'; + +export const PERCENT_ROLLOUT_GROUP_ID = 'default'; + +export const DEFAULT_PERCENT_ROLLOUT = '100'; + +export const ALL_ENVIRONMENTS_NAME = '*'; + +export const INTERNAL_ID_PREFIX = 'internal_'; + +export const fetchPercentageParams = property(['parameters', 'percentage']); +export const fetchUserIdParams = property(['parameters', 'userIds']); + +export const NEW_VERSION_FLAG = 'new_version_flag'; +export const LEGACY_FLAG = 'legacy_flag'; + +export const NEW_FLAG_ALERT = s__( + 'FeatureFlags|Feature Flags will look different in the next milestone. No action is needed, but you may notice the functionality was changed to improve the workflow.', +); + +export const FEATURE_FLAG_SCOPE = 'featureFlags'; +export const USER_LIST_SCOPE = 'userLists'; diff --git a/app/assets/javascripts/feature_flags/edit.js b/app/assets/javascripts/feature_flags/edit.js new file mode 100644 index 00000000000..390a1f7555d --- /dev/null +++ b/app/assets/javascripts/feature_flags/edit.js @@ -0,0 +1,33 @@ +import Vue from 'vue'; +import EditFeatureFlag from '~/feature_flags/components/edit_feature_flag.vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; + +export default () => { + const el = document.querySelector('#js-edit-feature-flag'); + const { environmentsScopeDocsPath, strategyTypeDocsPagePath } = el.dataset; + + return new Vue({ + el, + components: { + EditFeatureFlag, + }, + provide: { + environmentsScopeDocsPath, + strategyTypeDocsPagePath, + }, + render(createElement) { + return createElement('edit-feature-flag', { + props: { + endpoint: el.dataset.endpoint, + path: el.dataset.featureFlagsPath, + environmentsEndpoint: el.dataset.environmentsEndpoint, + projectId: el.dataset.projectId, + featureFlagIssuesEndpoint: el.dataset.featureFlagIssuesEndpoint, + userCalloutsPath: el.dataset.userCalloutsPath, + userCalloutId: el.dataset.userCalloutId, + showUserCallout: parseBoolean(el.dataset.showUserCallout), + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/feature_flags/index.js b/app/assets/javascripts/feature_flags/index.js new file mode 100644 index 00000000000..90857c5f2da --- /dev/null +++ b/app/assets/javascripts/feature_flags/index.js @@ -0,0 +1,41 @@ +import Vue from 'vue'; +import FeatureFlagsComponent from '~/feature_flags/components/feature_flags.vue'; +import csrf from '~/lib/utils/csrf'; + +export default () => + new Vue({ + el: '#feature-flags-vue', + components: { + FeatureFlagsComponent, + }, + data() { + return { + dataset: document.querySelector(this.$options.el).dataset, + }; + }, + provide() { + return { + projectName: this.dataset.projectName, + featureFlagsHelpPagePath: this.dataset.featureFlagsHelpPagePath, + errorStateSvgPath: this.dataset.errorStateSvgPath, + }; + }, + render(createElement) { + return createElement('feature-flags-component', { + props: { + endpoint: this.dataset.endpoint, + projectId: this.dataset.projectId, + featureFlagsClientLibrariesHelpPagePath: this.dataset + .featureFlagsClientLibrariesHelpPagePath, + featureFlagsClientExampleHelpPagePath: this.dataset.featureFlagsClientExampleHelpPagePath, + unleashApiUrl: this.dataset.unleashApiUrl, + unleashApiInstanceId: this.dataset.unleashApiInstanceId || '', + csrfToken: csrf.token, + canUserConfigure: this.dataset.canUserAdminFeatureFlag, + newFeatureFlagPath: this.dataset.newFeatureFlagPath, + rotateInstanceIdPath: this.dataset.rotateInstanceIdPath, + newUserListPath: this.dataset.newUserListPath, + }, + }); + }, + }); diff --git a/app/assets/javascripts/feature_flags/new.js b/app/assets/javascripts/feature_flags/new.js new file mode 100644 index 00000000000..f14dd151910 --- /dev/null +++ b/app/assets/javascripts/feature_flags/new.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import NewFeatureFlag from '~/feature_flags/components/new_feature_flag.vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; + +export default () => { + const el = document.querySelector('#js-new-feature-flag'); + const { environmentsScopeDocsPath, strategyTypeDocsPagePath } = el.dataset; + + return new Vue({ + el, + components: { + NewFeatureFlag, + }, + provide: { + environmentsScopeDocsPath, + strategyTypeDocsPagePath, + }, + render(createElement) { + return createElement('new-feature-flag', { + props: { + endpoint: el.dataset.endpoint, + path: el.dataset.featureFlagsPath, + environmentsEndpoint: el.dataset.environmentsEndpoint, + projectId: el.dataset.projectId, + userCalloutsPath: el.dataset.userCalloutsPath, + userCalloutId: el.dataset.userCalloutId, + showUserCallout: parseBoolean(el.dataset.showUserCallout), + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/feature_flags/store/index.js b/app/assets/javascripts/feature_flags/store/index.js new file mode 100644 index 00000000000..f4f49c20895 --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/index.js @@ -0,0 +1,18 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import indexModule from './modules/index'; +import newModule from './modules/new'; +import editModule from './modules/edit'; + +Vue.use(Vuex); + +export const createStore = () => + new Vuex.Store({ + modules: { + index: indexModule, + new: newModule, + edit: editModule, + }, + }); + +export default createStore(); diff --git a/app/assets/javascripts/feature_flags/store/modules/edit/actions.js b/app/assets/javascripts/feature_flags/store/modules/edit/actions.js new file mode 100644 index 00000000000..351f36d8fa6 --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/modules/edit/actions.js @@ -0,0 +1,75 @@ +import * as types from './mutation_types'; +import axios from '~/lib/utils/axios_utils'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; +import { __ } from '~/locale'; +import { NEW_VERSION_FLAG } from '../../../constants'; +import { mapFromScopesViewModel, mapStrategiesToRails } from '../helpers'; + +/** + * Commits mutation to set the main endpoint + * @param {String} endpoint + */ +export const setEndpoint = ({ commit }, endpoint) => commit(types.SET_ENDPOINT, endpoint); + +/** + * Commits mutation to set the feature flag path. + * Used to redirect the user after form submission + * + * @param {String} path + */ +export const setPath = ({ commit }, path) => commit(types.SET_PATH, path); + +/** + * Handles the edition of a feature flag. + * + * Will dispatch `requestUpdateFeatureFlag` + * Serializes the params and makes a put request + * Dispatches an action acording to the request status. + * + * @param {Object} params + */ +export const updateFeatureFlag = ({ state, dispatch }, params) => { + dispatch('requestUpdateFeatureFlag'); + + axios + .put( + state.endpoint, + params.version === NEW_VERSION_FLAG + ? mapStrategiesToRails(params) + : mapFromScopesViewModel(params), + ) + .then(() => { + dispatch('receiveUpdateFeatureFlagSuccess'); + visitUrl(state.path); + }) + .catch(error => dispatch('receiveUpdateFeatureFlagError', error.response.data)); +}; + +export const requestUpdateFeatureFlag = ({ commit }) => commit(types.REQUEST_UPDATE_FEATURE_FLAG); +export const receiveUpdateFeatureFlagSuccess = ({ commit }) => + commit(types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS); +export const receiveUpdateFeatureFlagError = ({ commit }, error) => + commit(types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR, error); + +/** + * Fetches the feature flag data for the edit form + */ +export const fetchFeatureFlag = ({ state, dispatch }) => { + dispatch('requestFeatureFlag'); + + axios + .get(state.endpoint) + .then(({ data }) => dispatch('receiveFeatureFlagSuccess', data)) + .catch(() => dispatch('receiveFeatureFlagError')); +}; + +export const requestFeatureFlag = ({ commit }) => commit(types.REQUEST_FEATURE_FLAG); +export const receiveFeatureFlagSuccess = ({ commit }, response) => + commit(types.RECEIVE_FEATURE_FLAG_SUCCESS, response); +export const receiveFeatureFlagError = ({ commit }) => { + commit(types.RECEIVE_FEATURE_FLAG_ERROR); + createFlash(__('Something went wrong on our end. Please try again!')); +}; + +export const toggleActive = ({ commit }, active) => commit(types.TOGGLE_ACTIVE, active); diff --git a/app/assets/javascripts/feature_flags/store/modules/edit/index.js b/app/assets/javascripts/feature_flags/store/modules/edit/index.js new file mode 100644 index 00000000000..665bb29a17e --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/modules/edit/index.js @@ -0,0 +1,10 @@ +import state from './state'; +import * as actions from './actions'; +import mutations from './mutations'; + +export default { + namespaced: true, + actions, + mutations, + state: state(), +}; diff --git a/app/assets/javascripts/feature_flags/store/modules/edit/mutation_types.js b/app/assets/javascripts/feature_flags/store/modules/edit/mutation_types.js new file mode 100644 index 00000000000..b2715e501f4 --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/modules/edit/mutation_types.js @@ -0,0 +1,12 @@ +export const SET_ENDPOINT = 'SET_ENDPOINT'; +export const SET_PATH = 'SET_PATH'; + +export const REQUEST_UPDATE_FEATURE_FLAG = 'REQUEST_UPDATE_FEATURE_FLAG'; +export const RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS = 'RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS'; +export const RECEIVE_UPDATE_FEATURE_FLAG_ERROR = 'RECEIVE_UPDATE_FEATURE_FLAG_ERROR'; + +export const REQUEST_FEATURE_FLAG = 'REQUEST_FEATURE_FLAG'; +export const RECEIVE_FEATURE_FLAG_SUCCESS = 'RECEIVE_FEATURE_FLAG_SUCCESS'; +export const RECEIVE_FEATURE_FLAG_ERROR = 'RECEIVE_FEATURE_FLAG_ERROR'; + +export const TOGGLE_ACTIVE = 'TOGGLE_ACTIVE'; diff --git a/app/assets/javascripts/feature_flags/store/modules/edit/mutations.js b/app/assets/javascripts/feature_flags/store/modules/edit/mutations.js new file mode 100644 index 00000000000..1d2721e037d --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/modules/edit/mutations.js @@ -0,0 +1,45 @@ +import * as types from './mutation_types'; +import { mapToScopesViewModel, mapStrategiesToViewModel } from '../helpers'; +import { LEGACY_FLAG } from '../../../constants'; + +export default { + [types.SET_ENDPOINT](state, endpoint) { + state.endpoint = endpoint; + }, + [types.SET_PATH](state, path) { + state.path = path; + }, + [types.REQUEST_FEATURE_FLAG](state) { + state.isLoading = true; + }, + [types.RECEIVE_FEATURE_FLAG_SUCCESS](state, response) { + state.isLoading = false; + state.hasError = false; + + state.name = response.name; + state.description = response.description; + state.iid = response.iid; + state.active = response.active; + state.scopes = mapToScopesViewModel(response.scopes); + state.strategies = mapStrategiesToViewModel(response.strategies); + state.version = response.version || LEGACY_FLAG; + }, + [types.RECEIVE_FEATURE_FLAG_ERROR](state) { + state.isLoading = false; + state.hasError = true; + }, + [types.REQUEST_UPDATE_FEATURE_FLAG](state) { + state.isSendingRequest = true; + state.error = []; + }, + [types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS](state) { + state.isSendingRequest = false; + }, + [types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR](state, error) { + state.isSendingRequest = false; + state.error = error.message || []; + }, + [types.TOGGLE_ACTIVE](state, active) { + state.active = active; + }, +}; diff --git a/app/assets/javascripts/feature_flags/store/modules/edit/state.js b/app/assets/javascripts/feature_flags/store/modules/edit/state.js new file mode 100644 index 00000000000..7de05b49482 --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/modules/edit/state.js @@ -0,0 +1,18 @@ +import { LEGACY_FLAG } from '../../../constants'; + +export default () => ({ + endpoint: null, + path: null, + isSendingRequest: false, + error: [], + + name: null, + description: null, + scopes: [], + isLoading: false, + hasError: false, + iid: null, + active: true, + strategies: [], + version: LEGACY_FLAG, +}); diff --git a/app/assets/javascripts/feature_flags/store/modules/helpers.js b/app/assets/javascripts/feature_flags/store/modules/helpers.js new file mode 100644 index 00000000000..5a8d7bc6af3 --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/modules/helpers.js @@ -0,0 +1,213 @@ +import { isEmpty, uniqueId, isString } from 'lodash'; +import { + ROLLOUT_STRATEGY_ALL_USERS, + ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + ROLLOUT_STRATEGY_USER_ID, + ROLLOUT_STRATEGY_GITLAB_USER_LIST, + INTERNAL_ID_PREFIX, + DEFAULT_PERCENT_ROLLOUT, + PERCENT_ROLLOUT_GROUP_ID, + fetchPercentageParams, + fetchUserIdParams, + LEGACY_FLAG, +} from '../../constants'; + +/** + * Converts raw scope objects fetched from the API into an array of scope + * objects that is easier/nicer to bind to in Vue. + * @param {Array} scopesFromRails An array of scope objects fetched from the API + */ +export const mapToScopesViewModel = scopesFromRails => + (scopesFromRails || []).map(s => { + const percentStrategy = (s.strategies || []).find( + strat => strat.name === ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + ); + + const rolloutPercentage = fetchPercentageParams(percentStrategy) || DEFAULT_PERCENT_ROLLOUT; + + const userStrategy = (s.strategies || []).find( + strat => strat.name === ROLLOUT_STRATEGY_USER_ID, + ); + + const rolloutStrategy = + (percentStrategy && percentStrategy.name) || + (userStrategy && userStrategy.name) || + ROLLOUT_STRATEGY_ALL_USERS; + + const rolloutUserIds = (fetchUserIdParams(userStrategy) || '') + .split(',') + .filter(id => id) + .join(', '); + + return { + id: s.id, + environmentScope: s.environment_scope, + active: Boolean(s.active), + canUpdate: Boolean(s.can_update), + protected: Boolean(s.protected), + rolloutStrategy, + rolloutPercentage, + rolloutUserIds, + + // eslint-disable-next-line no-underscore-dangle + shouldBeDestroyed: Boolean(s._destroy), + shouldIncludeUserIds: rolloutUserIds.length > 0 && percentStrategy !== null, + }; + }); +/** + * Converts the parameters emitted by the Vue component into + * the shape that the Rails API expects. + * @param {Array} scopesFromVue An array of scope objects from the Vue component + */ +export const mapFromScopesViewModel = params => { + const scopes = (params.scopes || []).map(s => { + const parameters = {}; + if (s.rolloutStrategy === ROLLOUT_STRATEGY_PERCENT_ROLLOUT) { + parameters.groupId = PERCENT_ROLLOUT_GROUP_ID; + parameters.percentage = s.rolloutPercentage; + } else if (s.rolloutStrategy === ROLLOUT_STRATEGY_USER_ID) { + parameters.userIds = (s.rolloutUserIds || '').replace(/, /g, ','); + } + + const userIdParameters = {}; + + if (s.shouldIncludeUserIds && s.rolloutStrategy !== ROLLOUT_STRATEGY_USER_ID) { + userIdParameters.userIds = (s.rolloutUserIds || '').replace(/, /g, ','); + } + + // Strip out any internal IDs + const id = isString(s.id) && s.id.startsWith(INTERNAL_ID_PREFIX) ? undefined : s.id; + + const strategies = [ + { + name: s.rolloutStrategy, + parameters, + }, + ]; + + if (!isEmpty(userIdParameters)) { + strategies.push({ name: ROLLOUT_STRATEGY_USER_ID, parameters: userIdParameters }); + } + + return { + id, + environment_scope: s.environmentScope, + active: s.active, + can_update: s.canUpdate, + protected: s.protected, + _destroy: s.shouldBeDestroyed, + strategies, + }; + }); + + const model = { + operations_feature_flag: { + name: params.name, + description: params.description, + active: params.active, + scopes_attributes: scopes, + version: LEGACY_FLAG, + }, + }; + + return model; +}; + +/** + * Creates a new feature flag environment scope object for use + * in a Vue component. An optional parameter can be passed to + * override the property values that are created by default. + * + * @param {Object} overrides An optional object whose + * property values will be used to override the default values. + * + */ +export const createNewEnvironmentScope = (overrides = {}, featureFlagPermissions = false) => { + const defaultScope = { + environmentScope: '', + active: false, + id: uniqueId(INTERNAL_ID_PREFIX), + rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS, + rolloutPercentage: DEFAULT_PERCENT_ROLLOUT, + rolloutUserIds: '', + }; + + const newScope = { + ...defaultScope, + ...overrides, + }; + + if (featureFlagPermissions) { + newScope.canUpdate = true; + newScope.protected = false; + } + + return newScope; +}; + +const mapStrategyScopesToRails = scopes => + scopes.length === 0 + ? [{ environment_scope: '*' }] + : scopes.map(s => ({ + id: s.id, + _destroy: s.shouldBeDestroyed, + environment_scope: s.environmentScope, + })); + +const mapStrategyScopesToView = scopes => + scopes.map(s => ({ + id: s.id, + // eslint-disable-next-line no-underscore-dangle + shouldBeDestroyed: Boolean(s._destroy), + environmentScope: s.environment_scope, + })); + +const mapStrategiesParametersToViewModel = params => { + if (params.userIds) { + return { ...params, userIds: params.userIds.split(',').join(', ') }; + } + return params; +}; + +export const mapStrategiesToViewModel = strategiesFromRails => + (strategiesFromRails || []).map(s => ({ + id: s.id, + name: s.name, + parameters: mapStrategiesParametersToViewModel(s.parameters), + userListId: s.user_list?.id, + // eslint-disable-next-line no-underscore-dangle + shouldBeDestroyed: Boolean(s._destroy), + scopes: mapStrategyScopesToView(s.scopes), + })); + +const mapStrategiesParametersToRails = params => { + if (params.userIds) { + return { ...params, userIds: params.userIds.split(', ').join(',') }; + } + return params; +}; + +const mapStrategyToRails = strategy => { + const mappedStrategy = { + id: strategy.id, + name: strategy.name, + _destroy: strategy.shouldBeDestroyed, + scopes_attributes: mapStrategyScopesToRails(strategy.scopes || []), + parameters: mapStrategiesParametersToRails(strategy.parameters), + }; + + if (strategy.name === ROLLOUT_STRATEGY_GITLAB_USER_LIST) { + mappedStrategy.user_list_id = strategy.userListId; + } + return mappedStrategy; +}; + +export const mapStrategiesToRails = params => ({ + operations_feature_flag: { + name: params.name, + description: params.description, + version: params.version, + active: params.active, + strategies_attributes: (params.strategies || []).map(mapStrategyToRails), + }, +}); diff --git a/app/assets/javascripts/feature_flags/store/modules/index/actions.js b/app/assets/javascripts/feature_flags/store/modules/index/actions.js new file mode 100644 index 00000000000..ed41dd34e4d --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/modules/index/actions.js @@ -0,0 +1,107 @@ +import Api from '~/api'; +import * as types from './mutation_types'; +import axios from '~/lib/utils/axios_utils'; + +export const setFeatureFlagsEndpoint = ({ commit }, endpoint) => + commit(types.SET_FEATURE_FLAGS_ENDPOINT, endpoint); + +export const setFeatureFlagsOptions = ({ commit }, options) => + commit(types.SET_FEATURE_FLAGS_OPTIONS, options); + +export const setInstanceIdEndpoint = ({ commit }, endpoint) => + commit(types.SET_INSTANCE_ID_ENDPOINT, endpoint); + +export const setProjectId = ({ commit }, endpoint) => commit(types.SET_PROJECT_ID, endpoint); + +export const setInstanceId = ({ commit }, instanceId) => commit(types.SET_INSTANCE_ID, instanceId); + +export const fetchFeatureFlags = ({ state, dispatch }) => { + dispatch('requestFeatureFlags'); + + axios + .get(state.endpoint, { + params: state.options, + }) + .then(response => + dispatch('receiveFeatureFlagsSuccess', { + data: response.data || {}, + headers: response.headers, + }), + ) + .catch(() => dispatch('receiveFeatureFlagsError')); +}; + +export const requestFeatureFlags = ({ commit }) => commit(types.REQUEST_FEATURE_FLAGS); +export const receiveFeatureFlagsSuccess = ({ commit }, response) => + commit(types.RECEIVE_FEATURE_FLAGS_SUCCESS, response); +export const receiveFeatureFlagsError = ({ commit }) => commit(types.RECEIVE_FEATURE_FLAGS_ERROR); + +export const fetchUserLists = ({ state, dispatch }) => { + dispatch('requestUserLists'); + + return Api.fetchFeatureFlagUserLists(state.projectId, state.options.page) + .then(({ data, headers }) => dispatch('receiveUserListsSuccess', { data, headers })) + .catch(() => dispatch('receiveUserListsError')); +}; + +export const requestUserLists = ({ commit }) => commit(types.REQUEST_USER_LISTS); +export const receiveUserListsSuccess = ({ commit }, response) => + commit(types.RECEIVE_USER_LISTS_SUCCESS, response); +export const receiveUserListsError = ({ commit }) => commit(types.RECEIVE_USER_LISTS_ERROR); + +export const toggleFeatureFlag = ({ dispatch }, flag) => { + dispatch('updateFeatureFlag', flag); + + axios + .put(flag.update_path, { + operations_feature_flag: flag, + }) + .then(response => dispatch('receiveUpdateFeatureFlagSuccess', response.data)) + .catch(() => dispatch('receiveUpdateFeatureFlagError', flag.id)); +}; + +export const updateFeatureFlag = ({ commit }, flag) => commit(types.UPDATE_FEATURE_FLAG, flag); + +export const receiveUpdateFeatureFlagSuccess = ({ commit }, data) => + commit(types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS, data); +export const receiveUpdateFeatureFlagError = ({ commit }, id) => + commit(types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR, id); + +export const deleteUserList = ({ state, dispatch }, list) => { + dispatch('requestDeleteUserList', list); + + return Api.deleteFeatureFlagUserList(state.projectId, list.iid) + .then(() => dispatch('fetchUserLists')) + .catch(error => + dispatch('receiveDeleteUserListError', { + list, + error: error?.response?.data ?? error, + }), + ); +}; + +export const requestDeleteUserList = ({ commit }, list) => + commit(types.REQUEST_DELETE_USER_LIST, list); + +export const receiveDeleteUserListError = ({ commit }, { error, list }) => { + commit(types.RECEIVE_DELETE_USER_LIST_ERROR, { error, list }); +}; + +export const rotateInstanceId = ({ state, dispatch }) => { + dispatch('requestRotateInstanceId'); + + axios + .post(state.rotateEndpoint) + .then(({ data = {}, headers }) => dispatch('receiveRotateInstanceIdSuccess', { data, headers })) + .catch(() => dispatch('receiveRotateInstanceIdError')); +}; + +export const requestRotateInstanceId = ({ commit }) => commit(types.REQUEST_ROTATE_INSTANCE_ID); +export const receiveRotateInstanceIdSuccess = ({ commit }, response) => + commit(types.RECEIVE_ROTATE_INSTANCE_ID_SUCCESS, response); +export const receiveRotateInstanceIdError = ({ commit }) => + commit(types.RECEIVE_ROTATE_INSTANCE_ID_ERROR); + +export const clearAlert = ({ commit }, index) => { + commit(types.RECEIVE_CLEAR_ALERT, index); +}; diff --git a/app/assets/javascripts/feature_flags/store/modules/index/index.js b/app/assets/javascripts/feature_flags/store/modules/index/index.js new file mode 100644 index 00000000000..665bb29a17e --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/modules/index/index.js @@ -0,0 +1,10 @@ +import state from './state'; +import * as actions from './actions'; +import mutations from './mutations'; + +export default { + namespaced: true, + actions, + mutations, + state: state(), +}; diff --git a/app/assets/javascripts/feature_flags/store/modules/index/mutation_types.js b/app/assets/javascripts/feature_flags/store/modules/index/mutation_types.js new file mode 100644 index 00000000000..4a4bd13c945 --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/modules/index/mutation_types.js @@ -0,0 +1,26 @@ +export const SET_FEATURE_FLAGS_ENDPOINT = 'SET_FEATURE_FLAGS_ENDPOINT'; +export const SET_FEATURE_FLAGS_OPTIONS = 'SET_FEATURE_FLAGS_OPTIONS'; +export const SET_INSTANCE_ID_ENDPOINT = 'SET_INSTANCE_ID_ENDPOINT'; +export const SET_INSTANCE_ID = 'SET_INSTANCE_ID'; +export const SET_PROJECT_ID = 'SET_PROJECT_ID'; + +export const REQUEST_FEATURE_FLAGS = 'REQUEST_FEATURE_FLAGS'; +export const RECEIVE_FEATURE_FLAGS_SUCCESS = 'RECEIVE_FEATURE_FLAGS_SUCCESS'; +export const RECEIVE_FEATURE_FLAGS_ERROR = 'RECEIVE_FEATURE_FLAGS_ERROR'; + +export const REQUEST_USER_LISTS = 'REQUEST_USER_LISTS'; +export const RECEIVE_USER_LISTS_SUCCESS = 'RECEIVE_USER_LISTS_SUCCESS'; +export const RECEIVE_USER_LISTS_ERROR = 'RECEIVE_USER_LISTS_ERROR'; + +export const REQUEST_DELETE_USER_LIST = 'REQUEST_DELETE_USER_LIST'; +export const RECEIVE_DELETE_USER_LIST_ERROR = 'RECEIVE_DELETE_USER_LIST_ERROR'; + +export const UPDATE_FEATURE_FLAG = 'UPDATE_FEATURE_FLAG'; +export const RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS = 'RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS'; +export const RECEIVE_UPDATE_FEATURE_FLAG_ERROR = 'RECEIVE_UPDATE_FEATURE_FLAG_ERROR'; + +export const REQUEST_ROTATE_INSTANCE_ID = 'REQUEST_ROTATE_INSTANCE_ID'; +export const RECEIVE_ROTATE_INSTANCE_ID_SUCCESS = 'RECEIVE_ROTATE_INSTANCE_ID_SUCCESS'; +export const RECEIVE_ROTATE_INSTANCE_ID_ERROR = 'RECEIVE_ROTATE_INSTANCE_ID_ERROR'; + +export const RECEIVE_CLEAR_ALERT = 'RECEIVE_CLEAR_ALERT'; diff --git a/app/assets/javascripts/feature_flags/store/modules/index/mutations.js b/app/assets/javascripts/feature_flags/store/modules/index/mutations.js new file mode 100644 index 00000000000..948786a3533 --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/modules/index/mutations.js @@ -0,0 +1,125 @@ +import Vue from 'vue'; +import * as types from './mutation_types'; +import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; +import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '../../../constants'; +import { mapToScopesViewModel } from '../helpers'; + +const mapFlag = flag => ({ ...flag, scopes: mapToScopesViewModel(flag.scopes || []) }); + +const updateFlag = (state, flag) => { + const index = state[FEATURE_FLAG_SCOPE].findIndex(({ id }) => id === flag.id); + Vue.set(state[FEATURE_FLAG_SCOPE], index, flag); +}; + +const createPaginationInfo = (state, headers) => { + let paginationInfo; + if (Object.keys(headers).length) { + const normalizedHeaders = normalizeHeaders(headers); + paginationInfo = parseIntPagination(normalizedHeaders); + } else { + paginationInfo = headers; + } + return paginationInfo; +}; + +export default { + [types.SET_FEATURE_FLAGS_ENDPOINT](state, endpoint) { + state.endpoint = endpoint; + }, + [types.SET_FEATURE_FLAGS_OPTIONS](state, options = {}) { + state.options = options; + }, + [types.SET_INSTANCE_ID_ENDPOINT](state, endpoint) { + state.rotateEndpoint = endpoint; + }, + [types.SET_INSTANCE_ID](state, instance) { + state.instanceId = instance; + }, + [types.SET_PROJECT_ID](state, project) { + state.projectId = project; + }, + [types.REQUEST_FEATURE_FLAGS](state) { + state.isLoading = true; + }, + [types.RECEIVE_FEATURE_FLAGS_SUCCESS](state, response) { + state.isLoading = false; + state.hasError = false; + state[FEATURE_FLAG_SCOPE] = (response.data.feature_flags || []).map(mapFlag); + + const paginationInfo = createPaginationInfo(state, response.headers); + state.count = { + ...state.count, + [FEATURE_FLAG_SCOPE]: paginationInfo?.total ?? state[FEATURE_FLAG_SCOPE].length, + }; + state.pageInfo = { + ...state.pageInfo, + [FEATURE_FLAG_SCOPE]: paginationInfo, + }; + }, + [types.RECEIVE_FEATURE_FLAGS_ERROR](state) { + state.isLoading = false; + state.hasError = true; + }, + [types.REQUEST_USER_LISTS](state) { + state.isLoading = true; + }, + [types.RECEIVE_USER_LISTS_SUCCESS](state, response) { + state.isLoading = false; + state.hasError = false; + state[USER_LIST_SCOPE] = response.data || []; + + const paginationInfo = createPaginationInfo(state, response.headers); + state.count = { + ...state.count, + [USER_LIST_SCOPE]: paginationInfo?.total ?? state[USER_LIST_SCOPE].length, + }; + state.pageInfo = { + ...state.pageInfo, + [USER_LIST_SCOPE]: paginationInfo, + }; + }, + [types.RECEIVE_USER_LISTS_ERROR](state) { + state.isLoading = false; + state.hasError = true; + }, + [types.REQUEST_ROTATE_INSTANCE_ID](state) { + state.isRotating = true; + state.hasRotateError = false; + }, + [types.RECEIVE_ROTATE_INSTANCE_ID_SUCCESS]( + state, + { + data: { token }, + }, + ) { + state.isRotating = false; + state.instanceId = token; + state.hasRotateError = false; + }, + [types.RECEIVE_ROTATE_INSTANCE_ID_ERROR](state) { + state.isRotating = false; + state.hasRotateError = true; + }, + [types.UPDATE_FEATURE_FLAG](state, flag) { + updateFlag(state, flag); + }, + [types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS](state, data) { + updateFlag(state, mapFlag(data)); + }, + [types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR](state, i) { + const flag = state[FEATURE_FLAG_SCOPE].find(({ id }) => i === id); + updateFlag(state, { ...flag, active: !flag.active }); + }, + [types.REQUEST_DELETE_USER_LIST](state, list) { + state.userLists = state.userLists.filter(l => l !== list); + }, + [types.RECEIVE_DELETE_USER_LIST_ERROR](state, { error, list }) { + state.isLoading = false; + state.hasError = false; + state.alerts = [].concat(error.message); + state.userLists = state.userLists.concat(list).sort((l1, l2) => l1.iid - l2.iid); + }, + [types.RECEIVE_CLEAR_ALERT](state, index) { + state.alerts.splice(index, 1); + }, +}; diff --git a/app/assets/javascripts/feature_flags/store/modules/index/state.js b/app/assets/javascripts/feature_flags/store/modules/index/state.js new file mode 100644 index 00000000000..443a12d485d --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/modules/index/state.js @@ -0,0 +1,18 @@ +import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '../../../constants'; + +export default () => ({ + [FEATURE_FLAG_SCOPE]: [], + [USER_LIST_SCOPE]: [], + alerts: [], + count: {}, + pageInfo: { [FEATURE_FLAG_SCOPE]: {}, [USER_LIST_SCOPE]: {} }, + isLoading: true, + hasError: false, + endpoint: null, + rotateEndpoint: null, + instanceId: '', + isRotating: false, + hasRotateError: false, + options: {}, + projectId: '', +}); diff --git a/app/assets/javascripts/feature_flags/store/modules/new/actions.js b/app/assets/javascripts/feature_flags/store/modules/new/actions.js new file mode 100644 index 00000000000..d2159d55d53 --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/modules/new/actions.js @@ -0,0 +1,51 @@ +import * as types from './mutation_types'; +import axios from '~/lib/utils/axios_utils'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { NEW_VERSION_FLAG } from '../../../constants'; +import { mapFromScopesViewModel, mapStrategiesToRails } from '../helpers'; + +/** + * Commits mutation to set the main endpoint + * @param {String} endpoint + */ +export const setEndpoint = ({ commit }, endpoint) => commit(types.SET_ENDPOINT, endpoint); + +/** + * Commits mutation to set the feature flag path. + * Used to redirect the user after form submission + * + * @param {String} path + */ +export const setPath = ({ commit }, path) => commit(types.SET_PATH, path); + +/** + * Handles the creation of a new feature flag. + * + * Will dispatch `requestCreateFeatureFlag` + * Serializes the params and makes a post request + * Dispatches an action acording to the request status. + * + * @param {Object} params + */ +export const createFeatureFlag = ({ state, dispatch }, params) => { + dispatch('requestCreateFeatureFlag'); + + return axios + .post( + state.endpoint, + params.version === NEW_VERSION_FLAG + ? mapStrategiesToRails(params) + : mapFromScopesViewModel(params), + ) + .then(() => { + dispatch('receiveCreateFeatureFlagSuccess'); + visitUrl(state.path); + }) + .catch(error => dispatch('receiveCreateFeatureFlagError', error.response.data)); +}; + +export const requestCreateFeatureFlag = ({ commit }) => commit(types.REQUEST_CREATE_FEATURE_FLAG); +export const receiveCreateFeatureFlagSuccess = ({ commit }) => + commit(types.RECEIVE_CREATE_FEATURE_FLAG_SUCCESS); +export const receiveCreateFeatureFlagError = ({ commit }, error) => + commit(types.RECEIVE_CREATE_FEATURE_FLAG_ERROR, error); diff --git a/app/assets/javascripts/feature_flags/store/modules/new/index.js b/app/assets/javascripts/feature_flags/store/modules/new/index.js new file mode 100644 index 00000000000..665bb29a17e --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/modules/new/index.js @@ -0,0 +1,10 @@ +import state from './state'; +import * as actions from './actions'; +import mutations from './mutations'; + +export default { + namespaced: true, + actions, + mutations, + state: state(), +}; diff --git a/app/assets/javascripts/feature_flags/store/modules/new/mutation_types.js b/app/assets/javascripts/feature_flags/store/modules/new/mutation_types.js new file mode 100644 index 00000000000..317f3689dfd --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/modules/new/mutation_types.js @@ -0,0 +1,6 @@ +export const SET_ENDPOINT = 'SET_ENDPOINT'; +export const SET_PATH = 'SET_PATH'; + +export const REQUEST_CREATE_FEATURE_FLAG = 'REQUEST_CREATE_FEATURE_FLAG'; +export const RECEIVE_CREATE_FEATURE_FLAG_SUCCESS = 'RECEIVE_CREATE_FEATURE_FLAG_SUCCESS'; +export const RECEIVE_CREATE_FEATURE_FLAG_ERROR = 'RECEIVE_CREATE_FEATURE_FLAG_ERROR'; diff --git a/app/assets/javascripts/feature_flags/store/modules/new/mutations.js b/app/assets/javascripts/feature_flags/store/modules/new/mutations.js new file mode 100644 index 00000000000..06e467c04f1 --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/modules/new/mutations.js @@ -0,0 +1,21 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_ENDPOINT](state, endpoint) { + state.endpoint = endpoint; + }, + [types.SET_PATH](state, path) { + state.path = path; + }, + [types.REQUEST_CREATE_FEATURE_FLAG](state) { + state.isSendingRequest = true; + state.error = []; + }, + [types.RECEIVE_CREATE_FEATURE_FLAG_SUCCESS](state) { + state.isSendingRequest = false; + }, + [types.RECEIVE_CREATE_FEATURE_FLAG_ERROR](state, error) { + state.isSendingRequest = false; + state.error = error.message || []; + }, +}; diff --git a/app/assets/javascripts/feature_flags/store/modules/new/state.js b/app/assets/javascripts/feature_flags/store/modules/new/state.js new file mode 100644 index 00000000000..6f9263dbb2a --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/modules/new/state.js @@ -0,0 +1,6 @@ +export default () => ({ + endpoint: null, + path: null, + isSendingRequest: false, + error: [], +}); diff --git a/app/assets/javascripts/feature_flags/utils.js b/app/assets/javascripts/feature_flags/utils.js new file mode 100644 index 00000000000..1017a3d0c2a --- /dev/null +++ b/app/assets/javascripts/feature_flags/utils.js @@ -0,0 +1,48 @@ +import { s__, n__, sprintf } from '~/locale'; +import { + ALL_ENVIRONMENTS_NAME, + ROLLOUT_STRATEGY_ALL_USERS, + ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + ROLLOUT_STRATEGY_USER_ID, + ROLLOUT_STRATEGY_GITLAB_USER_LIST, +} from './constants'; + +const badgeTextByType = { + [ROLLOUT_STRATEGY_ALL_USERS]: { + name: s__('FeatureFlags|All Users'), + parameters: null, + }, + [ROLLOUT_STRATEGY_PERCENT_ROLLOUT]: { + name: s__('FeatureFlags|Percent of users'), + parameters: ({ parameters: { percentage } }) => `${percentage}%`, + }, + [ROLLOUT_STRATEGY_USER_ID]: { + name: s__('FeatureFlags|User IDs'), + parameters: ({ parameters: { userIds } }) => + sprintf(n__('FeatureFlags|%d user', 'FeatureFlags|%d users', userIds.split(',').length)), + }, + [ROLLOUT_STRATEGY_GITLAB_USER_LIST]: { + name: s__('FeatureFlags|User List'), + parameters: ({ user_list: { name } }) => name, + }, +}; + +const scopeName = ({ environment_scope: scope }) => + scope === ALL_ENVIRONMENTS_NAME ? s__('FeatureFlags|All Environments') : scope; + +export default strategy => { + const { name, parameters } = badgeTextByType[strategy.name]; + + if (parameters) { + return sprintf('%{name} - %{parameters}: %{scopes}', { + name, + parameters: parameters(strategy), + scopes: strategy.scopes.map(scopeName).join(', '), + }); + } + + return sprintf('%{name}: %{scopes}', { + name, + scopes: strategy.scopes.map(scopeName).join(', '), + }); +}; diff --git a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js index 80f78c154ee..1bfbab9ef96 100644 --- a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js +++ b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js @@ -63,4 +63,47 @@ export default IssuableTokenKeys => { IssuableTokenKeys.tokenKeys.push(targetBranchToken); IssuableTokenKeys.tokenKeysWithAlternative.push(targetBranchToken); + + const approvedBy = { + token: { + formattedKey: __('Approved-By'), + key: 'approved-by', + type: 'array', + param: 'usernames[]', + symbol: '@', + icon: 'approval', + tag: '@approved-by', + }, + condition: [ + { + url: 'approved_by_usernames[]=None', + tokenKey: 'approved-by', + value: __('None'), + operator: '=', + }, + { + url: 'not[approved_by_usernames][]=None', + tokenKey: 'approved-by', + value: __('None'), + operator: '!=', + }, + { + url: 'approved_by_usernames[]=Any', + tokenKey: 'approved-by', + value: __('Any'), + operator: '=', + }, + { + url: 'not[approved_by_usernames][]=Any', + tokenKey: 'approved-by', + value: __('Any'), + operator: '!=', + }, + ], + }; + + const tokenPosition = 2; + IssuableTokenKeys.tokenKeys.splice(tokenPosition, 0, ...[approvedBy.token]); + IssuableTokenKeys.tokenKeysWithAlternative.splice(tokenPosition, 0, ...[approvedBy.token]); + IssuableTokenKeys.conditions.push(...approvedBy.condition); }; diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js index 49bd3cda127..5b4af96c861 100644 --- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js +++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js @@ -69,6 +69,11 @@ export default class AvailableDropdownMappings { gl: DropdownUser, element: this.container.querySelector('#js-dropdown-assignee'), }, + 'approved-by': { + reference: null, + gl: DropdownUser, + element: this.container.querySelector('#js-dropdown-approved-by'), + }, milestone: { reference: null, gl: DropdownNonUser, diff --git a/app/assets/javascripts/filtered_search/constants.js b/app/assets/javascripts/filtered_search/constants.js index 0b9fe969da1..6cd6f9c9906 100644 --- a/app/assets/javascripts/filtered_search/constants.js +++ b/app/assets/javascripts/filtered_search/constants.js @@ -1,4 +1,4 @@ -export const USER_TOKEN_TYPES = ['author', 'assignee']; +export const USER_TOKEN_TYPES = ['author', 'assignee', 'approved-by']; export const DROPDOWN_TYPE = { hint: 'hint', diff --git a/app/assets/javascripts/frequent_items/utils.js b/app/assets/javascripts/frequent_items/utils.js index 112e8eaaf17..954d426c86c 100644 --- a/app/assets/javascripts/frequent_items/utils.js +++ b/app/assets/javascripts/frequent_items/utils.js @@ -1,6 +1,6 @@ import { take } from 'lodash'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; -import { sanitize } from 'dompurify'; +import { sanitize } from '~/lib/dompurify'; import { FREQUENT_ITEMS, HOUR_IN_MS } from './constants'; export const isMobile = () => ['md', 'sm', 'xs'].includes(bp.getBreakpointSize()); diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 409733c73b9..0329006c62a 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -4,6 +4,7 @@ import { escape, template } from 'lodash'; import SidebarMediator from '~/sidebar/sidebar_mediator'; import glRegexp from './lib/utils/regexp'; import AjaxCache from './lib/utils/ajax_cache'; +import axios from '~/lib/utils/axios_utils'; import { spriteIcon } from './lib/utils/common_utils'; import * as Emoji from '~/emoji'; @@ -60,6 +61,7 @@ class GfmAutoComplete { this.dataSources = dataSources; this.cachedData = {}; this.isLoadingData = {}; + this.previousQuery = ''; } setup(input, enableMap = defaultAutocompleteConfig) { @@ -523,7 +525,7 @@ class GfmAutoComplete { } getDefaultCallbacks() { - const fetchData = this.fetchData.bind(this); + const self = this; return { sorter(query, items, searchKey) { @@ -536,7 +538,15 @@ class GfmAutoComplete { }, filter(query, data, searchKey) { if (GfmAutoComplete.isLoading(data)) { - fetchData(this.$inputor, this.at); + self.fetchData(this.$inputor, this.at); + return data; + } + if ( + GfmAutoComplete.typesWithBackendFiltering.includes(GfmAutoComplete.atTypeMap[this.at]) && + self.previousQuery !== query + ) { + self.fetchData(this.$inputor, this.at, query); + self.previousQuery = query; return data; } return $.fn.atwho.default.callbacks.filter(query, data, searchKey); @@ -584,13 +594,22 @@ class GfmAutoComplete { }; } - fetchData($input, at) { + fetchData($input, at, search) { if (this.isLoadingData[at]) return; this.isLoadingData[at] = true; const dataSource = this.dataSources[GfmAutoComplete.atTypeMap[at]]; - if (this.cachedData[at]) { + if (GfmAutoComplete.typesWithBackendFiltering.includes(GfmAutoComplete.atTypeMap[at])) { + axios + .get(dataSource, { params: { search } }) + .then(({ data }) => { + this.loadData($input, at, data); + }) + .catch(() => { + this.isLoadingData[at] = false; + }); + } else if (this.cachedData[at]) { this.loadData($input, at, this.cachedData[at]); } else if (GfmAutoComplete.atTypeMap[at] === 'emojis') { Emoji.initEmojiMap() @@ -684,6 +703,8 @@ GfmAutoComplete.atTypeMap = { $: 'snippets', }; +GfmAutoComplete.typesWithBackendFiltering = ['vulnerabilities']; + // Emoji GfmAutoComplete.glEmojiTag = null; GfmAutoComplete.Emoji = { diff --git a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue index 79494cb173b..7a991ac2455 100644 --- a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue +++ b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue @@ -92,11 +92,9 @@ export default { </a> </p> </gl-form-group> - <div class="gl-display-flex gl-justify-content-end"> - <gl-button variant="success" category="primary" @click="updateGrafanaIntegration"> - {{ __('Save Changes') }} - </gl-button> - </div> + <gl-button variant="success" category="primary" @click="updateGrafanaIntegration"> + {{ __('Save Changes') }} + </gl-button> </form> </div> </section> diff --git a/app/assets/javascripts/groups/components/group_folder.vue b/app/assets/javascripts/groups/components/group_folder.vue index 8c7192b49a0..d2a613bed4f 100644 --- a/app/assets/javascripts/groups/components/group_folder.vue +++ b/app/assets/javascripts/groups/components/group_folder.vue @@ -1,8 +1,12 @@ <script> +import { GlIcon } from '@gitlab/ui'; import { n__ } from '../../locale'; import { MAX_CHILDREN_COUNT } from '../constants'; export default { + components: { + GlIcon, + }, props: { parentGroup: { type: Object, @@ -45,7 +49,7 @@ export default { /> <li v-if="hasMoreChildren" class="group-row"> <a :href="parentGroup.relativePath" class="group-row-contents has-more-items py-2"> - <i class="fa fa-external-link" aria-hidden="true"> </i> {{ moreChildrenStats }} + <gl-icon name="external-link" aria-hidden="true" /> {{ moreChildrenStats }} </a> </li> </ul> diff --git a/app/assets/javascripts/groups/components/item_actions.vue b/app/assets/javascripts/groups/components/item_actions.vue index 5487e25066e..2e92a608f76 100644 --- a/app/assets/javascripts/groups/components/item_actions.vue +++ b/app/assets/javascripts/groups/components/item_actions.vue @@ -53,6 +53,7 @@ export default { :aria-label="leaveBtnTitle" data-container="body" data-placement="bottom" + data-testid="leave-group-btn" class="leave-group btn btn-xs no-expand gl-text-gray-500 gl-ml-5" @click.prevent="onLeaveGroup" > @@ -66,6 +67,7 @@ export default { :aria-label="editBtnTitle" data-container="body" data-placement="bottom" + data-testid="edit-group-btn" class="edit-group btn btn-xs no-expand gl-text-gray-500 gl-ml-5" > <gl-icon name="settings" class="position-top-0 align-middle" /> diff --git a/app/assets/javascripts/groups/components/item_stats_value.vue b/app/assets/javascripts/groups/components/item_stats_value.vue index 18efd8c6823..2185284c892 100644 --- a/app/assets/javascripts/groups/components/item_stats_value.vue +++ b/app/assets/javascripts/groups/components/item_stats_value.vue @@ -57,6 +57,7 @@ export default { :title="title" data-container="body" > - <gl-icon :name="iconName" /> <span v-if="isValuePresent" class="stat-value"> {{ value }} </span> + <gl-icon :name="iconName" /> + <span v-if="isValuePresent" class="stat-value" data-testid="itemStatValue"> {{ value }} </span> </span> </template> diff --git a/app/assets/javascripts/groups/members/components/app.vue b/app/assets/javascripts/groups/members/components/app.vue index e94b28f5773..32b832644b9 100644 --- a/app/assets/javascripts/groups/members/components/app.vue +++ b/app/assets/javascripts/groups/members/components/app.vue @@ -1,11 +1,12 @@ <script> +import MembersTable from '~/vue_shared/components/members/table/members_table.vue'; + export default { name: 'GroupMembersApp', + components: { MembersTable }, }; </script> <template> - <span> - <!-- Temporary empty template --> - </span> + <members-table /> </template> diff --git a/app/assets/javascripts/groups/members/index.js b/app/assets/javascripts/groups/members/index.js index 4ca1756f10c..0a032eacf05 100644 --- a/app/assets/javascripts/groups/members/index.js +++ b/app/assets/javascripts/groups/members/index.js @@ -4,7 +4,7 @@ import App from './components/app.vue'; import membersModule from '~/vuex_shared/modules/members'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -export default el => { +export const initGroupMembersApp = (el, tableFields) => { if (!el) { return () => {}; } @@ -18,6 +18,7 @@ export default el => { members: convertObjectPropsToCamelCase(JSON.parse(members), { deep: true }), sourceId: parseInt(groupId, 10), currentUserId: gon.current_user_id || null, + tableFields, }), }); diff --git a/app/assets/javascripts/ide/components/activity_bar.vue b/app/assets/javascripts/ide/components/activity_bar.vue index 183816921c1..69e5cd839b4 100644 --- a/app/assets/javascripts/ide/components/activity_bar.vue +++ b/app/assets/javascripts/ide/components/activity_bar.vue @@ -32,7 +32,7 @@ export default { </script> <template> - <nav class="ide-activity-bar"> + <nav class="ide-activity-bar" data-testid="left-sidebar"> <ul class="list-unstyled"> <li> <button diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue index de4b0a34002..b89329c92ec 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue @@ -1,8 +1,8 @@ <script> -/* eslint-disable vue/no-v-html */ import { escape } from 'lodash'; import { mapState, mapGetters, createNamespacedHelpers } from 'vuex'; -import { sprintf, s__ } from '~/locale'; +import { GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; import consts from '../../stores/modules/commit/constants'; import RadioGroup from './radio_group.vue'; import NewMergeRequestOption from './new_merge_request_option.vue'; @@ -13,6 +13,7 @@ const { mapState: mapCommitState, mapActions: mapCommitActions } = createNamespa export default { components: { + GlSprintf, RadioGroup, NewMergeRequestOption, }, @@ -20,12 +21,8 @@ export default { ...mapState(['currentBranchId', 'changedFiles', 'stagedFiles']), ...mapCommitState(['commitAction']), ...mapGetters(['currentBranch', 'emptyRepo', 'canPushToBranch']), - commitToCurrentBranchText() { - return sprintf( - s__('IDE|Commit to %{branchName} branch'), - { branchName: `<strong class="monospace">${escape(this.currentBranchId)}</strong>` }, - false, - ); + currentBranchText() { + return escape(this.currentBranchId); }, containsStagedChanges() { return this.changedFiles.length > 0 && this.stagedFiles.length > 0; @@ -77,11 +74,13 @@ export default { :disabled="!canPushToBranch" :title="$options.currentBranchPermissionsTooltip" > - <span - class="ide-option-label" - data-qa-selector="commit_to_current_branch_radio" - v-html="commitToCurrentBranchText" - ></span> + <span class="ide-option-label" data-qa-selector="commit_to_current_branch_radio"> + <gl-sprintf :message="s__('IDE|Commit to %{branchName} branch')"> + <template #branchName> + <strong class="monospace">{{ currentBranchText }}</strong> + </template> + </gl-sprintf> + </span> </radio-group> <template v-if="!emptyRepo"> <radio-group 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 2787b10a48b..5b392470e41 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue @@ -1,5 +1,5 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlPopover } from '@gitlab/ui'; import { __, sprintf } from '../../../locale'; import popover from '../../../vue_shared/directives/popover'; import { MAX_TITLE_LENGTH, MAX_BODY_LENGTH } from '../../constants'; @@ -10,6 +10,7 @@ export default { }, components: { GlIcon, + GlPopover, }, props: { text: { @@ -58,7 +59,7 @@ export default { }, }, popoverOptions: { - trigger: 'hover', + triggers: 'hover', placement: 'top', content: sprintf( __(` @@ -83,9 +84,16 @@ export default { <ul class="nav-links"> <li> {{ __('Commit Message') }} - <span v-popover="$options.popoverOptions" class="form-text text-muted gl-ml-3"> - <gl-icon name="question" /> - </span> + <div id="ide-commit-message-popover-container"> + <span id="ide-commit-message-question" class="form-text text-muted gl-ml-3"> + <gl-icon name="question" /> + </span> + <gl-popover + target="ide-commit-message-question" + container="ide-commit-message-popover-container" + v-bind="$options.popoverOptions" + /> + </div> </li> </ul> </div> diff --git a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue index 732fa0786b0..dec8aa61838 100644 --- a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue +++ b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue @@ -1,8 +1,12 @@ <script> +import { GlButton } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import { viewerTypes } from '../constants'; export default { + components: { + GlButton, + }, props: { viewer: { type: String, @@ -31,7 +35,7 @@ export default { <template> <div class="dropdown"> - <button type="button" class="btn btn-link" data-toggle="dropdown">{{ __('Edit') }}</button> + <gl-button variant="link" data-toggle="dropdown">{{ __('Edit') }}</gl-button> <div class="dropdown-menu dropdown-menu-selectable dropdown-open-left"> <ul> <li> diff --git a/app/assets/javascripts/ide/components/file_templates/dropdown.vue b/app/assets/javascripts/ide/components/file_templates/dropdown.vue index d80662f6ae1..cfd2555b769 100644 --- a/app/assets/javascripts/ide/components/file_templates/dropdown.vue +++ b/app/assets/javascripts/ide/components/file_templates/dropdown.vue @@ -1,12 +1,13 @@ <script> import $ from 'jquery'; import { mapActions, mapState } from 'vuex'; -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlIcon, GlLoadingIcon } from '@gitlab/ui'; import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue'; export default { components: { DropdownButton, + GlIcon, GlLoadingIcon, }, props: { @@ -85,7 +86,7 @@ export default { type="search" class="dropdown-input-field qa-dropdown-filter-input" /> - <i aria-hidden="true" class="fa fa-search dropdown-input-search"></i> + <gl-icon name="search" class="dropdown-input-search" aria-hidden="true" /> </div> <div class="dropdown-content"> <gl-loading-icon v-if="showLoading" size="lg" /> diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index 1b03d9eee8b..b08497f8f82 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -2,7 +2,18 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; +import { + WEBIDE_MARK_APP_START, + WEBIDE_MARK_FILE_FINISH, + WEBIDE_MARK_FILE_CLICKED, + WEBIDE_MARK_TREE_FINISH, + WEBIDE_MEASURE_TREE_FROM_REQUEST, + WEBIDE_MEASURE_FILE_FROM_REQUEST, + WEBIDE_MEASURE_FILE_AFTER_INTERACTION, +} from '~/performance_constants'; +import { performanceMarkAndMeasure } from '~/performance_utils'; import { modalTypes } from '../constants'; +import eventHub from '../eventhub'; import FindFile from '~/vue_shared/components/file_finder/index.vue'; import NewModal from './new_dropdown/modal.vue'; import IdeSidebar from './ide_side_bar.vue'; @@ -14,6 +25,50 @@ import ErrorMessage from './error_message.vue'; import CommitEditorHeader from './commit_sidebar/editor_header.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +const markPerformance = params => { + performanceMarkAndMeasure(params); +}; +const markTreePerformance = () => { + markPerformance({ + mark: WEBIDE_MARK_TREE_FINISH, + measures: [ + { + name: WEBIDE_MEASURE_TREE_FROM_REQUEST, + start: undefined, + end: WEBIDE_MARK_TREE_FINISH, + }, + ], + }); +}; +const markEditorLoadPerformance = () => { + markPerformance({ + mark: WEBIDE_MARK_FILE_FINISH, + measures: [ + { + name: WEBIDE_MEASURE_FILE_FROM_REQUEST, + start: undefined, + end: WEBIDE_MARK_FILE_FINISH, + }, + ], + }); +}; +const markEditorInteractionPerformance = () => { + markPerformance({ + mark: WEBIDE_MARK_FILE_FINISH, + measures: [ + { + name: WEBIDE_MEASURE_FILE_AFTER_INTERACTION, + start: WEBIDE_MARK_FILE_CLICKED, + end: WEBIDE_MARK_FILE_FINISH, + }, + ], + }); +}; + +eventHub.$on(WEBIDE_MEASURE_TREE_FROM_REQUEST, markTreePerformance); +eventHub.$on(WEBIDE_MEASURE_FILE_FROM_REQUEST, markEditorLoadPerformance); +eventHub.$on(WEBIDE_MEASURE_FILE_AFTER_INTERACTION, markEditorInteractionPerformance); + export default { components: { NewModal, @@ -59,6 +114,9 @@ export default { if (this.themeName) document.querySelector('.navbar-gitlab').classList.add(`theme-${this.themeName}`); }, + beforeCreate() { + performance.mark(WEBIDE_MARK_APP_START); + }, methods: { ...mapActions(['toggleFileFinder']), onBeforeUnload(e = {}) { diff --git a/app/assets/javascripts/ide/components/ide_review.vue b/app/assets/javascripts/ide/components/ide_review.vue index e36d0a5a5b1..7d2f0acb08c 100644 --- a/app/assets/javascripts/ide/components/ide_review.vue +++ b/app/assets/javascripts/ide/components/ide_review.vue @@ -23,26 +23,32 @@ export default { }, }, mounted() { - if (this.activeFile && this.activeFile.pending && !this.activeFile.deleted) { - this.$router.push(this.getUrlForPath(this.activeFile.path), () => { - this.updateViewer('editor'); - }); - } else if (this.activeFile && this.activeFile.deleted) { - this.resetOpenFiles(); - } - - this.$nextTick(() => { - this.updateViewer(this.currentMergeRequestId ? viewerTypes.mr : viewerTypes.diff); - }); + this.initialize(); + }, + activated() { + this.initialize(); }, methods: { ...mapActions(['updateViewer', 'resetOpenFiles']), + initialize() { + if (this.activeFile && this.activeFile.pending && !this.activeFile.deleted) { + this.$router.push(this.getUrlForPath(this.activeFile.path), () => { + this.updateViewer(viewerTypes.edit); + }); + } else if (this.activeFile && this.activeFile.deleted) { + this.resetOpenFiles(); + } + + this.$nextTick(() => { + this.updateViewer(this.currentMergeRequestId ? viewerTypes.mr : viewerTypes.diff); + }); + }, }, }; </script> <template> - <ide-tree-list :viewer-type="viewer" header-class="ide-review-header"> + <ide-tree-list header-class="ide-review-header"> <template #header> <div class="ide-review-button-holder"> {{ __('Review') }} diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue index ed68ca5cae9..53dfc133fc8 100644 --- a/app/assets/javascripts/ide/components/ide_side_bar.vue +++ b/app/assets/javascripts/ide/components/ide_side_bar.vue @@ -7,9 +7,8 @@ import ActivityBar from './activity_bar.vue'; import RepoCommitSection from './repo_commit_section.vue'; import CommitForm from './commit_sidebar/form.vue'; import IdeReview from './ide_review.vue'; -import SuccessMessage from './commit_sidebar/success_message.vue'; import IdeProjectHeader from './ide_project_header.vue'; -import { leftSidebarViews, SIDEBAR_INIT_WIDTH } from '../constants'; +import { SIDEBAR_INIT_WIDTH } from '../constants'; export default { components: { @@ -20,18 +19,11 @@ export default { IdeTree, CommitForm, IdeReview, - SuccessMessage, IdeProjectHeader, }, computed: { ...mapState(['loading', 'currentActivityView', 'changedFiles', 'stagedFiles', 'lastCommitMsg']), ...mapGetters(['currentProject', 'someUncommittedChanges']), - showSuccessMessage() { - return ( - this.currentActivityView === leftSidebarViews.edit.name && - (this.lastCommitMsg && !this.someUncommittedChanges) - ); - }, }, SIDEBAR_INIT_WIDTH, }; @@ -44,7 +36,7 @@ export default { class="multi-file-commit-panel flex-column" > <template v-if="loading"> - <div class="multi-file-commit-panel-inner"> + <div class="multi-file-commit-panel-inner" data-testid="ide-side-bar-inner"> <div v-for="n in 3" :key="n" class="multi-file-loading-container"> <gl-skeleton-loading /> </div> @@ -54,9 +46,11 @@ export default { <ide-project-header :project="currentProject" /> <div class="ide-context-body d-flex flex-fill"> <activity-bar /> - <div class="multi-file-commit-panel-inner"> + <div class="multi-file-commit-panel-inner" data-testid="ide-side-bar-inner"> <div class="multi-file-commit-panel-inner-content"> - <component :is="currentActivityView" /> + <keep-alive> + <component :is="currentActivityView" /> + </keep-alive> </div> <commit-form /> </div> diff --git a/app/assets/javascripts/ide/components/ide_tree.vue b/app/assets/javascripts/ide/components/ide_tree.vue index 747d5044790..51d783df0ad 100644 --- a/app/assets/javascripts/ide/components/ide_tree.vue +++ b/app/assets/javascripts/ide/components/ide_tree.vue @@ -1,6 +1,6 @@ <script> import { mapState, mapGetters, mapActions } from 'vuex'; -import { modalTypes } from '../constants'; +import { modalTypes, viewerTypes } from '../constants'; import IdeTreeList from './ide_tree_list.vue'; import Upload from './new_dropdown/upload.vue'; import NewEntryButton from './new_dropdown/button.vue'; @@ -18,15 +18,10 @@ export default { ...mapGetters(['currentProject', 'currentTree', 'activeFile', 'getUrlForPath']), }, mounted() { - if (!this.activeFile) return; - - if (this.activeFile.pending && !this.activeFile.deleted) { - this.$router.push(this.getUrlForPath(this.activeFile.path), () => { - this.updateViewer('editor'); - }); - } else if (this.activeFile.deleted) { - this.resetOpenFiles(); - } + this.initialize(); + }, + activated() { + this.initialize(); }, methods: { ...mapActions(['updateViewer', 'createTempEntry', 'resetOpenFiles']), @@ -36,12 +31,27 @@ export default { createNewFolder() { this.$refs.newModal.open(modalTypes.tree); }, + initialize() { + this.$nextTick(() => { + this.updateViewer(viewerTypes.edit); + }); + + if (!this.activeFile) return; + + if (this.activeFile.pending && !this.activeFile.deleted) { + this.$router.push(this.getUrlForPath(this.activeFile.path), () => { + this.updateViewer(viewerTypes.edit); + }); + } else if (this.activeFile.deleted) { + this.resetOpenFiles(); + } + }, }, }; </script> <template> - <ide-tree-list viewer-type="editor"> + <ide-tree-list> <template #header> {{ __('Edit') }} <div class="ide-tree-actions ml-auto d-flex"> diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue index 776d8459515..bbec20776bf 100644 --- a/app/assets/javascripts/ide/components/ide_tree_list.vue +++ b/app/assets/javascripts/ide/components/ide_tree_list.vue @@ -2,6 +2,13 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; import FileTree from '~/vue_shared/components/file_tree.vue'; +import { + WEBIDE_MARK_TREE_START, + WEBIDE_MEASURE_TREE_FROM_REQUEST, + WEBIDE_MARK_FILE_CLICKED, +} from '~/performance_constants'; +import { performanceMarkAndMeasure } from '~/performance_utils'; +import eventHub from '../eventhub'; import IdeFileRow from './ide_file_row.vue'; import NavDropdown from './nav_dropdown.vue'; @@ -12,10 +19,6 @@ export default { FileTree, }, props: { - viewerType: { - type: String, - required: true, - }, headerClass: { type: String, required: false, @@ -29,11 +32,21 @@ export default { return !this.currentTree || this.currentTree.loading; }, }, - mounted() { - this.updateViewer(this.viewerType); + beforeCreate() { + performanceMarkAndMeasure({ mark: WEBIDE_MARK_TREE_START }); + }, + updated() { + if (this.currentTree?.tree?.length) { + this.$nextTick(() => { + eventHub.$emit(WEBIDE_MEASURE_TREE_FROM_REQUEST); + }); + } }, methods: { - ...mapActions(['updateViewer', 'toggleTreeOpen']), + ...mapActions(['toggleTreeOpen']), + clickedFile() { + performanceMarkAndMeasure({ mark: WEBIDE_MARK_FILE_CLICKED }); + }, }, IdeFileRow, }; @@ -51,7 +64,7 @@ export default { <nav-dropdown /> <slot name="header"></slot> </header> - <div class="ide-tree-body h-100"> + <div class="ide-tree-body h-100" data-testid="ide-tree-body"> <template v-if="currentTree.tree.length"> <file-tree v-for="file in currentTree.tree" @@ -60,6 +73,7 @@ export default { :level="0" :file-row-component="$options.IdeFileRow" @toggleTreeOpen="toggleTreeOpen" + @clickFile="clickedFile" /> </template> <div v-else class="file-row">{{ __('No files') }}</div> diff --git a/app/assets/javascripts/ide/components/jobs/detail.vue b/app/assets/javascripts/ide/components/jobs/detail.vue index 11033a5cc88..394a512f5bd 100644 --- a/app/assets/javascripts/ide/components/jobs/detail.vue +++ b/app/assets/javascripts/ide/components/jobs/detail.vue @@ -2,9 +2,8 @@ /* eslint-disable vue/no-v-html */ import { mapActions, mapState } from 'vuex'; import { throttle } from 'lodash'; -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '../../../locale'; -import tooltip from '../../../vue_shared/directives/tooltip'; import ScrollButton from './detail/scroll_button.vue'; import JobDescription from './detail/description.vue'; @@ -15,7 +14,7 @@ const scrollPositions = { export default { directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, components: { GlIcon, @@ -84,7 +83,7 @@ export default { <job-description :job="detailJob" /> <div class="controllers ml-auto"> <a - v-tooltip + v-gl-tooltip :title="__('Show complete raw log')" :href="detailJob.rawPath" data-placement="top" diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index 528475849de..5ad836f346a 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -152,6 +152,7 @@ export default { v-model.trim="entryName" type="text" class="form-control" + data-testid="file-name-field" data-qa-selector="file_name_field" :placeholder="placeholder" /> diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue index 5eed57bb6c5..92b99b5c731 100644 --- a/app/assets/javascripts/ide/components/repo_commit_section.vue +++ b/app/assets/javascripts/ide/components/repo_commit_section.vue @@ -26,28 +26,34 @@ export default { }, }, mounted() { - const file = - this.lastOpenedFile && this.lastOpenedFile.type !== 'tree' - ? this.lastOpenedFile - : this.activeFile; - - if (!file) return; - - this.openPendingTab({ - file, - keyPrefix: file.staged ? stageKeys.staged : stageKeys.unstaged, - }) - .then(changeViewer => { - if (changeViewer) { - this.updateViewer('diff'); - } - }) - .catch(e => { - throw e; - }); + this.initialize(); + }, + activated() { + this.initialize(); }, methods: { ...mapActions(['openPendingTab', 'updateViewer', 'updateActivityBarView']), + initialize() { + const file = + this.lastOpenedFile && this.lastOpenedFile.type !== 'tree' + ? this.lastOpenedFile + : this.activeFile; + + if (!file) return; + + this.openPendingTab({ + file, + keyPrefix: file.staged ? stageKeys.staged : stageKeys.unstaged, + }) + .then(changeViewer => { + if (changeViewer) { + this.updateViewer('diff'); + } + }) + .catch(e => { + throw e; + }); + }, }, stageKeys, }; diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index f342ce1739c..7465772d86a 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -5,6 +5,14 @@ import { deprecatedCreateFlash as flash } from '~/flash'; import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; import { + WEBIDE_MARK_FILE_CLICKED, + WEBIDE_MARK_FILE_START, + WEBIDE_MEASURE_FILE_AFTER_INTERACTION, + WEBIDE_MEASURE_FILE_FROM_REQUEST, +} from '~/performance_constants'; +import { performanceMarkAndMeasure } from '~/performance_utils'; +import eventHub from '../eventhub'; +import { leftSidebarViews, viewerTypes, FILE_VIEW_MODE_EDITOR, @@ -164,6 +172,9 @@ export default { } }, }, + beforeCreate() { + performanceMarkAndMeasure({ mark: WEBIDE_MARK_FILE_START }); + }, beforeDestroy() { this.editor.dispose(); }, @@ -289,6 +300,13 @@ export default { }); this.$emit('editorSetup'); + this.$nextTick(() => { + if (performance.getEntriesByName(WEBIDE_MARK_FILE_CLICKED).length) { + eventHub.$emit(WEBIDE_MEASURE_FILE_AFTER_INTERACTION); + } else { + eventHub.$emit(WEBIDE_MEASURE_FILE_FROM_REQUEST); + } + }); }, refreshEditorDimensions() { if (this.showEditor) { diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index 59b1969face..bdb11e6b004 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -47,9 +47,9 @@ export const diffViewerErrors = Object.freeze({ }); export const leftSidebarViews = { - edit: { name: 'ide-tree', keepAlive: false }, - review: { name: 'ide-review', keepAlive: false }, - commit: { name: 'repo-commit-section', keepAlive: false }, + edit: { name: 'ide-tree' }, + review: { name: 'ide-review' }, + commit: { name: 'repo-commit-section' }, }; export const rightSidebarViews = { diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue index 670c42cbdac..78eb828fd19 100644 --- a/app/assets/javascripts/incidents/components/incidents_list.vue +++ b/app/assets/javascripts/incidents/components/incidents_list.vue @@ -8,7 +8,6 @@ import { GlAvatar, GlTooltipDirective, GlButton, - GlSearchBoxByType, GlIcon, GlPagination, GlTabs, @@ -16,18 +15,35 @@ import { GlBadge, GlEmptyState, } from '@gitlab/ui'; -import { debounce } from 'lodash'; +import Api from '~/api'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { convertToSnakeCase } from '~/lib/utils/text_utility'; -import { s__ } from '~/locale'; -import { mergeUrlParams, joinPaths, visitUrl } from '~/lib/utils/url_utility'; +import { s__, __ } from '~/locale'; +import { urlParamsToObject } from '~/lib/utils/common_utils'; +import { + visitUrl, + mergeUrlParams, + joinPaths, + updateHistory, + setUrlParams, +} from '~/lib/utils/url_utility'; import getIncidents from '../graphql/queries/get_incidents.query.graphql'; import getIncidentsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql'; import SeverityToken from '~/sidebar/components/severity/severity.vue'; import { INCIDENT_SEVERITY } from '~/sidebar/components/severity/constants'; -import { I18N, DEFAULT_PAGE_SIZE, INCIDENT_SEARCH_DELAY, INCIDENT_STATUS_TABS } from '../constants'; +import { + I18N, + DEFAULT_PAGE_SIZE, + INCIDENT_STATUS_TABS, + TH_CREATED_AT_TEST_ID, + TH_SEVERITY_TEST_ID, + TH_PUBLISHED_TEST_ID, + INCIDENT_DETAILS_PATH, +} from '../constants'; -const TH_TEST_ID = { 'data-testid': 'incident-management-created-at-sort' }; 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'; @@ -49,8 +65,10 @@ export default { { key: 'severity', label: s__('IncidentManagement|Severity'), - thClass: `gl-pointer-events-none`, - tdClass, + thClass, + tdClass: `${tdClass} sortable-cell`, + sortable: true, + thAttr: TH_SEVERITY_TEST_ID, }, { key: 'title', @@ -64,7 +82,7 @@ export default { thClass, tdClass: `${tdClass} sortable-cell`, sortable: true, - thAttr: TH_TEST_ID, + thAttr: TH_CREATED_AT_TEST_ID, }, { key: 'assignees', @@ -82,7 +100,6 @@ export default { GlAvatar, GlButton, TimeAgoTooltip, - GlSearchBoxByType, GlIcon, GlPagination, GlTabs, @@ -91,10 +108,12 @@ export default { GlBadge, GlEmptyState, SeverityToken, + FilteredSearchBar, }, directives: { GlTooltip: GlTooltipDirective, }, + mixins: [glFeatureFlagsMixin()], inject: [ 'projectPath', 'newIssuePath', @@ -103,6 +122,9 @@ export default { 'issuePath', 'publishedAvailable', 'emptyListSvgPath', + 'textQuery', + 'authorUsernamesQuery', + 'assigneeUsernamesQuery', ], apollo: { incidents: { @@ -118,6 +140,8 @@ export default { lastPageSize: this.pagination.lastPageSize, prevPageCursor: this.pagination.prevPageCursor, nextPageCursor: this.pagination.nextPageCursor, + authorUsername: this.authorUsername, + assigneeUsernames: this.assigneeUsernames, }; }, update({ project: { issues: { nodes = [], pageInfo = {} } = {} } = {} }) { @@ -135,6 +159,8 @@ export default { variables() { return { searchTerm: this.searchTerm, + authorUsername: this.authorUsername, + assigneeUsernames: this.assigneeUsernames, projectPath: this.projectPath, issueTypes: ['INCIDENT'], }; @@ -149,7 +175,7 @@ export default { errored: false, isErrorAlertDismissed: false, redirecting: false, - searchTerm: '', + searchTerm: this.textQuery, pagination: initialPaginationState, incidents: {}, sort: 'created_desc', @@ -157,6 +183,9 @@ export default { sortDesc: true, statusFilter: '', filteredByStatus: '', + authorUsername: this.authorUsernamesQuery, + assigneeUsernames: this.assigneeUsernamesQuery, + filterParams: {}, }; }, computed: { @@ -208,7 +237,10 @@ export default { { key: 'published', label: s__('IncidentManagement|Published'), - thClass: 'gl-pointer-events-none', + thClass, + tdClass: `${tdClass} sortable-cell`, + sortable: true, + thAttr: TH_PUBLISHED_TEST_ID, }, ], ] @@ -242,15 +274,59 @@ export default { btnText: createIncidentBtnLabel, }; }, + filteredSearchTokens() { + return [ + { + type: 'author_username', + icon: 'user', + title: __('Author'), + unique: true, + symbol: '@', + token: AuthorToken, + operators: [{ value: '=', description: __('is'), default: 'true' }], + fetchPath: this.projectPath, + fetchAuthors: Api.projectUsers.bind(Api), + }, + { + type: 'assignee_username', + icon: 'user', + title: __('Assignees'), + unique: true, + symbol: '@', + token: AuthorToken, + operators: [{ value: '=', description: __('is'), default: 'true' }], + fetchPath: this.projectPath, + fetchAuthors: Api.projectUsers.bind(Api), + }, + ]; + }, + filteredSearchValue() { + const value = []; + + if (this.authorUsername) { + value.push({ + type: 'author_username', + value: { data: this.authorUsername }, + }); + } + + if (this.assigneeUsernames) { + value.push({ + type: 'assignee_username', + value: { data: this.assigneeUsernames }, + }); + } + + if (this.searchTerm) { + value.push(this.searchTerm); + } + + return value; + }, }, methods: { - onInputChange: debounce(function debounceSearch(input) { - const trimmedInput = input.trim(); - if (trimmedInput !== this.searchTerm) { - this.searchTerm = trimmedInput; - } - }, INCIDENT_SEARCH_DELAY), filterIncidentsByStatus(tabIndex) { + this.resetPagination(); const { filters, status } = this.$options.statusTabs[tabIndex]; this.statusFilter = filters; this.filteredByStatus = status; @@ -259,7 +335,10 @@ export default { return Boolean(assignees.nodes?.length); }, navigateToIncidentDetails({ iid }) { - return visitUrl(joinPaths(this.issuePath, iid)); + const path = this.glFeatures.issuesIncidentDetails + ? joinPaths(this.issuePath, INCIDENT_DETAILS_PATH) + : this.issuePath; + return visitUrl(joinPaths(path, iid)); }, handlePageChange(page) { const { startCursor, endCursor } = this.incidents.pageInfo; @@ -284,14 +363,73 @@ export default { this.pagination = initialPaginationState; }, fetchSortedData({ sortBy, sortDesc }) { - const sortingDirection = sortDesc ? 'desc' : 'asc'; - const sortingColumn = convertToSnakeCase(sortBy).replace(/_.*/, ''); + const sortingDirection = sortDesc ? 'DESC' : 'ASC'; + const sortingColumn = convertToSnakeCase(sortBy) + .replace(/_.*/, '') + .toUpperCase(); + this.resetPagination(); this.sort = `${sortingColumn}_${sortingDirection}`; }, getSeverity(severity) { return INCIDENT_SEVERITY[severity]; }, + handleFilterIncidents(filters) { + this.resetPagination(); + const filterParams = { authorUsername: '', assigneeUsername: '', search: '' }; + + filters.forEach(filter => { + if (typeof filter === 'object') { + switch (filter.type) { + case 'author_username': + filterParams.authorUsername = filter.value.data; + break; + case 'assignee_username': + filterParams.assigneeUsername = filter.value.data; + break; + case 'filtered-search-term': + if (filter.value.data !== '') filterParams.search = filter.value.data; + break; + default: + break; + } + } + }); + + this.filterParams = filterParams; + this.updateUrl(); + this.searchTerm = filterParams?.search; + this.authorUsername = filterParams?.authorUsername; + this.assigneeUsernames = filterParams?.assigneeUsername; + }, + updateUrl() { + const queryParams = urlParamsToObject(window.location.search); + const { authorUsername, assigneeUsername, search } = this.filterParams || {}; + + if (authorUsername) { + queryParams.author_username = authorUsername; + } else { + delete queryParams.author_username; + } + + if (assigneeUsername) { + queryParams.assignee_username = assigneeUsername; + } else { + delete queryParams.assignee_username; + } + + if (search) { + queryParams.search = search; + } else { + delete queryParams.search; + } + + updateHistory({ + url: setUrlParams(queryParams, window.location.href, true), + title: document.title, + replace: true, + }); + }, }, }; </script> @@ -331,12 +469,16 @@ export default { </gl-button> </div> - <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 - :value="searchTerm" - class="gl-bg-white" - :placeholder="$options.i18n.searchPlaceholder" - @input="onInputChange" + <div class="filtered-search-wrapper"> + <filtered-search-bar + :namespace="projectPath" + :search-input-placeholder="$options.i18n.searchPlaceholder" + :tokens="filteredSearchTokens" + :initial-filter-value="filteredSearchValue" + initial-sortby="created_desc" + recent-searches-storage-key="incidents" + class="row-content-block" + @onFilter="handleFilterIncidents" /> </div> diff --git a/app/assets/javascripts/incidents/constants.js b/app/assets/javascripts/incidents/constants.js index 289b36d9848..797439495e3 100644 --- a/app/assets/javascripts/incidents/constants.js +++ b/app/assets/javascripts/incidents/constants.js @@ -6,7 +6,7 @@ export const I18N = { unassigned: s__('IncidentManagement|Unassigned'), createIncidentBtnLabel: s__('IncidentManagement|Create incident'), unPublished: s__('IncidentManagement|Unpublished'), - searchPlaceholder: __('Search results…'), + searchPlaceholder: __('Search or filter results…'), emptyState: { title: s__('IncidentManagement|Display your incidents in a dedicated view'), emptyClosedTabTitle: s__('IncidentManagement|There are no closed incidents'), @@ -34,5 +34,8 @@ export const INCIDENT_STATUS_TABS = [ }, ]; -export const INCIDENT_SEARCH_DELAY = 300; export const DEFAULT_PAGE_SIZE = 20; +export const TH_CREATED_AT_TEST_ID = { 'data-testid': 'incident-management-created-at-sort' }; +export const TH_SEVERITY_TEST_ID = { 'data-testid': 'incident-management-severity-sort' }; +export const TH_PUBLISHED_TEST_ID = { 'data-testid': 'incident-management-published-sort' }; +export const INCIDENT_DETAILS_PATH = 'incident'; diff --git a/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql b/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql index 0b784b104a8..fd96825c0f7 100644 --- a/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql +++ b/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql @@ -1,6 +1,17 @@ -query getIncidentsCountByStatus($searchTerm: String, $projectPath: ID!, $issueTypes: [IssueType!]) { +query getIncidentsCountByStatus( + $searchTerm: String + $projectPath: ID! + $issueTypes: [IssueType!] + $authorUsername: String = "" + $assigneeUsernames: String = "" +) { project(fullPath: $projectPath) { - issueStatusCounts(search: $searchTerm, types: $issueTypes) { + issueStatusCounts( + search: $searchTerm + types: $issueTypes + authorUsername: $authorUsername + assigneeUsername: $assigneeUsernames + ) { all opened closed diff --git a/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql b/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql index dab130835e2..dd2a42ba4e8 100644 --- a/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql +++ b/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql @@ -9,7 +9,9 @@ query getIncidents( $lastPageSize: Int $prevPageCursor: String = "" $nextPageCursor: String = "" - $searchTerm: String + $searchTerm: String = "" + $authorUsername: String = "" + $assigneeUsernames: String = "" ) { project(fullPath: $projectPath) { issues( @@ -17,6 +19,8 @@ query getIncidents( types: $issueTypes sort: $sort state: $status + authorUsername: $authorUsername + assigneeUsername: $assigneeUsernames first: $firstPageSize last: $lastPageSize after: $nextPageCursor diff --git a/app/assets/javascripts/incidents/list.js b/app/assets/javascripts/incidents/list.js index 7505d07449c..aeec4a258b9 100644 --- a/app/assets/javascripts/incidents/list.js +++ b/app/assets/javascripts/incidents/list.js @@ -16,6 +16,9 @@ export default () => { issuePath, publishedAvailable, emptyListSvgPath, + textQuery, + authorUsernamesQuery, + assigneeUsernamesQuery, } = domEl.dataset; const apolloProvider = new VueApollo({ @@ -32,6 +35,9 @@ export default () => { issuePath, publishedAvailable, emptyListSvgPath, + textQuery, + authorUsernamesQuery, + assigneeUsernamesQuery, }, apolloProvider, components: { diff --git a/app/assets/javascripts/incidents_settings/components/alerts_form.vue b/app/assets/javascripts/incidents_settings/components/alerts_form.vue index 17a77f650e0..5fe0badc56e 100644 --- a/app/assets/javascripts/incidents_settings/components/alerts_form.vue +++ b/app/assets/javascripts/incidents_settings/components/alerts_form.vue @@ -130,18 +130,16 @@ export default { <span>{{ $options.i18n.autoCloseIncidents.label }}</span> </gl-form-checkbox> </gl-form-group> - <div class="gl-display-flex gl-justify-content-end"> - <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> - </div> + <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/pagerduty_form.vue b/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue index 8b608d9f391..ae6b72679e1 100644 --- a/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue +++ b/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue @@ -149,17 +149,15 @@ export default { </template> </gl-sprintf> </div> - <div class="gl-display-flex gl-justify-content-end"> - <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> - </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" @@ -170,17 +168,15 @@ export default { {{ $options.i18n.webhookUrl.restKeyInfo }} </gl-modal> </gl-form-group> - <div class="gl-display-flex gl-justify-content-end"> - <gl-button - ref="submitBtn" - :disabled="isSaveDisabled" - variant="success" - type="submit" - class="js-no-auto-disable" - > - {{ $options.i18n.saveBtnLabel }} - </gl-button> - </div> + <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/init_issuable_sidebar.js b/app/assets/javascripts/init_issuable_sidebar.js index 528d5d8072f..1e82ecb05b5 100644 --- a/app/assets/javascripts/init_issuable_sidebar.js +++ b/app/assets/javascripts/init_issuable_sidebar.js @@ -5,10 +5,14 @@ import LabelsSelect from './labels_select'; import IssuableContext from './issuable_context'; import Sidebar from './right_sidebar'; import DueDateSelectors from './due_date_select'; -import { mountSidebarLabels } from '~/sidebar/mount_sidebar'; +import { mountSidebarLabels, getSidebarOptions } from '~/sidebar/mount_sidebar'; export default () => { - const sidebarOptions = JSON.parse(document.querySelector('.js-sidebar-options').innerHTML); + const sidebarOptEl = document.querySelector('.js-sidebar-options'); + + if (!sidebarOptEl) return; + + const sidebarOptions = getSidebarOptions(sidebarOptEl); new MilestoneSelect({ full_path: sidebarOptions.fullPath, diff --git a/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue b/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue new file mode 100644 index 00000000000..890381a8f29 --- /dev/null +++ b/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue @@ -0,0 +1,60 @@ +<script> +import { mapGetters } from 'vuex'; +import { GlModal } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { + GlModal, + }, + computed: { + ...mapGetters(['isSavingOrTesting']), + primaryProps() { + return { + text: __('Save'), + attributes: [ + { variant: 'success' }, + { category: 'primary' }, + { disabled: this.isSavingOrTesting }, + ], + }; + }, + cancelProps() { + return { + text: __('Cancel'), + }; + }, + }, + methods: { + onSubmit() { + this.$emit('submit'); + }, + }, +}; +</script> + +<template> + <gl-modal + modal-id="confirmSaveIntegration" + size="sm" + :title="s__('Integrations|Save settings?')" + :action-primary="primaryProps" + :action-cancel="cancelProps" + @primary="onSubmit" + > + <p> + {{ + s__( + 'Integrations|Saving will update the default settings for all projects that are not using custom settings.', + ) + }} + </p> + <p class="gl-mb-0"> + {{ + s__( + 'Integrations|Projects using custom settings will not be impacted unless the project owner chooses to use instance-level defaults.', + ) + }} + </p> + </gl-modal> +</template> diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue index 0460ed6791e..0fd39c5635d 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue @@ -1,8 +1,9 @@ <script> import { mapState, mapActions, mapGetters } from 'vuex'; -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlModalDirective } from '@gitlab/ui'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import eventHub from '../event_hub'; +import { integrationLevels } from '../constants'; import OverrideDropdown from './override_dropdown.vue'; import ActiveCheckbox from './active_checkbox.vue'; @@ -10,6 +11,7 @@ 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'; +import ConfirmationModal from './confirmation_modal.vue'; export default { name: 'IntegrationForm', @@ -20,8 +22,12 @@ export default { JiraIssuesFields, TriggerFields, DynamicField, + ConfirmationModal, GlButton, }, + directives: { + 'gl-modal': GlModalDirective, + }, mixins: [glFeatureFlagsMixin()], computed: { ...mapGetters(['currentKey', 'propsSource', 'isSavingOrTesting']), @@ -32,6 +38,9 @@ export default { isJira() { return this.propsSource.type === 'jira'; }, + isInstanceLevel() { + return this.propsSource.integrationLevel === integrationLevels.INSTANCE; + }, showJiraIssuesFields() { return this.isJira && this.glFeatures.jiraIssuesIntegration; }, @@ -82,7 +91,21 @@ export default { v-bind="propsSource.jiraIssuesProps" /> <div v-if="isEditable" class="footer-block row-content-block"> + <template v-if="isInstanceLevel"> + <gl-button + v-gl-modal.confirmSaveIntegration + category="primary" + variant="success" + :loading="isSaving" + :disabled="isSavingOrTesting" + data-qa-selector="save_changes_button" + > + {{ __('Save changes') }} + </gl-button> + <confirmation-modal @submit="onSaveClick" /> + </template> <gl-button + v-else category="primary" variant="success" type="submit" @@ -93,6 +116,7 @@ export default { > {{ __('Save changes') }} </gl-button> + <gl-button v-if="propsSource.canTest" :loading="isTesting" diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue new file mode 100644 index 00000000000..d2ea14a658b --- /dev/null +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -0,0 +1,224 @@ +<script> +import { + GlModal, + GlDropdown, + GlDropdownItem, + GlDatepicker, + GlLink, + GlSprintf, + GlSearchBoxByType, + GlButton, + GlFormInput, +} from '@gitlab/ui'; +import eventHub from '../event_hub'; +import { s__, sprintf } from '~/locale'; +import Api from '~/api'; + +export default { + name: 'InviteMembersModal', + components: { + GlDatepicker, + GlLink, + GlModal, + GlDropdown, + GlDropdownItem, + GlSprintf, + GlSearchBoxByType, + GlButton, + GlFormInput, + }, + props: { + groupId: { + type: String, + required: true, + }, + groupName: { + type: String, + required: true, + }, + accessLevels: { + type: Object, + required: true, + }, + defaultAccessLevel: { + type: String, + required: true, + }, + helpLink: { + type: String, + required: true, + }, + }, + data() { + return { + visible: true, + modalId: 'invite-members-modal', + selectedAccessLevel: this.defaultAccessLevel, + newUsersToInvite: '', + selectedDate: undefined, + }; + }, + computed: { + introText() { + return sprintf(s__("InviteMembersModal|You're inviting members to the %{group_name} group"), { + group_name: this.groupName, + }); + }, + toastOptions() { + return { + onComplete: () => { + this.selectedAccessLevel = this.defaultAccessLevel; + this.newUsersToInvite = ''; + }, + }; + }, + postData() { + return { + user_id: this.newUsersToInvite, + access_level: this.selectedAccessLevel, + expires_at: this.selectedDate, + format: 'json', + }; + }, + selectedRoleName() { + return Object.keys(this.accessLevels).find( + key => this.accessLevels[key] === Number(this.selectedAccessLevel), + ); + }, + }, + mounted() { + eventHub.$on('openModal', this.openModal); + }, + methods: { + openModal() { + this.$root.$emit('bv::show::modal', this.modalId); + }, + closeModal() { + this.$root.$emit('bv::hide::modal', this.modalId); + }, + sendInvite() { + this.submitForm(this.postData); + this.closeModal(); + }, + cancelInvite() { + this.selectedAccessLevel = this.defaultAccessLevel; + this.selectedDate = undefined; + this.newUsersToInvite = ''; + this.closeModal(); + }, + changeSelectedItem(item) { + this.selectedAccessLevel = item; + }, + submitForm(formData) { + return Api.inviteGroupMember(this.groupId, formData) + .then(() => { + this.showToastMessageSuccess(); + }) + .catch(error => { + this.showToastMessageError(error); + }); + }, + showToastMessageSuccess() { + this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions); + }, + showToastMessageError(error) { + const message = error.response.data.message || this.$options.labels.toastMessageUnsuccessful; + + this.$toast.show(message, this.toastOptions); + }, + }, + labels: { + modalTitle: s__('InviteMembersModal|Invite team members'), + userToInvite: s__('InviteMembersModal|GitLab member or Email address'), + userPlaceholder: s__('InviteMembersModal|Search for members to invite'), + accessLevel: s__('InviteMembersModal|Choose a role permission'), + accessExpireDate: s__('InviteMembersModal|Access expiration date (optional)'), + toastMessageSuccessful: s__('InviteMembersModal|Users were succesfully added'), + toastMessageUnsuccessful: s__('InviteMembersModal|User not invited. Feature coming soon!'), + readMoreText: s__(`InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions`), + inviteButtonText: s__('InviteMembersModal|Invite'), + cancelButtonText: s__('InviteMembersModal|Cancel'), + }, +}; +</script> +<template> + <gl-modal :modal-id="modalId" size="sm" :title="$options.labels.modalTitle"> + <div class="gl-ml-5 gl-mr-5"> + <div>{{ introText }}</div> + + <label class="gl-font-weight-bold gl-mt-5">{{ $options.labels.userToInvite }}</label> + <div class="gl-mt-2"> + <gl-search-box-by-type + v-model="newUsersToInvite" + :placeholder="$options.labels.userPlaceholder" + type="text" + autocomplete="off" + autocorrect="off" + autocapitalize="off" + spellcheck="false" + /> + </div> + + <label class="gl-font-weight-bold gl-mt-5">{{ $options.labels.accessLevel }}</label> + <div class="gl-mt-2 gl-w-half gl-xs-w-full"> + <gl-dropdown + menu-class="dropdown-menu-selectable" + class="gl-shadow-none gl-w-full" + v-bind="$attrs" + :text="selectedRoleName" + > + <template v-for="(key, item) in accessLevels"> + <gl-dropdown-item + :key="key" + active-class="is-active" + :is-checked="key === selectedAccessLevel" + @click="changeSelectedItem(key)" + > + <div>{{ item }}</div> + </gl-dropdown-item> + </template> + </gl-dropdown> + </div> + + <div class="gl-mt-2"> + <gl-sprintf :message="$options.labels.readMoreText"> + <template #link="{content}"> + <gl-link :href="helpLink" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> + + <label class="gl-font-weight-bold gl-mt-5" for="expires_at">{{ + $options.labels.accessExpireDate + }}</label> + <div class="gl-mt-2 gl-w-half gl-xs-w-full gl-display-inline-block"> + <gl-datepicker + v-model="selectedDate" + class="gl-display-inline!" + :min-date="new Date()" + :target="null" + > + <template #default="{ formattedDate }"> + <gl-form-input + class="gl-w-full" + :value="formattedDate" + :placeholder="__(`YYYY-MM-DD`)" + /> + </template> + </gl-datepicker> + </div> + </div> + + <template #modal-footer> + <div class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-p-3"> + <gl-button ref="cancelButton" @click="cancelInvite"> + {{ $options.labels.cancelButtonText }} + </gl-button> + <div class="gl-mr-3"></div> + <gl-button ref="inviteButton" variant="success" @click="sendInvite">{{ + $options.labels.inviteButtonText + }}</gl-button> + </div> + </template> + </gl-modal> +</template> diff --git a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue new file mode 100644 index 00000000000..d133e3655e3 --- /dev/null +++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue @@ -0,0 +1,38 @@ +<script> +import { GlLink, GlIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import eventHub from '../event_hub'; + +export default { + components: { + GlLink, + GlIcon, + }, + props: { + displayText: { + type: String, + required: false, + default: s__('InviteMembers|Invite team members'), + }, + icon: { + type: String, + required: false, + default: '', + }, + }, + methods: { + openModal() { + eventHub.$emit('openModal'); + }, + }, +}; +</script> + +<template> + <gl-link @click="openModal"> + <div v-if="icon" class="nav-icon-container"> + <gl-icon :size="16" :name="icon" /> + </div> + <span class="nav-item-name"> {{ displayText }} </span> + </gl-link> +</template> diff --git a/app/assets/javascripts/invite_members/event_hub.js b/app/assets/javascripts/invite_members/event_hub.js new file mode 100644 index 00000000000..e31806ad199 --- /dev/null +++ b/app/assets/javascripts/invite_members/event_hub.js @@ -0,0 +1,3 @@ +import createEventHub from '~/helpers/event_hub_factory'; + +export default createEventHub(); diff --git a/app/assets/javascripts/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js new file mode 100644 index 00000000000..92aa3187fc3 --- /dev/null +++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js @@ -0,0 +1,25 @@ +import Vue from 'vue'; +import { GlToast } from '@gitlab/ui'; +import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue'; + +Vue.use(GlToast); + +export default function initInviteMembersModal() { + const el = document.querySelector('.js-invite-members-modal'); + + if (!el) { + return false; + } + + return new Vue({ + el, + render: createElement => + createElement(InviteMembersModal, { + props: { + ...el.dataset, + accessLevels: JSON.parse(el.dataset.accessLevels), + groupName: el.dataset.groupName.toUpperCase(), + }, + }), + }); +} diff --git a/app/assets/javascripts/invite_members/init_invite_members_trigger.js b/app/assets/javascripts/invite_members/init_invite_members_trigger.js new file mode 100644 index 00000000000..bee4f1c0f72 --- /dev/null +++ b/app/assets/javascripts/invite_members/init_invite_members_trigger.js @@ -0,0 +1,20 @@ +import Vue from 'vue'; +import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; + +export default function initInviteMembersTrigger() { + const el = document.querySelector('.js-invite-members-trigger'); + + if (!el) { + return false; + } + + return new Vue({ + el, + render: createElement => + createElement(InviteMembersTrigger, { + props: { + ...el.dataset, + }, + }), + }); +} diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js index 566efa0d7d6..6f2bd2da078 100644 --- a/app/assets/javascripts/issuable_context.js +++ b/app/assets/javascripts/issuable_context.js @@ -6,6 +6,7 @@ import UsersSelect from './users_select'; export default class IssuableContext { constructor(currentUser) { this.userSelect = new UsersSelect(currentUser); + this.reviewersSelect = new UsersSelect(currentUser, '.js-reviewer-search'); import(/* webpackChunkName: 'select2' */ 'select2/select2') .then(() => { diff --git a/app/assets/javascripts/issuable_create/components/issuable_form.vue b/app/assets/javascripts/issuable_create/components/issuable_form.vue index 17e51b3dbac..d7b88cc7fc8 100644 --- a/app/assets/javascripts/issuable_create/components/issuable_form.vue +++ b/app/assets/javascripts/issuable_create/components/issuable_form.vue @@ -71,6 +71,7 @@ export default { :markdown-docs-path="descriptionHelpPath" :add-spacing-classes="false" :show-suggest-popover="true" + :textarea-value="issuableDescription" > <textarea id="issuable-description" diff --git a/app/assets/javascripts/issue_show/components/fields/description_template.vue b/app/assets/javascripts/issue_show/components/fields/description_template.vue index e1b308c6f57..8a1a8448bb8 100644 --- a/app/assets/javascripts/issue_show/components/fields/description_template.vue +++ b/app/assets/javascripts/issue_show/components/fields/description_template.vue @@ -1,5 +1,4 @@ <script> -/* eslint-disable @gitlab/vue-require-i18n-strings */ import $ from 'jquery'; import { GlIcon } from '@gitlab/ui'; import IssuableTemplateSelectors from '../../../templates/issuable_template_selectors'; @@ -62,11 +61,15 @@ export default { data-toggle="dropdown" > <span class="dropdown-toggle-text">{{ __('Choose a template') }}</span> - <i aria-hidden="true" class="fa fa-chevron-down"> </i> + <gl-icon + name="chevron-down" + class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" + aria-hidden="true" + /> </button> <div class="dropdown-menu dropdown-select"> <div class="dropdown-title gl-display-flex gl-justify-content-center"> - <span class="gl-ml-auto">Choose a template</span> + <span class="gl-ml-auto">{{ __('Choose a template') }}</span> <button class="dropdown-title-button dropdown-menu-close gl-ml-auto" :aria-label="__('Close')" @@ -82,7 +85,7 @@ export default { :placeholder="__('Filter')" autocomplete="off" /> - <i aria-hidden="true" class="fa fa-search dropdown-input-search"> </i> + <gl-icon name="search" class="dropdown-input-search" aria-hidden="true" /> <gl-icon name="close" class="dropdown-input-clear js-dropdown-input-clear" diff --git a/app/assets/javascripts/issue_show/components/incidents/highlight_bar.vue b/app/assets/javascripts/issue_show/components/incidents/highlight_bar.vue index a47fe4c84cf..b2aa5265331 100644 --- a/app/assets/javascripts/issue_show/components/incidents/highlight_bar.vue +++ b/app/assets/javascripts/issue_show/components/incidents/highlight_bar.vue @@ -1,11 +1,14 @@ <script> -import { GlLink } from '@gitlab/ui'; +import { GlLink, GlTooltipDirective } from '@gitlab/ui'; import { formatDate } from '~/lib/utils/datetime_utility'; export default { components: { GlLink, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { alert: { type: Object, @@ -22,19 +25,21 @@ export default { <template> <div - class="gl-border-solid gl-border-1 gl-border-gray-100 gl-p-5 gl-mb-3 gl-rounded-base gl-display-flex gl-justify-content-space-between" + class="gl-border-solid gl-border-1 gl-border-gray-100 gl-p-5 gl-mb-3 gl-rounded-base gl-display-flex gl-justify-content-space-between gl-xs-flex-direction-column" > - <div class="text-truncate gl-pr-3"> + <div class="gl-pr-3"> <span class="gl-font-weight-bold">{{ s__('HighlightBar|Original alert:') }}</span> - <gl-link :href="alert.detailsUrl">{{ alert.title }}</gl-link> + <gl-link v-gl-tooltip :title="alert.title" :href="alert.detailsUrl"> + #{{ alert.iid }} + </gl-link> </div> - <div class="gl-pr-3 gl-white-space-nowrap"> + <div class="gl-pr-3"> <span class="gl-font-weight-bold">{{ s__('HighlightBar|Alert start time:') }}</span> {{ startTime }} </div> - <div class="gl-white-space-nowrap"> + <div> <span class="gl-font-weight-bold">{{ s__('HighlightBar|Alert events:') }}</span> <span>{{ alert.eventCount }}</span> </div> diff --git a/app/assets/javascripts/issue_show/components/incidents/incident_tabs.vue b/app/assets/javascripts/issue_show/components/incidents/incident_tabs.vue index 4104ddbf06f..5925c013e89 100644 --- a/app/assets/javascripts/issue_show/components/incidents/incident_tabs.vue +++ b/app/assets/javascripts/issue_show/components/incidents/incident_tabs.vue @@ -45,13 +45,6 @@ export default { loading() { return this.$apollo.queries.alert.loading; }, - alertTableFields() { - if (this.alert) { - const { detailsUrl, __typename, ...restDetails } = this.alert; - return restDetails; - } - return null; - }, }, }; </script> @@ -64,7 +57,7 @@ export default { <description-component v-bind="$attrs" /> </gl-tab> <gl-tab v-if="alert" class="alert-management-details" :title="s__('Incident|Alert details')"> - <alert-details-table :alert="alertTableFields" :loading="loading" /> + <alert-details-table :alert="alert" :loading="loading" /> </gl-tab> </gl-tabs> </div> diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js index c6f7e892f9b..06bbd406e3a 100644 --- a/app/assets/javascripts/issue_show/stores/index.js +++ b/app/assets/javascripts/issue_show/stores/index.js @@ -1,4 +1,4 @@ -import { sanitize } from 'dompurify'; +import { sanitize } from '~/lib/dompurify'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import updateDescription from '../utils/update_description'; diff --git a/app/assets/javascripts/issue_show/utils/parse_data.js b/app/assets/javascripts/issue_show/utils/parse_data.js index a62a5167961..f2d1650fed1 100644 --- a/app/assets/javascripts/issue_show/utils/parse_data.js +++ b/app/assets/javascripts/issue_show/utils/parse_data.js @@ -1,4 +1,5 @@ -import { sanitize } from 'dompurify'; +import * as Sentry from '@sentry/browser'; +import { sanitize } from '~/lib/dompurify'; // We currently load + parse the data from the issue app and related merge request let cachedParsedData; @@ -7,10 +8,9 @@ export const parseIssuableData = () => { try { if (cachedParsedData) return cachedParsedData; - const initialDataEl = document.getElementById('js-issuable-app-initial-data'); - - const parsedData = JSON.parse(initialDataEl.textContent.replace(/"/g, '"')); + const initialDataEl = document.getElementById('js-issuable-app'); + const parsedData = JSON.parse(initialDataEl.dataset.initial); parsedData.initialTitleHtml = sanitize(parsedData.initialTitleHtml); parsedData.initialDescriptionHtml = sanitize(parsedData.initialDescriptionHtml); @@ -18,7 +18,7 @@ export const parseIssuableData = () => { return parsedData; } catch (e) { - console.error(e); // eslint-disable-line no-console + Sentry.captureException(e); return {}; } diff --git a/app/assets/javascripts/jira_import/index.js b/app/assets/javascripts/jira_import/index.js index 695a237bf50..003f3c7107e 100644 --- a/app/assets/javascripts/jira_import/index.js +++ b/app/assets/javascripts/jira_import/index.js @@ -6,7 +6,7 @@ import App from './components/jira_import_app.vue'; Vue.use(VueApollo); -const defaultClient = createDefaultClient(); +const defaultClient = createDefaultClient({}, { assumeImmutableResults: true }); const apolloProvider = new VueApollo({ defaultClient, diff --git a/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql b/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql index 8fda8287988..807374bf06c 100644 --- a/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql +++ b/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql @@ -2,7 +2,6 @@ mutation($input: JiraImportStartInput!) { jiraImportStart(input: $input) { - clientMutationId jiraImport { ...JiraImport } diff --git a/app/assets/javascripts/jira_import/utils/cache_update.js b/app/assets/javascripts/jira_import/utils/cache_update.js index 6aaf2010866..65b2e459f03 100644 --- a/app/assets/javascripts/jira_import/utils/cache_update.js +++ b/app/assets/javascripts/jira_import/utils/cache_update.js @@ -1,3 +1,4 @@ +import produce from 'immer'; import getJiraImportDetailsQuery from '../queries/get_jira_import_details.query.graphql'; import { IMPORT_STATE } from './jira_import_utils'; @@ -13,22 +14,20 @@ export const addInProgressImportToStore = (store, jiraImportStart, fullPath) => }, }; - const cacheData = store.readQuery({ + const sourceData = store.readQuery({ ...queryDetails, }); store.writeQuery({ ...queryDetails, - data: { - project: { - ...cacheData.project, - jiraImportStatus: IMPORT_STATE.SCHEDULED, - jiraImports: { - ...cacheData.project.jiraImports, - nodes: cacheData.project.jiraImports.nodes.concat(jiraImportStart.jiraImport), - }, - }, - }, + data: produce(sourceData, draftData => { + draftData.project.jiraImportStatus = IMPORT_STATE.SCHEDULED; // eslint-disable-line no-param-reassign + // eslint-disable-next-line no-param-reassign + draftData.project.jiraImports.nodes = [ + ...sourceData.project.jiraImports.nodes, + jiraImportStart.jiraImport, + ]; + }), }); }; diff --git a/app/assets/javascripts/jobs/components/commit_block.vue b/app/assets/javascripts/jobs/components/commit_block.vue index c4f180f200c..222fae6d9a8 100644 --- a/app/assets/javascripts/jobs/components/commit_block.vue +++ b/app/assets/javascripts/jobs/components/commit_block.vue @@ -32,26 +32,25 @@ export default { block: !isLastBlock, }" > - <p class="gl-mb-2"> - <span class="font-weight-bold">{{ __('Commit') }}</span> + <span class="font-weight-bold">{{ __('Commit') }}</span> - <gl-link :href="commit.commit_path" class="js-commit-sha commit-sha link-commit"> - {{ commit.short_id }} - </gl-link> + <gl-link :href="commit.commit_path" class="js-commit-sha commit-sha link-commit"> + {{ commit.short_id }} + </gl-link> - <clipboard-button - :text="commit.id" - :title="__('Copy commit SHA')" - css-class="btn btn-clipboard btn-transparent" - /> + <clipboard-button + :text="commit.id" + :title="__('Copy commit SHA')" + category="tertiary" + size="small" + /> - <span v-if="mergeRequest"> - in - <gl-link :href="mergeRequest.path" class="js-link-commit link-commit" - >!{{ mergeRequest.iid }}</gl-link - > - </span> - </p> + <span v-if="mergeRequest"> + in + <gl-link :href="mergeRequest.path" class="js-link-commit link-commit" + >!{{ mergeRequest.iid }}</gl-link + > + </span> <p class="gl-mb-0">{{ commit.title }}</p> </div> diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue index aa589989e8a..8701e05a01f 100644 --- a/app/assets/javascripts/jobs/components/sidebar.vue +++ b/app/assets/javascripts/jobs/components/sidebar.vue @@ -1,7 +1,7 @@ <script> import { isEmpty } from 'lodash'; import { mapActions, mapState } from 'vuex'; -import { GlLink, GlDeprecatedButton, GlIcon } from '@gitlab/ui'; +import { GlLink, GlButton, GlIcon } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; @@ -24,7 +24,7 @@ export default { StagesDropdown, JobsContainer, GlLink, - GlDeprecatedButton, + GlButton, TooltipOnTruncate, }, mixins: [timeagoMixin], @@ -143,14 +143,13 @@ export default { > </div> - <gl-deprecated-button + <gl-button :aria-label="__('Toggle Sidebar')" - type="button" - class="btn btn-blank gutter-toggle float-right d-block d-md-none js-sidebar-build-toggle" + class="d-md-none gl-ml-2 js-sidebar-build-toggle" + category="tertiary" + icon="chevron-double-lg-right" @click="toggleSidebar" - > - <i aria-hidden="true" data-hidden="true" class="fa fa-angle-double-right"></i> - </gl-deprecated-button> + /> </div> <div v-if="job.terminal_path || job.new_issue_path" class="block retry-link"> diff --git a/app/assets/javascripts/jobs/store/utils.js b/app/assets/javascripts/jobs/store/utils.js index 8d6e5aac566..ea9c214de32 100644 --- a/app/assets/javascripts/jobs/store/utils.js +++ b/app/assets/javascripts/jobs/store/utils.js @@ -1,3 +1,5 @@ +import { parseBoolean } from '../../lib/utils/common_utils'; + /** * Adds the line number property * @param Object line @@ -17,7 +19,7 @@ export const parseLine = (line = {}, lineNumber) => ({ * @param Number lineNumber */ export const parseHeaderLine = (line = {}, lineNumber) => ({ - isClosed: false, + isClosed: parseBoolean(line.section_options?.collapsed), isHeader: true, line: parseLine(line, lineNumber), lines: [], diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js index d7f5e6f8a5e..4d2955a8d3d 100644 --- a/app/assets/javascripts/layout_nav.js +++ b/app/assets/javascripts/layout_nav.js @@ -16,6 +16,15 @@ function initDeferred() { const whatsNewTriggerEl = document.querySelector('.js-whats-new-trigger'); if (whatsNewTriggerEl) { + const storageKey = whatsNewTriggerEl.getAttribute('data-storage-key'); + + $('.header-help').on('show.bs.dropdown', () => { + const displayNotification = JSON.parse(localStorage.getItem(storageKey)); + if (displayNotification === false) { + $('.js-whats-new-notification-count').remove(); + } + }); + whatsNewTriggerEl.addEventListener('click', () => { import(/* webpackChunkName: 'whatsNewApp' */ '~/whats_new') .then(({ default: initWhatsNew }) => { diff --git a/app/assets/javascripts/lib/dompurify.js b/app/assets/javascripts/lib/dompurify.js new file mode 100644 index 00000000000..d9ea57fbbce --- /dev/null +++ b/app/assets/javascripts/lib/dompurify.js @@ -0,0 +1,53 @@ +import { sanitize as dompurifySanitize, addHook } from 'dompurify'; +import { getBaseURL, relativePathToAbsolute } from '~/lib/utils/url_utility'; + +// Safely allow SVG <use> tags + +const defaultConfig = { + ADD_TAGS: ['use'], +}; + +// Only icons urls from `gon` are allowed +const getAllowedIconUrls = (gon = window.gon) => + [gon.sprite_file_icons, gon.sprite_icons].filter(Boolean); + +const isUrlAllowed = url => getAllowedIconUrls().some(allowedUrl => url.startsWith(allowedUrl)); + +const isHrefSafe = url => + isUrlAllowed(url) || isUrlAllowed(relativePathToAbsolute(url, getBaseURL())); + +const removeUnsafeHref = (node, attr) => { + if (!node.hasAttribute(attr)) { + return; + } + + if (!isHrefSafe(node.getAttribute(attr))) { + node.removeAttribute(attr); + } +}; + +/** + * Sanitize icons' <use> tag attributes, to safely include + * svgs such as in: + * + * <svg viewBox="0 0 100 100"> + * <use href="/assets/icons-xxx.svg#icon_name"></use> + * </svg> + * + * @param {Object} node - Node to sanitize + */ +const sanitizeSvgIcon = node => { + removeUnsafeHref(node, 'href'); + + // Note: `xlink:href` is deprecated, but still in use + // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/xlink:href + removeUnsafeHref(node, 'xlink:href'); +}; + +addHook('afterSanitizeAttributes', node => { + if (node.tagName.toLowerCase() === 'use') { + sanitizeSvgIcon(node); + } +}); + +export const sanitize = (val, config = defaultConfig) => dompurifySanitize(val, config); diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index d2907f401c0..0e07f7d8e44 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -31,6 +31,7 @@ export default (resolvers = {}, config = {}) => { // We set to `same-origin` which is default value in modern browsers. // See https://github.com/whatwg/fetch/pull/585 for more information. credentials: 'same-origin', + batchMax: config.batchMax || 10, }; const uploadsLink = ApolloLink.split( diff --git a/app/assets/javascripts/lib/utils/axios_startup_calls.js b/app/assets/javascripts/lib/utils/axios_startup_calls.js index 7e2665b910c..7bb1da5aed5 100644 --- a/app/assets/javascripts/lib/utils/axios_startup_calls.js +++ b/app/assets/javascripts/lib/utils/axios_startup_calls.js @@ -7,7 +7,7 @@ const removeGitLabUrl = url => url.replace(gon.gitlab_url, ''); const getFullUrl = req => { const url = removeGitLabUrl(req.url); - return mergeUrlParams(req.params || {}, url); + return mergeUrlParams(req.params || {}, url, { sort: true }); }; const handleStartupCall = async ({ fetchCall }, req) => { diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index bcf302cc262..28b624168d5 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -44,6 +44,7 @@ export const checkPageAndAction = (page, action) => { return pagePath === page && actionPath === action; }; +export const isInIncidentPage = () => checkPageAndAction('issues', 'incident'); export const isInIssuePage = () => checkPageAndAction('issues', 'show'); export const isInMRPage = () => checkPageAndAction('merge_requests', 'show'); export const isInEpicPage = () => checkPageAndAction('epics', 'show'); diff --git a/app/assets/javascripts/lib/utils/csrf.js b/app/assets/javascripts/lib/utils/csrf.js index ca9828c4682..3114a2a0dfb 100644 --- a/app/assets/javascripts/lib/utils/csrf.js +++ b/app/assets/javascripts/lib/utils/csrf.js @@ -1,5 +1,3 @@ -import $ from 'jquery'; - /* This module provides easy access to the CSRF token and caches it for re-use. It also exposes some values commonly used in relation @@ -20,7 +18,6 @@ If you need to compose a headers object, use the spread operator: see also http://guides.rubyonrails.org/security.html#cross-site-request-forgery-csrf and https://github.com/rails/jquery-rails/blob/v4.3.1/vendor/assets/javascripts/jquery_ujs.js#L59-L62 */ - const csrf = { init() { const tokenEl = document.querySelector('meta[name=csrf-token]'); @@ -52,9 +49,4 @@ const csrf = { csrf.init(); -// use our cached token for any $.rails-generated AJAX requests -if ($.rails) { - $.rails.csrfToken = () => csrf.token; -} - export default csrf; diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index b193a8b2c9a..261f76a0f2d 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -743,3 +743,22 @@ export const differenceInMilliseconds = (startDate, endDate = Date.now()) => { const endDateInMS = endDate instanceof Date ? endDate.getTime() : endDate; return endDateInMS - startDateInMS; }; + +/** + * A utility which returns a new date at the first day of the month for any given date. + * + * @param {Date} date + * + * @return {Date} the date at the first day of the month + */ +export const dateAtFirstDayOfMonth = date => new Date(newDate(date).setDate(1)); + +/** + * A utility function which checks if two dates match. + * + * @param {Date|Int} date1 Can be either a date object or a unix timestamp. + * @param {Date|Int} date2 Can be either a date object or a unix timestamp. + * + * @return {Boolean} true if the dates match + */ +export const datesMatch = (date1, date2) => differenceInMilliseconds(date1, date2) === 0; diff --git a/app/assets/javascripts/lib/utils/experimentation.js b/app/assets/javascripts/lib/utils/experimentation.js new file mode 100644 index 00000000000..555e76055e0 --- /dev/null +++ b/app/assets/javascripts/lib/utils/experimentation.js @@ -0,0 +1,3 @@ +export function isExperimentEnabled(experimentKey) { + return Boolean(window.gon?.experiments?.[experimentKey]); +} diff --git a/app/assets/javascripts/lib/utils/highlight.js b/app/assets/javascripts/lib/utils/highlight.js index 32553af9af3..8fa8af670b3 100644 --- a/app/assets/javascripts/lib/utils/highlight.js +++ b/app/assets/javascripts/lib/utils/highlight.js @@ -1,5 +1,5 @@ import fuzzaldrinPlus from 'fuzzaldrin-plus'; -import { sanitize } from 'dompurify'; +import { sanitize } from '~/lib/dompurify'; /** * Wraps substring matches with HTML `<span>` elements. diff --git a/app/assets/javascripts/lib/utils/rails_ujs.js b/app/assets/javascripts/lib/utils/rails_ujs.js new file mode 100644 index 00000000000..8b40cc7bd11 --- /dev/null +++ b/app/assets/javascripts/lib/utils/rails_ujs.js @@ -0,0 +1,20 @@ +import Rails from '@rails/ujs'; + +export const initRails = () => { + // eslint-disable-next-line no-underscore-dangle + if (!window._rails_loaded) { + Rails.start(); + + // Count XHR requests for tests. See spec/support/helpers/wait_for_requests.rb + window.pendingRailsUJSRequests = 0; + document.body.addEventListener('ajax:complete', () => { + window.pendingRailsUJSRequests -= 1; + }); + + document.body.addEventListener('ajax:beforeSend', () => { + window.pendingRailsUJSRequests += 1; + }); + } +}; + +export { Rails }; diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index e9c3fe0a406..7f6b212b5fc 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -16,7 +16,7 @@ function decodeUrlParameter(val) { return decodeURIComponent(val.replace(/\+/g, '%20')); } -function cleanLeadingSeparator(path) { +export function cleanLeadingSeparator(path) { return path.replace(PATH_SEPARATOR_LEADING_REGEX, ''); } @@ -73,6 +73,7 @@ export function getParameterValues(sParam, url = window.location) { * @param {String} url * @param {Object} options * @param {Boolean} options.spreadArrays - split array values into separate key/value-pairs + * @param {Boolean} options.sort - alphabetically sort params in the returned url (in asc order, i.e., a-z) */ export function mergeUrlParams(params, url, options = {}) { const { spreadArrays = false, sort = false } = options; @@ -255,6 +256,15 @@ export function getBaseURL() { } /** + * Takes a URL and returns content from the start until the final '/' + * + * @param {String} url - full url, including protocol and host + */ +export function stripFinalUrlSegment(url) { + return new URL('.', url).href; +} + +/** * Returns true if url is an absolute URL * * @param {String} url @@ -434,3 +444,24 @@ export function getHTTPProtocol(url) { const protocol = url.split(':'); return protocol.length > 1 ? protocol[0] : undefined; } + +/** + * Strips the filename from the given path by removing every non-slash character from the end of the + * passed parameter. + * @param {string} path + */ +export function stripPathTail(path = '') { + return path.replace(/[^/]+$/, ''); +} + +export function getURLOrigin(url) { + if (!url) { + return window.location.origin; + } + + try { + return new URL(url).origin; + } catch (e) { + return null; + } +} diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 9fcf881a1ac..d60f949c49d 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -11,6 +11,7 @@ import './behaviors'; // lib/utils import applyGitLabUIConfig from '@gitlab/ui/dist/config'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; +import { initRails } from '~/lib/utils/rails_ujs'; import { handleLocationHash, addSelectOnFocusBehaviour, @@ -38,6 +39,8 @@ import initPersistentUserCallouts from './persistent_user_callouts'; import { initUserTracking, initDefaultTrackers } from './tracking'; import { __ } from './locale'; +import * as tooltips from '~/tooltips'; + import 'ee_else_ce/main_ee'; applyGitLabUIConfig(); @@ -76,7 +79,7 @@ document.addEventListener('beforeunload', () => { // Unbind scroll events $(document).off('scroll'); // Close any open tooltips - $('.has-tooltip, [data-toggle="tooltip"]').tooltip('dispose'); + tooltips.dispose(document.querySelectorAll('.has-tooltip, [data-toggle="tooltip"]')); // Close any open popover $('[data-toggle="popover"]').popover('dispose'); }); @@ -96,6 +99,8 @@ gl.lazyLoader = new LazyLoader({ observerNode: '#content-body', }); +initRails(); + // Put all initialisations here that can also wait after everything is rendered and ready function deferredInitialisation() { const $body = $('body'); @@ -130,8 +135,10 @@ function deferredInitialisation() { addSelectOnFocusBehaviour('.js-select-on-focus'); $('.remove-row').on('ajax:success', function removeRowAjaxSuccessCallback() { + tooltips.dispose(this); + + // eslint-disable-next-line no-jquery/no-fade $(this) - .tooltip('dispose') .closest('li') .fadeOut(); }); @@ -151,7 +158,7 @@ function deferredInitialisation() { const delay = glTooltipDelay ? JSON.parse(glTooltipDelay) : 0; // Initialize tooltips - $body.tooltip({ + tooltips.initTooltips({ selector: '.has-tooltip, [data-toggle="tooltip"]', trigger: 'hover', boundary: 'viewport', diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js index c3fbb5d6acf..252706b3647 100644 --- a/app/assets/javascripts/members.js +++ b/app/assets/javascripts/members.js @@ -1,6 +1,8 @@ import $ from 'jquery'; +import { Rails } from '~/lib/utils/rails_ujs'; import { disableButtonIfEmptyField } from '~/lib/utils/common_utils'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; +import { __, sprintf } from '~/locale'; export default class Members { constructor() { @@ -54,15 +56,37 @@ export default class Members { formSubmit(e, $el = null) { const $this = e ? $(e.currentTarget) : $el; const { $toggle, $dateInput } = this.getMemberListItems($this); + const formEl = $this.closest('form').get(0); - $this.closest('form').trigger('submit.rails'); + Rails.fire(formEl, 'submit'); $toggle.disable(); $dateInput.disable(); } formSuccess(e) { - const { $toggle, $dateInput } = this.getMemberListItems($(e.currentTarget).closest('.member')); + const { $toggle, $dateInput, $expiresIn, $expiresInText } = this.getMemberListItems( + $(e.currentTarget).closest('.js-member'), + ); + + const [data] = e.detail; + const expiresIn = data?.expires_in; + + if (expiresIn) { + $expiresIn.removeClass('gl-display-none'); + + $expiresInText.text(sprintf(__('Expires in %{expires_at}'), { expires_at: expiresIn })); + + const { expires_soon: expiresSoon } = data; + + if (expiresSoon) { + $expiresInText.addClass('text-warning'); + } else { + $expiresInText.removeClass('text-warning'); + } + } else { + $expiresIn.addClass('gl-display-none'); + } $toggle.enable(); $dateInput.enable(); @@ -70,10 +94,12 @@ export default class Members { // eslint-disable-next-line class-methods-use-this getMemberListItems($el) { - const $memberListItem = $el.is('.member') ? $el : $(`#${$el.data('elId')}`); + const $memberListItem = $el.is('.js-member') ? $el : $(`#${$el.data('elId')}`); return { $memberListItem, + $expiresIn: $memberListItem.find('.js-expires-in'), + $expiresInText: $memberListItem.find('.js-expires-in-text'), $toggle: $memberListItem.find('.dropdown-menu-toggle'), $dateInput: $memberListItem.find('.js-access-expiration-date'), }; diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 79a4c3700ef..fe4e2cee69f 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -3,11 +3,12 @@ import $ from 'jquery'; import axios from './lib/utils/axios_utils'; import { __ } from '~/locale'; +import eventHub from '~/vue_merge_request_widget/event_hub'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import TaskList from './task_list'; import MergeRequestTabs from './merge_request_tabs'; -import IssuablesHelper from './helpers/issuables_helper'; import { addDelimiter } from './lib/utils/text_utility'; +import { getParameterValues, setUrlParams } from './lib/utils/url_utility'; function MergeRequest(opts) { // Initialize MergeRequest behavior @@ -23,7 +24,6 @@ function MergeRequest(opts) { this.initTabs(); this.initMRBtnListeners(); this.initCommitMessageListeners(); - this.closeReopenReportToggle = IssuablesHelper.initCloseReopenReport(); if ($('.description.js-task-list-container').length) { this.taskList = new TaskList({ @@ -66,13 +66,38 @@ MergeRequest.prototype.showAllCommits = function() { MergeRequest.prototype.initMRBtnListeners = function() { const _this = this; + const draftToggles = document.querySelectorAll('.js-draft-toggle-button'); - $('.report-abuse-link').on('click', e => { - // this is needed because of the implementation of - // the dropdown toggle and Report Abuse needing to be - // linked to another page. - e.stopPropagation(); - }); + if (draftToggles.length) { + draftToggles.forEach(draftToggle => { + draftToggle.addEventListener('click', e => { + e.preventDefault(); + e.stopImmediatePropagation(); + + const url = draftToggle.href; + const wipEvent = getParameterValues('merge_request[wip_event]', url)[0]; + const mobileDropdown = draftToggle.closest('.dropdown.show'); + + if (mobileDropdown) { + $(mobileDropdown.firstElementChild).dropdown('toggle'); + } + + draftToggle.setAttribute('disabled', 'disabled'); + + axios + .put(draftToggle.href, null, { params: { format: 'json' } }) + .then(({ data }) => { + draftToggle.removeAttribute('disabled'); + eventHub.$emit('MRWidgetUpdateRequested'); + MergeRequest.toggleDraftStatus(data.title, wipEvent === 'unwip'); + }) + .catch(() => { + draftToggle.removeAttribute('disabled'); + createFlash(__('Something went wrong. Please try again.')); + }); + }); + }); + } return $('.btn-close, .btn-reopen').on('click', function(e) { const $this = $(this); @@ -89,8 +114,6 @@ MergeRequest.prototype.initMRBtnListeners = function() { return; } - if (this.closeReopenReportToggle) this.closeReopenReportToggle.setDisable(); - if (shouldSubmit) { if ($this.hasClass('btn-comment-and-close') || $this.hasClass('btn-comment-and-reopen')) { e.preventDefault(); @@ -151,14 +174,35 @@ MergeRequest.hideCloseButton = function() { const closeDropdownItem = el.querySelector('li.close-item'); if (closeDropdownItem) { closeDropdownItem.classList.add('hidden'); - // Selects the next dropdown item - el.querySelector('li.report-item').click(); - } else { - // No dropdown just hide the Close button - el.querySelector('.btn-close').classList.add('hidden'); } // Dropdown for mobile screen el.querySelector('li.js-close-item').classList.add('hidden'); }; +MergeRequest.toggleDraftStatus = function(title, isReady) { + if (isReady) { + createFlash(__('The merge request can now be merged.'), 'notice'); + } + const titleEl = document.querySelector('.merge-request .detail-page-description .title'); + + if (titleEl) { + titleEl.textContent = title; + } + + const draftToggles = document.querySelectorAll('.js-draft-toggle-button'); + + if (draftToggles.length) { + draftToggles.forEach(el => { + const draftToggle = el; + const url = setUrlParams( + { 'merge_request[wip_event]': isReady ? 'wip' : 'unwip' }, + draftToggle.href, + ); + + draftToggle.setAttribute('href', url); + draftToggle.textContent = isReady ? __('Mark as draft') : __('Mark as ready'); + }); + } +}; + export default MergeRequest; diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index b7cf39db00c..52fa0038fbb 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -396,10 +396,6 @@ export default class MergeRequestTabs { initChangesDropdown(this.stickyTop); - if (typeof gl.diffNotesCompileComponents !== 'undefined') { - gl.diffNotesCompileComponents(); - } - localTimeAgo($('.js-timeago', 'div#diffs')); syntaxHighlight($('#diffs .js-syntax-highlight')); diff --git a/app/assets/javascripts/milestone.js b/app/assets/javascripts/milestone.js index 20d9fb82554..52e9b67c77d 100644 --- a/app/assets/javascripts/milestone.js +++ b/app/assets/javascripts/milestone.js @@ -7,11 +7,6 @@ import { __ } from './locale'; export default class Milestone { constructor() { this.bindTabsSwitching(); - - // Load merge request tab if it is active - // merge request tab is active based on different conditions in the backend - this.loadTab($('.js-milestone-tabs .active a')); - this.loadInitialTab(); } @@ -23,12 +18,14 @@ export default class Milestone { this.loadTab($target); }); } - // eslint-disable-next-line class-methods-use-this + loadInitialTab() { - const $target = $(`.js-milestone-tabs a[href="${window.location.hash}"]`); + const $target = $(`.js-milestone-tabs a:not(.active)[href="${window.location.hash}"]`); if ($target.length) { $target.tab('show'); + } else { + this.loadTab($('.js-milestone-tabs a.active')); } } // eslint-disable-next-line class-methods-use-this diff --git a/app/assets/javascripts/monitoring/components/alert_widget_form.vue b/app/assets/javascripts/monitoring/components/alert_widget_form.vue index 132df9c9516..6f29b34141d 100644 --- a/app/assets/javascripts/monitoring/components/alert_widget_form.vue +++ b/app/assets/javascripts/monitoring/components/alert_widget_form.vue @@ -3,7 +3,7 @@ import { isEmpty, findKey } from 'lodash'; import Vue from 'vue'; import { GlLink, - GlDeprecatedButton, + GlButton, GlButtonGroup, GlFormGroup, GlFormInput, @@ -36,7 +36,7 @@ const SUBMIT_BUTTON_CLASS = { export default { components: { - GlDeprecatedButton, + GlButton, GlButtonGroup, GlFormGroup, GlFormInput, @@ -267,30 +267,27 @@ export default { </gl-dropdown> </gl-form-group> <gl-button-group class="mb-3" :label="s__('PrometheusAlerts|Operator')"> - <gl-deprecated-button + <gl-button :class="{ active: operator === operators.greaterThan }" :disabled="formDisabled" - type="button" @click="operator = operators.greaterThan" > {{ operators.greaterThan }} - </gl-deprecated-button> - <gl-deprecated-button + </gl-button> + <gl-button :class="{ active: operator === operators.equalTo }" :disabled="formDisabled" - type="button" @click="operator = operators.equalTo" > {{ operators.equalTo }} - </gl-deprecated-button> - <gl-deprecated-button + </gl-button> + <gl-button :class="{ active: operator === operators.lessThan }" :disabled="formDisabled" - type="button" @click="operator = operators.lessThan" > {{ operators.lessThan }} - </gl-deprecated-button> + </gl-button> </gl-button-group> <gl-form-group :label="s__('PrometheusAlerts|Threshold')" label-for="alerts-threshold"> <gl-form-input diff --git a/app/assets/javascripts/monitoring/components/group_empty_state.vue b/app/assets/javascripts/monitoring/components/group_empty_state.vue index 499823fae3f..0365fc66331 100644 --- a/app/assets/javascripts/monitoring/components/group_empty_state.vue +++ b/app/assets/javascripts/monitoring/components/group_empty_state.vue @@ -1,6 +1,5 @@ <script> -/* eslint-disable vue/no-v-html */ -import { GlEmptyState } from '@gitlab/ui'; +import { GlEmptyState, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import { metricStates } from '../constants'; @@ -8,6 +7,9 @@ export default { components: { GlEmptyState, }, + directives: { + SafeHtml, + }, props: { documentationPath: { type: String, @@ -100,7 +102,7 @@ export default { :compact="true" > <template v-if="currentState.slottedDescription" #description> - <div v-html="currentState.slottedDescription"></div> + <div v-safe-html="currentState.slottedDescription"></div> </template> </gl-empty-state> </template> diff --git a/app/assets/javascripts/namespaces/leave_by_url.js b/app/assets/javascripts/namespaces/leave_by_url.js index bf77617d516..7b15253d872 100644 --- a/app/assets/javascripts/namespaces/leave_by_url.js +++ b/app/assets/javascripts/namespaces/leave_by_url.js @@ -1,3 +1,4 @@ +import { initRails } from '~/lib/utils/rails_ujs'; import { deprecatedCreateFlash as Flash } from '~/flash'; import { __, sprintf } from '~/locale'; import { getParameterByName } from '~/lib/utils/common_utils'; @@ -11,6 +12,8 @@ export default function leaveByUrl(namespaceType) { const param = getParameterByName(PARAMETER_NAME); if (!param) return; + initRails(); + const leaveLink = document.querySelector(LEAVE_LINK_SELECTOR); if (leaveLink) { leaveLink.click(); diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue index 3bbaa44ec42..c04f2a2d465 100644 --- a/app/assets/javascripts/notebook/cells/markdown.vue +++ b/app/assets/javascripts/notebook/cells/markdown.vue @@ -1,8 +1,8 @@ <script> /* eslint-disable vue/no-v-html */ import marked from 'marked'; -import { sanitize } from 'dompurify'; import katex from 'katex'; +import { sanitize } from '~/lib/dompurify'; import Prompt from './prompt.vue'; const renderer = new marked.Renderer(); diff --git a/app/assets/javascripts/notebook/cells/output/html.vue b/app/assets/javascripts/notebook/cells/output/html.vue index 856c8f31796..4d527baf730 100644 --- a/app/assets/javascripts/notebook/cells/output/html.vue +++ b/app/assets/javascripts/notebook/cells/output/html.vue @@ -1,6 +1,6 @@ <script> /* eslint-disable vue/no-v-html */ -import { sanitize } from 'dompurify'; +import { sanitize } from '~/lib/dompurify'; import Prompt from '../prompt.vue'; export default { diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 340fbe4d887..37bb79defd1 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -479,11 +479,6 @@ export default class Notes { row = form; } - const lineType = this.isParallelView() ? form.find('#line_type').val() : 'old'; - const diffAvatarContainer = row - .prevAll('.line_holder') - .first() - .find(`.js-avatar-container.${lineType}_line`); // is this the first note of discussion? discussionContainer = $(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`); if (!discussionContainer.length) { @@ -519,12 +514,6 @@ export default class Notes { Notes.animateAppendNote(noteEntity.html, discussionContainer); } - if (typeof gl.diffNotesCompileComponents !== 'undefined' && noteEntity.discussion_resolvable) { - gl.diffNotesCompileComponents(); - - this.renderDiscussionAvatar(diffAvatarContainer, noteEntity); - } - localTimeAgo($('.js-timeago'), false); Notes.checkMergeRequestStatus(); return this.updateNotesCount(1); @@ -538,19 +527,6 @@ export default class Notes { .get(0); } - renderDiscussionAvatar(diffAvatarContainer, noteEntity) { - let avatarHolder = diffAvatarContainer.find('.diff-comment-avatar-holders'); - - if (!avatarHolder.length) { - avatarHolder = document.createElement('diff-note-avatars'); - avatarHolder.setAttribute('discussion-id', noteEntity.discussion_id); - - diffAvatarContainer.append(avatarHolder); - - gl.diffNotesCompileComponents(); - } - } - /** * Called in response the main target form has been successfully submitted. * @@ -605,10 +581,6 @@ export default class Notes { form.find('#note_type').val(''); form.find('#note_project_id').remove(); form.find('#in_reply_to_discussion_id').remove(); - form - .find('.js-comment-resolve-button') - .closest('comment-and-resolve-btn') - .remove(); this.parentTimeline = form.parents('.timeline'); if (form.length) { @@ -714,10 +686,6 @@ export default class Notes { $note_li.replaceWith($noteEntityEl); this.setupNewNote($noteEntityEl); - - if (typeof gl.diffNotesCompileComponents !== 'undefined') { - gl.diffNotesCompileComponents(); - } } checkContentToAllowEditing($el) { @@ -844,12 +812,6 @@ export default class Notes { const $notes = $note.closest('.discussion-notes'); const discussionId = $('.notes', $notes).data('discussionId'); - if (typeof gl.diffNotesCompileComponents !== 'undefined') { - if (gl.diffNoteApps[noteElId]) { - gl.diffNoteApps[noteElId].$destroy(); - } - } - $note.remove(); // check if this is the last note for this line @@ -979,13 +941,6 @@ export default class Notes { form.removeClass('js-main-target-form').addClass('discussion-form js-discussion-note-form'); - if (typeof gl.diffNotesCompileComponents !== 'undefined') { - const $commentBtn = form.find('comment-and-resolve-btn'); - $commentBtn.attr(':discussion-id', `'${discussionID}'`); - - gl.diffNotesCompileComponents(); - } - form.find('.js-note-text').focus(); form.find('.js-comment-resolve-button').attr('data-discussion-id', discussionID); } diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 54fcf41ca50..cfdadbceaf6 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -371,6 +371,7 @@ export default { :markdown-docs-path="markdownDocsPath" :quick-actions-docs-path="quickActionsDocsPath" :add-spacing-classes="false" + :textarea-value="note" > <textarea id="note-body" @@ -380,7 +381,8 @@ 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 qa-comment-input" + class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area" + data-qa-selector="comment_field" data-supports-quick-actions="true" :aria-label="__('Description')" :placeholder="__('Write a comment or drag your files here…')" @@ -425,7 +427,8 @@ export default { > <gl-button :disabled="isSubmitButtonDisabled" - class="js-comment-button js-comment-submit-button qa-comment-button" + class="js-comment-button js-comment-submit-button" + data-qa-selector="comment_button" type="submit" category="primary" variant="success" @@ -439,7 +442,8 @@ export default { name="button" category="primary" variant="success" - class="note-type-toggle js-note-new-discussion dropdown-toggle qa-note-dropdown" + class="note-type-toggle js-note-new-discussion dropdown-toggle" + data-qa-selector="note_dropdown" data-display="static" data-toggle="dropdown" icon="chevron-down" @@ -468,7 +472,10 @@ export default { </li> <li class="divider droplab-item-ignore"></li> <li :class="{ 'droplab-item-selected': noteType === 'discussion' }"> - <button class="qa-discussion-option" @click.prevent="setNoteType('discussion')"> + <button + data-qa-selector="discussion_menu_item" + @click.prevent="setNoteType('discussion')" + > <i aria-hidden="true" class="fa fa-check icon"></i> <div class="description"> <strong>{{ __('Start thread') }}</strong> diff --git a/app/assets/javascripts/notes/components/diff_discussion_header.vue b/app/assets/javascripts/notes/components/diff_discussion_header.vue index 8e6c01ba63f..ee39a529345 100644 --- a/app/assets/javascripts/notes/components/diff_discussion_header.vue +++ b/app/assets/javascripts/notes/components/diff_discussion_header.vue @@ -1,7 +1,7 @@ <script> -/* eslint-disable vue/no-v-html */ import { mapActions } from 'vuex'; import { escape } from 'lodash'; +import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { s__, __, sprintf } from '~/locale'; import { truncateSha } from '~/lib/utils/text_utility'; @@ -17,6 +17,9 @@ export default { noteEditedText, noteHeader, }, + directives: { + SafeHtml, + }, props: { discussion: { type: Object, @@ -113,7 +116,7 @@ export default { :expanded="discussion.expanded" @toggleHandler="toggleDiscussionHandler" > - <span v-html="headerText"></span> + <span v-safe-html="headerText"></span> </note-header> <note-edited-text v-if="discussion.resolved" diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue index c01cd8f8037..a4271852563 100644 --- a/app/assets/javascripts/notes/components/diff_with_note.vue +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -76,7 +76,7 @@ export default { :discussion-path="discussion.discussion_path" :diff-file="discussion.diff_file" :can-current-user-fork="false" - :expanded="!discussion.diff_file.viewer.collapsed" + :expanded="!discussion.diff_file.viewer.automaticallyCollapsed" /> <div v-if="isTextFile" class="diff-content"> <table class="code js-syntax-highlight" :class="$options.userColorSchemeClass"> diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue index c6fab271376..2427a3f98ad 100644 --- a/app/assets/javascripts/notes/components/discussion_counter.vue +++ b/app/assets/javascripts/notes/components/discussion_counter.vue @@ -1,6 +1,7 @@ <script> import { mapGetters, mapActions } from 'vuex'; -import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { GlTooltipDirective, GlIcon, GlButton, GlButtonGroup } from '@gitlab/ui'; +import { __ } from '~/locale'; import discussionNavigation from '../mixins/discussion_navigation'; export default { @@ -9,6 +10,8 @@ export default { }, components: { GlIcon, + GlButton, + GlButtonGroup, }, mixins: [discussionNavigation], computed: { @@ -34,6 +37,12 @@ export default { allExpanded() { return this.toggeableDiscussions.every(discussion => discussion.expanded); }, + lineResolveClass() { + return this.allResolved ? 'line-resolve-btn is-active' : 'line-resolve-text'; + }, + toggleThreadsLabel() { + return this.allExpanded ? __('Collapse all threads') : __('Expand all threads'); + }, }, methods: { ...mapActions(['setExpandDiscussions']), @@ -51,59 +60,49 @@ export default { <div v-if="resolvableDiscussionsCount > 0" ref="discussionCounter" - class="line-resolve-all-container full-width-mobile" + class="line-resolve-all-container full-width-mobile gl-display-flex d-sm-flex" > - <div class="full-width-mobile d-flex d-sm-flex"> - <div class="line-resolve-all"> - <span - :class="{ 'line-resolve-btn is-active': allResolved, 'line-resolve-text': !allResolved }" - > - <template v-if="allResolved"> - <gl-icon name="check-circle-filled" /> - {{ __('All threads resolved') }} - </template> - <template v-else> - {{ n__('%d unresolved thread', '%d unresolved threads', unresolvedDiscussionsCount) }} - </template> - </span> - </div> - <div - v-if="resolveAllDiscussionsIssuePath && !allResolved" - class="btn-group btn-group-sm" - role="group" - > - <a - v-gl-tooltip - :href="resolveAllDiscussionsIssuePath" - :title="s__('Resolve all threads in new issue')" - class="new-issue-for-discussion btn btn-default discussion-create-issue-btn" - > - <gl-icon name="issue-new" /> - </a> - </div> - <div v-if="isLoggedIn && !allResolved" class="btn-group btn-group-sm" role="group"> - <button - v-gl-tooltip - :title="__('Jump to next unresolved thread')" - class="btn btn-default discussion-next-btn" - data-track-event="click_button" - data-track-label="mr_next_unresolved_thread" - data-track-property="click_next_unresolved_thread_top" - @click="jumpToNextDiscussion" - > - <gl-icon name="comment-next" /> - </button> - </div> - <div class="btn-group btn-group-sm" role="group"> - <button - v-gl-tooltip - :title="__('Toggle all threads')" - class="btn btn-default toggle-all-discussions-btn" - @click="handleExpandDiscussions" - > - <gl-icon :name="allExpanded ? 'angle-up' : 'angle-down'" /> - </button> - </div> + <div class="line-resolve-all"> + <span :class="lineResolveClass"> + <template v-if="allResolved"> + <gl-icon name="check-circle-filled" /> + {{ __('All threads resolved') }} + </template> + <template v-else> + {{ n__('%d unresolved thread', '%d unresolved threads', unresolvedDiscussionsCount) }} + </template> + </span> </div> + <gl-button-group> + <gl-button + v-if="resolveAllDiscussionsIssuePath && !allResolved" + v-gl-tooltip + :href="resolveAllDiscussionsIssuePath" + :title="s__('Resolve all threads in new issue')" + :aria-label="s__('Resolve all threads in new issue')" + class="new-issue-for-discussion discussion-create-issue-btn" + icon="issue-new" + /> + <gl-button + v-if="isLoggedIn && !allResolved" + v-gl-tooltip + :title="__('Jump to next unresolved thread')" + :aria-label="__('Jump to next unresolved thread')" + class="discussion-next-btn" + data-track-event="click_button" + data-track-label="mr_next_unresolved_thread" + data-track-property="click_next_unresolved_thread_top" + icon="comment-next" + @click="jumpToNextDiscussion" + /> + <gl-button + v-gl-tooltip + :title="toggleThreadsLabel" + :aria-label="toggleThreadsLabel" + class="toggle-all-discussions-btn" + :icon="allExpanded ? 'angle-up' : 'angle-down'" + @click="handleExpandDiscussions" + /> + </gl-button-group> </div> </template> diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue index 989ce9ff144..0e7ed854032 100644 --- a/app/assets/javascripts/notes/components/discussion_filter.vue +++ b/app/assets/javascripts/notes/components/discussion_filter.vue @@ -1,7 +1,6 @@ <script> -import $ from 'jquery'; import { mapGetters, mapActions } from 'vuex'; -import { GlIcon } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui'; import { getLocationHash, doesHashExistInUrl } from '../../lib/utils/url_utility'; import { DISCUSSION_FILTERS_DEFAULT_VALUE, @@ -14,7 +13,9 @@ import notesEventHub from '../event_hub'; export default { components: { - GlIcon, + GlDropdown, + GlDropdownItem, + GlDropdownDivider, }, props: { filters: { @@ -66,9 +67,6 @@ export default { selectFilter(value, persistFilter = true) { const filter = parseInt(value, 10); - // close dropdown - this.toggleDropdown(); - if (filter === this.currentValue) return; this.currentValue = filter; this.filterDiscussion({ @@ -78,9 +76,6 @@ export default { }); this.toggleCommentsForm(); }, - toggleDropdown() { - $(this.$refs.dropdownToggle).dropdown('toggle'); - }, toggleCommentsForm() { this.setCommentsDisabled(this.currentValue === HISTORY_ONLY_FILTER_VALUE); }, @@ -92,7 +87,6 @@ export default { if (/^note_/.test(hash) && this.currentValue !== DISCUSSION_FILTERS_DEFAULT_VALUE) { this.selectFilter(this.defaultValue, false); - this.toggleDropdown(); // close dropdown this.setTargetNoteHash(hash); } }, @@ -109,43 +103,24 @@ export default { </script> <template> - <div + <gl-dropdown v-if="displayFilters" - class="discussion-filter-container js-discussion-filter-container d-inline-block align-bottom full-width-mobile" + id="discussion-filter-dropdown" + class="gl-mr-3 full-width-mobile discussion-filter-container js-discussion-filter-container qa-discussion-filter" + :text="currentFilter.title" > - <button - id="discussion-filter-dropdown" - ref="dropdownToggle" - class="btn btn-sm qa-discussion-filter" - data-toggle="dropdown" - aria-expanded="false" - > - {{ currentFilter.title }} <gl-icon name="chevron-down" /> - </button> - <div - ref="dropdownMenu" - class="dropdown-menu dropdown-menu-selectable dropdown-menu-right" - aria-labelledby="discussion-filter-dropdown" - > - <div class="dropdown-content"> - <ul> - <li - v-for="filter in filters" - :key="filter.value" - :data-filter-type="filterType(filter.value)" - > - <button - :class="{ 'is-active': filter.value === currentValue }" - class="qa-filter-options" - type="button" - @click="selectFilter(filter.value)" - > - {{ filter.title }} - </button> - <div v-if="filter.value === defaultValue" class="dropdown-divider"></div> - </li> - </ul> - </div> + <div v-for="filter in filters" :key="filter.value" class="dropdown-item-wrapper"> + <gl-dropdown-item + :is-check-item="true" + :is-checked="filter.value === currentValue" + :class="{ 'is-active': filter.value === currentValue }" + :data-filter-type="filterType(filter.value)" + class="qa-filter-options" + @click.prevent="selectFilter(filter.value)" + > + {{ filter.title }} + </gl-dropdown-item> + <gl-dropdown-divider v-if="filter.value === defaultValue" /> </div> - </div> + </gl-dropdown> </template> diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index a8057276f1a..c2f40b2d21a 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -160,7 +160,7 @@ export default { }); }, displayMemberBadgeText() { - return sprintf(__('This user is a %{access} of the %{name} project.'), { + return sprintf(__('This user has the %{access} role in the %{name} project.'), { access: this.accessLevel.toLowerCase(), name: this.projectName, }); @@ -275,7 +275,8 @@ export default { v-gl-tooltip type="button" title="Edit comment" - class="note-action-button js-note-edit btn btn-transparent qa-note-edit-button" + class="note-action-button js-note-edit btn btn-transparent" + data-qa-selector="note_edit_button" @click="onEdit" > <gl-icon name="pencil" class="link-highlight" /> diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index 314fa762768..65b89b94eaa 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -45,7 +45,7 @@ export default { }, }, computed: { - ...mapGetters(['getDiscussion']), + ...mapGetters(['getDiscussion', 'suggestionsCount']), discussion() { if (!this.note.isDraft) return {}; @@ -125,6 +125,7 @@ export default { <suggestions v-if="hasSuggestion && !isEditing" :suggestions="note.suggestions" + :suggestions-count="suggestionsCount" :batch-suggestions-info="batchSuggestionsInfo" :note-html="note.note_html" :line-type="lineType" diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 88b4461cf38..4b3f23e742d 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -328,6 +328,7 @@ export default { :add-spacing-classes="false" :help-page-path="helpPagePath" :show-suggest-popover="showSuggestPopover" + :textarea-value="updatedNoteBody" @handleSuggestDismissed="() => $emit('handleSuggestDismissed')" > <textarea @@ -337,7 +338,8 @@ export default { v-model="updatedNoteBody" :data-supports-quick-actions="!isEditing" name="note[note]" - class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form qa-reply-input" + class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form" + data-qa-selector="reply_field" dir="auto" :aria-label="__('Description')" :placeholder="__('Write a comment or drag your files here…')" @@ -376,7 +378,8 @@ export default { <button :disabled="isDisabled" type="button" - class="btn btn-success qa-start-review" + class="btn btn-success" + data-qa-selector="start_review_button" @click="handleAddToReview" > <template v-if="hasDrafts">{{ __('Add to review') }}</template> @@ -385,7 +388,8 @@ export default { <button :disabled="isDisabled" type="button" - class="btn qa-comment-now js-comment-button" + class="btn js-comment-button" + data-qa-selector="comment_now_button" @click="handleUpdate()" > {{ __('Add comment now') }} @@ -404,7 +408,8 @@ export default { <button :disabled="isDisabled" type="button" - class="js-vue-issue-save btn btn-success js-comment-button qa-reply-comment-button" + class="js-vue-issue-save btn btn-success js-comment-button" + data-qa-selector="reply_comment_button" @click="handleUpdate()" > {{ saveButtonTitle }} diff --git a/app/assets/javascripts/notes/components/sort_discussion.vue b/app/assets/javascripts/notes/components/sort_discussion.vue index 60b531d7597..113c00ffe8e 100644 --- a/app/assets/javascripts/notes/components/sort_discussion.vue +++ b/app/assets/javascripts/notes/components/sort_discussion.vue @@ -1,6 +1,5 @@ -gs <script> -import { GlIcon } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { mapActions, mapGetters } from 'vuex'; import { __ } from '~/locale'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; @@ -15,7 +14,8 @@ const SORT_OPTIONS = [ export default { SORT_OPTIONS, components: { - GlIcon, + GlDropdown, + GlDropdownItem, LocalStorageSync, }, mixins: [Tracking.mixin()], @@ -49,33 +49,27 @@ export default { </script> <template> - <div - data-testid="sort-discussion-filter" - class="gl-mr-2 gl-display-inline-block gl-vertical-align-bottom full-width-mobile" - > + <div class="gl-mr-3 gl-display-inline-block gl-vertical-align-bottom full-width-mobile"> <local-storage-sync :value="sortDirection" :storage-key="storageKey" @input="setDiscussionSortDirection" /> - <button class="btn btn-sm js-dropdown-text" data-toggle="dropdown" aria-expanded="false"> - {{ dropdownText }} - <gl-icon name="chevron-down" /> - </button> - <div ref="dropdownMenu" class="dropdown-menu dropdown-menu-selectable dropdown-menu-right"> - <div class="dropdown-content"> - <ul> - <li v-for="{ text, key, cls } in $options.SORT_OPTIONS" :key="key"> - <button - :class="[cls, { 'is-active': isDropdownItemActive(key) }]" - type="button" - @click="fetchSortedDiscussions(key)" - > - {{ text }} - </button> - </li> - </ul> - </div> - </div> + <gl-dropdown + :text="dropdownText" + data-testid="sort-discussion-filter" + class="js-dropdown-text full-width-mobile" + > + <gl-dropdown-item + v-for="{ text, key, cls } in $options.SORT_OPTIONS" + :key="key" + :class="cls" + :is-check-item="true" + :is-checked="isDropdownItemActive(key)" + @click="fetchSortedDiscussions(key)" + > + {{ text }} + </gl-dropdown-item> + </gl-dropdown> </div> </template> diff --git a/app/assets/javascripts/notes/components/toggle_replies_widget.vue b/app/assets/javascripts/notes/components/toggle_replies_widget.vue index bddac60647d..f49fd2c3fa3 100644 --- a/app/assets/javascripts/notes/components/toggle_replies_widget.vue +++ b/app/assets/javascripts/notes/components/toggle_replies_widget.vue @@ -57,7 +57,12 @@ export default { tooltip-placement="bottom" /> </div> - <button class="btn btn-link js-replies-text qa-expand-replies" type="button" @click="toggle"> + <button + class="btn btn-link js-replies-text" + data-qa-selector="expand_replies_button" + type="button" + @click="toggle" + > {{ replies.length }} {{ n__('reply', 'replies', replies.length) }} </button> {{ __('Last reply by') }} @@ -68,7 +73,8 @@ export default { </template> <span v-else - class="collapse-replies-btn js-collapse-replies qa-collapse-replies" + class="collapse-replies-btn js-collapse-replies" + data-qa-selector="collapse_replies_button" @click="toggle" > <gl-icon name="chevron-down" /> {{ s__('Notes|Collapse replies') }} diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index 7bf465482b3..ca186123a83 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -5,15 +5,19 @@ import initSortDiscussions from './sort_discussions'; import { store } from './stores'; document.addEventListener('DOMContentLoaded', () => { + const el = document.getElementById('js-vue-notes'); + + if (!el) return; + // eslint-disable-next-line no-new new Vue({ - el: '#js-vue-notes', + el, components: { notesApp, }, store, data() { - const notesDataset = document.getElementById('js-vue-notes').dataset; + const notesDataset = el.dataset; const parsedUserData = JSON.parse(notesDataset.currentUserData); const noteableData = JSON.parse(notesDataset.noteableData); let currentUserData = {}; diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index 7d60fbffb10..fbdb71925ea 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -231,3 +231,6 @@ export const getDiscussion = state => discussionId => state.discussions.find(discussion => discussion.id === discussionId); export const commentsDisabled = state => state.commentsDisabled; + +export const suggestionsCount = (state, getters) => + Object.values(getters.notesById).filter(n => n.suggestions.length).length; diff --git a/app/assets/javascripts/notifications_dropdown.js b/app/assets/javascripts/notifications_dropdown.js index 47fb5b271d1..ae992dd5dc5 100644 --- a/app/assets/javascripts/notifications_dropdown.js +++ b/app/assets/javascripts/notifications_dropdown.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { Rails } from '~/lib/utils/rails_ujs'; import { deprecatedCreateFlash as Flash } from './flash'; import { __ } from '~/locale'; @@ -21,10 +22,12 @@ export default function notificationsDropdown() { form.find('.js-notifications-icon').toggleClass('hidden'); } form.find('#notification_setting_level').val(notificationLevel); - form.submit(); + Rails.fire(form[0], 'submit'); }); - $(document).on('ajax:success', '.notification-form', (e, data) => { + $(document).on('ajax:success', '.notification-form', e => { + const data = e.detail[0]; + if (data.saved) { $(e.currentTarget) .closest('.js-notification-dropdown') diff --git a/app/assets/javascripts/operation_settings/components/metrics_settings.vue b/app/assets/javascripts/operation_settings/components/metrics_settings.vue index 9df6a412930..2e972dd7154 100644 --- a/app/assets/javascripts/operation_settings/components/metrics_settings.vue +++ b/app/assets/javascripts/operation_settings/components/metrics_settings.vue @@ -44,11 +44,9 @@ export default { <form> <dashboard-timezone /> <external-dashboard /> - <div class="gl-display-flex gl-justify-content-end"> - <gl-button variant="success" category="primary" @click="saveChanges"> - {{ __('Save Changes') }} - </gl-button> - </div> + <gl-button variant="success" category="primary" @click="saveChanges"> + {{ __('Save Changes') }} + </gl-button> </form> </div> </section> diff --git a/app/assets/javascripts/packages/details/store/getters.js b/app/assets/javascripts/packages/details/store/getters.js index ede6d39bde7..04f75fc8333 100644 --- a/app/assets/javascripts/packages/details/store/getters.js +++ b/app/assets/javascripts/packages/details/store/getters.js @@ -98,7 +98,7 @@ export const nugetSetupCommand = ({ nugetPath }) => export const pypiPipCommand = ({ pypiPath, packageEntity }) => // eslint-disable-next-line @gitlab/require-i18n-strings - `pip install ${packageEntity.name} --index-url ${pypiPath}`; + `pip install ${packageEntity.name} --extra-index-url ${pypiPath}`; export const pypiSetupCommand = ({ pypiSetupPath }) => `[gitlab] repository = ${pypiSetupPath} diff --git a/app/assets/javascripts/packages/list/components/package_title.vue b/app/assets/javascripts/packages/list/components/package_title.vue new file mode 100644 index 00000000000..e5cab310bc8 --- /dev/null +++ b/app/assets/javascripts/packages/list/components/package_title.vue @@ -0,0 +1,47 @@ +<script> +import { n__ } from '~/locale'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; +import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; +import { LIST_INTRO_TEXT, LIST_TITLE_TEXT } from '../constants'; + +export default { + name: 'PackageTitle', + components: { + TitleArea, + MetadataItem, + }, + props: { + packagesCount: { + type: Number, + required: false, + default: null, + }, + packageHelpUrl: { + type: String, + required: true, + }, + }, + computed: { + showPackageCount() { + return Number.isInteger(this.packagesCount); + }, + packageAmountText() { + return n__(`%d Package`, `%d Packages`, this.packagesCount); + }, + infoMessages() { + return [{ text: LIST_INTRO_TEXT, link: this.packageHelpUrl }]; + }, + }, + i18n: { + LIST_TITLE_TEXT, + }, +}; +</script> + +<template> + <title-area :title="$options.i18n.LIST_TITLE_TEXT" :info-messages="infoMessages"> + <template #metadata_amount> + <metadata-item v-if="showPackageCount" icon="package" :text="packageAmountText" /> + </template> + </title-area> +</template> diff --git a/app/assets/javascripts/packages/list/components/packages_list_app.vue b/app/assets/javascripts/packages/list/components/packages_list_app.vue index 6304f723f6a..ad60ee6f379 100644 --- a/app/assets/javascripts/packages/list/components/packages_list_app.vue +++ b/app/assets/javascripts/packages/list/components/packages_list_app.vue @@ -3,13 +3,14 @@ import { mapActions, mapState } from 'vuex'; import { GlEmptyState, GlTab, GlTabs, GlLink, GlSprintf } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; import createFlash from '~/flash'; +import { historyReplaceState } from '~/lib/utils/common_utils'; +import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants'; import PackageFilter from './packages_filter.vue'; import PackageList from './packages_list.vue'; import PackageSort from './packages_sort.vue'; import { PACKAGE_REGISTRY_TABS, DELETE_PACKAGE_SUCCESS_MESSAGE } from '../constants'; import PackagesComingSoon from '../coming_soon/packages_coming_soon.vue'; -import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants'; -import { historyReplaceState } from '~/lib/utils/common_utils'; +import PackageTitle from './package_title.vue'; export default { components: { @@ -22,6 +23,7 @@ export default { PackageList, PackageSort, PackagesComingSoon, + PackageTitle, }, computed: { ...mapState({ @@ -30,6 +32,8 @@ export default { comingSoon: state => state.config.comingSoon, filterQuery: state => state.filterQuery, selectedType: state => state.selectedType, + packageHelpUrl: state => state.config.packageHelpUrl, + packagesCount: state => state.pagination?.total, }), tabsToRender() { return PACKAGE_REGISTRY_TABS; @@ -89,39 +93,43 @@ export default { </script> <template> - <gl-tabs @input="tabChanged"> - <template #tabs-end> - <div - class="gl-display-flex gl-align-self-center gl-py-2 gl-flex-grow-1 gl-justify-content-end" - > - <package-filter class="mr-1" @filter="requestPackagesList" /> - <package-sort @sort:changed="requestPackagesList" /> - </div> - </template> + <div> + <package-title :package-help-url="packageHelpUrl" :packages-count="packagesCount" /> + + <gl-tabs @input="tabChanged"> + <template #tabs-end> + <div + class="gl-display-flex gl-align-self-center gl-py-2 gl-flex-grow-1 gl-justify-content-end" + > + <package-filter class="gl-mr-2" @filter="requestPackagesList" /> + <package-sort @sort:changed="requestPackagesList" /> + </div> + </template> - <gl-tab v-for="(tab, index) in tabsToRender" :key="index" :title="tab.title"> - <package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest"> - <template #empty-state> - <gl-empty-state :title="emptyStateTitle(tab)" :svg-path="emptyListIllustration"> - <template #description> - <gl-sprintf v-if="filterQuery" :message="$options.i18n.widenFilters" /> - <gl-sprintf v-else :message="$options.i18n.noResults"> - <template #noPackagesLink="{content}"> - <gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </template> - </gl-empty-state> - </template> - </package-list> - </gl-tab> + <gl-tab v-for="(tab, index) in tabsToRender" :key="index" :title="tab.title"> + <package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest"> + <template #empty-state> + <gl-empty-state :title="emptyStateTitle(tab)" :svg-path="emptyListIllustration"> + <template #description> + <gl-sprintf v-if="filterQuery" :message="$options.i18n.widenFilters" /> + <gl-sprintf v-else :message="$options.i18n.noResults"> + <template #noPackagesLink="{content}"> + <gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </template> + </gl-empty-state> + </template> + </package-list> + </gl-tab> - <gl-tab v-if="comingSoon" :title="__('Coming soon')" lazy> - <packages-coming-soon - :illustration="emptyListIllustration" - :project-path="comingSoon.projectPath" - :suggested-contributions-path="comingSoon.suggestedContributions" - /> - </gl-tab> - </gl-tabs> + <gl-tab v-if="comingSoon" :title="__('Coming soon')" lazy> + <packages-coming-soon + :illustration="emptyListIllustration" + :project-path="comingSoon.projectPath" + :suggested-contributions-path="comingSoon.suggestedContributions" + /> + </gl-tab> + </gl-tabs> + </div> </template> diff --git a/app/assets/javascripts/packages/list/components/packages_sort.vue b/app/assets/javascripts/packages/list/components/packages_sort.vue index fa8f4f39d54..47e51bbdca5 100644 --- a/app/assets/javascripts/packages/list/components/packages_sort.vue +++ b/app/assets/javascripts/packages/list/components/packages_sort.vue @@ -51,7 +51,7 @@ export default { <gl-sorting-item v-for="item in sortableFields" ref="packageListSortItem" - :key="item.key" + :key="item.orderBy" @click="onSortItemClick(item.orderBy)" > {{ item.label }} diff --git a/app/assets/javascripts/packages/list/constants.js b/app/assets/javascripts/packages/list/constants.js index 0ff8c86362d..37242822e35 100644 --- a/app/assets/javascripts/packages/list/constants.js +++ b/app/assets/javascripts/packages/list/constants.js @@ -15,7 +15,7 @@ export const GROUP_PAGE_TYPE = 'groups'; export const LIST_KEY_NAME = 'name'; export const LIST_KEY_PROJECT = 'project_path'; export const LIST_KEY_VERSION = 'version'; -export const LIST_KEY_PACKAGE_TYPE = 'package_type'; +export const LIST_KEY_PACKAGE_TYPE = 'type'; export const LIST_KEY_CREATED_AT = 'created_at'; export const LIST_KEY_ACTIONS = 'actions'; @@ -23,47 +23,35 @@ export const LIST_LABEL_NAME = __('Name'); export const LIST_LABEL_PROJECT = __('Project'); export const LIST_LABEL_VERSION = __('Version'); export const LIST_LABEL_PACKAGE_TYPE = __('Type'); -export const LIST_LABEL_CREATED_AT = __('Created'); +export const LIST_LABEL_CREATED_AT = __('Published'); export const LIST_LABEL_ACTIONS = ''; -export const LIST_ORDER_BY_PACKAGE_TYPE = 'type'; - export const ASCENDING_ODER = 'asc'; export const DESCENDING_ORDER = 'desc'; // The following is not translated because it is used to build a JavaScript exception error message export const MISSING_DELETE_PATH_ERROR = 'Missing delete_api_path link'; -export const TABLE_HEADER_FIELDS = [ +export const SORT_FIELDS = [ { - key: LIST_KEY_NAME, - label: LIST_LABEL_NAME, orderBy: LIST_KEY_NAME, - class: ['text-left'], + label: LIST_LABEL_NAME, }, { - key: LIST_KEY_PROJECT, - label: LIST_LABEL_PROJECT, orderBy: LIST_KEY_PROJECT, - class: ['text-left'], + label: LIST_LABEL_PROJECT, }, { - key: LIST_KEY_VERSION, - label: LIST_LABEL_VERSION, orderBy: LIST_KEY_VERSION, - class: ['text-center'], + label: LIST_LABEL_VERSION, }, { - key: LIST_KEY_PACKAGE_TYPE, + orderBy: LIST_KEY_PACKAGE_TYPE, label: LIST_LABEL_PACKAGE_TYPE, - orderBy: LIST_ORDER_BY_PACKAGE_TYPE, - class: ['text-center'], }, { - key: LIST_KEY_CREATED_AT, - label: LIST_LABEL_CREATED_AT, orderBy: LIST_KEY_CREATED_AT, - class: ['text-center'], + label: LIST_LABEL_CREATED_AT, }, ]; @@ -98,3 +86,9 @@ export const PACKAGE_REGISTRY_TABS = [ type: PackageType.PYPI, }, ]; + +export const LIST_TITLE_TEXT = s__('PackageRegistry|Package Registry'); + +export const LIST_INTRO_TEXT = s__( + 'PackageRegistry|Publish and share packages for a variety of common package managers. %{docLinkStart}More information%{docLinkEnd}', +); diff --git a/app/assets/javascripts/packages/list/utils.js b/app/assets/javascripts/packages/list/utils.js index 98d78db8706..6a300d7bfe6 100644 --- a/app/assets/javascripts/packages/list/utils.js +++ b/app/assets/javascripts/packages/list/utils.js @@ -1,7 +1,6 @@ -import { LIST_KEY_PROJECT, TABLE_HEADER_FIELDS } from './constants'; +import { LIST_KEY_PROJECT, SORT_FIELDS } from './constants'; -export default isGroupPage => - TABLE_HEADER_FIELDS.filter(f => f.key !== LIST_KEY_PROJECT || isGroupPage); +export default isGroupPage => SORT_FIELDS.filter(f => f.key !== LIST_KEY_PROJECT || isGroupPage); /** * A small util function that works out if the delete action has deleted the diff --git a/app/assets/javascripts/packages/shared/components/package_list_row.vue b/app/assets/javascripts/packages/shared/components/package_list_row.vue index f93bc51d185..d55ca80a7fc 100644 --- a/app/assets/javascripts/packages/shared/components/package_list_row.vue +++ b/app/assets/javascripts/packages/shared/components/package_list_row.vue @@ -1,6 +1,7 @@ <script> import { GlButton, GlIcon, GlLink, GlSprintf, GlTooltipDirective, GlTruncate } from '@gitlab/ui'; import PackageTags from './package_tags.vue'; +import PackagePath from './package_path.vue'; import PublishMethod from './publish_method.vue'; import { getPackageTypeLabel } from '../utils'; import timeagoMixin from '~/vue_shared/mixins/timeago'; @@ -15,6 +16,7 @@ export default { GlSprintf, GlTruncate, PackageTags, + PackagePath, PublishMethod, ListItem, }, @@ -92,22 +94,12 @@ export default { </gl-sprintf> </div> - <div v-if="hasProjectLink" class="gl-display-flex gl-align-items-center"> - <gl-icon name="review-list" class="gl-ml-3 gl-mr-2 gl-min-w-0" /> - - <gl-link - class="gl-text-body gl-min-w-0" - data-testid="packages-row-project" - :href="`/${packageEntity.project_path}`" - > - <gl-truncate :text="packageEntity.projectPathName" /> - </gl-link> - </div> - <div v-if="showPackageType" class="d-flex align-items-center" data-testid="package-type"> <gl-icon name="package" class="gl-ml-3 gl-mr-2" /> <span>{{ packageType }}</span> </div> + + <package-path v-if="hasProjectLink" :path="packageEntity.project_path" /> </div> </template> diff --git a/app/assets/javascripts/packages/shared/components/package_path.vue b/app/assets/javascripts/packages/shared/components/package_path.vue new file mode 100644 index 00000000000..9afe06ab497 --- /dev/null +++ b/app/assets/javascripts/packages/shared/components/package_path.vue @@ -0,0 +1,71 @@ +<script> +import { GlIcon, GlLink, GlTooltipDirective } from '@gitlab/ui'; +import { joinPaths } from '~/lib/utils/url_utility'; + +export default { + name: 'PackagePath', + components: { + GlIcon, + GlLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + path: { + type: String, + required: true, + }, + }, + computed: { + pathPieces() { + return this.path.split('/'); + }, + root() { + // we skip the first part of the path since is the 'base' group + return this.pathPieces[1]; + }, + rootLink() { + return joinPaths(this.pathPieces[0], this.root); + }, + leaf() { + return this.pathPieces[this.pathPieces.length - 1]; + }, + deeplyNested() { + return this.pathPieces.length > 3; + }, + hasGroup() { + return this.root !== this.leaf; + }, + }, +}; +</script> + +<template> + <div data-qa-selector="package-path" class="gl-display-flex gl-align-items-center"> + <gl-icon data-testid="base-icon" name="project" class="gl-mx-3 gl-min-w-0" /> + + <gl-link data-testid="root-link" class="gl-text-gray-500 gl-min-w-0" :href="`/${rootLink}`"> + {{ root }} + </gl-link> + + <template v-if="hasGroup"> + <gl-icon data-testid="root-chevron" name="chevron-right" class="gl-mx-2 gl-min-w-0" /> + + <template v-if="deeplyNested"> + <span + v-gl-tooltip="{ title: path }" + data-testid="ellipsis-icon" + class="gl-inset-border-1-gray-200 gl-rounded-base gl-px-2 gl-min-w-0" + > + <gl-icon name="ellipsis_h" /> + </span> + <gl-icon data-testid="ellipsis-chevron" name="chevron-right" class="gl-mx-2 gl-min-w-0" /> + </template> + + <gl-link data-testid="leaf-link" class="gl-text-gray-500 gl-min-w-0" :href="`/${path}`"> + {{ leaf }} + </gl-link> + </template> + </div> +</template> diff --git a/app/assets/javascripts/packages/shared/components/publish_method.vue b/app/assets/javascripts/packages/shared/components/publish_method.vue index d17e23c4032..8a66a33f2ab 100644 --- a/app/assets/javascripts/packages/shared/components/publish_method.vue +++ b/app/assets/javascripts/packages/shared/components/publish_method.vue @@ -49,7 +49,8 @@ export default { <clipboard-button :text="packageEntity.pipeline.sha" :title="__('Copy commit SHA')" - css-class="gl-border-0 gl-py-0 gl-px-2" + category="tertiary" + size="small" /> </template> diff --git a/app/assets/javascripts/pages/admin/instance_statistics/index.js b/app/assets/javascripts/pages/admin/instance_statistics/index.js new file mode 100644 index 00000000000..d6b0a834ce3 --- /dev/null +++ b/app/assets/javascripts/pages/admin/instance_statistics/index.js @@ -0,0 +1,3 @@ +import initInstanceStatisticsApp from '~/analytics/instance_statistics'; + +document.addEventListener('DOMContentLoaded', () => initInstanceStatisticsApp()); diff --git a/app/assets/javascripts/pages/admin/keys/index.js b/app/assets/javascripts/pages/admin/keys/index.js new file mode 100644 index 00000000000..45b83ffcd67 --- /dev/null +++ b/app/assets/javascripts/pages/admin/keys/index.js @@ -0,0 +1,5 @@ +import initConfirmModal from '~/confirm_modal'; + +document.addEventListener('DOMContentLoaded', () => { + initConfirmModal(); +}); diff --git a/app/assets/javascripts/pages/admin/users/keys/index.js b/app/assets/javascripts/pages/admin/users/keys/index.js new file mode 100644 index 00000000000..45b83ffcd67 --- /dev/null +++ b/app/assets/javascripts/pages/admin/users/keys/index.js @@ -0,0 +1,5 @@ +import initConfirmModal from '~/confirm_modal'; + +document.addEventListener('DOMContentLoaded', () => { + initConfirmModal(); +}); diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js index 3fa3a132dfa..30731f0e09c 100644 --- a/app/assets/javascripts/pages/groups/group_members/index.js +++ b/app/assets/javascripts/pages/groups/group_members/index.js @@ -4,7 +4,7 @@ import memberExpirationDate from '~/member_expiration_date'; import UsersSelect from '~/users_select'; import groupsSelect from '~/groups_select'; import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue'; -import initGroupMembersApp from '~/groups/members'; +import { initGroupMembersApp } from '~/groups/members'; function mountRemoveMemberModal() { const el = document.querySelector('.js-remove-member-modal'); @@ -26,10 +26,24 @@ document.addEventListener('DOMContentLoaded', () => { memberExpirationDate('.js-access-expiration-date-groups'); mountRemoveMemberModal(); - initGroupMembersApp(document.querySelector('.js-group-members-list')); - initGroupMembersApp(document.querySelector('.js-group-linked-list')); - initGroupMembersApp(document.querySelector('.js-group-invited-members-list')); - initGroupMembersApp(document.querySelector('.js-group-access-requests-list')); + const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions']; + + initGroupMembersApp( + document.querySelector('.js-group-members-list'), + SHARED_FIELDS.concat(['source', 'granted']), + ); + initGroupMembersApp( + document.querySelector('.js-group-linked-list'), + SHARED_FIELDS.concat('granted'), + ); + initGroupMembersApp( + document.querySelector('.js-group-invited-members-list'), + SHARED_FIELDS.concat('invited'), + ); + initGroupMembersApp( + document.querySelector('.js-group-access-requests-list'), + SHARED_FIELDS.concat('requested'), + ); new Members(); // eslint-disable-line no-new new UsersSelect(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/profiles/keys/index.js b/app/assets/javascripts/pages/profiles/keys/index.js index d3dcd21f456..4214d5bffb2 100644 --- a/app/assets/javascripts/pages/profiles/keys/index.js +++ b/app/assets/javascripts/pages/profiles/keys/index.js @@ -1,6 +1,9 @@ +import initConfirmModal from '~/confirm_modal'; import AddSshKeyValidation from '~/profile/add_ssh_key_validation'; document.addEventListener('DOMContentLoaded', () => { + initConfirmModal(); + const input = document.querySelector('.js-add-ssh-key-validation-input'); if (!input) return; diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index 46e59cd6572..0a52ac67aca 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -5,32 +5,30 @@ import initBlob from '~/pages/projects/init_blob'; import GpgBadges from '~/gpg_badges'; import '~/sourcegraph/load'; import PipelineTourSuccessModal from '~/blob/pipeline_tour_success_modal.vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import { isExperimentEnabled } from '~/lib/utils/experimentation'; const createGitlabCiYmlVisualization = (containerId = '#js-blob-toggle-graph-preview') => { const el = document.querySelector(containerId); - const { filename, blobData } = el?.dataset; + const { isCiConfigFile, blobData } = el?.dataset; - const nameRegexp = /\.gitlab-ci.yml/; - - if (!el || !nameRegexp.test(filename)) { - return; + if (el && parseBoolean(isCiConfigFile)) { + // eslint-disable-next-line no-new + new Vue({ + el, + components: { + GitlabCiYamlVisualization: () => + import('~/pipelines/components/pipeline_graph/gitlab_ci_yaml_visualization.vue'), + }, + render(createElement) { + return createElement('gitlabCiYamlVisualization', { + props: { + blobData, + }, + }); + }, + }); } - - // eslint-disable-next-line no-new - new Vue({ - el, - components: { - GitlabCiYamlVisualization: () => - import('~/pipelines/components/pipeline_graph/gitlab_ci_yaml_visualization.vue'), - }, - render(createElement) { - return createElement('gitlabCiYamlVisualization', { - props: { - blobData, - }, - }); - }, - }); }; document.addEventListener('DOMContentLoaded', () => { @@ -61,7 +59,7 @@ document.addEventListener('DOMContentLoaded', () => { const codeNavEl = document.getElementById('js-code-navigation'); - if (gon.features?.codeNavigation && codeNavEl) { + if (codeNavEl) { const { codeNavigationPath, blobPath, definitionPathPrefix } = codeNavEl.dataset; // eslint-disable-next-line promise/catch-or-return @@ -73,7 +71,7 @@ document.addEventListener('DOMContentLoaded', () => { ); } - if (gon.features?.suggestPipeline) { + if (isExperimentEnabled('suggestPipeline')) { const successPipelineEl = document.querySelector('.js-success-pipeline-modal'); if (successPipelineEl) { diff --git a/app/assets/javascripts/pages/projects/clusters/index/index.js b/app/assets/javascripts/pages/projects/clusters/index/index.js index 744be65bfbe..1124eb5d939 100644 --- a/app/assets/javascripts/pages/projects/clusters/index/index.js +++ b/app/assets/javascripts/pages/projects/clusters/index/index.js @@ -1,5 +1,5 @@ +import initClustersListApp from 'ee_else_ce/clusters_list'; import PersistentUserCallout from '~/persistent_user_callout'; -import initClustersListApp from '~/clusters_list'; document.addEventListener('DOMContentLoaded', () => { const callout = document.querySelector('.gcp-signup-offer'); diff --git a/app/assets/javascripts/pages/projects/commit/pipelines/index.js b/app/assets/javascripts/pages/projects/commit/pipelines/index.js index 1415a6f60c8..26dea17ca8a 100644 --- a/app/assets/javascripts/pages/projects/commit/pipelines/index.js +++ b/app/assets/javascripts/pages/projects/commit/pipelines/index.js @@ -1,14 +1,8 @@ -import $ from 'jquery'; -import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown'; +import { initCommitBoxInfo } from '~/projects/commit_box/info'; import initPipelines from '~/commit/pipelines/pipelines_bundle'; -import { fetchCommitMergeRequests } from '~/commit_merge_requests'; document.addEventListener('DOMContentLoaded', () => { - new MiniPipelineGraph({ - container: '.js-commit-pipeline-graph', - }).bindEvents(); - // eslint-disable-next-line no-jquery/no-load - $('.commit-info.branches').load(document.querySelector('.js-commit-box').dataset.commitPath); - fetchCommitMergeRequests(); + initCommitBoxInfo(); + initPipelines(); }); diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js index d5fb2a8be3c..32fb35f97e3 100644 --- a/app/assets/javascripts/pages/projects/commit/show/index.js +++ b/app/assets/javascripts/pages/projects/commit/show/index.js @@ -4,10 +4,8 @@ import $ from 'jquery'; import Diff from '~/diff'; import ZenMode from '~/zen_mode'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; -import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown'; import initNotes from '~/init_notes'; import initChangesDropdown from '~/init_changes_dropdown'; -import { fetchCommitMergeRequests } from '~/commit_merge_requests'; import '~/sourcegraph/load'; import { handleLocationHash } from '~/lib/utils/common_utils'; import axios from '~/lib/utils/axios_utils'; @@ -15,6 +13,7 @@ import syntaxHighlight from '~/syntax_highlight'; import flash from '~/flash'; import { __ } from '~/locale'; import loadAwardsHandler from '~/awards_handler'; +import { initCommitBoxInfo } from '~/projects/commit_box/info'; document.addEventListener('DOMContentLoaded', () => { const hasPerfBar = document.querySelector('.with-performance-bar'); @@ -22,13 +21,10 @@ document.addEventListener('DOMContentLoaded', () => { initChangesDropdown(document.querySelector('.navbar-gitlab').offsetHeight + performanceHeight); new ZenMode(); new ShortcutsNavigation(); - new MiniPipelineGraph({ - container: '.js-commit-pipeline-graph', - }).bindEvents(); + + initCommitBoxInfo(); + initNotes(); - // eslint-disable-next-line no-jquery/no-load - $('.commit-info.branches').load(document.querySelector('.js-commit-box').dataset.commitPath); - fetchCommitMergeRequests(); const filesContainer = $('.js-diffs-batch'); diff --git a/app/assets/javascripts/pages/projects/feature_flags/edit/index.js b/app/assets/javascripts/pages/projects/feature_flags/edit/index.js new file mode 100644 index 00000000000..62c85ada63b --- /dev/null +++ b/app/assets/javascripts/pages/projects/feature_flags/edit/index.js @@ -0,0 +1,3 @@ +import initEditFeatureFlags from '~/feature_flags/edit'; + +document.addEventListener('DOMContentLoaded', initEditFeatureFlags); diff --git a/app/assets/javascripts/pages/projects/feature_flags/index/index.js b/app/assets/javascripts/pages/projects/feature_flags/index/index.js new file mode 100644 index 00000000000..54e8dd73553 --- /dev/null +++ b/app/assets/javascripts/pages/projects/feature_flags/index/index.js @@ -0,0 +1,3 @@ +import initFeatureFlags from '~/feature_flags'; + +document.addEventListener('DOMContentLoaded', initFeatureFlags); diff --git a/app/assets/javascripts/pages/projects/feature_flags/new/index.js b/app/assets/javascripts/pages/projects/feature_flags/new/index.js new file mode 100644 index 00000000000..c5f29ae08a8 --- /dev/null +++ b/app/assets/javascripts/pages/projects/feature_flags/new/index.js @@ -0,0 +1,3 @@ +import initNewFeatureFlags from '~/feature_flags/new'; + +document.addEventListener('DOMContentLoaded', initNewFeatureFlags); diff --git a/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js b/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js new file mode 100644 index 00000000000..bbe84322462 --- /dev/null +++ b/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js @@ -0,0 +1,19 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import EditUserList from '~/user_lists/components/edit_user_list.vue'; +import createStore from '~/user_lists/store/edit'; + +Vue.use(Vuex); + +document.addEventListener('DOMContentLoaded', () => { + const el = document.getElementById('js-edit-user-list'); + const { userListsDocsPath } = el.dataset; + return new Vue({ + el, + store: createStore(el.dataset), + provide: { userListsDocsPath }, + render(h) { + return h(EditUserList, {}); + }, + }); +}); diff --git a/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js b/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js new file mode 100644 index 00000000000..679f0af8efc --- /dev/null +++ b/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js @@ -0,0 +1,22 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import NewUserList from '~/user_lists/components/new_user_list.vue'; +import createStore from '~/user_lists/store/new'; + +Vue.use(Vuex); + +document.addEventListener('DOMContentLoaded', () => { + const el = document.getElementById('js-new-user-list'); + const { userListsDocsPath, featureFlagsPath } = el.dataset; + return new Vue({ + el, + store: createStore(el.dataset), + provide: { + userListsDocsPath, + featureFlagsPath, + }, + render(h) { + return h(NewUserList); + }, + }); +}); diff --git a/app/assets/javascripts/pages/projects/feature_flags_user_lists/show/index.js b/app/assets/javascripts/pages/projects/feature_flags_user_lists/show/index.js new file mode 100644 index 00000000000..bccd9dce2ec --- /dev/null +++ b/app/assets/javascripts/pages/projects/feature_flags_user_lists/show/index.js @@ -0,0 +1,18 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import UserList from '~/user_lists/components/user_list.vue'; +import createStore from '~/user_lists/store/show'; + +Vue.use(Vuex); + +document.addEventListener('DOMContentLoaded', () => { + const el = document.getElementById('js-edit-user-list'); + return new Vue({ + el, + store: createStore(el.dataset), + render(h) { + const { emptyStatePath } = el.dataset; + return h(UserList, { props: { emptyStatePath } }); + }, + }); +}); diff --git a/app/assets/javascripts/pages/projects/incidents/show/index.js b/app/assets/javascripts/pages/projects/incidents/show/index.js new file mode 100644 index 00000000000..540b0dd1de8 --- /dev/null +++ b/app/assets/javascripts/pages/projects/incidents/show/index.js @@ -0,0 +1,7 @@ +import initRelatedIssues from '~/related_issues'; +import initShow from '../../issues/show'; + +document.addEventListener('DOMContentLoaded', () => { + initShow(); + initRelatedIssues(); +}); diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js index 98ae4e26257..80710f48a9c 100644 --- a/app/assets/javascripts/pages/projects/issues/show.js +++ b/app/assets/javascripts/pages/projects/issues/show.js @@ -18,7 +18,7 @@ export default function() { if (issueType === 'incident') { initIncidentApp(issuableData); - } else { + } else if (issueType === 'issue') { initIssueApp(issuableData); } diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue index 7a3923dfefd..a138a3a3425 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue @@ -1,11 +1,8 @@ <script> -/* eslint-disable vue/no-v-html */ import Vue from 'vue'; import Cookies from 'js-cookie'; import { GlIcon } from '@gitlab/ui'; import Translate from '../../../../../vue_shared/translate'; -// Full path is needed for Jest to be able to correctly mock this file -import illustrationSvg from '~/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg'; import { parseBoolean } from '~/lib/utils/common_utils'; Vue.use(Translate); @@ -20,12 +17,10 @@ export default { data() { return { docsUrl: document.getElementById('pipeline-schedules-callout').dataset.docsUrl, + imageUrl: document.getElementById('pipeline-schedules-callout').dataset.imageUrl, calloutDismissed: parseBoolean(Cookies.get(cookieKey)), }; }, - created() { - this.illustrationSvg = illustrationSvg; - }, methods: { dismissCallout() { this.calloutDismissed = true; @@ -40,7 +35,9 @@ export default { <button id="dismiss-callout-btn" class="btn btn-default close" @click="dismissCallout"> <gl-icon name="close" aria-hidden="true" /> </button> - <div class="svg-container" v-html="illustrationSvg"></div> + <div class="svg-container"> + <img :src="imageUrl" /> + </div> <div class="user-callout-copy"> <h4>{{ __('Scheduling Pipelines') }}</h4> <p> diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg deleted file mode 100644 index 26d1ff97b3e..00000000000 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg +++ /dev/null @@ -1 +0,0 @@ -<svg width="140" height="102" viewBox="0 0 140 102" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>illustration</title><defs><rect id="a" width="12.033" height="40.197" rx="3"/><rect id="b" width="12.033" height="40.197" rx="3"/></defs><g fill="none" fill-rule="evenodd"><g transform="translate(-.446)"><path d="M91.747 35.675v-6.039a2.996 2.996 0 0 0-2.999-3.005H54.635a2.997 2.997 0 0 0-2.999 3.005v6.039H40.092a3.007 3.007 0 0 0-2.996 3.005v34.187a2.995 2.995 0 0 0 2.996 3.005h11.544V79.9a2.996 2.996 0 0 0 2.999 3.005h34.113a2.997 2.997 0 0 0 2.999-3.005v-4.03h11.544a3.007 3.007 0 0 0 2.996-3.004V38.68a2.995 2.995 0 0 0-2.996-3.005H91.747z" stroke="#B5A7DD" stroke-width="2"/><rect stroke="#E5E5E5" stroke-width="2" fill="#FFF" x="21.556" y="38.69" width="98.27" height="34.167" rx="3"/><path d="M121.325 38.19c.55 0 .995.444.995 1.002 0 .554-.453 1.003-.995 1.003h-4.039a1.004 1.004 0 0 1 0-2.006h4.039zm9.044 0c.55 0 .996.444.996 1.002 0 .554-.454 1.003-.996 1.003h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0c.55 0 .996.444.996 1.002 0 .554-.453 1.003-.996 1.003h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zM121.325 71.854a1.004 1.004 0 0 1 0 2.006h-4.039a1.004 1.004 0 0 1 0-2.006h4.039zm9.044 0a1.004 1.004 0 0 1 0 2.006h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0a1.004 1.004 0 0 1 0 2.006h-4.038a1.004 1.004 0 0 1 0-2.006h4.038z" fill="#E5E5E5"/><g transform="translate(110.3 35.675)"><use fill="#FFF" xlink:href="#a"/><rect stroke="#FDE5D8" stroke-width="2" x="1" y="1" width="10.033" height="38.197" rx="3"/><ellipse fill="#FC8A51" cx="6.017" cy="9.547" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="20.099" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="30.65" rx="1.504" ry="1.507"/></g><path d="M6.008 38.19c.55 0 .996.444.996 1.002 0 .554-.454 1.003-.996 1.003H1.97a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0c.55 0 .996.444.996 1.002 0 .554-.453 1.003-.996 1.003h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.045 0c.55 0 .995.444.995 1.002 0 .554-.453 1.003-.995 1.003h-4.039a1.004 1.004 0 0 1 0-2.006h4.039zM6.008 71.854a1.004 1.004 0 0 1 0 2.006H1.97a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0a1.004 1.004 0 0 1 0 2.006h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.045 0a1.004 1.004 0 0 1 0 2.006h-4.039a1.004 1.004 0 0 1 0-2.006h4.039z" fill="#E5E5E5"/><g transform="translate(19.05 35.675)"><use fill="#FFF" xlink:href="#b"/><rect stroke="#FDE5D8" stroke-width="2" x="1" y="1" width="10.033" height="38.197" rx="3"/><ellipse fill="#FC8A51" cx="6.017" cy="10.049" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="20.601" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="31.153" rx="1.504" ry="1.507"/></g><g transform="translate(47.096)"><g transform="translate(7.05)"><ellipse fill="#FC8A51" cx="17.548" cy="5.025" rx="4.512" ry="4.522"/><rect stroke="#B5A7DD" stroke-width="2" fill="#FFF" x="13.036" y="4.02" width="9.025" height="20.099" rx="1.5"/><rect stroke="#FDE5D8" stroke-width="2" fill="#FFF" y="4.02" width="35.096" height="4.02" rx="2.01"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" x="4.512" y="18.089" width="26.072" height="17.084" rx="1.5"/></g><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" transform="rotate(-45 43.117 35.117)" x="38.168" y="31.416" width="9.899" height="7.403" rx="3.702"/><ellipse stroke="#6B4FBB" stroke-width="2" fill="#FFF" cx="25" cy="55" rx="25" ry="25"/><ellipse stroke="#6B4FBB" stroke-width="2" fill="#FFF" cx="25" cy="55" rx="21" ry="21"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" x="43.05" y="53.281" width="2.95" height="1.538" rx=".769"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" x="4.305" y="53.281" width="2.95" height="1.538" rx=".769"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" transform="rotate(90 25.153 74.422)" x="23.677" y="73.653" width="2.95" height="1.538" rx=".769"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" transform="rotate(90 25.153 35.51)" x="23.844" y="34.742" width="2.616" height="1.538" rx=".769"/><path d="M13.362 42.502c-.124-.543.198-.854.74-.69l2.321.704c.533.161.643.592.235.972l-.22.206 7.06 7.572a1.002 1.002 0 1 1-1.467 1.368l-7.06-7.573-.118.11c-.402.375-.826.248-.952-.304l-.54-2.365zM21.606 67.576c-.408.38-.84.255-.968-.295l-.551-2.363c-.127-.542.191-.852.725-.69l.288.089 3.027-9.901a1.002 1.002 0 1 1 1.918.586l-3.027 9.901.154.047c.525.16.627.592.213.977l-1.779 1.65z" fill="#FC8A51"/><ellipse stroke="#6B4FBB" stroke-width="2" fill="#FFF" cx="25.099" cy="54.768" rx="2.507" ry="2.512"/></g></g><path d="M52.697 96.966a1.004 1.004 0 0 1 2.006 0v4.038a1.004 1.004 0 0 1-2.006 0v-4.038zm0-9.044a1.004 1.004 0 0 1 2.006 0v4.038a1.004 1.004 0 0 1-2.006 0v-4.038zM86.29 96.966c0-.55.444-.996 1.002-.996.554 0 1.003.454 1.003.996v4.038a1.004 1.004 0 0 1-2.006 0v-4.038zm0-9.044c0-.55.444-.996 1.002-.996.554 0 1.003.453 1.003.996v4.038a1.004 1.004 0 0 1-2.006 0v-4.038z" fill="#E5E5E5"/></g></svg>
\ No newline at end of file diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js index ab2a7c099c4..40816420eef 100644 --- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js @@ -4,6 +4,7 @@ import AjaxVariableList from '~/ci_variable_list/ajax_variable_list'; import registrySettingsApp from '~/registry/settings/registry_settings_bundle'; import initVariableList from '~/ci_variable_list'; import initDeployFreeze from '~/deploy_freeze'; +import initSettingsPipelinesTriggers from '~/ci_settings_pipeline_triggers'; document.addEventListener('DOMContentLoaded', () => { // Initialize expandable settings panels @@ -42,4 +43,6 @@ document.addEventListener('DOMContentLoaded', () => { registrySettingsApp(); initDeployFreeze(); + + initSettingsPipelinesTriggers(); }); diff --git a/app/assets/javascripts/pages/projects/tags/index/index.js b/app/assets/javascripts/pages/projects/tags/index/index.js new file mode 100644 index 00000000000..ec56fa3e075 --- /dev/null +++ b/app/assets/javascripts/pages/projects/tags/index/index.js @@ -0,0 +1,12 @@ +import { initRemoveTag } from '../remove_tag'; + +document.addEventListener('DOMContentLoaded', () => { + initRemoveTag({ + onDelete: path => { + document + .querySelector(`[data-path="${path}"]`) + .closest('.js-tag-list') + .remove(); + }, + }); +}); diff --git a/app/assets/javascripts/pages/projects/tags/remove_tag.js b/app/assets/javascripts/pages/projects/tags/remove_tag.js new file mode 100644 index 00000000000..7e83dbe0565 --- /dev/null +++ b/app/assets/javascripts/pages/projects/tags/remove_tag.js @@ -0,0 +1,16 @@ +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import initConfirmModal from '~/confirm_modal'; + +export const initRemoveTag = ({ onDelete = () => {} }) => { + return initConfirmModal({ + handleSubmit: (path = '') => + axios + .delete(path) + .then(() => onDelete(path)) + .catch(({ response: { data } }) => { + const { message } = data; + createFlash({ message }); + }), + }); +}; diff --git a/app/assets/javascripts/pages/projects/tags/show/index.js b/app/assets/javascripts/pages/projects/tags/show/index.js new file mode 100644 index 00000000000..651cc05ca4f --- /dev/null +++ b/app/assets/javascripts/pages/projects/tags/show/index.js @@ -0,0 +1,10 @@ +import { redirectTo, getBaseURL, stripFinalUrlSegment } from '~/lib/utils/url_utility'; +import { initRemoveTag } from '../remove_tag'; + +document.addEventListener('DOMContentLoaded', () => { + initRemoveTag({ + onDelete: (path = '') => { + redirectTo(stripFinalUrlSegment([getBaseURL(), path].join(''))); + }, + }); +}); diff --git a/app/assets/javascripts/pages/search/show/index.js b/app/assets/javascripts/pages/search/show/index.js index 92d01343bd5..8dcc5aee00e 100644 --- a/app/assets/javascripts/pages/search/show/index.js +++ b/app/assets/javascripts/pages/search/show/index.js @@ -1,7 +1,10 @@ import Search from './search'; import initStateFilter from '~/search/state_filter'; +import initConfidentialFilter from '~/search/confidential_filter'; document.addEventListener('DOMContentLoaded', () => { initStateFilter(); + initConfidentialFilter(); + return new Search(); }); diff --git a/app/assets/javascripts/performance_bar/performance_bar_log.js b/app/assets/javascripts/performance_bar/performance_bar_log.js index 638c544f2e1..55b4d626e56 100644 --- a/app/assets/javascripts/performance_bar/performance_bar_log.js +++ b/app/assets/javascripts/performance_bar/performance_bar_log.js @@ -1,5 +1,6 @@ /* eslint-disable no-console */ import { getCLS, getFID, getLCP } from 'web-vitals'; +import { PERFORMANCE_TYPE_MARK, PERFORMANCE_TYPE_MEASURE } from '~/performance_constants'; const initVitalsLog = () => { const reportVital = data => { @@ -16,6 +17,29 @@ const initVitalsLog = () => { getLCP(reportVital); }; +const logUserTimingMetrics = () => { + const metricsProcessor = list => { + const entries = list.getEntries(); + entries.forEach(entry => { + const { name, entryType, startTime, duration } = entry; + const typeMapper = { + [PERFORMANCE_TYPE_MARK]: String.fromCodePoint(0x1f3af), + [PERFORMANCE_TYPE_MEASURE]: String.fromCodePoint(0x1f4d0), + }; + console.group(`${typeMapper[entryType]} ${name}`); + if (entryType === PERFORMANCE_TYPE_MARK) { + console.log(`Start time: ${startTime}`); + } else if (entryType === PERFORMANCE_TYPE_MEASURE) { + console.log(`Duration: ${duration}`); + } + console.log(entry); + console.groupEnd(); + }); + }; + const observer = new PerformanceObserver(metricsProcessor); + observer.observe({ entryTypes: [PERFORMANCE_TYPE_MEASURE, PERFORMANCE_TYPE_MARK] }); +}; + const initPerformanceBarLog = () => { console.log( `%c ${String.fromCodePoint(0x1f98a)} GitLab performance bar`, @@ -23,6 +47,7 @@ const initPerformanceBarLog = () => { ); initVitalsLog(); + logUserTimingMetrics(); }; export default initPerformanceBarLog; diff --git a/app/assets/javascripts/performance_constants.js b/app/assets/javascripts/performance_constants.js index 1a53b925aa4..66a8880281c 100644 --- a/app/assets/javascripts/performance_constants.js +++ b/app/assets/javascripts/performance_constants.js @@ -1,8 +1,11 @@ +export const PERFORMANCE_TYPE_MARK = 'mark'; +export const PERFORMANCE_TYPE_MEASURE = 'measure'; + // // SNIPPET namespace // -// marks +// Marks export const SNIPPET_MARK_VIEW_APP_START = 'snippet-view-app-start'; export const SNIPPET_MARK_EDIT_APP_START = 'snippet-edit-app-start'; export const SNIPPET_MARK_BLOBS_CONTENT = 'snippet-blobs-content-finished'; @@ -10,3 +13,20 @@ export const SNIPPET_MARK_BLOBS_CONTENT = 'snippet-blobs-content-finished'; // Measures export const SNIPPET_MEASURE_BLOBS_CONTENT = 'snippet-blobs-content'; export const SNIPPET_MEASURE_BLOBS_CONTENT_WITHIN_APP = 'snippet-blobs-content-within-app'; + +// +// WebIDE namespace +// + +// Marks +export const WEBIDE_MARK_APP_START = 'webide-app-start'; +export const WEBIDE_MARK_TREE_START = 'webide-tree-start'; +export const WEBIDE_MARK_TREE_FINISH = 'webide-tree-finished'; +export const WEBIDE_MARK_FILE_START = 'webide-file-start'; +export const WEBIDE_MARK_FILE_CLICKED = 'webide-file-clicked'; +export const WEBIDE_MARK_FILE_FINISH = 'webide-file-finished'; + +// Measures +export const WEBIDE_MEASURE_TREE_FROM_REQUEST = 'webide-tree-loading-from-request'; +export const WEBIDE_MEASURE_FILE_FROM_REQUEST = 'webide-file-loading-from-request'; +export const WEBIDE_MEASURE_FILE_AFTER_INTERACTION = 'webide-file-loading-after-interaction'; diff --git a/app/assets/javascripts/performance_utils.js b/app/assets/javascripts/performance_utils.js new file mode 100644 index 00000000000..48a2958f5cc --- /dev/null +++ b/app/assets/javascripts/performance_utils.js @@ -0,0 +1,12 @@ +export const performanceMarkAndMeasure = ({ mark, measures = [] } = {}) => { + window.requestAnimationFrame(() => { + if (mark && !performance.getEntriesByName(mark).length) { + performance.mark(mark); + } + measures.forEach(measure => { + window.requestAnimationFrame(() => + performance.measure(measure.name, measure.start, measure.end), + ); + }); + }); +}; diff --git a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue index be8ce832d20..1cec08b93bd 100644 --- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue +++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue @@ -1,8 +1,8 @@ <script> -import Vue from 'vue'; import { uniqueId } from 'lodash'; import { GlAlert, + GlIcon, GlButton, GlForm, GlFormGroup, @@ -27,12 +27,13 @@ export default { variablesDescription: s__( 'Pipeline|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used by default.', ), - formElementClasses: 'gl-mr-3 gl-mb-3 table-section section-15', + formElementClasses: 'gl-mr-3 gl-mb-3 gl-flex-basis-quarter gl-flex-shrink-0 gl-flex-grow-0', errorTitle: __('The form contains the following error:'), warningTitle: __('The form contains the following warning:'), maxWarningsSummary: __('%{total} warnings found: showing first %{warningsDisplayed}'), components: { GlAlert, + GlIcon, GlButton, GlForm, GlFormGroup, @@ -85,7 +86,7 @@ export default { return { searchTerm: '', refValue: this.refParam, - variables: {}, + variables: [], error: null, warnings: [], totalWarnings: 0, @@ -97,9 +98,6 @@ export default { const lowerCasedSearchTerm = this.searchTerm.toLowerCase(); return this.refs.filter(ref => ref.toLowerCase().includes(lowerCasedSearchTerm)); }, - variablesLength() { - return Object.keys(this.variables).length; - }, overMaxWarningsLimit() { return this.totalWarnings > this.maxWarnings; }, @@ -114,6 +112,8 @@ export default { }, }, created() { + this.addEmptyVariable(); + if (this.variableParams) { this.setVariableParams(VARIABLE_TYPE, this.variableParams); } @@ -121,24 +121,26 @@ export default { if (this.fileParams) { this.setVariableParams(FILE_TYPE, this.fileParams); } - - this.addEmptyVariable(); }, methods: { - addEmptyVariable() { - this.variables[uniqueId('var')] = { - variable_type: VARIABLE_TYPE, - key: '', - value: '', - }; - }, - setVariableParams(type, paramsObj) { - Object.entries(paramsObj).forEach(([key, value]) => { - this.variables[uniqueId('var')] = { + setVariable(type, key, value) { + const variable = this.variables.find(v => v.key === key); + if (variable) { + variable.type = type; + variable.value = value; + } else { + // insert before the empty variable + this.variables.splice(this.variables.length - 1, 0, { + uniqueId: uniqueId('var'), key, value, variable_type: type, - }; + }); + } + }, + setVariableParams(type, paramsObj) { + Object.entries(paramsObj).forEach(([key, value]) => { + this.setVariable(type, key, value); }); }, setRefSelected(ref) { @@ -147,29 +149,34 @@ export default { isSelected(ref) { return ref === this.refValue; }, - insertNewVariable() { - Vue.set(this.variables, uniqueId('var'), { + addEmptyVariable() { + this.variables.push({ + uniqueId: uniqueId('var'), variable_type: VARIABLE_TYPE, key: '', value: '', }); }, - removeVariable(key) { - Vue.delete(this.variables, key); + removeVariable(index) { + this.variables.splice(index, 1); }, canRemove(index) { - return index < this.variablesLength - 1; + return index < this.variables.length - 1; }, createPipeline() { - const filteredVariables = Object.values(this.variables).filter( - ({ key, value }) => key !== '' && value !== '', - ); + const filteredVariables = this.variables + .filter(({ key, value }) => key !== '' && value !== '') + .map(({ variable_type, key, value }) => ({ + variable_type, + key, + secret_value: value, + })); return axios .post(this.pipelinesPath, { ref: this.refValue, - variables: filteredVariables, + variables_attributes: filteredVariables, }) .then(({ data }) => { redirectTo(`${this.pipelinesPath}/${data.id}`); @@ -253,35 +260,47 @@ export default { <gl-form-group :label="s__('Pipeline|Variables')"> <div - v-for="(value, key, index) in variables" - :key="key" - class="gl-display-flex gl-align-items-center gl-mb-4 gl-pb-2 gl-border-b-solid gl-border-gray-200 gl-border-b-1 gl-flex-direction-column gl-md-flex-direction-row" + v-for="(variable, index) in variables" + :key="variable.uniqueId" + class="gl-display-flex gl-align-items-stretch gl-align-items-center gl-mb-4 gl-ml-n3 gl-pb-2 gl-border-b-solid gl-border-gray-200 gl-border-b-1 gl-flex-direction-column gl-md-flex-direction-row" data-testid="ci-variable-row" > <gl-form-select - v-model="variables[key].variable_type" + v-model="variable.variable_type" :class="$options.formElementClasses" :options="$options.typeOptions" /> <gl-form-input - v-model="variables[key].key" + v-model="variable.key" :placeholder="s__('CiVariables|Input variable key')" :class="$options.formElementClasses" data-testid="pipeline-form-ci-variable-key" - @change.once="insertNewVariable()" + @change.once="addEmptyVariable()" /> <gl-form-input - v-model="variables[key].value" + v-model="variable.value" :placeholder="s__('CiVariables|Input variable value')" - class="gl-mr-5 gl-mb-3 table-section section-15" - /> - <gl-button - v-if="canRemove(index)" - icon="issue-close" class="gl-mb-3" - data-testid="remove-ci-variable-row" - @click="removeVariable(key)" /> + + <template v-if="variables.length > 1"> + <gl-button + v-if="canRemove(index)" + class="gl-md-ml-3 gl-mb-3" + data-testid="remove-ci-variable-row" + variant="danger" + category="secondary" + @click="removeVariable(index)" + > + <gl-icon class="gl-mr-0! gl-display-none gl-display-md-block" name="clear" /> + <span class="gl-display-md-none">{{ s__('CiVariables|Remove variable') }}</span> + </gl-button> + <gl-button + v-else + class="gl-md-ml-3 gl-mb-3 gl-display-none gl-display-md-block gl-visibility-hidden" + icon="clear" + /> + </template> </div> <template #description @@ -295,9 +314,13 @@ export default { <div class="gl-border-t-solid gl-border-gray-100 gl-border-t-1 gl-p-5 gl-bg-gray-10 gl-display-flex gl-justify-content-space-between" > - <gl-button type="submit" category="primary" variant="success">{{ - s__('Pipeline|Run Pipeline') - }}</gl-button> + <gl-button + type="submit" + category="primary" + variant="success" + data-qa-selector="run_pipeline_button" + >{{ s__('Pipeline|Run Pipeline') }}</gl-button + > <gl-button :href="pipelinesPath">{{ __('Cancel') }}</gl-button> </div> </gl-form> diff --git a/app/assets/javascripts/pipelines/components/dag/constants.js b/app/assets/javascripts/pipelines/components/dag/constants.js index b6a98fdc488..cd89055737f 100644 --- a/app/assets/javascripts/pipelines/components/dag/constants.js +++ b/app/assets/javascripts/pipelines/components/dag/constants.js @@ -1,9 +1,3 @@ -/* Error constants */ -export const PARSE_FAILURE = 'parse_failure'; -export const LOAD_FAILURE = 'load_failure'; -export const UNSUPPORTED_DATA = 'unsupported_data'; -export const DEFAULT = 'default'; - /* Interaction handles */ export const IS_HIGHLIGHTED = 'dag-highlighted'; export const LINK_SELECTOR = 'dag-link'; diff --git a/app/assets/javascripts/pipelines/components/dag/dag.vue b/app/assets/javascripts/pipelines/components/dag/dag.vue index 8487da3d621..ab736061a2e 100644 --- a/app/assets/javascripts/pipelines/components/dag/dag.vue +++ b/app/assets/javascripts/pipelines/components/dag/dag.vue @@ -6,16 +6,9 @@ import { fetchPolicies } from '~/lib/graphql'; import getDagVisData from '../../graphql/queries/get_dag_vis_data.query.graphql'; import DagGraph from './dag_graph.vue'; import DagAnnotations from './dag_annotations.vue'; -import { - DEFAULT, - PARSE_FAILURE, - LOAD_FAILURE, - UNSUPPORTED_DATA, - ADD_NOTE, - REMOVE_NOTE, - REPLACE_NOTES, -} from './constants'; +import { ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from './constants'; import { parseData } from './parsing_utils'; +import { DEFAULT, PARSE_FAILURE, LOAD_FAILURE, UNSUPPORTED_DATA } from '../../constants'; export default { // eslint-disable-next-line @gitlab/require-i18n-strings diff --git a/app/assets/javascripts/pipelines/components/dag/dag_graph.vue b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue index d12baa9617e..34ff89a5e6f 100644 --- a/app/assets/javascripts/pipelines/components/dag/dag_graph.vue +++ b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue @@ -1,14 +1,7 @@ <script> import * as d3 from 'd3'; import { uniqueId } from 'lodash'; -import { - LINK_SELECTOR, - NODE_SELECTOR, - PARSE_FAILURE, - ADD_NOTE, - REMOVE_NOTE, - REPLACE_NOTES, -} from './constants'; +import { LINK_SELECTOR, NODE_SELECTOR, ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from './constants'; import { currentIsLive, getLiveLinksAsDict, @@ -19,6 +12,7 @@ import { } from './interactions'; import { getMaxNodes, removeOrphanNodes } from './parsing_utils'; import { calculateClip, createLinkPath, createSankey, labelPosition } from './drawing_utils'; +import { PARSE_FAILURE } from '../../constants'; export default { viewOptions: { diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue index efa11580c41..a580ee11627 100644 --- a/app/assets/javascripts/pipelines/components/graph/action_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue @@ -88,7 +88,7 @@ export default { :class="cssClass" :disabled="isDisabled" class="js-ci-action ci-action-icon-container ci-action-icon-wrapper gl-display-flex gl-align-items-center gl-justify-content-center" - @click="onClickAction" + @click.stop="onClickAction" > <gl-loading-icon v-if="isLoading" class="js-action-icon-loading" /> <gl-icon v-else :name="actionIcon" class="gl-mr-0!" /> diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 924cdeebba1..0f5a8cb8fbf 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -1,7 +1,7 @@ <script> +import { escape, capitalize } from 'lodash'; import { GlLoadingIcon } from '@gitlab/ui'; import StageColumnComponent from './stage_column_component.vue'; -import GraphMixin from '../../mixins/graph_component_mixin'; import GraphWidthMixin from '../../mixins/graph_width_mixin'; import LinkedPipelinesColumn from './linked_pipelines_column.vue'; import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin'; @@ -13,7 +13,7 @@ export default { GlLoadingIcon, LinkedPipelinesColumn, }, - mixins: [GraphMixin, GraphWidthMixin, GraphBundleMixin], + mixins: [GraphWidthMixin, GraphBundleMixin], props: { isLoading: { type: Boolean, @@ -51,6 +51,9 @@ export default { }; }, computed: { + graph() { + return this.pipeline.details?.stages; + }, hasTriggeredBy() { return ( this.type !== this.$options.downstream && @@ -92,6 +95,39 @@ export default { }, }, methods: { + capitalizeStageName(name) { + const escapedName = escape(name); + return capitalize(escapedName); + }, + isFirstColumn(index) { + return index === 0; + }, + stageConnectorClass(index, stage) { + let className; + + // If it's the first stage column and only has one job + if (this.isFirstColumn(index) && stage.groups.length === 1) { + className = 'no-margin'; + } else if (index > 0) { + // If it is not the first column + className = 'left-margin'; + } + + return className; + }, + refreshPipelineGraph() { + this.$emit('refreshPipelineGraph'); + }, + /** + * CSS class is applied: + * - if pipeline graph contains only one stage column component + * + * @param {number} index + * @returns {boolean} + */ + shouldAddRightMargin(index) { + return !(index === this.graph.length - 1); + }, handleClickedDownstream(pipeline, clickedIndex, downstreamNode) { /** * Calculates the margin top of the clicked downstream pipeline by diff --git a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue index 11fb2b18e9d..133965f0aca 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue @@ -1,5 +1,4 @@ <script> -import $ from 'jquery'; import { GlTooltipDirective } from '@gitlab/ui'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import JobItem from './job_item.vue'; @@ -30,27 +29,7 @@ export default { return `${name} - ${status.label}`; }, }, - mounted() { - this.stopDropdownClickPropagation(); - }, methods: { - /** - * When the user right clicks or cmd/ctrl + click in the group name or the action icon - * the dropdown should not be closed so we stop propagation - * of the click event inside the dropdown. - * - * Since this component is rendered multiple times per page we need to guarantee we only - * target the click event of this component. - */ - stopDropdownClickPropagation() { - $( - '.js-grouped-pipeline-dropdown button, .js-grouped-pipeline-dropdown a.mini-pipeline-graph-dropdown-item', - this.$el, - ).on('click', e => { - e.stopPropagation(); - }); - }, - pipelineActionRequestComplete() { this.$emit('pipelineActionRequestComplete'); }, diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index 0fe0b671273..9f7fe85fb0d 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -135,6 +135,7 @@ export default { :class="jobClasses" class="js-pipeline-graph-job-link qa-job-link menu-item" data-testid="job-with-link" + @click.stop > <job-name-component :name="job.name" :status="job.status" /> </gl-link> 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 1453c349f44..a75ec585b95 100644 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -71,7 +71,7 @@ export default { :action-icon="action.icon" :tooltip-text="action.title" :link="action.path" - class="js-stage-action stage-action position-absolute position-top-0 rounded" + class="js-stage-action stage-action rounded" @pipelineActionRequestComplete="pipelineActionRequestComplete" /> </div> diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index c7b72be36ad..b26f28fa6af 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -1,8 +1,11 @@ <script> -import { GlLoadingIcon, GlModal, GlModalDirective, GlButton } from '@gitlab/ui'; -import ciHeader from '~/vue_shared/components/header_ci_component.vue'; -import eventHub from '../event_hub'; +import { GlAlert, GlButton, GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui'; import { __ } from '~/locale'; +import axios from '~/lib/utils/axios_utils'; +import ciHeader from '~/vue_shared/components/header_ci_component.vue'; +import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility'; +import getPipelineQuery from '../graphql/queries/get_pipeline_header_data.query.graphql'; +import { LOAD_FAILURE, POST_FAILURE, DELETE_FAILURE, DEFAULT } from '../constants'; const DELETE_MODAL_ID = 'pipeline-delete-modal'; @@ -10,57 +13,143 @@ export default { name: 'PipelineHeaderSection', components: { ciHeader, + GlAlert, + GlButton, GlLoadingIcon, GlModal, - GlButton, }, directives: { GlModal: GlModalDirective, }, - props: { - pipeline: { - type: Object, - required: true, + errorTexts: { + [LOAD_FAILURE]: __('We are currently unable to fetch data for the pipeline header.'), + [POST_FAILURE]: __('An error occurred while making the request.'), + [DELETE_FAILURE]: __('An error occurred while deleting the pipeline.'), + [DEFAULT]: __('An unknown error occurred.'), + }, + inject: { + // Receive `cancel`, `delete`, `fullProject` and `retry` + paths: { + default: {}, + }, + pipelineId: { + default: '', }, - isLoading: { - type: Boolean, - required: true, + pipelineIid: { + default: '', + }, + }, + apollo: { + pipeline: { + query: getPipelineQuery, + variables() { + return { + fullPath: this.paths.fullProject, + iid: this.pipelineIid, + }; + }, + update: data => data.project.pipeline, + error() { + this.reportFailure(LOAD_FAILURE); + }, + pollInterval: 10000, + watchLoading(isLoading) { + if (!isLoading) { + // To ensure apollo has updated the cache, + // we only remove the loading state in sync with GraphQL + this.isCanceling = false; + this.isRetrying = false; + } + }, }, }, data() { return { + pipeline: null, + failureType: null, isCanceling: false, isRetrying: false, isDeleting: false, }; }, - computed: { - status() { - return this.pipeline.details && this.pipeline.details.status; - }, - shouldRenderContent() { - return !this.isLoading && Object.keys(this.pipeline).length; - }, deleteModalConfirmationText() { return __( 'Are you sure you want to delete this pipeline? Doing so will expire all pipeline caches and delete all related objects, such as builds, logs, artifacts, and triggers. This action cannot be undone.', ); }, + hasError() { + return this.failureType; + }, + hasPipelineData() { + return Boolean(this.pipeline); + }, + isLoadingInitialQuery() { + return this.$apollo.queries.pipeline.loading && !this.hasPipelineData; + }, + status() { + return this.pipeline?.status; + }, + shouldRenderContent() { + return !this.isLoadingInitialQuery && this.hasPipelineData; + }, + failure() { + switch (this.failureType) { + case LOAD_FAILURE: + return { + text: this.$options.errorTexts[LOAD_FAILURE], + variant: 'danger', + }; + case POST_FAILURE: + return { + text: this.$options.errorTexts[POST_FAILURE], + variant: 'danger', + }; + case DELETE_FAILURE: + return { + text: this.$options.errorTexts[DELETE_FAILURE], + variant: 'danger', + }; + default: + return { + text: this.$options.errorTexts[DEFAULT], + variant: 'danger', + }; + } + }, }, - methods: { - cancelPipeline() { + reportFailure(errorType) { + this.failureType = errorType; + }, + async postAction(path) { + try { + await axios.post(path); + this.$apollo.queries.pipeline.refetch(); + } catch { + this.reportFailure(POST_FAILURE); + } + }, + async cancelPipeline() { this.isCanceling = true; - eventHub.$emit('headerPostAction', this.pipeline.cancel_path); + this.postAction(this.paths.cancel); }, - retryPipeline() { + async retryPipeline() { this.isRetrying = true; - eventHub.$emit('headerPostAction', this.pipeline.retry_path); + this.postAction(this.paths.retry); }, - deletePipeline() { + async deletePipeline() { this.isDeleting = true; - eventHub.$emit('headerDeleteAction', this.pipeline.delete_path); + this.$apollo.queries.pipeline.stopPolling(); + + try { + const { request } = await axios.delete(this.paths.delete); + redirectTo(setUrlFragment(request.responseURL, 'delete_success')); + } catch { + this.$apollo.queries.pipeline.startPolling(); + this.reportFailure(DELETE_FAILURE); + this.isDeleting = false; + } }, }, DELETE_MODAL_ID, @@ -68,54 +157,53 @@ export default { </script> <template> <div class="pipeline-header-container"> + <gl-alert v-if="hasError" :variant="failure.variant">{{ failure.text }}</gl-alert> <ci-header v-if="shouldRenderContent" - :status="status" - :item-id="pipeline.id" - :time="pipeline.created_at" + :status="pipeline.detailedStatus" + :time="pipeline.createdAt" :user="pipeline.user" + :item-id="Number(pipelineId)" item-name="Pipeline" > <gl-button - v-if="pipeline.retry_path" + v-if="pipeline.retryable" :loading="isRetrying" :disabled="isRetrying" - data-testid="retryButton" category="secondary" variant="info" + data-testid="retryPipeline" + class="js-retry-button" @click="retryPipeline()" > {{ __('Retry') }} </gl-button> <gl-button - v-if="pipeline.cancel_path" + v-if="pipeline.cancelable" :loading="isCanceling" :disabled="isCanceling" - data-testid="cancelPipeline" - class="gl-ml-3" - category="primary" variant="danger" + data-testid="cancelPipeline" @click="cancelPipeline()" > {{ __('Cancel running') }} </gl-button> <gl-button - v-if="pipeline.delete_path" + v-if="pipeline.userPermissions.destroyPipeline" v-gl-modal="$options.DELETE_MODAL_ID" :loading="isDeleting" :disabled="isDeleting" - data-testid="deletePipeline" class="gl-ml-3" - category="secondary" variant="danger" + category="secondary" + data-testid="deletePipeline" > {{ __('Delete') }} </gl-button> </ci-header> - - <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3 gl-mb-3" /> + <gl-loading-icon v-if="isLoadingInitialQuery" 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/legacy_header_component.vue b/app/assets/javascripts/pipelines/components/legacy_header_component.vue new file mode 100644 index 00000000000..c7b72be36ad --- /dev/null +++ b/app/assets/javascripts/pipelines/components/legacy_header_component.vue @@ -0,0 +1,132 @@ +<script> +import { GlLoadingIcon, GlModal, GlModalDirective, GlButton } from '@gitlab/ui'; +import ciHeader from '~/vue_shared/components/header_ci_component.vue'; +import eventHub from '../event_hub'; +import { __ } from '~/locale'; + +const DELETE_MODAL_ID = 'pipeline-delete-modal'; + +export default { + name: 'PipelineHeaderSection', + components: { + ciHeader, + GlLoadingIcon, + GlModal, + GlButton, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + pipeline: { + type: Object, + required: true, + }, + isLoading: { + type: Boolean, + required: true, + }, + }, + data() { + return { + isCanceling: false, + isRetrying: false, + isDeleting: false, + }; + }, + + computed: { + status() { + return this.pipeline.details && this.pipeline.details.status; + }, + shouldRenderContent() { + return !this.isLoading && Object.keys(this.pipeline).length; + }, + deleteModalConfirmationText() { + return __( + 'Are you sure you want to delete this pipeline? Doing so will expire all pipeline caches and delete all related objects, such as builds, logs, artifacts, and triggers. This action cannot be undone.', + ); + }, + }, + + methods: { + cancelPipeline() { + this.isCanceling = true; + eventHub.$emit('headerPostAction', this.pipeline.cancel_path); + }, + retryPipeline() { + this.isRetrying = true; + eventHub.$emit('headerPostAction', this.pipeline.retry_path); + }, + deletePipeline() { + this.isDeleting = true; + eventHub.$emit('headerDeleteAction', this.pipeline.delete_path); + }, + }, + DELETE_MODAL_ID, +}; +</script> +<template> + <div class="pipeline-header-container"> + <ci-header + v-if="shouldRenderContent" + :status="status" + :item-id="pipeline.id" + :time="pipeline.created_at" + :user="pipeline.user" + item-name="Pipeline" + > + <gl-button + v-if="pipeline.retry_path" + :loading="isRetrying" + :disabled="isRetrying" + data-testid="retryButton" + category="secondary" + variant="info" + @click="retryPipeline()" + > + {{ __('Retry') }} + </gl-button> + + <gl-button + v-if="pipeline.cancel_path" + :loading="isCanceling" + :disabled="isCanceling" + data-testid="cancelPipeline" + class="gl-ml-3" + category="primary" + variant="danger" + @click="cancelPipeline()" + > + {{ __('Cancel running') }} + </gl-button> + + <gl-button + v-if="pipeline.delete_path" + v-gl-modal="$options.DELETE_MODAL_ID" + :loading="isDeleting" + :disabled="isDeleting" + data-testid="deletePipeline" + class="gl-ml-3" + category="secondary" + variant="danger" + > + {{ __('Delete') }} + </gl-button> + </ci-header> + + <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3 gl-mb-3" /> + + <gl-modal + :modal-id="$options.DELETE_MODAL_ID" + :title="__('Delete pipeline')" + :ok-title="__('Delete pipeline')" + ok-variant="danger" + @ok="deletePipeline()" + > + <p> + {{ deleteModalConfirmationText }} + </p> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue index fe8e3bd2b78..c5f30c8aef0 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue @@ -48,6 +48,7 @@ export default { variant="info" category="primary" class="js-get-started-pipelines" + data-testid="get-started-pipelines" > {{ s__('Pipelines|Get started with Pipelines') }} </gl-button> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue b/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue index d7b6e033bd1..cf0849751df 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue @@ -46,6 +46,8 @@ export default { variant="success" category="primary" class="js-run-pipeline" + data-testid="run-pipeline-button" + data-qa-selector="run_pipeline_button" > {{ s__('Pipelines|Run Pipeline') }} </gl-button> @@ -54,12 +56,13 @@ export default { v-if="resetCachePath" :loading="isResetCacheButtonLoading" class="js-clear-cache" + data-testid="clear-cache-button" @click="onClickResetCache" > {{ s__('Pipelines|Clear Runner Caches') }} </gl-button> - <gl-button v-if="ciLintPath" :href="ciLintPath" class="js-ci-lint"> + <gl-button v-if="ciLintPath" :href="ciLintPath" class="js-ci-lint" data-testid="ci-lint-button"> {{ s__('Pipelines|CI Lint') }} </gl-button> </div> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue index f0614298bd3..e0f65643d37 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue @@ -98,7 +98,7 @@ export default { placement="top" > <template #title> - <div class="autodevops-title"> + <div class="gl-font-weight-normal gl-line-height-normal"> <gl-sprintf :message=" __( @@ -112,12 +112,7 @@ export default { </gl-sprintf> </div> </template> - <gl-link - class="autodevops-link" - :href="autoDevopsHelpPath" - target="_blank" - rel="noopener noreferrer nofollow" - > + <gl-link :href="autoDevopsHelpPath" target="_blank" rel="noopener noreferrer nofollow"> {{ __('Learn more about Auto DevOps') }} </gl-link> </gl-popover> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue index b8112149778..6c60594efca 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue @@ -91,6 +91,10 @@ export default { <div class="table-section section-15 js-pipeline-stages pipeline-stages" role="rowheader"> {{ s__('Pipeline|Stages') }} </div> + <div class="table-section section-15" role="rowheader"></div> + <div class="table-section section-20" role="rowheader"> + <slot name="table-header-actions"></slot> + </div> </div> <pipelines-table-row-component v-for="model in pipelines" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue index 7d13ee582c6..8de18aef639 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue @@ -1,12 +1,11 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import '~/lib/utils/datetime_utility'; -import tooltip from '~/vue_shared/directives/tooltip'; import timeagoMixin from '~/vue_shared/mixins/timeago'; export default { directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, components: { GlIcon }, mixins: [timeagoMixin], @@ -63,7 +62,7 @@ export default { <gl-icon name="calendar" class="gl-vertical-align-baseline!" aria-hidden="true" /> <time - v-tooltip + v-gl-tooltip :title="tooltipTitle(finishedTime)" data-placement="top" data-container="body" diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js index abe5e1060c8..d3acd1ef3d0 100644 --- a/app/assets/javascripts/pipelines/constants.js +++ b/app/assets/javascripts/pipelines/constants.js @@ -13,6 +13,8 @@ export const TestStatus = { FAILED: 'failed', SKIPPED: 'skipped', SUCCESS: 'success', + ERROR: 'error', + UNKNOWN: 'unknown', }; export const FETCH_AUTHOR_ERROR_MESSAGE = __('There was a problem fetching project users.'); @@ -21,3 +23,11 @@ export const FETCH_TAG_ERROR_MESSAGE = __('There was a problem fetching project export const RAW_TEXT_WARNING = s__( 'Pipeline|Raw text search is not currently supported. Please use the available search tokens.', ); + +/* Error constants shared across graphs */ +export const DEFAULT = 'default'; +export const DELETE_FAILURE = 'delete_pipeline_failure'; +export const LOAD_FAILURE = 'load_failure'; +export const PARSE_FAILURE = 'parse_failure'; +export const POST_FAILURE = 'post_failure'; +export const UNSUPPORTED_DATA = 'unsupported_data'; diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql new file mode 100644 index 00000000000..06083daeca0 --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql @@ -0,0 +1,30 @@ +query getPipelineHeaderData($fullPath: ID!, $iid: ID!) { + project(fullPath: $fullPath) { + pipeline(iid: $iid) { + id + status + retryable + cancelable + userPermissions { + destroyPipeline + } + detailedStatus { + detailsPath + icon + group + text + } + createdAt + user { + name + webPath + email + avatarUrl + status { + message + emoji + } + } + } + } +} diff --git a/app/assets/javascripts/pipelines/mixins/graph_component_mixin.js b/app/assets/javascripts/pipelines/mixins/graph_component_mixin.js deleted file mode 100644 index 53b7a174517..00000000000 --- a/app/assets/javascripts/pipelines/mixins/graph_component_mixin.js +++ /dev/null @@ -1,54 +0,0 @@ -import { escape } from 'lodash'; - -export default { - props: { - isLoading: { - type: Boolean, - required: true, - }, - pipeline: { - type: Object, - required: true, - }, - }, - computed: { - graph() { - return this.pipeline.details && this.pipeline.details.stages; - }, - }, - methods: { - capitalizeStageName(name) { - const escapedName = escape(name); - return escapedName.charAt(0).toUpperCase() + escapedName.slice(1); - }, - isFirstColumn(index) { - return index === 0; - }, - stageConnectorClass(index, stage) { - let className; - - // If it's the first stage column and only has one job - if (index === 0 && stage.groups.length === 1) { - className = 'no-margin'; - } else if (index > 0) { - // If it is not the first column - className = 'left-margin'; - } - - return className; - }, - refreshPipelineGraph() { - this.$emit('refreshPipelineGraph'); - }, - /** - * CSS class is applied: - * - if pipeline graph contains only one stage column component - * - * @param {number} index - * @returns {boolean} - */ - shouldAddRightMargin(index) { - return !(index === this.graph.length - 1); - }, - }, -}; diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index 745f5b886a5..67aec12655a 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -7,10 +7,11 @@ import pipelineGraph from './components/graph/graph_component.vue'; import createDagApp from './pipeline_details_dag'; import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin'; import PipelinesMediator from './pipeline_details_mediator'; -import pipelineHeader from './components/header_component.vue'; +import legacyPipelineHeader from './components/legacy_header_component.vue'; import eventHub from './event_hub'; import TestReports from './components/test_reports/test_reports.vue'; import createTestReportsStore from './stores/test_reports'; +import { createPipelineHeaderApp } from './pipeline_details_header'; Vue.use(Translate); @@ -56,7 +57,7 @@ const createPipelinesDetailApp = mediator => { }); }; -const createPipelineHeaderApp = mediator => { +const createLegacyPipelineHeaderApp = mediator => { if (!document.querySelector(SELECTORS.PIPELINE_HEADER)) { return; } @@ -64,7 +65,7 @@ const createPipelineHeaderApp = mediator => { new Vue({ el: SELECTORS.PIPELINE_HEADER, components: { - pipelineHeader, + legacyPipelineHeader, }, data() { return { @@ -95,7 +96,7 @@ const createPipelineHeaderApp = mediator => { }, }, render(createElement) { - return createElement('pipeline-header', { + return createElement('legacy-pipeline-header', { props: { isLoading: this.mediator.state.isLoading, pipeline: this.mediator.store.state.pipeline, @@ -132,7 +133,12 @@ export default () => { mediator.fetchPipeline(); createPipelinesDetailApp(mediator); - createPipelineHeaderApp(mediator); + + if (gon.features.graphqlPipelineHeader) { + createPipelineHeaderApp(SELECTORS.PIPELINE_HEADER); + } else { + createLegacyPipelineHeaderApp(mediator); + } createTestDetails(); createDagApp(); }; diff --git a/app/assets/javascripts/pipelines/pipeline_details_header.js b/app/assets/javascripts/pipelines/pipeline_details_header.js new file mode 100644 index 00000000000..27fe9ba3f19 --- /dev/null +++ b/app/assets/javascripts/pipelines/pipeline_details_header.js @@ -0,0 +1,41 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import pipelineHeader from './components/header_component.vue'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +export const createPipelineHeaderApp = elSelector => { + const el = document.querySelector(elSelector); + + if (!el) { + return; + } + + const { cancelPath, deletePath, fullPath, pipelineId, pipelineIid, retryPath } = el?.dataset; + // eslint-disable-next-line no-new + new Vue({ + el, + components: { + pipelineHeader, + }, + apolloProvider, + provide: { + paths: { + cancel: cancelPath, + delete: deletePath, + fullProject: fullPath, + retry: retryPath, + }, + pipelineId, + pipelineIid, + }, + render(createElement) { + return createElement('pipeline-header', {}); + }, + }); +}; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/utils.js b/app/assets/javascripts/pipelines/stores/test_reports/utils.js index 8f1ac305cda..42406e5a67a 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/utils.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/utils.js @@ -1,13 +1,19 @@ import { __, sprintf } from '../../../locale'; +import { TestStatus } from '../../constants'; export function iconForTestStatus(status) { switch (status) { - case 'success': + case TestStatus.SUCCESS: return 'status_success_borderless'; - case 'failed': + case TestStatus.FAILED: return 'status_failed_borderless'; - default: + case TestStatus.ERROR: + return 'status_warning_borderless'; + case TestStatus.SKIPPED: return 'status_skipped_borderless'; + case TestStatus.UNKNOWN: + default: + return 'status_notfound_borderless'; } } diff --git a/app/assets/javascripts/profile/account/components/update_username.vue b/app/assets/javascripts/profile/account/components/update_username.vue index 4aaa2cff2ac..200e5ba255f 100644 --- a/app/assets/javascripts/profile/account/components/update_username.vue +++ b/app/assets/javascripts/profile/account/components/update_username.vue @@ -1,6 +1,7 @@ <script> /* eslint-disable vue/no-v-html */ import { escape } from 'lodash'; +import { GlButton } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; import { s__, sprintf } from '~/locale'; @@ -9,6 +10,7 @@ import { deprecatedCreateFlash as Flash } from '~/flash'; export default { components: { GlModal: DeprecatedModal2, + GlButton, }, props: { actionUrl: { @@ -100,15 +102,15 @@ Please update your Git repository remotes as soon as possible.`), </div> <p class="form-text text-muted">{{ path }}</p> </div> - <button + <gl-button :data-target="`#${$options.modalId}`" :disabled="isRequestPending || newUsername === username" - class="btn btn-warning" - type="button" + category="primary" + variant="warning" data-toggle="modal" > {{ $options.buttonText }} - </button> + </gl-button> <gl-modal :id="$options.modalId" :header-title-text="s__('Profiles|Change username') + '?'" diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js index 6822fa8f7c7..4755a4aa9ba 100644 --- a/app/assets/javascripts/profile/profile.js +++ b/app/assets/javascripts/profile/profile.js @@ -1,5 +1,6 @@ import $ from 'jquery'; import axios from '~/lib/utils/axios_utils'; +import { Rails } from '~/lib/utils/rails_ujs'; import { deprecatedCreateFlash as flash } from '../flash'; import { parseBoolean } from '~/lib/utils/common_utils'; import TimezoneDropdown, { @@ -48,9 +49,13 @@ export default class Profile { } submitForm() { - return $(this) - .parents('form') - .submit(); + const $form = $(this).parents('form'); + + if ($form.data('remote')) { + Rails.fire($form[0], 'submit'); + } else { + $form.submit(); + } } onSubmitForm(e) { diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js index 70fce4a4d09..5403c67aa8e 100644 --- a/app/assets/javascripts/project_find_file.js +++ b/app/assets/javascripts/project_find_file.js @@ -2,7 +2,7 @@ import $ from 'jquery'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; -import { sanitize } from 'dompurify'; +import { sanitize } from '~/lib/dompurify'; import axios from '~/lib/utils/axios_utils'; import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility'; import { deprecatedCreateFlash as flash } from '~/flash'; diff --git a/app/assets/javascripts/projects/commit_box/info/index.js b/app/assets/javascripts/projects/commit_box/info/index.js new file mode 100644 index 00000000000..352ac39f3c4 --- /dev/null +++ b/app/assets/javascripts/projects/commit_box/info/index.js @@ -0,0 +1,18 @@ +import { loadBranches } from './load_branches'; +import { fetchCommitMergeRequests } from '~/commit_merge_requests'; +import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown'; + +export const initCommitBoxInfo = (containerSelector = '.js-commit-box-info') => { + const containerEl = document.querySelector(containerSelector); + + // Display commit related branches + loadBranches(containerEl); + + // Related merge requests to this commit + fetchCommitMergeRequests(); + + // Display pipeline info for this commit + new MiniPipelineGraph({ + container: '.js-commit-pipeline-graph', + }).bindEvents(); +}; diff --git a/app/assets/javascripts/projects/commit_box/info/load_branches.js b/app/assets/javascripts/projects/commit_box/info/load_branches.js new file mode 100644 index 00000000000..0efa1998507 --- /dev/null +++ b/app/assets/javascripts/projects/commit_box/info/load_branches.js @@ -0,0 +1,20 @@ +import axios from 'axios'; +import { sanitize } from '~/lib/dompurify'; +import { __ } from '~/locale'; + +export const loadBranches = containerEl => { + if (!containerEl) { + return; + } + + const { commitPath } = containerEl.dataset; + const branchesEl = containerEl.querySelector('.commit-info.branches'); + axios + .get(commitPath) + .then(({ data }) => { + branchesEl.innerHTML = sanitize(data); + }) + .catch(() => { + branchesEl.textContent = __('Failed to load branches. Please try again.'); + }); +}; diff --git a/app/assets/javascripts/projects/default_project_templates.js b/app/assets/javascripts/projects/default_project_templates.js index 2d321ead33e..a6019e9c01b 100644 --- a/app/assets/javascripts/projects/default_project_templates.js +++ b/app/assets/javascripts/projects/default_project_templates.js @@ -57,6 +57,10 @@ export default { text: s__('ProjectTemplates|Static Site Editor/Middleman'), icon: '.template-option .icon-sse_middleman', }, + gitpod_spring_petclinic: { + text: s__('ProjectTemplates|Gitpod/Spring Petclinic'), + icon: '.template-option .icon-gitpod_spring_petclinic', + }, nfhugo: { text: s__('ProjectTemplates|Netlify/Hugo'), icon: '.template-option .icon-nfhugo', diff --git a/app/assets/javascripts/projects/settings/access_dropdown.js b/app/assets/javascripts/projects/settings/access_dropdown.js index 5d51b7ea57b..c189e617105 100644 --- a/app/assets/javascripts/projects/settings/access_dropdown.js +++ b/app/assets/javascripts/projects/settings/access_dropdown.js @@ -2,8 +2,8 @@ import { escape, find, countBy } from 'lodash'; import axios from '~/lib/utils/axios_utils'; import { deprecatedCreateFlash as Flash } from '~/flash'; -import { n__, s__, __ } from '~/locale'; -import { LEVEL_TYPES, LEVEL_ID_PROP, ACCESS_LEVEL_NONE } from './constants'; +import { n__, s__, __, sprintf } from '~/locale'; +import { LEVEL_TYPES, LEVEL_ID_PROP, ACCESS_LEVELS, ACCESS_LEVEL_NONE } from './constants'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; export default class AccessDropdown { @@ -11,6 +11,7 @@ export default class AccessDropdown { const { $dropdown, accessLevel, accessLevelsData, hasLicense = true } = options; this.options = options; this.hasLicense = hasLicense; + this.deployKeysOnProtectedBranchesEnabled = gon.features.deployKeysOnProtectedBranches; this.groups = []; this.accessLevel = accessLevel; this.accessLevelsData = accessLevelsData.roles; @@ -18,6 +19,7 @@ export default class AccessDropdown { this.$wrap = this.$dropdown.closest(`.${this.accessLevel}-container`); this.usersPath = '/-/autocomplete/users.json'; this.groupsPath = '/-/autocomplete/project_groups.json'; + this.deployKeysPath = '/-/autocomplete/deploy_keys_with_owners.json'; this.defaultLabel = this.$dropdown.data('defaultLabel'); this.setSelectedItems([]); @@ -146,6 +148,8 @@ export default class AccessDropdown { obj.access_level = item.access_level; } else if (item.type === LEVEL_TYPES.USER) { obj.user_id = item.user_id; + } else if (item.type === LEVEL_TYPES.DEPLOY_KEY) { + obj.deploy_key_id = item.deploy_key_id; } else if (item.type === LEVEL_TYPES.GROUP) { obj.group_id = item.group_id; } @@ -177,6 +181,9 @@ export default class AccessDropdown { case LEVEL_TYPES.GROUP: comparator = LEVEL_ID_PROP.GROUP; break; + case LEVEL_TYPES.DEPLOY_KEY: + comparator = LEVEL_ID_PROP.DEPLOY_KEY; + break; case LEVEL_TYPES.USER: comparator = LEVEL_ID_PROP.USER; break; @@ -218,6 +225,11 @@ export default class AccessDropdown { group_id: selectedItem.id, type: LEVEL_TYPES.GROUP, }; + } else if (selectedItem.type === LEVEL_TYPES.DEPLOY_KEY) { + itemToAdd = { + deploy_key_id: selectedItem.id, + type: LEVEL_TYPES.DEPLOY_KEY, + }; } this.items.push(itemToAdd); @@ -233,11 +245,12 @@ export default class AccessDropdown { return true; } - if (item.type === LEVEL_TYPES.USER && item.user_id === itemToDelete.id) { - index = i; - } else if (item.type === LEVEL_TYPES.ROLE && item.access_level === itemToDelete.id) { - index = i; - } else if (item.type === LEVEL_TYPES.GROUP && item.group_id === itemToDelete.id) { + if ( + (item.type === LEVEL_TYPES.USER && item.user_id === itemToDelete.id) || + (item.type === LEVEL_TYPES.ROLE && item.access_level === itemToDelete.id) || + (item.type === LEVEL_TYPES.DEPLOY_KEY && item.deploy_key_id === itemToDelete.id) || + (item.type === LEVEL_TYPES.GROUP && item.group_id === itemToDelete.id) + ) { index = i; } @@ -289,6 +302,10 @@ export default class AccessDropdown { labelPieces.push(n__('1 user', '%d users', counts[LEVEL_TYPES.USER])); } + if (counts[LEVEL_TYPES.DEPLOY_KEY] > 0) { + labelPieces.push(n__('1 deploy key', '%d deploy keys', counts[LEVEL_TYPES.DEPLOY_KEY])); + } + if (counts[LEVEL_TYPES.GROUP] > 0) { labelPieces.push(n__('1 group', '%d groups', counts[LEVEL_TYPES.GROUP])); } @@ -299,20 +316,31 @@ export default class AccessDropdown { getData(query, callback) { if (this.hasLicense) { Promise.all([ + this.getDeployKeys(query), this.getUsers(query), this.groupsData ? Promise.resolve(this.groupsData) : this.getGroups(), ]) - .then(([usersResponse, groupsResponse]) => { + .then(([deployKeysResponse, usersResponse, groupsResponse]) => { this.groupsData = groupsResponse; - callback(this.consolidateData(usersResponse.data, groupsResponse.data)); + callback( + this.consolidateData(deployKeysResponse.data, usersResponse.data, groupsResponse.data), + ); }) - .catch(() => Flash(__('Failed to load groups & users.'))); + .catch(() => { + if (this.deployKeysOnProtectedBranchesEnabled) { + Flash(__('Failed to load groups, users and deploy keys.')); + } else { + Flash(__('Failed to load groups & users.')); + } + }); } else { - callback(this.consolidateData()); + this.getDeployKeys(query) + .then(deployKeysResponse => callback(this.consolidateData(deployKeysResponse.data))) + .catch(() => Flash(__('Failed to load deploy keys.'))); } } - consolidateData(usersResponse = [], groupsResponse = []) { + consolidateData(deployKeysResponse, usersResponse = [], groupsResponse = []) { let consolidatedData = []; // ID property is handled differently locally from the server @@ -328,6 +356,10 @@ export default class AccessDropdown { // For Users // In dropdown: `id` // For submit: `user_id` + // + // For Deploy Keys + // In dropdown: `id` + // For submit: `deploy_key_id` /* * Build roles @@ -410,6 +442,38 @@ export default class AccessDropdown { } } + if (this.deployKeysOnProtectedBranchesEnabled) { + const deployKeys = deployKeysResponse.map(response => { + const { + id, + fingerprint, + title, + owner: { avatar_url, name, username }, + } = response; + + const shortFingerprint = `(${fingerprint.substring(0, 14)}...)`; + + return { + id, + title: title.concat(' ', shortFingerprint), + avatar_url, + fullname: name, + username, + type: LEVEL_TYPES.DEPLOY_KEY, + }; + }); + + if (this.accessLevel === ACCESS_LEVELS.PUSH) { + if (deployKeys.length) { + consolidatedData = consolidatedData.concat( + [{ type: 'divider' }], + [{ type: 'header', content: s__('AccessDropdown|Deploy Keys') }], + deployKeys, + ); + } + } + } + return consolidatedData; } @@ -433,6 +497,22 @@ export default class AccessDropdown { }); } + getDeployKeys(query) { + if (this.deployKeysOnProtectedBranchesEnabled) { + return axios.get(this.buildUrl(gon.relative_url_root, this.deployKeysPath), { + params: { + search: query, + per_page: 20, + active: true, + project_id: gon.current_project_id, + push_code: true, + }, + }); + } + + return Promise.resolve({ data: [] }); + } + buildUrl(urlRoot, url) { let newUrl; if (urlRoot != null) { @@ -454,6 +534,9 @@ export default class AccessDropdown { case LEVEL_TYPES.ROLE: criteria = { access_level: item.id }; break; + case LEVEL_TYPES.DEPLOY_KEY: + criteria = { deploy_key_id: item.id }; + break; case LEVEL_TYPES.GROUP: criteria = { group_id: item.id }; break; @@ -470,6 +553,10 @@ export default class AccessDropdown { case LEVEL_TYPES.ROLE: groupRowEl = this.roleRowHtml(item, isActive); break; + case LEVEL_TYPES.DEPLOY_KEY: + groupRowEl = + this.accessLevel === ACCESS_LEVELS.PUSH ? this.deployKeyRowHtml(item, isActive) : ''; + break; case LEVEL_TYPES.GROUP: groupRowEl = this.groupRowHtml(item, isActive); break; @@ -495,6 +582,31 @@ export default class AccessDropdown { `; } + deployKeyRowHtml(key, isActive) { + const isActiveClass = isActive || ''; + + return ` + <li> + <a href="#" class="${isActiveClass}"> + <strong>${key.title}</strong> + <p> + ${sprintf( + __('Owned by %{image_tag}'), + { + image_tag: `<img src="${key.avatar_url}" class="avatar avatar-inline s26" width="30">`, + }, + false, + )} + <strong class="dropdown-menu-user-full-name gl-display-inline">${escape( + key.fullname, + )}</strong> + <span class="dropdown-menu-user-username gl-display-inline">${key.username}</span> + </p> + </a> + </li> + `; + } + groupRowHtml(group, isActive) { const isActiveClass = isActive || ''; const avatarEl = group.avatar_url diff --git a/app/assets/javascripts/projects/settings/constants.js b/app/assets/javascripts/projects/settings/constants.js index fadb1f4f178..f5591c43dc4 100644 --- a/app/assets/javascripts/projects/settings/constants.js +++ b/app/assets/javascripts/projects/settings/constants.js @@ -1,13 +1,20 @@ export const LEVEL_TYPES = { ROLE: 'role', USER: 'user', + DEPLOY_KEY: 'deploy_key', GROUP: 'group', }; export const LEVEL_ID_PROP = { ROLE: 'access_level', USER: 'user_id', + DEPLOY_KEY: 'deploy_key_id', GROUP: 'group_id', }; +export const ACCESS_LEVELS = { + MERGE: 'merge_access_levels', + PUSH: 'push_access_levels', +}; + export const ACCESS_LEVEL_NONE = 0; diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue index 81367f7d6b4..4bfed6d489d 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue @@ -1,6 +1,6 @@ <script> import { GlAlert } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { __, sprintf } from '~/locale'; import ServiceDeskSetting from './service_desk_setting.vue'; import ServiceDeskService from '../services/service_desk_service'; import eventHub from '../event_hub'; @@ -122,11 +122,13 @@ export default { this.incomingEmail = data?.service_desk_address; this.showAlert(__('Changes were successfully made.'), 'success'); }) - .catch(() => + .catch(err => { this.showAlert( - __('An error occurred while saving the template. Please check if the template exists.'), - ), - ) + sprintf(__('An error occured while making the changes: %{error}'), { + error: err?.response?.data?.message, + }), + ); + }) .finally(() => { this.isTemplateSaving = false; }); diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue index 6a0810ad3a1..089cac9ee4c 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlFormSelect, GlToggle, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlFormSelect, GlToggle, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; import { __ } from '~/locale'; import tooltip from '~/vue_shared/directives/tooltip'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -17,6 +17,7 @@ export default { GlFormSelect, GlToggle, GlLoadingIcon, + GlSprintf, }, mixins: [glFeatureFlagsMixin()], props: { @@ -60,6 +61,7 @@ export default { selectedTemplate: this.initialSelectedTemplate, outgoingName: this.initialOutgoingName || __('GitLab Support Bot'), projectKey: this.initialProjectKey, + baseEmail: this.incomingEmail.replace(this.initialProjectKey, ''), }; }, computed: { @@ -108,7 +110,7 @@ export default { <input ref="service-desk-incoming-email" type="text" - class="form-control incoming-email h-auto" + class="form-control incoming-email" :placeholder="__('Incoming email')" :aria-label="__('Incoming email')" aria-describedby="incoming-email-describer" @@ -119,16 +121,37 @@ export default { <clipboard-button :title="__('Copy')" :text="incomingEmail" - css-class="btn qa-clipboard-button" + css-class="input-group-text qa-clipboard-button" /> </div> </div> + <span v-if="projectKey" class="form-text text-muted"> + <gl-sprintf :message="__('Emails sent to %{email} will still be supported')"> + <template #email> + <code>{{ baseEmail }}</code> + </template> + </gl-sprintf> + </span> </template> <template v-else> <gl-loading-icon :inline="true" /> <span class="sr-only">{{ __('Fetching incoming email') }}</span> </template> + <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"> + {{ + __( + '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> + <label for="service-desk-template-select" class="mt-3"> {{ __('Template to append to all Service Desk issues') }} </label> @@ -144,19 +167,6 @@ export default { <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> <div class="gl-display-flex gl-justify-content-end"> <gl-button variant="success" diff --git a/app/assets/javascripts/protected_branches/constants.js b/app/assets/javascripts/protected_branches/constants.js index a17ae6811b7..ae5eaa8e622 100644 --- a/app/assets/javascripts/protected_branches/constants.js +++ b/app/assets/javascripts/protected_branches/constants.js @@ -7,12 +7,14 @@ export const LEVEL_TYPES = { ROLE: 'role', USER: 'user', GROUP: 'group', + DEPLOY_KEY: 'deploy_key', }; export const LEVEL_ID_PROP = { ROLE: 'access_level', USER: 'user_id', GROUP: 'group_id', + DEPLOY_KEY: 'deploy_key_id', }; export const ACCESS_LEVEL_NONE = 0; diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js index 5ccffe9700e..19f6666fd52 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_create.js +++ b/app/assets/javascripts/protected_branches/protected_branch_create.js @@ -108,6 +108,10 @@ export default class ProtectedBranchCreate { levelAttributes.push({ group_id: item.group_id, }); + } else if (item.type === LEVEL_TYPES.DEPLOY_KEY) { + levelAttributes.push({ + deploy_key_id: item.deploy_key_id, + }); } }); diff --git a/app/assets/javascripts/registry/explorer/components/details_page/partial_cleanup_alert.vue b/app/assets/javascripts/registry/explorer/components/details_page/partial_cleanup_alert.vue new file mode 100644 index 00000000000..d13d815a59e --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/details_page/partial_cleanup_alert.vue @@ -0,0 +1,38 @@ +<script> +import { GlSprintf, GlAlert, GlLink } from '@gitlab/ui'; + +import { DELETE_ALERT_TITLE, DELETE_ALERT_LINK_TEXT } from '../../constants/index'; + +export default { + components: { + GlSprintf, + GlAlert, + GlLink, + }, + props: { + runCleanupPoliciesHelpPagePath: { type: String, required: false, default: '' }, + cleanupPoliciesHelpPagePath: { type: String, required: false, default: '' }, + }, + i18n: { + DELETE_ALERT_TITLE, + DELETE_ALERT_LINK_TEXT, + }, +}; +</script> + +<template> + <gl-alert variant="warning" :title="$options.i18n.DELETE_ALERT_TITLE" @dismiss="$emit('dismiss')"> + <gl-sprintf :message="$options.i18n.DELETE_ALERT_LINK_TEXT"> + <template #adminLink="{content}"> + <gl-link data-testid="run-link" :href="runCleanupPoliciesHelpPagePath" target="_blank">{{ + content + }}</gl-link> + </template> + <template #docLink="{content}"> + <gl-link data-testid="help-link" :href="cleanupPoliciesHelpPagePath" target="_blank">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </gl-alert> +</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 index 661213733ac..8d48430560e 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue +++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue @@ -197,7 +197,8 @@ export default { v-if="tag.digest" :title="tag.digest" :text="tag.digest" - css-class="btn-default btn-transparent btn-clipboard gl-p-0" + category="tertiary" + size="small" /> </details-row> </template> @@ -212,7 +213,8 @@ export default { v-if="formattedRevision" :title="formattedRevision" :text="formattedRevision" - css-class="btn-default btn-transparent btn-clipboard gl-p-0" + category="tertiary" + size="small" /> </details-row> </template> 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 32bf27f1143..29ce9150c89 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 @@ -42,6 +42,7 @@ export default { name: this.item.path, tags_path: this.item.tags_path, id: this.item.id, + cleanup_policy_started_at: this.item.cleanup_policy_started_at, }); return window.btoa(params); }, @@ -82,7 +83,7 @@ export default { :disabled="item.deleting" :text="item.location" :title="item.location" - css-class="btn-default btn-transparent btn-clipboard gl-text-gray-300" + category="tertiary" /> <gl-icon v-if="item.failedDelete" diff --git a/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue b/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue index 7be68e77def..228a660c997 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue @@ -1,5 +1,4 @@ <script> -import { GlSprintf, GlLink } from '@gitlab/ui'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; import { n__, sprintf } from '~/locale'; @@ -15,8 +14,6 @@ import { export default { components: { - GlSprintf, - GlLink, TitleArea, MetadataItem, }, @@ -54,8 +51,6 @@ export default { }, i18n: { CONTAINER_REGISTRY_TITLE, - LIST_INTRO_TEXT, - EXPIRATION_POLICY_DISABLED_MESSAGE, }, computed: { imagesCountText() { @@ -83,52 +78,40 @@ export default { !this.expirationPolicyEnabled && this.imagesCount > 0 && !this.hideExpirationPolicyData ); }, + infoMessages() { + const base = [{ text: LIST_INTRO_TEXT, link: this.helpPagePath }]; + return this.showExpirationPolicyTip + ? [ + ...base, + { text: EXPIRATION_POLICY_DISABLED_MESSAGE, link: this.expirationPolicyHelpPagePath }, + ] + : base; + }, }, }; </script> <template> - <div> - <title-area :title="$options.i18n.CONTAINER_REGISTRY_TITLE"> - <template #right-actions> - <slot name="commands"></slot> - </template> - <template #metadata_count> - <metadata-item - v-if="imagesCount" - data-testid="images-count" - icon="container-image" - :text="imagesCountText" - /> - </template> - <template #metadata_exp_policies> - <metadata-item - v-if="!hideExpirationPolicyData" - data-testid="expiration-policy" - icon="expire" - :text="expirationPolicyText" - size="xl" - /> - </template> - </title-area> - - <div data-testid="info-area"> - <p> - <span data-testid="default-intro"> - <gl-sprintf :message="$options.i18n.LIST_INTRO_TEXT"> - <template #docLink="{content}"> - <gl-link :href="helpPagePath" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </span> - <span v-if="showExpirationPolicyTip" data-testid="expiration-disabled-message"> - <gl-sprintf :message="$options.i18n.EXPIRATION_POLICY_DISABLED_MESSAGE"> - <template #docLink="{content}"> - <gl-link :href="expirationPolicyHelpPagePath" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </span> - </p> - </div> - </div> + <title-area :title="$options.i18n.CONTAINER_REGISTRY_TITLE" :info-messages="infoMessages"> + <template #right-actions> + <slot name="commands"></slot> + </template> + <template #metadata_count> + <metadata-item + v-if="imagesCount" + data-testid="images-count" + icon="container-image" + :text="imagesCountText" + /> + </template> + <template #metadata_exp_policies> + <metadata-item + v-if="!hideExpirationPolicyData" + data-testid="expiration-policy" + icon="expire" + :text="expirationPolicyText" + size="xl" + /> + </template> + </title-area> </template> diff --git a/app/assets/javascripts/registry/explorer/constants/expiration_policies.js b/app/assets/javascripts/registry/explorer/constants/expiration_policies.js index 8af25ca6ecc..5f73834d995 100644 --- a/app/assets/javascripts/registry/explorer/constants/expiration_policies.js +++ b/app/assets/javascripts/registry/explorer/constants/expiration_policies.js @@ -9,3 +9,7 @@ export const EXPIRATION_POLICY_DISABLED_TEXT = s__( export const EXPIRATION_POLICY_DISABLED_MESSAGE = s__( 'ContainerRegistry|Expiration policies help manage the storage space used by the Container Registry, but the expiration policies for this registry are disabled. Contact your administrator to enable. %{docLinkStart}More information%{docLinkEnd}', ); +export const DELETE_ALERT_TITLE = s__('ContainerRegistry|Some tags were not deleted'); +export const DELETE_ALERT_LINK_TEXT = s__( + 'ContainerRegistry|The cleanup policy timed out before it could delete all tags. An administrator can %{adminLinkStart}manually run cleanup now%{adminLinkEnd} or you can wait for the cleanup policy to automatically run again. %{docLinkStart}More information%{docLinkEnd}', +); diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue index b697bca6259..d2fb695dbfa 100644 --- a/app/assets/javascripts/registry/explorer/pages/details.vue +++ b/app/assets/javascripts/registry/explorer/pages/details.vue @@ -4,6 +4,7 @@ import { GlPagination, GlResizeObserverDirective } from '@gitlab/ui'; import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; import Tracking from '~/tracking'; import DeleteAlert from '../components/details_page/delete_alert.vue'; +import PartialCleanupAlert from '../components/details_page/partial_cleanup_alert.vue'; import DeleteModal from '../components/details_page/delete_modal.vue'; import DetailsHeader from '../components/details_page/details_header.vue'; import TagsList from '../components/details_page/tags_list.vue'; @@ -21,6 +22,7 @@ import { export default { components: { DeleteAlert, + PartialCleanupAlert, DetailsHeader, GlPagination, DeleteModal, @@ -37,13 +39,16 @@ export default { itemsToBeDeleted: [], isDesktop: true, deleteAlertType: null, + dismissPartialCleanupWarning: false, }; }, computed: { ...mapState(['tagsPagination', 'isLoading', 'config', 'tags']), - imageName() { - const { name } = decodeAndParse(this.$route.params.id); - return name; + queryParameters() { + return decodeAndParse(this.$route.params.id); + }, + showPartialCleanupWarning() { + return this.queryParameters.cleanup_policy_started_at && !this.dismissPartialCleanupWarning; }, tracking() { return { @@ -120,7 +125,14 @@ export default { class="gl-my-2" /> - <details-header :image-name="imageName" /> + <partial-cleanup-alert + v-if="showPartialCleanupWarning" + :run-cleanup-policies-help-page-path="config.runCleanupPoliciesHelpPagePath" + :cleanup-policies-help-page-path="config.cleanupPoliciesHelpPagePath" + @dismiss="dismissPartialCleanupWarning = true" + /> + + <details-header :image-name="queryParameters.name" /> <tags-loader v-if="isLoading" /> <template v-else> 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 2ee7bbef4c6..fcb86fd18f0 100644 --- a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue +++ b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue @@ -1,7 +1,7 @@ <script> -import { mapActions, mapGetters, mapState } from 'vuex'; import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; - +import { isEqual } from 'lodash'; +import expirationPolicyQuery from '../graphql/queries/get_expiration_policy.graphql'; import { FETCH_SETTINGS_ERROR_MESSAGE } from '../../shared/constants'; import SettingsForm from './settings_form.vue'; @@ -19,21 +19,39 @@ export default { GlSprintf, GlLink, }, + inject: ['projectPath', 'isAdmin', 'adminSettingsPath', 'enableHistoricEntries'], i18n: { UNAVAILABLE_FEATURE_TITLE, UNAVAILABLE_FEATURE_INTRO_TEXT, FETCH_SETTINGS_ERROR_MESSAGE, }, + apollo: { + containerExpirationPolicy: { + query: expirationPolicyQuery, + variables() { + return { + projectPath: this.projectPath, + }; + }, + update: data => data.project?.containerExpirationPolicy, + result({ data }) { + this.workingCopy = { ...data.project?.containerExpirationPolicy }; + }, + error(e) { + this.fetchSettingsError = e; + }, + }, + }, data() { return { fetchSettingsError: false, + containerExpirationPolicy: null, + workingCopy: {}, }; }, computed: { - ...mapState(['isAdmin', 'adminSettingsPath']), - ...mapGetters({ isDisabled: 'getIsDisabled' }), - showSettingForm() { - return !this.isDisabled && !this.fetchSettingsError; + isDisabled() { + return !(this.containerExpirationPolicy || this.enableHistoricEntries); }, showDisabledFormMessage() { return this.isDisabled && !this.fetchSettingsError; @@ -41,21 +59,27 @@ export default { unavailableFeatureMessage() { return this.isAdmin ? UNAVAILABLE_ADMIN_FEATURE_TEXT : UNAVAILABLE_USER_FEATURE_TEXT; }, - }, - mounted() { - this.fetchSettings().catch(() => { - this.fetchSettingsError = true; - }); + isEdited() { + return !isEqual(this.containerExpirationPolicy, this.workingCopy); + }, }, methods: { - ...mapActions(['fetchSettings']), + restoreOriginal() { + this.workingCopy = { ...this.containerExpirationPolicy }; + }, }, }; </script> <template> <div> - <settings-form v-if="showSettingForm" /> + <settings-form + v-if="containerExpirationPolicy" + v-model="workingCopy" + :is-loading="$apollo.queries.containerExpirationPolicy.loading" + :is-edited="isEdited" + @reset="restoreOriginal" + /> <template v-else> <gl-alert v-if="showDisabledFormMessage" diff --git a/app/assets/javascripts/registry/settings/components/settings_form.vue b/app/assets/javascripts/registry/settings/components/settings_form.vue index 7a26fb5cbee..7deb1f92686 100644 --- a/app/assets/javascripts/registry/settings/components/settings_form.vue +++ b/app/assets/javascripts/registry/settings/components/settings_form.vue @@ -1,28 +1,45 @@ <script> -import { get } from 'lodash'; -import { mapActions, mapState, mapGetters } from 'vuex'; -import { GlCard, GlButton, GlLoadingIcon } from '@gitlab/ui'; +import { GlCard, GlButton } 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 ExpirationPolicyFields from '../../shared/components/expiration_policy_fields.vue'; import { SET_CLEANUP_POLICY_BUTTON, CLEANUP_POLICY_CARD_HEADER } from '../constants'; +import { formOptionsGenerator } from '~/registry/shared/utils'; +import updateContainerExpirationPolicyMutation from '../graphql/mutations/update_container_expiration_policy.graphql'; +import { updateContainerExpirationPolicy } from '../graphql/utils/cache_update'; export default { components: { GlCard, GlButton, - GlLoadingIcon, ExpirationPolicyFields, }, mixins: [Tracking.mixin()], + inject: ['projectPath'], + props: { + value: { + type: Object, + required: true, + }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, + isEdited: { + type: Boolean, + required: false, + default: false, + }, + }, labelsConfig: { cols: 3, align: 'right', }, + formOptions: formOptionsGenerator(), i18n: { CLEANUP_POLICY_CARD_HEADER, SET_CLEANUP_POLICY_BUTTON, @@ -34,49 +51,74 @@ export default { }, fieldsAreValid: true, apiErrors: null, + mutationLoading: false, }; }, computed: { - ...mapState(['formOptions', 'isLoading']), - ...mapGetters({ isEdited: 'getIsEdited' }), - ...mapComputed([{ key: 'settings', getter: 'getSettings' }], 'updateSettings'), + showLoadingIcon() { + return this.isLoading || this.mutationLoading; + }, isSubmitButtonDisabled() { - return !this.fieldsAreValid || this.isLoading; + return !this.fieldsAreValid || this.showLoadingIcon; }, isCancelButtonDisabled() { - return !this.isEdited || this.isLoading; + return !this.isEdited || this.isLoading || this.mutationLoading; + }, + mutationVariables() { + return { + projectPath: this.projectPath, + enabled: this.value.enabled, + cadence: this.value.cadence, + olderThan: this.value.olderThan, + keepN: this.value.keepN, + nameRegex: this.value.nameRegex, + nameRegexKeep: this.value.nameRegexKeep, + }; }, }, methods: { - ...mapActions(['resetSettings', 'saveSettings']), reset() { this.track('reset_form'); this.apiErrors = null; - this.resetSettings(); + this.$emit('reset'); }, 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], ''); - } + this.apiErrors = response.graphQLErrors.reduce((acc, curr) => { + curr.extensions.problems.forEach(item => { + acc[item.path[0]] = item.message; + }); return acc; }, {}); }, submit() { this.track('submit_form'); this.apiErrors = null; - this.saveSettings() - .then(() => this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE, { type: 'success' })) - .catch(({ response }) => { - this.setApiErrors(response); + this.mutationLoading = true; + return this.$apollo + .mutate({ + mutation: updateContainerExpirationPolicyMutation, + variables: { + input: this.mutationVariables, + }, + update: updateContainerExpirationPolicy(this.projectPath), + }) + .then(({ data }) => { + const errorMessage = data?.updateContainerExpirationPolicy?.errors[0]; + if (errorMessage) { + this.$toast.show(errorMessage, { type: 'error' }); + } + this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE, { type: 'success' }); + }) + .catch(error => { + this.setApiErrors(error); this.$toast.show(UPDATE_SETTINGS_ERROR_MESSAGE, { type: 'error' }); + }) + .finally(() => { + this.mutationLoading = false; }); }, onModelChange(changePayload) { - this.settings = changePayload.newValue; + this.$emit('input', changePayload.newValue); if (this.apiErrors) { this.apiErrors[changePayload.modified] = undefined; } @@ -93,8 +135,8 @@ export default { </template> <template #default> <expiration-policy-fields - :value="settings" - :form-options="formOptions" + :value="value" + :form-options="$options.formOptions" :is-loading="isLoading" :api-errors="apiErrors" @validated="fieldsAreValid = true" @@ -103,27 +145,25 @@ export default { /> </template> <template #footer> - <div class="gl-display-flex gl-justify-content-end"> - <gl-button - ref="cancel-button" - type="reset" - class="gl-mr-3 gl-display-block" - :disabled="isCancelButtonDisabled" - > - {{ __('Cancel') }} - </gl-button> - <gl-button - ref="save-button" - type="submit" - :disabled="isSubmitButtonDisabled" - variant="success" - category="primary" - class="gl-display-flex gl-justify-content-center gl-align-items-center js-no-auto-disable" - > - {{ $options.i18n.SET_CLEANUP_POLICY_BUTTON }} - <gl-loading-icon v-if="isLoading" class="gl-ml-3" /> - </gl-button> - </div> + <gl-button + ref="cancel-button" + type="reset" + class="gl-mr-3 gl-display-block float-right" + :disabled="isCancelButtonDisabled" + > + {{ __('Cancel') }} + </gl-button> + <gl-button + ref="save-button" + type="submit" + :disabled="isSubmitButtonDisabled" + :loading="showLoadingIcon" + variant="success" + category="primary" + class="js-no-auto-disable" + > + {{ $options.i18n.SET_CLEANUP_POLICY_BUTTON }} + </gl-button> </template> </gl-card> </form> diff --git a/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql b/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql new file mode 100644 index 00000000000..224e0ed9472 --- /dev/null +++ b/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql @@ -0,0 +1,8 @@ +fragment ContainerExpirationPolicyFields on ContainerExpirationPolicy { + cadence + enabled + keepN + nameRegex + nameRegexKeep + olderThan +} diff --git a/app/assets/javascripts/registry/settings/graphql/index.js b/app/assets/javascripts/registry/settings/graphql/index.js new file mode 100644 index 00000000000..16152eb81f6 --- /dev/null +++ b/app/assets/javascripts/registry/settings/graphql/index.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; + +Vue.use(VueApollo); + +export const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient( + {}, + { + assumeImmutableResults: true, + }, + ), +}); diff --git a/app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.graphql b/app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.graphql new file mode 100644 index 00000000000..c40cd115ab0 --- /dev/null +++ b/app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.graphql @@ -0,0 +1,10 @@ +#import "../fragments/container_expiration_policy.fragment.graphql" + +mutation updateContainerExpirationPolicy($input: UpdateContainerExpirationPolicyInput!) { + updateContainerExpirationPolicy(input: $input) { + containerExpirationPolicy { + ...ContainerExpirationPolicyFields + } + errors + } +} diff --git a/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.graphql b/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.graphql new file mode 100644 index 00000000000..c171be0ad07 --- /dev/null +++ b/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.graphql @@ -0,0 +1,9 @@ +#import "../fragments/container_expiration_policy.fragment.graphql" + +query getProjectExpirationPolicy($projectPath: ID!) { + project(fullPath: $projectPath) { + containerExpirationPolicy { + ...ContainerExpirationPolicyFields + } + } +} diff --git a/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js b/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js new file mode 100644 index 00000000000..88067d52b51 --- /dev/null +++ b/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js @@ -0,0 +1,22 @@ +import { produce } from 'immer'; +import expirationPolicyQuery from '../queries/get_expiration_policy.graphql'; + +export const updateContainerExpirationPolicy = projectPath => (client, { data: updatedData }) => { + const queryAndParams = { + query: expirationPolicyQuery, + variables: { projectPath }, + }; + const sourceData = client.readQuery(queryAndParams); + + const data = produce(sourceData, draftState => { + // eslint-disable-next-line no-param-reassign + draftState.project.containerExpirationPolicy = { + ...updatedData.updateContainerExpirationPolicy.containerExpirationPolicy, + }; + }); + + client.writeQuery({ + ...queryAndParams, + data, + }); +}; diff --git a/app/assets/javascripts/registry/settings/registry_settings_bundle.js b/app/assets/javascripts/registry/settings/registry_settings_bundle.js index a318aa2a694..5f25d508e2f 100644 --- a/app/assets/javascripts/registry/settings/registry_settings_bundle.js +++ b/app/assets/javascripts/registry/settings/registry_settings_bundle.js @@ -1,8 +1,8 @@ import Vue from 'vue'; import { GlToast } from '@gitlab/ui'; import Translate from '~/vue_shared/translate'; -import store from './store'; import RegistrySettingsApp from './components/registry_settings_app.vue'; +import { apolloProvider } from './graphql/index'; Vue.use(GlToast); Vue.use(Translate); @@ -12,13 +12,19 @@ export default () => { if (!el) { return null; } - store.dispatch('setInitialState', el.dataset); + const { projectPath, isAdmin, adminSettingsPath, enableHistoricEntries } = el.dataset; return new Vue({ el, - store, + apolloProvider, components: { RegistrySettingsApp, }, + provide: { + projectPath, + isAdmin, + adminSettingsPath, + enableHistoricEntries, + }, render(createElement) { return createElement('registry-settings-app', {}); }, diff --git a/app/assets/javascripts/registry/settings/store/actions.js b/app/assets/javascripts/registry/settings/store/actions.js deleted file mode 100644 index 0530a870ecc..00000000000 --- a/app/assets/javascripts/registry/settings/store/actions.js +++ /dev/null @@ -1,30 +0,0 @@ -import Api from '~/api'; -import * as types from './mutation_types'; - -export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data); -export const updateSettings = ({ commit }, data) => commit(types.UPDATE_SETTINGS, data); -export const toggleLoading = ({ commit }) => commit(types.TOGGLE_LOADING); -export const receiveSettingsSuccess = ({ commit }, data) => { - commit(types.SET_SETTINGS, data); -}; -export const resetSettings = ({ commit }) => commit(types.RESET_SETTINGS); - -export const fetchSettings = ({ dispatch, state }) => { - dispatch('toggleLoading'); - return Api.project(state.projectId) - .then(({ data: { container_expiration_policy } }) => - dispatch('receiveSettingsSuccess', container_expiration_policy), - ) - .finally(() => dispatch('toggleLoading')); -}; - -export const saveSettings = ({ dispatch, state }) => { - dispatch('toggleLoading'); - return Api.updateProject(state.projectId, { - container_expiration_policy_attributes: state.settings, - }) - .then(({ data: { container_expiration_policy } }) => - dispatch('receiveSettingsSuccess', container_expiration_policy), - ) - .finally(() => dispatch('toggleLoading')); -}; diff --git a/app/assets/javascripts/registry/settings/store/getters.js b/app/assets/javascripts/registry/settings/store/getters.js deleted file mode 100644 index ac1a931d8e0..00000000000 --- a/app/assets/javascripts/registry/settings/store/getters.js +++ /dev/null @@ -1,26 +0,0 @@ -import { isEqual } from 'lodash'; -import { findDefaultOption } from '../../shared/utils'; - -export const getCadence = state => - state.settings.cadence || findDefaultOption(state.formOptions.cadence); - -export const getKeepN = state => - state.settings.keep_n || findDefaultOption(state.formOptions.keepN); - -export const getOlderThan = state => - state.settings.older_than || findDefaultOption(state.formOptions.olderThan); - -export const getSettings = (state, getters) => ({ - enabled: state.settings.enabled, - cadence: getters.getCadence, - older_than: getters.getOlderThan, - keep_n: getters.getKeepN, - name_regex: state.settings.name_regex, - name_regex_keep: state.settings.name_regex_keep, -}); - -export const getIsEdited = state => !isEqual(state.original, state.settings); - -export const getIsDisabled = state => { - return !(state.original || state.enableHistoricEntries); -}; diff --git a/app/assets/javascripts/registry/settings/store/index.js b/app/assets/javascripts/registry/settings/store/index.js deleted file mode 100644 index c2500454d8e..00000000000 --- a/app/assets/javascripts/registry/settings/store/index.js +++ /dev/null @@ -1,18 +0,0 @@ -import Vue from 'vue'; -import Vuex from 'vuex'; -import * as actions from './actions'; -import mutations from './mutations'; -import * as getters from './getters'; -import state from './state'; - -Vue.use(Vuex); - -export const createStore = () => - new Vuex.Store({ - state, - actions, - mutations, - getters, - }); - -export default createStore(); diff --git a/app/assets/javascripts/registry/settings/store/mutation_types.js b/app/assets/javascripts/registry/settings/store/mutation_types.js deleted file mode 100644 index db499ffa761..00000000000 --- a/app/assets/javascripts/registry/settings/store/mutation_types.js +++ /dev/null @@ -1,5 +0,0 @@ -export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; -export const UPDATE_SETTINGS = 'UPDATE_SETTINGS'; -export const TOGGLE_LOADING = 'TOGGLE_LOADING'; -export const SET_SETTINGS = 'SET_SETTINGS'; -export const RESET_SETTINGS = 'RESET_SETTINGS'; diff --git a/app/assets/javascripts/registry/settings/store/mutations.js b/app/assets/javascripts/registry/settings/store/mutations.js deleted file mode 100644 index 3ba13419b98..00000000000 --- a/app/assets/javascripts/registry/settings/store/mutations.js +++ /dev/null @@ -1,29 +0,0 @@ -import { parseBoolean } from '~/lib/utils/common_utils'; -import * as types from './mutation_types'; - -export default { - [types.SET_INITIAL_STATE](state, initialState) { - state.projectId = initialState.projectId; - state.formOptions = { - cadence: JSON.parse(initialState.cadenceOptions), - keepN: JSON.parse(initialState.keepNOptions), - olderThan: JSON.parse(initialState.olderThanOptions), - }; - state.enableHistoricEntries = parseBoolean(initialState.enableHistoricEntries); - state.isAdmin = parseBoolean(initialState.isAdmin); - state.adminSettingsPath = initialState.adminSettingsPath; - }, - [types.UPDATE_SETTINGS](state, data) { - state.settings = { ...state.settings, ...data.settings }; - }, - [types.SET_SETTINGS](state, settings) { - state.settings = settings ?? state.settings; - state.original = Object.freeze(settings); - }, - [types.RESET_SETTINGS](state) { - state.settings = { ...state.original }; - }, - [types.TOGGLE_LOADING](state) { - state.isLoading = !state.isLoading; - }, -}; diff --git a/app/assets/javascripts/registry/settings/store/state.js b/app/assets/javascripts/registry/settings/store/state.js deleted file mode 100644 index fccc0991c1c..00000000000 --- a/app/assets/javascripts/registry/settings/store/state.js +++ /dev/null @@ -1,42 +0,0 @@ -export default () => ({ - /* - * Project Id used to build the API call - */ - projectId: '', - /* - * Boolean to determine if the UI is loading data from the API - */ - isLoading: false, - /* - * Boolean to determine if the user is an admin - */ - isAdmin: false, - /* - * String containing the full path to the admin config page for CI/CD - */ - adminSettingsPath: '', - /* - * Boolean to determine if project created before 12.8 can use this feature - */ - enableHistoricEntries: false, - /* - * This contains the data shown and manipulated in the UI - * Has the following structure: - * { - * enabled: Boolean - * cadence: String, - * older_than: String, - * keep_n: String, - * name_regex: String - * } - */ - settings: {}, - /* - * Same structure as settings, above but Frozen object and used only in case the user clicks 'cancel', initialized to null - */ - original: null, - /* - * Contains the options used to populate the form selects - */ - formOptions: {}, -}); 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 1ff2f6f99e5..2b8e9f6ff64 100644 --- a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue +++ b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue @@ -68,34 +68,31 @@ export default { { name: 'expiration-policy-interval', label: EXPIRATION_INTERVAL_LABEL, - model: 'older_than', - optionKey: 'olderThan', + model: 'olderThan', }, { name: 'expiration-policy-schedule', label: EXPIRATION_SCHEDULE_LABEL, model: 'cadence', - optionKey: 'cadence', }, { name: 'expiration-policy-latest', label: KEEP_N_LABEL, - model: 'keep_n', - optionKey: 'keepN', + model: 'keepN', }, ], textAreaList: [ { name: 'expiration-policy-name-matching', label: NAME_REGEX_LABEL, - model: 'name_regex', + model: 'nameRegex', placeholder: NAME_REGEX_PLACEHOLDER, description: NAME_REGEX_DESCRIPTION, }, { name: 'expiration-policy-keep-name', label: NAME_REGEX_KEEP_LABEL, - model: 'name_regex_keep', + model: 'nameRegexKeep', placeholder: NAME_REGEX_KEEP_PLACEHOLDER, description: NAME_REGEX_KEEP_DESCRIPTION, }, @@ -107,17 +104,16 @@ export default { }, computed: { ...mapComputedToEvent( - ['enabled', 'cadence', 'older_than', 'keep_n', 'name_regex', 'name_regex_keep'], + ['enabled', 'cadence', 'olderThan', 'keepN', 'nameRegex', 'nameRegexKeep'], 'value', ), policyEnabledText() { return this.enabled ? ENABLED_TEXT : DISABLED_TEXT; }, textAreaValidation() { - const nameRegexErrors = - this.apiErrors?.name_regex || this.validateRegexLength(this.name_regex); + const nameRegexErrors = this.apiErrors?.nameRegex || this.validateRegexLength(this.nameRegex); const nameKeepRegexErrors = - this.apiErrors?.name_regex_keep || this.validateRegexLength(this.name_regex_keep); + this.apiErrors?.nameRegexKeep || this.validateRegexLength(this.nameRegexKeep); return { /* @@ -127,11 +123,11 @@ export default { * 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: { + nameRegex: { state: nameRegexErrors === null ? null : !nameRegexErrors, message: nameRegexErrors, }, - name_regex_keep: { + nameRegexKeep: { state: nameKeepRegexErrors === null ? null : !nameKeepRegexErrors, message: nameKeepRegexErrors, }, @@ -139,8 +135,8 @@ export default { }, fieldsValidity() { return ( - this.textAreaValidation.name_regex.state !== false && - this.textAreaValidation.name_regex_keep.state !== false + this.textAreaValidation.nameRegex.state !== false && + this.textAreaValidation.nameRegexKeep.state !== false ); }, isFormElementDisabled() { @@ -216,11 +212,7 @@ export default { :disabled="isFormElementDisabled" @input="updateModel($event, select.model)" > - <option - v-for="option in formOptions[select.optionKey]" - :key="option.key" - :value="option.key" - > + <option v-for="option in formOptions[select.model]" :key="option.key" :value="option.key"> {{ option.label }} </option> </gl-form-select> diff --git a/app/assets/javascripts/registry/shared/constants.js b/app/assets/javascripts/registry/shared/constants.js index 36d55c7610e..735d72972e6 100644 --- a/app/assets/javascripts/registry/shared/constants.js +++ b/app/assets/javascripts/registry/shared/constants.js @@ -43,3 +43,27 @@ export const NAME_REGEX_KEEP_PLACEHOLDER = ''; export const NAME_REGEX_KEEP_DESCRIPTION = s__( 'ContainerRegistry|Wildcards such as %{codeStart}.*-master%{codeEnd} or %{codeStart}release-.*%{codeEnd} are supported', ); + +export const KEEP_N_OPTIONS = [ + { variable: 1, key: 'ONE_TAG', default: false }, + { variable: 5, key: 'FIVE_TAGS', default: false }, + { variable: 10, key: 'TEN_TAGS', default: true }, + { variable: 25, key: 'TWENTY_FIVE_TAGS', default: false }, + { variable: 50, key: 'FIFTY_TAGS', default: false }, + { variable: 100, key: 'ONE_HUNDRED_TAGS', default: false }, +]; + +export const CADENCE_OPTIONS = [ + { key: 'EVERY_DAY', label: __('Every day'), default: true }, + { key: 'EVERY_WEEK', label: __('Every week'), default: false }, + { key: 'EVERY_TWO_WEEKS', label: __('Every two weeks'), default: false }, + { key: 'EVERY_MONTH', label: __('Every month'), default: false }, + { key: 'EVERY_THREE_MONTHS', label: __('Every three months'), default: false }, +]; + +export const OLDER_THAN_OPTIONS = [ + { key: 'SEVEN_DAYS', variable: 7, default: false }, + { key: 'FOURTEEN_DAYS', variable: 14, default: false }, + { key: 'THIRTY_DAYS', variable: 30, default: false }, + { key: 'NINETY_DAYS', variable: 90, default: true }, +]; diff --git a/app/assets/javascripts/registry/shared/utils.js b/app/assets/javascripts/registry/shared/utils.js index a7377773842..bdf1ab9507d 100644 --- a/app/assets/javascripts/registry/shared/utils.js +++ b/app/assets/javascripts/registry/shared/utils.js @@ -1,3 +1,6 @@ +import { n__ } from '~/locale'; +import { KEEP_N_OPTIONS, CADENCE_OPTIONS, OLDER_THAN_OPTIONS } from './constants'; + export const findDefaultOption = options => { const item = options.find(o => o.default); return item ? item.key : null; @@ -17,3 +20,27 @@ export const mapComputedToEvent = (list, root) => { }); return result; }; + +export const olderThanTranslationGenerator = variable => + n__( + '%d day until tags are automatically removed', + '%d days until tags are automatically removed', + variable, + ); + +export const keepNTranslationGenerator = variable => + n__('%d tag per image name', '%d tags per image name', variable); + +export const optionLabelGenerator = (collection, translationFn) => + collection.map(option => ({ + ...option, + label: translationFn(option.variable), + })); + +export const formOptionsGenerator = () => { + return { + olderThan: optionLabelGenerator(OLDER_THAN_OPTIONS, olderThanTranslationGenerator), + cadence: CADENCE_OPTIONS, + keepN: optionLabelGenerator(KEEP_N_OPTIONS, keepNTranslationGenerator), + }; +}; diff --git a/app/assets/javascripts/related_issues/components/add_issuable_form.vue b/app/assets/javascripts/related_issues/components/add_issuable_form.vue index 63d61989cba..6fbae95094a 100644 --- a/app/assets/javascripts/related_issues/components/add_issuable_form.vue +++ b/app/assets/javascripts/related_issues/components/add_issuable_form.vue @@ -195,7 +195,8 @@ export default { :disabled="isSubmitButtonDisabled" :loading="isSubmitting" type="submit" - class="js-add-issuable-form-add-button float-left qa-add-issue-button" + class="js-add-issuable-form-add-button float-left" + data-qa-selector="add_issue_button" > {{ __('Add') }} </gl-button> diff --git a/app/assets/javascripts/related_issues/components/issue_token.vue b/app/assets/javascripts/related_issues/components/issue_token.vue index 31d0c7dbbb0..bbbdf2cdb49 100644 --- a/app/assets/javascripts/related_issues/components/issue_token.vue +++ b/app/assets/javascripts/related_issues/components/issue_token.vue @@ -90,6 +90,7 @@ export default { :size="12" :title="stateTitle" :aria-label="state" + data-testid="referenceIcon" /> {{ displayReference }} </component> @@ -105,6 +106,7 @@ export default { :title="removeButtonLabel" :aria-label="removeButtonLabel" :disabled="removeDisabled" + data-testid="removeBtn" type="button" class="js-issue-token-remove-button" @click="onRemoveRequest" diff --git a/app/assets/javascripts/related_issues/components/related_issuable_input.vue b/app/assets/javascripts/related_issues/components/related_issuable_input.vue index 1931cfb2c00..9809b228308 100644 --- a/app/assets/javascripts/related_issues/components/related_issuable_input.vue +++ b/app/assets/javascripts/related_issues/components/related_issuable_input.vue @@ -219,7 +219,8 @@ export default { :value="inputValue" :placeholder="inputPlaceholder" type="text" - class="js-add-issuable-form-input add-issuable-form-input qa-add-issue-input" + class="js-add-issuable-form-input add-issuable-form-input" + data-qa-selector="add_issue_field" @input="onInput" @focus="onFocus" @blur="onBlur" diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue index e1edf3d689d..3080dd0e424 100644 --- a/app/assets/javascripts/releases/components/app_edit_new.vue +++ b/app/assets/javascripts/releases/components/app_edit_new.vue @@ -1,8 +1,7 @@ <script> -/* eslint-disable vue/no-v-html */ import { mapState, mapActions, mapGetters } from 'vuex'; -import { GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui'; -import { __, sprintf } from '~/locale'; +import { GlButton, GlFormInput, GlFormGroup, GlSprintf } from '@gitlab/ui'; +import { __ } from '~/locale'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import { BACK_URL_PARAM } from '~/releases/constants'; import { getParameterByName } from '~/lib/utils/common_utils'; @@ -17,6 +16,7 @@ export default { GlFormInput, GlFormGroup, GlButton, + GlSprintf, MarkdownField, AssetLinksForm, MilestoneCombobox, @@ -41,18 +41,6 @@ export default { showForm() { return Boolean(!this.isFetchingRelease && !this.fetchError && this.release); }, - subtitleText() { - return sprintf( - __( - 'Releases are based on Git tags. We recommend tags that use semantic versioning, for example %{codeStart}v1.0%{codeEnd}, %{codeStart}v2.0-pre%{codeEnd}.', - ), - { - codeStart: '<code>', - codeEnd: '</code>', - }, - false, - ); - }, releaseTitle: { get() { return this.$store.state.detail.release.name; @@ -127,7 +115,19 @@ export default { </script> <template> <div class="d-flex flex-column"> - <p class="pt-3 js-subtitle-text" v-html="subtitleText"></p> + <p class="pt-3 js-subtitle-text"> + <gl-sprintf + :message=" + __( + 'Releases are based on Git tags. We recommend tags that use semantic versioning, for example %{codeStart}v1.0%{codeEnd}, %{codeStart}v2.0-pre%{codeEnd}.', + ) + " + > + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> <form v-if="showForm" class="js-quick-submit" @submit.prevent="submitForm"> <tag-field /> <gl-form-group> @@ -150,7 +150,7 @@ export default { /> </div> </gl-form-group> - <gl-form-group> + <gl-form-group data-testid="release-notes"> <label for="release-notes">{{ __('Release notes') }}</label> <div class="bordered-box pr-3 pl-3"> <markdown-field @@ -158,6 +158,7 @@ export default { :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" :add-spacing-classes="false" + :textarea-value="releaseNotes" class="gl-mt-3 gl-mb-3" > <template #textarea> diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue index b8cf6ce478f..422d8bf630d 100644 --- a/app/assets/javascripts/releases/components/app_index.vue +++ b/app/assets/javascripts/releases/components/app_index.vue @@ -1,29 +1,21 @@ <script> import { mapState, mapActions } from 'vuex'; -import { - GlDeprecatedSkeletonLoading as GlSkeletonLoading, - GlEmptyState, - GlLink, - GlButton, -} from '@gitlab/ui'; -import { - getParameterByName, - historyPushState, - buildUrlWithCurrentLocation, -} from '~/lib/utils/common_utils'; +import { GlEmptyState, GlLink, GlButton } from '@gitlab/ui'; +import { getParameterByName } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; -import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; import ReleaseBlock from './release_block.vue'; +import ReleasesPagination from './releases_pagination.vue'; +import ReleaseSkeletonLoader from './release_skeleton_loader.vue'; export default { name: 'ReleasesApp', components: { - GlSkeletonLoading, GlEmptyState, - ReleaseBlock, - TablePagination, GlLink, GlButton, + ReleaseBlock, + ReleasesPagination, + ReleaseSkeletonLoader, }, computed: { ...mapState('list', [ @@ -33,7 +25,6 @@ export default { 'isLoading', 'releases', 'hasError', - 'pageInfo', ]), shouldRenderEmptyState() { return !this.releases.length && !this.hasError && !this.isLoading; @@ -48,15 +39,23 @@ export default { }, }, created() { - this.fetchReleases({ - page: getParameterByName('page'), - }); + this.fetchReleases(); + + window.addEventListener('popstate', this.fetchReleases); }, methods: { - ...mapActions('list', ['fetchReleases']), - onChangePage(page) { - historyPushState(buildUrlWithCurrentLocation(`?page=${page}`)); - this.fetchReleases({ page }); + ...mapActions('list', { + fetchReleasesStoreAction: 'fetchReleases', + }), + fetchReleases() { + this.fetchReleasesStoreAction({ + // these two parameters are only used in "GraphQL mode" + before: getParameterByName('before'), + after: getParameterByName('after'), + + // this parameter is only used when in "REST mode" + page: getParameterByName('page'), + }); }, }, }; @@ -74,7 +73,7 @@ export default { {{ __('New release') }} </gl-button> - <gl-skeleton-loading v-if="isLoading" class="js-loading" /> + <release-skeleton-loader v-if="isLoading" class="js-loading" /> <gl-empty-state v-else-if="shouldRenderEmptyState" @@ -105,7 +104,7 @@ export default { /> </div> - <table-pagination v-if="!isLoading" :change="onChangePage" :page-info="pageInfo" /> + <releases-pagination v-if="!isLoading" /> </div> </template> <style> diff --git a/app/assets/javascripts/releases/components/app_show.vue b/app/assets/javascripts/releases/components/app_show.vue index 8b89f0cf3fc..9ef38503c10 100644 --- a/app/assets/javascripts/releases/components/app_show.vue +++ b/app/assets/javascripts/releases/components/app_show.vue @@ -1,13 +1,13 @@ <script> import { mapState, mapActions } from 'vuex'; -import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; import ReleaseBlock from './release_block.vue'; +import ReleaseSkeletonLoader from './release_skeleton_loader.vue'; export default { name: 'ReleaseShowApp', components: { - GlSkeletonLoading, ReleaseBlock, + ReleaseSkeletonLoader, }, computed: { ...mapState('detail', ['isFetchingRelease', 'fetchError', 'release']), @@ -22,7 +22,7 @@ export default { </script> <template> <div class="gl-mt-3"> - <gl-skeleton-loading v-if="isFetchingRelease" /> + <release-skeleton-loader v-if="isFetchingRelease" /> <release-block v-else-if="!fetchError" :release="release" /> </div> diff --git a/app/assets/javascripts/releases/components/evidence_block.vue b/app/assets/javascripts/releases/components/evidence_block.vue index 3724162f6d5..6e6017637d4 100644 --- a/app/assets/javascripts/releases/components/evidence_block.vue +++ b/app/assets/javascripts/releases/components/evidence_block.vue @@ -83,11 +83,7 @@ export default { <span class="js-expanded monospace gl-pl-2">{{ sha(index) }}</span> </template> </expand-button> - <clipboard-button - :title="__('Copy evidence SHA')" - :text="sha(index)" - css-class="btn-default btn-transparent btn-clipboard" - /> + <clipboard-button :title="__('Copy evidence SHA')" :text="sha(index)" category="tertiary" /> </div> <div class="d-flex align-items-center text-muted"> diff --git a/app/assets/javascripts/releases/components/release_block_assets.vue b/app/assets/javascripts/releases/components/release_block_assets.vue index 8824cbefd7e..60d2b3adfc9 100644 --- a/app/assets/javascripts/releases/components/release_block_assets.vue +++ b/app/assets/javascripts/releases/components/release_block_assets.vue @@ -119,7 +119,7 @@ export default { {{ section.title }} </h5> <ul :key="`section-body-${index}`" class="list-unstyled gl-m-0"> - <li v-for="link in section.links" :key="link.url"> + <li v-for="link in section.links" :key="link.url" class="gl-display-flex"> <gl-link :href="link.directAssetUrl || link.url" class="gl-display-flex gl-align-items-center gl-line-height-24" diff --git a/app/assets/javascripts/releases/components/release_block_header.vue b/app/assets/javascripts/releases/components/release_block_header.vue index 95292a26bce..87538244f1a 100644 --- a/app/assets/javascripts/releases/components/release_block_header.vue +++ b/app/assets/javascripts/releases/components/release_block_header.vue @@ -1,5 +1,5 @@ <script> -import { GlTooltipDirective, GlLink, GlBadge, GlButton, GlIcon } from '@gitlab/ui'; +import { GlTooltipDirective, GlLink, GlBadge, GlButton } from '@gitlab/ui'; import { BACK_URL_PARAM } from '~/releases/constants'; import { setUrlParams } from '~/lib/utils/url_utility'; @@ -8,7 +8,6 @@ export default { components: { GlLink, GlBadge, - GlIcon, GlButton, }, directives: { @@ -55,11 +54,10 @@ export default { v-gl-tooltip category="primary" variant="default" + icon="pencil" class="gl-mr-3 js-edit-button ml-2 pb-2" :title="__('Edit this release')" :href="editLink" - > - <gl-icon name="pencil" /> - </gl-button> + /> </div> </template> diff --git a/app/assets/javascripts/releases/components/release_skeleton_loader.vue b/app/assets/javascripts/releases/components/release_skeleton_loader.vue new file mode 100644 index 00000000000..054620af636 --- /dev/null +++ b/app/assets/javascripts/releases/components/release_skeleton_loader.vue @@ -0,0 +1,51 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; + +export default { + name: 'ReleaseSkeletonLoader', + components: { GlSkeletonLoader }, +}; +</script> +<template> + <gl-skeleton-loader :width="1248" :height="420"> + <!-- Outside border --> + <path + d="M 4.5 0 C 2.0156486 0 0 2.0156486 0 4.5 L 0 415.5 C 0 417.98435 2.0156486 420 4.5 420 L 1243.5 420 C 1245.9844 420 1248 417.98435 1248 415.5 L 1248 4.5 C 1248 2.0156486 1245.9844 0 1243.5 0 L 4.5 0 z M 4.5 1 L 1243.5 1 C 1245.4476 1 1247 2.5523514 1247 4.5 L 1247 415.5 C 1247 417.44765 1245.4476 419 1243.5 419 L 4.5 419 C 2.5523514 419 1 417.44765 1 415.5 L 1 4.5 C 1 2.5523514 2.5523514 1 4.5 1 z " + /> + + <!-- Header bottom border --> + <rect x="0" y="63.5" width="1248" height="1" /> + + <!-- Release title --> + <rect x="16" y="20" width="293" height="24" /> + + <!-- Edit (pencil) button --> + <rect x="1207" y="16" rx="4" width="32" height="32" /> + + <!-- Asset link 1 --> + <rect x="40" y="121" rx="4" width="16" height="16" /> + <rect x="60" y="125" width="116" height="8" /> + + <!-- Asset link 2 --> + <rect x="40" y="145" rx="4" width="16" height="16" /> + <rect x="60" y="149" width="132" height="8" /> + + <!-- Asset link 3 --> + <rect x="40" y="169" rx="4" width="16" height="16" /> + <rect x="60" y="173" width="140" height="8" /> + + <!-- Asset link 4 --> + <rect x="40" y="193" rx="4" width="16" height="16" /> + <rect x="60" y="197" width="112" height="8" /> + + <!-- Release notes --> + <rect x="16" y="228" width="480" height="8" /> + <rect x="16" y="252" width="560" height="8" /> + <rect x="16" y="276" width="480" height="8" /> + <rect x="16" y="300" width="560" height="8" /> + <rect x="16" y="324" width="320" height="8" /> + + <!-- Footer top border --> + <rect x="0" y="373" width="1248" height="1" /> + </gl-skeleton-loader> +</template> diff --git a/app/assets/javascripts/releases/components/releases_pagination_graphql.vue b/app/assets/javascripts/releases/components/releases_pagination_graphql.vue index a4fe407a5bd..cb6f1fa18a1 100644 --- a/app/assets/javascripts/releases/components/releases_pagination_graphql.vue +++ b/app/assets/javascripts/releases/components/releases_pagination_graphql.vue @@ -13,14 +13,14 @@ export default { }, }, methods: { - ...mapActions('list', ['fetchReleasesGraphQl']), + ...mapActions('list', ['fetchReleases']), onPrev(before) { historyPushState(buildUrlWithCurrentLocation(`?before=${before}`)); - this.fetchReleasesGraphQl({ before }); + this.fetchReleases({ before }); }, onNext(after) { historyPushState(buildUrlWithCurrentLocation(`?after=${after}`)); - this.fetchReleasesGraphQl({ after }); + this.fetchReleases({ after }); }, }, }; diff --git a/app/assets/javascripts/releases/components/releases_pagination_rest.vue b/app/assets/javascripts/releases/components/releases_pagination_rest.vue index 992cc4cd469..334458a2302 100644 --- a/app/assets/javascripts/releases/components/releases_pagination_rest.vue +++ b/app/assets/javascripts/releases/components/releases_pagination_rest.vue @@ -7,18 +7,18 @@ export default { name: 'ReleasesPaginationRest', components: { TablePagination }, computed: { - ...mapState('list', ['pageInfo']), + ...mapState('list', ['restPageInfo']), }, methods: { - ...mapActions('list', ['fetchReleasesRest']), + ...mapActions('list', ['fetchReleases']), onChangePage(page) { historyPushState(buildUrlWithCurrentLocation(`?page=${page}`)); - this.fetchReleasesRest({ page }); + this.fetchReleases({ page }); }, }, }; </script> <template> - <table-pagination :change="onChangePage" :page-info="pageInfo" /> + <table-pagination :change="onChangePage" :page-info="restPageInfo" /> </template> diff --git a/app/assets/javascripts/releases/constants.js b/app/assets/javascripts/releases/constants.js index 361cee70747..953e7b4189c 100644 --- a/app/assets/javascripts/releases/constants.js +++ b/app/assets/javascripts/releases/constants.js @@ -10,3 +10,5 @@ export const ASSET_LINK_TYPE = Object.freeze({ }); export const DEFAULT_ASSET_LINK_TYPE = ASSET_LINK_TYPE.OTHER; + +export const PAGE_SIZE = 20; diff --git a/app/assets/javascripts/releases/queries/all_releases.query.graphql b/app/assets/javascripts/releases/queries/all_releases.query.graphql index 7a99f32fdfa..e74b7769abe 100644 --- a/app/assets/javascripts/releases/queries/all_releases.query.graphql +++ b/app/assets/javascripts/releases/queries/all_releases.query.graphql @@ -1,7 +1,6 @@ -query allReleases($fullPath: ID!) { +query allReleases($fullPath: ID!, $first: Int, $last: Int, $before: String, $after: String) { project(fullPath: $fullPath) { - releases(first: 20) { - count + releases(first: $first, last: $last, before: $before, after: $after) { nodes { name tagName @@ -64,6 +63,12 @@ query allReleases($fullPath: ID!) { } } } + pageInfo { + startCursor + hasPreviousPage + hasNextPage + endCursor + } } } } diff --git a/app/assets/javascripts/releases/stores/getters.js b/app/assets/javascripts/releases/stores/getters.js new file mode 100644 index 00000000000..6a1da63289c --- /dev/null +++ b/app/assets/javascripts/releases/stores/getters.js @@ -0,0 +1,11 @@ +/** + * @returns {Boolean} `true` if all the feature flags + * required to enable the GraphQL endpoint are enabled + */ +export const useGraphQLEndpoint = rootState => { + return Boolean( + rootState.featureFlags.graphqlReleaseData && + rootState.featureFlags.graphqlReleasesPage && + rootState.featureFlags.graphqlMilestoneStats, + ); +}; diff --git a/app/assets/javascripts/releases/stores/index.js b/app/assets/javascripts/releases/stores/index.js index b2e93d789d7..cc8b586964f 100644 --- a/app/assets/javascripts/releases/stores/index.js +++ b/app/assets/javascripts/releases/stores/index.js @@ -1,7 +1,9 @@ import Vuex from 'vuex'; +import * as getters from './getters'; export default ({ modules, featureFlags }) => new Vuex.Store({ modules, state: { featureFlags }, + getters, }); diff --git a/app/assets/javascripts/releases/stores/modules/detail/actions.js b/app/assets/javascripts/releases/stores/modules/detail/actions.js index 5b682a0ab0f..2f298faf37e 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/actions.js +++ b/app/assets/javascripts/releases/stores/modules/detail/actions.js @@ -45,6 +45,9 @@ export const updateReleaseNotes = ({ commit }, notes) => commit(types.UPDATE_REL export const updateReleaseMilestones = ({ commit }, milestones) => commit(types.UPDATE_RELEASE_MILESTONES, milestones); +export const updateReleaseGroupMilestones = ({ commit }, groupMilestones) => + commit(types.UPDATE_RELEASE_GROUP_MILESTONES, groupMilestones); + export const addEmptyAssetLink = ({ commit }) => { commit(types.ADD_EMPTY_ASSET_LINK); }; diff --git a/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js b/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js index 7784e0cc741..1b2f5f33f02 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js +++ b/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js @@ -9,6 +9,7 @@ export const UPDATE_CREATE_FROM = 'UPDATE_CREATE_FROM'; export const UPDATE_RELEASE_TITLE = 'UPDATE_RELEASE_TITLE'; export const UPDATE_RELEASE_NOTES = 'UPDATE_RELEASE_NOTES'; export const UPDATE_RELEASE_MILESTONES = 'UPDATE_RELEASE_MILESTONES'; +export const UPDATE_RELEASE_GROUP_MILESTONES = 'UPDATE_RELEASE_GROUP_MILESTONES'; export const REQUEST_SAVE_RELEASE = 'REQUEST_SAVE_RELEASE'; export const RECEIVE_SAVE_RELEASE_SUCCESS = 'RECEIVE_SAVE_RELEASE_SUCCESS'; diff --git a/app/assets/javascripts/releases/stores/modules/detail/mutations.js b/app/assets/javascripts/releases/stores/modules/detail/mutations.js index 750f496665d..58a1958c5e2 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/mutations.js +++ b/app/assets/javascripts/releases/stores/modules/detail/mutations.js @@ -13,6 +13,7 @@ export default { name: '', description: '', milestones: [], + groupMilestones: [], assets: { links: [], }, @@ -51,6 +52,10 @@ export default { state.release.milestones = milestones; }, + [types.UPDATE_RELEASE_GROUP_MILESTONES](state, groupMilestones) { + state.release.groupMilestones = groupMilestones; + }, + [types.REQUEST_SAVE_RELEASE](state) { state.isUpdatingRelease = true; }, diff --git a/app/assets/javascripts/releases/stores/modules/list/actions.js b/app/assets/javascripts/releases/stores/modules/list/actions.js index 945b093b983..a7bb6a3a1d0 100644 --- a/app/assets/javascripts/releases/stores/modules/list/actions.js +++ b/app/assets/javascripts/releases/stores/modules/list/actions.js @@ -9,54 +9,89 @@ import { } from '~/lib/utils/common_utils'; import allReleasesQuery from '~/releases/queries/all_releases.query.graphql'; import { gqClient, convertGraphQLResponse } from '../../../util'; +import { PAGE_SIZE } from '../../../constants'; /** - * Commits a mutation to update the state while the main endpoint is being requested. + * Gets a paginated list of releases from the server + * + * @param {Object} vuexParams + * @param {Object} actionParams + * @param {Number} [actionParams.page] The page number of results to fetch + * (this parameter is only used when fetching results from the REST API) + * @param {String} [actionParams.before] A GraphQL cursor. If provided, + * the items returned will proceed the provided cursor (this parameter is only + * used when fetching results from the GraphQL API). + * @param {String} [actionParams.after] A GraphQL cursor. If provided, + * the items returned will follow the provided cursor (this parameter is only + * used when fetching results from the GraphQL API). */ -export const requestReleases = ({ commit }) => commit(types.REQUEST_RELEASES); +export const fetchReleases = ({ dispatch, rootGetters }, { page = 1, before, after }) => { + if (rootGetters.useGraphQLEndpoint) { + dispatch('fetchReleasesGraphQl', { before, after }); + } else { + dispatch('fetchReleasesRest', { page }); + } +}; /** - * Fetches the main endpoint. - * Will dispatch requestNamespace action before starting the request. - * Will dispatch receiveNamespaceSuccess if the request is successful - * Will dispatch receiveNamesapceError if the request returns an error - * - * @param {String} projectId + * Gets a paginated list of releases from the GraphQL endpoint */ -export const fetchReleases = ({ dispatch, rootState, state }, { page = '1' }) => { - dispatch('requestReleases'); +export const fetchReleasesGraphQl = ( + { dispatch, commit, state }, + { before = null, after = null }, +) => { + commit(types.REQUEST_RELEASES); - if ( - rootState.featureFlags.graphqlReleaseData && - rootState.featureFlags.graphqlReleasesPage && - rootState.featureFlags.graphqlMilestoneStats - ) { - gqClient - .query({ - query: allReleasesQuery, - variables: { - fullPath: state.projectPath, - }, - }) - .then(response => { - dispatch('receiveReleasesSuccess', convertGraphQLResponse(response)); - }) - .catch(() => dispatch('receiveReleasesError')); + let paginationParams; + if (!before && !after) { + paginationParams = { first: PAGE_SIZE }; + } else if (before && !after) { + paginationParams = { last: PAGE_SIZE, before }; + } else if (!before && after) { + paginationParams = { first: PAGE_SIZE, after }; } else { - api - .releases(state.projectId, { page }) - .then(response => dispatch('receiveReleasesSuccess', response)) - .catch(() => dispatch('receiveReleasesError')); + throw new Error( + 'Both a `before` and an `after` parameter were provided to fetchReleasesGraphQl. These parameters cannot be used together.', + ); } + + gqClient + .query({ + query: allReleasesQuery, + variables: { + fullPath: state.projectPath, + ...paginationParams, + }, + }) + .then(response => { + const { data, paginationInfo: graphQlPageInfo } = convertGraphQLResponse(response); + + commit(types.RECEIVE_RELEASES_SUCCESS, { + data, + graphQlPageInfo, + }); + }) + .catch(() => dispatch('receiveReleasesError')); }; -export const receiveReleasesSuccess = ({ commit }, { data, headers }) => { - const pageInfo = parseIntPagination(normalizeHeaders(headers)); - const camelCasedReleases = convertObjectPropsToCamelCase(data, { deep: true }); - commit(types.RECEIVE_RELEASES_SUCCESS, { - data: camelCasedReleases, - pageInfo, - }); +/** + * Gets a paginated list of releases from the REST endpoint + */ +export const fetchReleasesRest = ({ dispatch, commit, state }, { page }) => { + commit(types.REQUEST_RELEASES); + + api + .releases(state.projectId, { page }) + .then(({ data, headers }) => { + const restPageInfo = parseIntPagination(normalizeHeaders(headers)); + const camelCasedReleases = convertObjectPropsToCamelCase(data, { deep: true }); + + commit(types.RECEIVE_RELEASES_SUCCESS, { + data: camelCasedReleases, + restPageInfo, + }); + }) + .catch(() => dispatch('receiveReleasesError')); }; export const receiveReleasesError = ({ commit }) => { diff --git a/app/assets/javascripts/releases/stores/modules/list/mutations.js b/app/assets/javascripts/releases/stores/modules/list/mutations.js index 99fc096264a..296487cfee2 100644 --- a/app/assets/javascripts/releases/stores/modules/list/mutations.js +++ b/app/assets/javascripts/releases/stores/modules/list/mutations.js @@ -17,11 +17,12 @@ export default { * @param {Object} state * @param {Object} resp */ - [types.RECEIVE_RELEASES_SUCCESS](state, { data, pageInfo }) { + [types.RECEIVE_RELEASES_SUCCESS](state, { data, restPageInfo, graphQlPageInfo }) { state.hasError = false; state.isLoading = false; state.releases = data; - state.pageInfo = pageInfo; + state.restPageInfo = restPageInfo; + state.graphQlPageInfo = graphQlPageInfo; }, /** @@ -35,5 +36,7 @@ export default { state.isLoading = false; state.releases = []; state.hasError = true; + state.restPageInfo = {}; + state.graphQlPageInfo = {}; }, }; diff --git a/app/assets/javascripts/releases/stores/modules/list/state.js b/app/assets/javascripts/releases/stores/modules/list/state.js index 9fe313745fc..0bffaa0f9db 100644 --- a/app/assets/javascripts/releases/stores/modules/list/state.js +++ b/app/assets/javascripts/releases/stores/modules/list/state.js @@ -14,5 +14,6 @@ export default ({ isLoading: false, hasError: false, releases: [], - pageInfo: {}, + restPageInfo: {}, + graphQlPageInfo: {}, }); diff --git a/app/assets/javascripts/releases/util.js b/app/assets/javascripts/releases/util.js index d7fac7a9b65..e890b4b008d 100644 --- a/app/assets/javascripts/releases/util.js +++ b/app/assets/javascripts/releases/util.js @@ -126,5 +126,9 @@ export const convertGraphQLResponse = response => { ...convertMilestones(r), })); - return { data: releases }; + const paginationInfo = { + ...response.data.project.releases.pageInfo, + }; + + return { data: releases, paginationInfo }; }; diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index 59831890a4e..3e87833f7f5 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -1,6 +1,6 @@ <script> /* eslint-disable vue/no-v-html */ -import { GlTooltipDirective, GlLink, GlDeprecatedButton, GlLoadingIcon, GlIcon } from '@gitlab/ui'; +import { GlTooltipDirective, GlLink, GlButton, GlButtonGroup, GlLoadingIcon } from '@gitlab/ui'; import defaultAvatarUrl from 'images/no_avatar.png'; import { sprintf, s__ } from '~/locale'; import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; @@ -13,13 +13,13 @@ import pathLastCommitQuery from '../queries/path_last_commit.query.graphql'; export default { components: { - GlIcon, UserAvatarLink, TimeagoTooltip, ClipboardButton, CiIcon, + GlButton, + GlButtonGroup, GlLink, - GlDeprecatedButton, GlLoadingIcon, }, directives: { @@ -123,15 +123,14 @@ export default { class="commit-row-message item-title" v-html="commit.titleHtml" /> - <gl-deprecated-button + <gl-button v-if="commit.descriptionHtml" :class="{ open: showDescription }" :aria-label="__('Show commit description')" - class="text-expander" + class="text-expander gl-vertical-align-bottom!" + icon="ellipsis_h" @click="toggleShowDescription" - > - <gl-icon name="ellipsis_h" :size="10" /> - </gl-deprecated-button> + /> <div class="committer"> <gl-link v-if="commit.author" @@ -169,16 +168,19 @@ export default { /> </gl-link> </div> - <div class="commit-sha-group d-flex"> - <div class="label label-monospace monospace"> - {{ showCommitId }} - </div> + <gl-button-group class="gl-ml-4 js-commit-sha-group"> + <gl-button + label + class="gl-font-monospace" + data-testid="last-commit-id-label" + v-text="showCommitId" + /> <clipboard-button :text="commit.sha" :title="__('Copy commit SHA')" - tooltip-placement="bottom" + class="input-group-text" /> - </div> + </gl-button-group> </div> </div> </template> diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue index 365b6cbb550..78b8baaa75e 100644 --- a/app/assets/javascripts/repository/components/tree_content.vue +++ b/app/assets/javascripts/repository/components/tree_content.vue @@ -75,6 +75,7 @@ export default { }, methods: { fetchFiles() { + const originalPath = this.path || '/'; this.isLoadingFiles = true; return this.$apollo @@ -83,14 +84,14 @@ export default { variables: { projectPath: this.projectPath, ref: this.ref, - path: this.path || '/', + path: originalPath, nextPageCursor: this.nextPageCursor, pageSize: this.pageSize, }, }) .then(({ data }) => { if (data.errors) throw data.errors; - if (!data?.project?.repository) return; + if (!data?.project?.repository || originalPath !== (this.path || '/')) return; const pageInfo = this.hasNextPage(data.project.repository.tree); diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 7f72524b6fe..65da8f70b40 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -113,9 +113,10 @@ export default function setupVueRepositoryList() { const webIdeLinkEl = document.getElementById('js-tree-web-ide-link'); if (webIdeLinkEl) { - const { ideBasePath, ...options } = convertObjectPropsToCamelCase( - JSON.parse(webIdeLinkEl.dataset.options), - ); + const { + webIdeUrlData: { path: ideBasePath, isFork: webIdeIsFork }, + ...options + } = convertObjectPropsToCamelCase(JSON.parse(webIdeLinkEl.dataset.options), { deep: true }); // eslint-disable-next-line no-new new Vue({ @@ -127,6 +128,7 @@ export default function setupVueRepositoryList() { webIdeUrl: webIDEUrl( joinPaths('/', ideBasePath, 'edit', ref, '-', this.$route.params.path || '', '/'), ), + webIdeIsFork, ...options, }, }); diff --git a/app/assets/javascripts/repository/log_tree.js b/app/assets/javascripts/repository/log_tree.js index 361e0b62bb7..fc8fa40a855 100644 --- a/app/assets/javascripts/repository/log_tree.js +++ b/app/assets/javascripts/repository/log_tree.js @@ -5,8 +5,8 @@ import commitsQuery from './queries/commits.query.graphql'; import projectPathQuery from './queries/project_path.query.graphql'; import refQuery from './queries/ref.query.graphql'; -let fetchpromise; -let resolvers = []; +const fetchpromises = {}; +const resolvers = {}; export function resolveCommit(commits, path, { resolve, entry }) { const commit = commits.find(c => c.filePath === `${path}/${entry.name}` && c.type === entry.type); @@ -18,15 +18,19 @@ export function resolveCommit(commits, path, { resolve, entry }) { export function fetchLogsTree(client, path, offset, resolver = null) { if (resolver) { - resolvers.push(resolver); + if (!resolvers[path]) { + resolvers[path] = [resolver]; + } else { + resolvers[path].push(resolver); + } } - if (fetchpromise) return fetchpromise; + if (fetchpromises[path]) return fetchpromises[path]; const { projectPath } = client.readQuery({ query: projectPathQuery }); const { escapedRef } = client.readQuery({ query: refQuery }); - fetchpromise = axios + fetchpromises[path] = axios .get( `${gon.relative_url_root}/${projectPath}/-/refs/${escapedRef}/logs_tree/${encodeURIComponent( path.replace(/^\//, ''), @@ -46,16 +50,16 @@ export function fetchLogsTree(client, path, offset, resolver = null) { data, }); - resolvers.forEach(r => resolveCommit(data.commits, path, r)); + resolvers[path].forEach(r => resolveCommit(data.commits, path, r)); - fetchpromise = null; + delete fetchpromises[path]; if (headerLogsOffset) { fetchLogsTree(client, path, headerLogsOffset); } else { - resolvers = []; + delete resolvers[path]; } }); - return fetchpromise; + return fetchpromises[path]; } diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index 8bebd16ace7..87c8aa541d8 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -5,6 +5,7 @@ import Cookies from 'js-cookie'; import { deprecatedCreateFlash as flash } from './flash'; import axios from './lib/utils/axios_utils'; import { sprintf, s__, __ } from './locale'; +import { fixTitle, hide } from '~/tooltips'; function Sidebar() { this.toggleTodo = this.toggleTodo.bind(this); @@ -42,13 +43,17 @@ Sidebar.prototype.addEventListeners = function() { Sidebar.prototype.sidebarToggleClicked = function(e, triggered) { const $this = $(this); - const isExpanded = $this.find('i').hasClass('fa-angle-double-right'); + const $collapseIcon = $('.js-sidebar-collapse'); + const $expandIcon = $('.js-sidebar-expand'); + const $toggleContainer = $('.js-sidebar-toggle-container'); + const isExpanded = $toggleContainer.data('is-expanded'); const tooltipLabel = isExpanded ? __('Expand sidebar') : __('Collapse sidebar'); - const $allGutterToggleIcons = $('.js-sidebar-toggle i'); e.preventDefault(); if (isExpanded) { - $allGutterToggleIcons.removeClass('fa-angle-double-right').addClass('fa-angle-double-left'); + $toggleContainer.data('is-expanded', false); + $collapseIcon.addClass('hidden'); + $expandIcon.removeClass('hidden'); $('aside.right-sidebar') .removeClass('right-sidebar-expanded') .addClass('right-sidebar-collapsed'); @@ -56,7 +61,9 @@ Sidebar.prototype.sidebarToggleClicked = function(e, triggered) { .removeClass('right-sidebar-expanded') .addClass('right-sidebar-collapsed'); } else { - $allGutterToggleIcons.removeClass('fa-angle-double-left').addClass('fa-angle-double-right'); + $toggleContainer.data('is-expanded', true); + $expandIcon.addClass('hidden'); + $collapseIcon.removeClass('hidden'); $('aside.right-sidebar') .removeClass('right-sidebar-collapsed') .addClass('right-sidebar-expanded'); @@ -77,7 +84,7 @@ Sidebar.prototype.toggleTodo = function(e) { const ajaxType = $this.data('deletePath') ? 'delete' : 'post'; const url = String($this.data('deletePath') || $this.data('createPath')); - $this.tooltip('hide'); + hide($this); $('.js-issuable-todo') .disable() @@ -119,7 +126,7 @@ Sidebar.prototype.todoUpdateDone = function(data) { .data('deletePath', deletePath); if ($el.hasClass('has-tooltip')) { - $el.tooltip('_fixTitle'); + fixTitle($el); } if (typeof $el.data('isCollapsed') !== 'undefined') { diff --git a/app/assets/javascripts/search/components/dropdown_filter.vue b/app/assets/javascripts/search/components/dropdown_filter.vue new file mode 100644 index 00000000000..cd9237026f2 --- /dev/null +++ b/app/assets/javascripts/search/components/dropdown_filter.vue @@ -0,0 +1,111 @@ +<script> +import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui'; +import { setUrlParams, visitUrl } from '~/lib/utils/url_utility'; +import { sprintf, s__ } from '~/locale'; + +export default { + name: 'DropdownFilter', + components: { + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + }, + props: { + initialFilter: { + type: String, + required: false, + default: null, + }, + filters: { + type: Object, + required: true, + }, + filtersArray: { + type: Array, + required: true, + }, + header: { + type: String, + required: true, + }, + param: { + type: String, + required: true, + }, + scope: { + type: String, + required: true, + }, + supportedScopes: { + type: Array, + required: true, + }, + }, + computed: { + filter() { + return this.initialFilter || this.filters.ANY.value; + }, + selectedFilterText() { + const f = this.filtersArray.find(({ value }) => value === this.selectedFilter); + if (!f || f === this.filters.ANY) { + return sprintf(s__('Any %{header}'), { header: this.header }); + } + + return f.label; + }, + showDropdown() { + return this.supportedScopes.includes(this.scope); + }, + selectedFilter: { + get() { + if (this.filtersArray.some(({ value }) => value === this.filter)) { + return this.filter; + } + + return this.filters.ANY.value; + }, + set(filter) { + visitUrl(setUrlParams({ [this.param]: filter })); + }, + }, + }, + methods: { + dropDownItemClass(filter) { + return { + 'gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2! gl-mb-2': + filter === this.filters.ANY, + }; + }, + isFilterSelected(filter) { + return filter === this.selectedFilter; + }, + handleFilterChange(filter) { + this.selectedFilter = filter; + }, + }, +}; +</script> + +<template> + <gl-dropdown + v-if="showDropdown" + :text="selectedFilterText" + class="col-3 gl-pt-4 gl-pl-0 gl-pr-0 gl-mr-4" + menu-class="gl-w-full! gl-pl-0" + > + <header class="gl-text-center gl-font-weight-bold gl-font-lg"> + {{ header }} + </header> + <gl-dropdown-divider /> + <gl-dropdown-item + v-for="f in filtersArray" + :key="f.value" + :is-check-item="true" + :is-checked="isFilterSelected(f.value)" + :class="dropDownItemClass(f)" + @click="handleFilterChange(f.value)" + > + {{ f.label }} + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/search/confidential_filter/constants.js b/app/assets/javascripts/search/confidential_filter/constants.js new file mode 100644 index 00000000000..4665ce6a5d1 --- /dev/null +++ b/app/assets/javascripts/search/confidential_filter/constants.js @@ -0,0 +1,28 @@ +import { __ } from '~/locale'; + +export const FILTER_HEADER = __('Confidentiality'); + +export const FILTER_STATES = { + ANY: { + label: __('Any'), + value: null, + }, + CONFIDENTIAL: { + label: __('Confidential'), + value: 'yes', + }, + NOT_CONFIDENTIAL: { + label: __('Not confidential'), + value: 'no', + }, +}; + +export const SCOPES = { + ISSUES: 'issues', +}; + +export const FILTER_STATES_BY_SCOPE = { + [SCOPES.ISSUES]: [FILTER_STATES.ANY, FILTER_STATES.CONFIDENTIAL, FILTER_STATES.NOT_CONFIDENTIAL], +}; + +export const FILTER_PARAM = 'confidential'; diff --git a/app/assets/javascripts/search/confidential_filter/index.js b/app/assets/javascripts/search/confidential_filter/index.js new file mode 100644 index 00000000000..bec772be0dd --- /dev/null +++ b/app/assets/javascripts/search/confidential_filter/index.js @@ -0,0 +1,39 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import DropdownFilter from '../components/dropdown_filter.vue'; +import { + FILTER_HEADER, + FILTER_PARAM, + FILTER_STATES_BY_SCOPE, + FILTER_STATES, + SCOPES, +} from './constants'; + +Vue.use(Translate); + +export default () => { + const el = document.getElementById('js-search-filter-by-confidential'); + + if (!el) return false; + + return new Vue({ + el, + data() { + return { ...el.dataset }; + }, + + render(createElement) { + return createElement(DropdownFilter, { + props: { + initialFilter: this.filter, + filtersArray: FILTER_STATES_BY_SCOPE[this.scope], + filters: FILTER_STATES, + header: FILTER_HEADER, + param: FILTER_PARAM, + scope: this.scope, + supportedScopes: Object.values(SCOPES), + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/search/state_filter/components/state_filter.vue b/app/assets/javascripts/search/state_filter/components/state_filter.vue deleted file mode 100644 index f08adaf8c83..00000000000 --- a/app/assets/javascripts/search/state_filter/components/state_filter.vue +++ /dev/null @@ -1,94 +0,0 @@ -<script> -import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui'; -import { - FILTER_STATES, - SCOPES, - FILTER_STATES_BY_SCOPE, - FILTER_HEADER, - FILTER_TEXT, -} from '../constants'; -import { setUrlParams, visitUrl } from '~/lib/utils/url_utility'; - -const FILTERS_ARRAY = Object.values(FILTER_STATES); - -export default { - name: 'StateFilter', - components: { - GlDropdown, - GlDropdownItem, - GlDropdownDivider, - }, - props: { - scope: { - type: String, - required: true, - }, - state: { - type: String, - required: false, - default: FILTER_STATES.ANY.value, - validator: v => FILTERS_ARRAY.some(({ value }) => value === v), - }, - }, - computed: { - selectedFilterText() { - const filter = FILTERS_ARRAY.find(({ value }) => value === this.selectedFilter); - if (!filter || filter === FILTER_STATES.ANY) { - return FILTER_TEXT; - } - - return filter.label; - }, - showDropdown() { - return Object.values(SCOPES).includes(this.scope); - }, - selectedFilter: { - get() { - if (FILTERS_ARRAY.some(({ value }) => value === this.state)) { - return this.state; - } - - return FILTER_STATES.ANY.value; - }, - set(state) { - visitUrl(setUrlParams({ state })); - }, - }, - }, - methods: { - dropDownItemClass(filter) { - return { - 'gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2! gl-mb-2': - filter === FILTER_STATES.ANY, - }; - }, - isFilterSelected(filter) { - return filter === this.selectedFilter; - }, - handleFilterChange(state) { - this.selectedFilter = state; - }, - }, - filterStates: FILTER_STATES, - filterHeader: FILTER_HEADER, - filtersByScope: FILTER_STATES_BY_SCOPE, -}; -</script> - -<template> - <gl-dropdown v-if="showDropdown" :text="selectedFilterText" class="col-sm-3 gl-pt-4 gl-pl-0"> - <header class="gl-text-center gl-font-weight-bold gl-font-lg"> - {{ $options.filterHeader }} - </header> - <gl-dropdown-divider /> - <gl-dropdown-item - v-for="filter in $options.filtersByScope[scope]" - :key="filter.value" - :is-check-item="true" - :is-checked="isFilterSelected(filter.value)" - :class="dropDownItemClass(filter)" - @click="handleFilterChange(filter.value)" - >{{ filter.label }}</gl-dropdown-item - > - </gl-dropdown> -</template> diff --git a/app/assets/javascripts/search/state_filter/constants.js b/app/assets/javascripts/search/state_filter/constants.js index 2f11cab9044..00ae1bd9750 100644 --- a/app/assets/javascripts/search/state_filter/constants.js +++ b/app/assets/javascripts/search/state_filter/constants.js @@ -2,8 +2,6 @@ import { __ } from '~/locale'; export const FILTER_HEADER = __('Status'); -export const FILTER_TEXT = __('Any Status'); - export const FILTER_STATES = { ANY: { label: __('Any'), @@ -37,3 +35,5 @@ export const FILTER_STATES_BY_SCOPE = { FILTER_STATES.CLOSED, ], }; + +export const FILTER_PARAM = 'state'; diff --git a/app/assets/javascripts/search/state_filter/index.js b/app/assets/javascripts/search/state_filter/index.js index 13708574cfb..2c12885c40b 100644 --- a/app/assets/javascripts/search/state_filter/index.js +++ b/app/assets/javascripts/search/state_filter/index.js @@ -1,6 +1,13 @@ import Vue from 'vue'; import Translate from '~/vue_shared/translate'; -import StateFilter from './components/state_filter.vue'; +import DropdownFilter from '../components/dropdown_filter.vue'; +import { + FILTER_HEADER, + FILTER_PARAM, + FILTER_STATES_BY_SCOPE, + FILTER_STATES, + SCOPES, +} from './constants'; Vue.use(Translate); @@ -11,22 +18,20 @@ export default () => { return new Vue({ el, - components: { - StateFilter, - }, data() { - const { dataset } = this.$options.el; - return { - scope: dataset.scope, - state: dataset.state, - }; + return { ...el.dataset }; }, render(createElement) { - return createElement('state-filter', { + return createElement(DropdownFilter, { props: { + initialFilter: this.filter, + filtersArray: FILTER_STATES_BY_SCOPE[this.scope], + filters: FILTER_STATES, + header: FILTER_HEADER, + param: FILTER_PARAM, scope: this.scope, - state: this.state, + supportedScopes: Object.values(SCOPES), }, }); }, diff --git a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue index 1ccf5e9e032..6776a9ebb22 100644 --- a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue +++ b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue @@ -1,7 +1,7 @@ <script> /* eslint-disable vue/no-v-html */ import Vue from 'vue'; -import { GlFormGroup, GlDeprecatedButton, GlModal, GlToast, GlToggle } from '@gitlab/ui'; +import { GlFormGroup, GlButton, GlModal, GlToast, GlToggle } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; import { __, s__, sprintf } from '~/locale'; import { visitUrl, getBaseURL } from '~/lib/utils/url_utility'; @@ -11,7 +11,7 @@ Vue.use(GlToast); export default { components: { GlFormGroup, - GlDeprecatedButton, + GlButton, GlModal, GlToggle, }, @@ -123,7 +123,7 @@ export default { <h4 class="js-section-header"> {{ s__('SelfMonitoring|Self monitoring') }} </h4> - <gl-deprecated-button class="js-settings-toggle">{{ __('Expand') }}</gl-deprecated-button> + <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button> <p class="js-section-sub-header"> {{ s__('SelfMonitoring|Enable or disable instance self monitoring') }} </p> @@ -146,6 +146,7 @@ export default { :ok-title="__('Delete project')" :cancel-title="__('Cancel')" ok-variant="danger" + category="primary" @ok="deleteProject" @cancel="hideSelfMonitorModal" > diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue index e15549f5864..d662cc7b802 100644 --- a/app/assets/javascripts/serverless/components/functions.vue +++ b/app/assets/javascripts/serverless/components/functions.vue @@ -1,7 +1,6 @@ <script> -/* eslint-disable vue/no-v-html */ import { mapState, mapActions, mapGetters } from 'vuex'; -import { GlLink, GlLoadingIcon } from '@gitlab/ui'; +import { GlLink, GlLoadingIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { sprintf, s__ } from '~/locale'; import EnvironmentRow from './environment_row.vue'; import EmptyState from './empty_state.vue'; @@ -14,6 +13,9 @@ export default { GlLink, GlLoadingIcon, }, + directives: { + SafeHtml, + }, computed: { ...mapState(['installed', 'isLoading', 'hasFunctionData', 'helpPath', 'statusPath']), ...mapGetters(['getFunctions']), @@ -92,9 +94,9 @@ export default { }} </p> <ul> - <li v-html="noServerlessConfigFile"></li> - <li v-html="noGitlabYamlConfigured"></li> - <li v-html="mismatchedServerlessFunctions"></li> + <li v-safe-html="noServerlessConfigFile"></li> + <li v-safe-html="noGitlabYamlConfigured"></li> + <li v-safe-html="mismatchedServerlessFunctions"></li> <li>{{ s__('Serverless|The deploy job has not finished.') }}</li> </ul> diff --git a/app/assets/javascripts/serverless/components/missing_prometheus.vue b/app/assets/javascripts/serverless/components/missing_prometheus.vue index 0d2c9f5151c..0b83d4b36eb 100644 --- a/app/assets/javascripts/serverless/components/missing_prometheus.vue +++ b/app/assets/javascripts/serverless/components/missing_prometheus.vue @@ -1,11 +1,11 @@ <script> -import { GlDeprecatedButton, GlLink } from '@gitlab/ui'; +import { GlButton, GlLink } from '@gitlab/ui'; import { mapState } from 'vuex'; import { s__ } from '../../locale'; export default { components: { - GlDeprecatedButton, + GlButton, GlLink, }, props: { @@ -47,9 +47,9 @@ export default { </p> <div v-if="!missingData" class="text-left"> - <gl-deprecated-button :href="clustersPath" variant="success"> + <gl-button :href="clustersPath" variant="success" category="primary"> {{ s__('ServerlessDetails|Install Prometheus') }} - </gl-deprecated-button> + </gl-button> </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 86bfacbfb9e..46d51138ccf 100644 --- a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue +++ b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue @@ -1,6 +1,6 @@ <script> import $ from 'jquery'; -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { mapActions } from 'vuex'; import { __ } from '~/locale'; import { deprecatedCreateFlash as Flash } from '~/flash'; @@ -8,7 +8,7 @@ import eventHub from '../../event_hub'; export default { components: { - GlLoadingIcon, + GlButton, }, props: { fullPath: { @@ -64,18 +64,18 @@ export default { <template> <div class="sidebar-item-warning-message-actions"> - <button type="button" class="btn btn-default gl-mr-3" @click="closeForm"> + <gl-button class="gl-mr-3" @click="closeForm"> {{ __('Cancel') }} - </button> - <button - type="button" - class="btn btn-close" - data-testid="confidential-toggle" + </gl-button> + <gl-button + category="secondary" + variant="warning" :disabled="isLoading" + :loading="isLoading" + data-testid="confidential-toggle" @click.prevent="submitForm" > - <gl-loading-icon v-if="isLoading" inline /> {{ toggleButtonText }} - </button> + </gl-button> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue index d7be8927c29..0851ee21289 100644 --- a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue +++ b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue @@ -89,6 +89,7 @@ export default { :labels-select-in-progress="labelsSelectInProgress" :selected-labels="selectedLabels" :variant="$options.sidebar" + data-qa-selector="labels_block" @onDropdownClose="handleDropdownClose" @updateSelectedLabels="handleUpdateSelectedLabels" > 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 ea7230ae488..26a7c8e4a80 100644 --- a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue +++ b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue @@ -1,6 +1,6 @@ <script> import $ from 'jquery'; -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { mapActions } from 'vuex'; import { __, sprintf } from '../../../locale'; import { deprecatedCreateFlash as Flash } from '~/flash'; @@ -8,7 +8,7 @@ import eventHub from '../../event_hub'; export default { components: { - GlLoadingIcon, + GlButton, }, inject: ['fullPath'], props: { @@ -65,19 +65,19 @@ export default { <template> <div class="sidebar-item-warning-message-actions"> - <button type="button" class="btn btn-default gl-mr-3" @click="closeForm"> + <gl-button class="gl-mr-3" @click="closeForm"> {{ __('Cancel') }} - </button> + </gl-button> - <button - type="button" + <gl-button data-testid="lock-toggle" - class="btn btn-close" + category="secondary" + variant="warning" :disabled="isLoading" + :loading="isLoading" @click.prevent="submitForm" > - <gl-loading-icon v-if="isLoading" inline /> {{ buttonText }} - </button> + </gl-button> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer.vue b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer.vue new file mode 100644 index 00000000000..6de926e0ff9 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer.vue @@ -0,0 +1,24 @@ +<script> +// NOTE! For the first iteration, we are simply copying the implementation of Assignees +// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 +import ReviewerAvatar from './reviewer_avatar.vue'; + +export default { + components: { + ReviewerAvatar, + }, + props: { + user: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <button type="button" class="btn-link"> + <reviewer-avatar :user="user" :img-size="24" /> + <span class="author"> {{ user.name }} </span> + </button> +</template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue new file mode 100644 index 00000000000..45707c18f7b --- /dev/null +++ b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue @@ -0,0 +1,107 @@ +<script> +// NOTE! For the first iteration, we are simply copying the implementation of Assignees +// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import CollapsedReviewer from './collapsed_reviewer.vue'; + +const DEFAULT_MAX_COUNTER = 99; +const DEFAULT_RENDER_COUNT = 5; + +export default { + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + CollapsedReviewer, + GlIcon, + }, + props: { + users: { + type: Array, + required: true, + }, + }, + computed: { + hasNoUsers() { + return !this.users.length; + }, + hasMoreThanOneReviewer() { + return this.users.length > 1; + }, + hasMoreThanTwoReviewers() { + return this.users.length > 2; + }, + allReviewersCanMerge() { + return this.users.every(user => user.can_merge); + }, + sidebarAvatarCounter() { + if (this.users.length > DEFAULT_MAX_COUNTER) { + return `${DEFAULT_MAX_COUNTER}+`; + } + + return `+${this.users.length - 1}`; + }, + collapsedUsers() { + const collapsedLength = this.hasMoreThanTwoReviewers ? 1 : this.users.length; + + return this.users.slice(0, collapsedLength); + }, + tooltipTitleMergeStatus() { + const mergeLength = this.users.filter(u => u.can_merge).length; + + if (mergeLength === this.users.length) { + return ''; + } else if (mergeLength > 0) { + return sprintf(__('%{mergeLength}/%{usersLength} can merge'), { + mergeLength, + usersLength: this.users.length, + }); + } + + return this.users.length === 1 ? __('cannot merge') : __('no one can merge'); + }, + tooltipTitle() { + const maxRender = Math.min(DEFAULT_RENDER_COUNT, this.users.length); + const renderUsers = this.users.slice(0, maxRender); + const names = renderUsers.map(u => u.name); + + if (!this.users.length) { + return __('Reviewer(s)'); + } + + if (this.users.length > names.length) { + names.push(sprintf(__('+ %{amount} more'), { amount: this.users.length - names.length })); + } + + const text = names.join(', '); + + return this.tooltipTitleMergeStatus ? `${text} (${this.tooltipTitleMergeStatus})` : text; + }, + + tooltipOptions() { + return { container: 'body', placement: 'left', boundary: 'viewport' }; + }, + }, +}; +</script> + +<template> + <div + v-gl-tooltip="tooltipOptions" + :class="{ 'multiple-users': hasMoreThanOneReviewer }" + :title="tooltipTitle" + class="sidebar-collapsed-icon sidebar-collapsed-user" + > + <gl-icon v-if="hasNoUsers" name="user" :aria-label="__('None')" /> + <collapsed-reviewer v-for="user in collapsedUsers" :key="user.id" :user="user" /> + <button v-if="hasMoreThanTwoReviewers" class="btn-link" type="button"> + <span class="avatar-counter sidebar-avatar-counter"> {{ sidebarAvatarCounter }} </span> + <i + v-if="!allReviewersCanMerge" + aria-hidden="true" + class="fa fa-exclamation-triangle merge-icon" + ></i> + </button> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue new file mode 100644 index 00000000000..9fa3fa38eac --- /dev/null +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue @@ -0,0 +1,43 @@ +<script> +// NOTE! For the first iteration, we are simply copying the implementation of Assignees +// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 +import { __, sprintf } from '~/locale'; + +export default { + props: { + user: { + type: Object, + required: true, + }, + imgSize: { + type: Number, + required: true, + }, + }, + computed: { + reviewerAlt() { + return sprintf(__("%{userName}'s avatar"), { userName: this.user.name }); + }, + avatarUrl() { + return this.user.avatar || this.user.avatar_url || gon.default_avatar_url; + }, + hasMergeIcon() { + return !this.user.can_merge; + }, + }, +}; +</script> + +<template> + <span class="position-relative"> + <img + :alt="reviewerAlt" + :src="avatarUrl" + :width="imgSize" + :class="`s${imgSize}`" + class="avatar avatar-inline m-0" + data-qa-selector="avatar_image" + /> + <i v-if="hasMergeIcon" aria-hidden="true" class="fa fa-exclamation-triangle merge-icon"></i> + </span> +</template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue new file mode 100644 index 00000000000..b1b04564a62 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue @@ -0,0 +1,84 @@ +<script> +// NOTE! For the first iteration, we are simply copying the implementation of Assignees +// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 +import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import ReviewerAvatar from './reviewer_avatar.vue'; + +export default { + components: { + ReviewerAvatar, + GlLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + user: { + type: Object, + required: true, + }, + rootPath: { + type: String, + required: true, + }, + tooltipPlacement: { + type: String, + default: 'bottom', + required: false, + }, + tooltipHasName: { + type: Boolean, + default: true, + required: false, + }, + issuableType: { + type: String, + default: 'issue', + required: false, + }, + }, + computed: { + cannotMerge() { + return this.issuableType === 'merge_request' && !this.user.can_merge; + }, + tooltipTitle() { + if (this.cannotMerge && this.tooltipHasName) { + return sprintf(__('%{userName} (cannot merge)'), { userName: this.user.name }); + } else if (this.cannotMerge) { + return __('Cannot merge'); + } else if (this.tooltipHasName) { + return this.user.name; + } + + return ''; + }, + tooltipOption() { + return { + container: 'body', + placement: this.tooltipPlacement, + boundary: 'viewport', + }; + }, + reviewerUrl() { + return this.user.web_url; + }, + }, +}; +</script> + +<template> + <!-- must be `d-inline-block` or parent flex-basis causes width issues --> + <gl-link + v-gl-tooltip="tooltipOption" + :href="reviewerUrl" + :title="tooltipTitle" + class="d-inline-block" + > + <!-- use d-flex so that slot can be appropriately styled --> + <span class="d-flex"> + <reviewer-avatar :user="user" :img-size="32" :issuable-type="issuableType" /> + <slot :user="user"></slot> + </span> + </gl-link> +</template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue new file mode 100644 index 00000000000..437f28907fd --- /dev/null +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue @@ -0,0 +1,64 @@ +<script> +// NOTE! For the first iteration, we are simply copying the implementation of Assignees +// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 +import { GlLoadingIcon } from '@gitlab/ui'; +import { n__ } from '~/locale'; + +export default { + name: 'ReviewerTitle', + components: { + GlLoadingIcon, + }, + props: { + loading: { + type: Boolean, + required: false, + default: false, + }, + numberOfReviewers: { + type: Number, + required: true, + }, + editable: { + type: Boolean, + required: true, + }, + showToggle: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + reviewerTitle() { + const reviewers = this.numberOfReviewers; + return n__('Reviewer', `%d Reviewers`, reviewers); + }, + }, +}; +</script> +<template> + <div class="title hide-collapsed"> + {{ reviewerTitle }} + <gl-loading-icon v-if="loading" inline class="align-bottom" /> + <a + v-if="editable" + class="js-sidebar-dropdown-toggle edit-link float-right" + href="#" + data-track-event="click_edit_button" + data-track-label="right_sidebar" + data-track-property="reviewer" + > + {{ __('Edit') }} + </a> + <a + v-if="showToggle" + :aria-label="__('Toggle sidebar')" + class="gutter-toggle float-right js-sidebar-toggle" + href="#" + role="button" + > + <i aria-hidden="true" data-hidden="true" class="fa fa-angle-double-right"></i> + </a> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue new file mode 100644 index 00000000000..6a3d88f6385 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue @@ -0,0 +1,72 @@ +<script> +// NOTE! For the first iteration, we are simply copying the implementation of Assignees +// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 +import CollapsedReviewerList from './collapsed_reviewer_list.vue'; +import UncollapsedReviewerList from './uncollapsed_reviewer_list.vue'; + +export default { + // name: 'Reviewers' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives + // eslint-disable-next-line @gitlab/require-i18n-strings + name: 'Reviewers', + components: { + CollapsedReviewerList, + UncollapsedReviewerList, + }, + props: { + rootPath: { + type: String, + required: true, + }, + users: { + type: Array, + required: true, + }, + editable: { + type: Boolean, + required: true, + }, + issuableType: { + type: String, + required: false, + default: 'issue', + }, + }, + computed: { + hasNoUsers() { + return !this.users.length; + }, + sortedReviewers() { + const canMergeUsers = this.users.filter(user => user.can_merge); + const canNotMergeUsers = this.users.filter(user => !user.can_merge); + + return [...canMergeUsers, ...canNotMergeUsers]; + }, + }, + methods: { + assignSelf() { + this.$emit('assign-self'); + }, + }, +}; +</script> + +<template> + <div> + <collapsed-reviewer-list :users="sortedReviewers" :issuable-type="issuableType" /> + + <div class="value hide-collapsed"> + <template v-if="hasNoUsers"> + <span class="assign-yourself no-value qa-assign-yourself"> + {{ __('None') }} + </span> + </template> + + <uncollapsed-reviewer-list + v-else + :users="sortedReviewers" + :root-path="rootPath" + :issuable-type="issuableType" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue new file mode 100644 index 00000000000..5d8a2e6fa65 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue @@ -0,0 +1,107 @@ +<script> +// NOTE! For the first iteration, we are simply copying the implementation of Assignees +// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 +import { deprecatedCreateFlash as Flash } from '~/flash'; +import eventHub from '~/sidebar/event_hub'; +import Store from '~/sidebar/stores/sidebar_store'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import ReviewerTitle from './reviewer_title.vue'; +import Reviewers from './reviewers.vue'; +import { __ } from '~/locale'; + +export default { + name: 'SidebarReviewers', + components: { + ReviewerTitle, + Reviewers, + }, + mixins: [glFeatureFlagsMixin()], + props: { + mediator: { + type: Object, + required: true, + }, + field: { + type: String, + required: true, + }, + signedIn: { + type: Boolean, + required: false, + default: false, + }, + issuableType: { + type: String, + required: false, + default: 'issue', + }, + issuableIid: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + }, + data() { + return { + store: new Store(), + loading: false, + }; + }, + created() { + this.removeReviewer = this.store.removeReviewer.bind(this.store); + this.addReviewer = this.store.addReviewer.bind(this.store); + this.removeAllReviewers = this.store.removeAllReviewers.bind(this.store); + + // Get events from deprecatedJQueryDropdown + eventHub.$on('sidebar.removeReviewer', this.removeReviewer); + eventHub.$on('sidebar.addReviewer', this.addReviewer); + eventHub.$on('sidebar.removeAllReviewers', this.removeAllReviewers); + eventHub.$on('sidebar.saveReviewers', this.saveReviewers); + }, + beforeDestroy() { + eventHub.$off('sidebar.removeReviewer', this.removeReviewer); + eventHub.$off('sidebar.addReviewer', this.addReviewer); + eventHub.$off('sidebar.removeAllReviewers', this.removeAllReviewers); + eventHub.$off('sidebar.saveReviewers', this.saveReviewers); + }, + methods: { + saveReviewers() { + this.loading = true; + + this.mediator + .saveReviewers(this.field) + .then(() => { + this.loading = false; + // Uncomment once this issue has been addressed > https://gitlab.com/gitlab-org/gitlab/-/issues/237922 + // refreshUserMergeRequestCounts(); + }) + .catch(() => { + this.loading = false; + return new Flash(__('Error occurred when saving reviewers')); + }); + }, + }, +}; +</script> + +<template> + <div> + <reviewer-title + :number-of-reviewers="store.reviewers.length" + :loading="loading || store.isFetching.reviewers" + :editable="store.editable" + :show-toggle="!signedIn" + /> + <reviewers + v-if="!store.isFetching.reviewers" + :root-path="store.rootPath" + :users="store.reviewers" + :editable="store.editable" + :issuable-type="issuableType" + class="value" + /> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue new file mode 100644 index 00000000000..2ae4a114b36 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue @@ -0,0 +1,103 @@ +<script> +// NOTE! For the first iteration, we are simply copying the implementation of Assignees +// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 +import { __, sprintf } from '~/locale'; +import ReviewerAvatarLink from './reviewer_avatar_link.vue'; + +const DEFAULT_RENDER_COUNT = 5; + +export default { + components: { + ReviewerAvatarLink, + }, + props: { + users: { + type: Array, + required: true, + }, + rootPath: { + type: String, + required: true, + }, + issuableType: { + type: String, + required: false, + default: 'issue', + }, + }, + data() { + return { + showLess: true, + }; + }, + computed: { + firstUser() { + return this.users[0]; + }, + hasOneUser() { + return this.users.length === 1; + }, + hiddenReviewersLabel() { + const { numberOfHiddenReviewers } = this; + return sprintf(__('+ %{numberOfHiddenReviewers} more'), { numberOfHiddenReviewers }); + }, + renderShowMoreSection() { + return this.users.length > DEFAULT_RENDER_COUNT; + }, + numberOfHiddenReviewers() { + return this.users.length - DEFAULT_RENDER_COUNT; + }, + uncollapsedUsers() { + const uncollapsedLength = this.showLess + ? Math.min(this.users.length, DEFAULT_RENDER_COUNT) + : this.users.length; + return this.showLess ? this.users.slice(0, uncollapsedLength) : this.users; + }, + username() { + return `@${this.firstUser.username}`; + }, + }, + methods: { + toggleShowLess() { + this.showLess = !this.showLess; + }, + }, +}; +</script> + +<template> + <reviewer-avatar-link + v-if="hasOneUser" + #default="{ user }" + tooltip-placement="left" + :tooltip-has-name="false" + :user="firstUser" + :root-path="rootPath" + :issuable-type="issuableType" + > + <div class="ml-2"> + <span class="author"> {{ user.name }} </span> + <span class="username"> {{ username }} </span> + </div> + </reviewer-avatar-link> + <div v-else> + <div class="user-list"> + <div v-for="user in uncollapsedUsers" :key="user.id" class="user-item"> + <reviewer-avatar-link :user="user" :root-path="rootPath" :issuable-type="issuableType" /> + </div> + </div> + <div v-if="renderShowMoreSection" class="user-list-more"> + <button + type="button" + class="btn-link" + data-qa-selector="more_reviewers_link" + @click="toggleShowLess" + > + <template v-if="showLess"> + {{ hiddenReviewersLabel }} + </template> + <template v-else>{{ __('- show less') }}</template> + </button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue index 05ad7b4ea3e..406677941b7 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue @@ -26,11 +26,14 @@ export default { methods: { listenForQuickActions() { $(document).on('ajax:success', '.gfm-form', this.quickActionListened); + eventHub.$on('timeTrackingUpdated', data => { - this.quickActionListened(null, data); + this.quickActionListened({ detail: [data] }); }); }, - quickActionListened(e, data) { + quickActionListened(e) { + const data = e.detail[0]; + const subscribedCommands = ['spend_time', 'time_estimate']; let changedCommands; if (data !== undefined) { diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index be559b16420..a25a7b0b2fe 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -5,6 +5,7 @@ import Vuex from 'vuex'; import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue'; import SidebarAssignees from './components/assignees/sidebar_assignees.vue'; import SidebarLabels from './components/labels/sidebar_labels.vue'; +import SidebarReviewers from './components/reviewers/sidebar_reviewers.vue'; import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue'; import SidebarMoveIssue from './lib/sidebar_move_issue'; import IssuableLockForm from './components/lock/issuable_lock_form.vue'; @@ -13,17 +14,17 @@ import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptio import SidebarSeverity from './components/severity/sidebar_severity.vue'; import Translate from '../vue_shared/translate'; import createDefaultClient from '~/lib/graphql'; -import { store } from '~/notes/stores'; -import { isInIssuePage, parseBoolean } from '~/lib/utils/common_utils'; -import mergeRequestStore from '~/mr_notes/stores'; +import { isInIssuePage, isInIncidentPage, parseBoolean } from '~/lib/utils/common_utils'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store'; Vue.use(Translate); Vue.use(VueApollo); Vue.use(Vuex); -function getSidebarOptions() { - return JSON.parse(document.querySelector('.js-sidebar-options').innerHTML); +function getSidebarOptions(sidebarOptEl = document.querySelector('.js-sidebar-options')) { + return JSON.parse(sidebarOptEl.innerHTML); } function mountAssigneesComponent(mediator) { @@ -50,6 +51,36 @@ function mountAssigneesComponent(mediator) { projectPath: fullPath, field: el.dataset.field, signedIn: el.hasAttribute('data-signed-in'), + issuableType: isInIssuePage() || isInIncidentPage() ? 'issue' : 'merge_request', + }, + }), + }); +} + +function mountReviewersComponent(mediator) { + const el = document.getElementById('js-vue-sidebar-reviewers'); + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + if (!el) return; + + const { iid, fullPath } = getSidebarOptions(); + // eslint-disable-next-line no-new + new Vue({ + el, + apolloProvider, + components: { + SidebarReviewers, + }, + render: createElement => + createElement('sidebar-reviewers', { + props: { + mediator, + issuableIid: String(iid), + projectPath: fullPath, + field: el.dataset.field, + signedIn: el.hasAttribute('data-signed-in'), issuableType: isInIssuePage() ? 'issue' : 'merge_request', }, }), @@ -89,47 +120,74 @@ function mountConfidentialComponent(mediator) { const dataNode = document.getElementById('js-confidential-issue-data'); const initialData = JSON.parse(dataNode.innerHTML); - // eslint-disable-next-line no-new - new Vue({ - el, - store, - components: { - ConfidentialIssueSidebar, - }, - render: createElement => - createElement('confidential-issue-sidebar', { - props: { - iid: String(iid), - fullPath, - isEditable: initialData.is_editable, - service: mediator.service, - }, - }), - }); + import(/* webpackChunkName: 'notesStore' */ '~/notes/stores') + .then( + ({ store }) => + new Vue({ + el, + store, + components: { + ConfidentialIssueSidebar, + }, + render: createElement => + createElement('confidential-issue-sidebar', { + props: { + iid: String(iid), + fullPath, + isEditable: initialData.is_editable, + service: mediator.service, + }, + }), + }), + ) + .catch(() => { + createFlash({ message: __('Failed to load sidebar confidential toggle') }); + }); } function mountLockComponent() { const el = document.getElementById('js-lock-entry-point'); + + if (!el) { + return; + } + const { fullPath } = getSidebarOptions(); const dataNode = document.getElementById('js-lock-issue-data'); const initialData = JSON.parse(dataNode.innerHTML); - return el - ? new Vue({ - el, - store: isInIssuePage() ? store : mergeRequestStore, - provide: { - fullPath, - }, - render: createElement => - createElement(IssuableLockForm, { - props: { - isEditable: initialData.is_editable, - }, - }), - }) - : undefined; + let importStore; + if (isInIssuePage() || isInIncidentPage()) { + importStore = import(/* webpackChunkName: 'notesStore' */ '~/notes/stores').then( + ({ store }) => store, + ); + } else { + importStore = import(/* webpackChunkName: 'mrNotesStore' */ '~/mr_notes/stores').then( + store => store.default, + ); + } + + importStore + .then( + store => + new Vue({ + el, + store, + provide: { + fullPath, + }, + render: createElement => + createElement(IssuableLockForm, { + props: { + isEditable: initialData.is_editable, + }, + }), + }), + ) + .catch(() => { + createFlash({ message: __('Failed to load sidebar lock status') }); + }); } function mountParticipantsComponent(mediator) { @@ -218,8 +276,9 @@ function mountSeverityComponent() { export function mountSidebar(mediator) { mountAssigneesComponent(mediator); + mountReviewersComponent(mediator); mountConfidentialComponent(mediator); - mountLockComponent(mediator); + mountLockComponent(); mountParticipantsComponent(mediator); mountSubscriptionsComponent(mediator); diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js index 8f1f76a2e02..2146fb83b13 100644 --- a/app/assets/javascripts/sidebar/sidebar_mediator.js +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -40,6 +40,17 @@ export default class SidebarMediator { return this.service.update(field, data); } + saveReviewers(field) { + const selected = this.store.reviewers.map(u => u.id); + + // If there are no ids, that means we have to unassign (which is id = 0) + // And it only accepts an array, hence [0] + const reviewers = selected.length === 0 ? [0] : selected; + const data = { reviewer_ids: reviewers }; + + return this.service.update(field, data); + } + setMoveToProjectId(projectId) { this.store.setMoveToProjectId(projectId); } @@ -55,6 +66,7 @@ export default class SidebarMediator { processFetchedData(data) { this.store.setAssigneeData(data); + this.store.setReviewerData(data); this.store.setTimeTrackingData(data); this.store.setParticipantsData(data); this.store.setSubscriptionsData(data); diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js index 095f93b72a9..8d0d093e920 100644 --- a/app/assets/javascripts/sidebar/stores/sidebar_store.js +++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js @@ -18,8 +18,10 @@ export default class SidebarStore { this.humanTimeSpent = ''; this.timeTrackingLimitToHours = timeTrackingLimitToHours; this.assignees = []; + this.reviewers = []; this.isFetching = { assignees: true, + reviewers: true, participants: true, subscriptions: true, }; @@ -42,6 +44,13 @@ export default class SidebarStore { } } + setReviewerData(data) { + this.isFetching.reviewers = false; + if (data.reviewers) { + this.reviewers = data.reviewers; + } + } + setTimeTrackingData(data) { this.timeEstimate = data.time_estimate; this.totalTimeSpent = data.total_time_spent; @@ -75,20 +84,40 @@ export default class SidebarStore { } } + addReviewer(reviewer) { + if (!this.findReviewer(reviewer)) { + this.reviewers.push(reviewer); + } + } + findAssignee(findAssignee) { return this.assignees.find(assignee => assignee.id === findAssignee.id); } + findReviewer(findReviewer) { + return this.reviewers.find(reviewer => reviewer.id === findReviewer.id); + } + removeAssignee(removeAssignee) { if (removeAssignee) { this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id); } } + removeReviewer(removeReviewer) { + if (removeReviewer) { + this.reviewers = this.reviewers.filter(reviewer => reviewer.id !== removeReviewer.id); + } + } + removeAllAssignees() { this.assignees = []; } + removeAllReviewers() { + this.reviewers = []; + } + setAssigneesFromRealtime(data) { this.assignees = data; } diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js index 586d1e62c2f..5fa6cef7195 100644 --- a/app/assets/javascripts/single_file_diff.js +++ b/app/assets/javascripts/single_file_diff.js @@ -57,16 +57,10 @@ export default class SingleFileDiff { this.content.hide(); this.$toggleIcon.addClass('fa-caret-right').removeClass('fa-caret-down'); this.collapsedContent.show(); - if (typeof gl.diffNotesCompileComponents !== 'undefined') { - gl.diffNotesCompileComponents(); - } } else if (this.content) { this.collapsedContent.hide(); this.content.show(); this.$toggleIcon.addClass('fa-caret-down').removeClass('fa-caret-right'); - if (typeof gl.diffNotesCompileComponents !== 'undefined') { - gl.diffNotesCompileComponents(); - } } else { this.$toggleIcon.addClass('fa-caret-down').removeClass('fa-caret-right'); return this.getContentHTML(cb); @@ -90,10 +84,6 @@ export default class SingleFileDiff { } this.collapsedContent.after(this.content); - if (typeof gl.diffNotesCompileComponents !== 'undefined') { - gl.diffNotesCompileComponents(); - } - const $file = $(this.file); FilesCommentButton.init($file); diff --git a/app/assets/javascripts/snippet/snippet_show.js b/app/assets/javascripts/snippet/snippet_show.js index bbddfc579c5..1899ff91f87 100644 --- a/app/assets/javascripts/snippet/snippet_show.js +++ b/app/assets/javascripts/snippet/snippet_show.js @@ -1,21 +1,33 @@ -import LineHighlighter from '~/line_highlighter'; -import BlobViewer from '~/blob/viewer'; -import ZenMode from '~/zen_mode'; import initNotes from '~/init_notes'; -import snippetEmbed from '~/snippet/snippet_embed'; -import { SnippetShowInit } from '~/snippets'; import loadAwardsHandler from '~/awards_handler'; -document.addEventListener('DOMContentLoaded', () => { - if (!gon.features.snippetsVue) { - new LineHighlighter(); // eslint-disable-line no-new - new BlobViewer(); // eslint-disable-line no-new - initNotes(); - new ZenMode(); // eslint-disable-line no-new - snippetEmbed(); - } else { - SnippetShowInit(); - initNotes(); - } - loadAwardsHandler(); -}); +if (!gon.features.snippetsVue) { + const LineHighlighterModule = import('~/line_highlighter'); + const BlobViewerModule = import('~/blob/viewer'); + const ZenModeModule = import('~/zen_mode'); + const SnippetEmbedModule = import('~/snippet/snippet_embed'); + + Promise.all([LineHighlighterModule, BlobViewerModule, ZenModeModule, SnippetEmbedModule]) + .then( + ([ + { default: LineHighlighter }, + { default: BlobViewer }, + { default: ZenMode }, + { default: SnippetEmbed }, + ]) => { + new LineHighlighter(); // eslint-disable-line no-new + new BlobViewer(); // eslint-disable-line no-new + new ZenMode(); // eslint-disable-line no-new + SnippetEmbed(); + }, + ) + .catch(() => {}); +} else { + import('~/snippets') + .then(({ SnippetShowInit }) => { + SnippetShowInit(); + }) + .catch(() => {}); +} +initNotes(); +loadAwardsHandler(); diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue index 1a539aa0876..e15aa10bd81 100644 --- a/app/assets/javascripts/snippets/components/edit.vue +++ b/app/assets/javascripts/snippets/components/edit.vue @@ -151,7 +151,7 @@ export default { this.newSnippet = false; }, onSnippetFetch(snippetRes) { - if (snippetRes.data.snippets.edges.length === 0) { + if (snippetRes.data.snippets.nodes.length === 0) { this.onNewSnippetFetched(); } else { this.onExistingSnippetFetched(); diff --git a/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue index 55cd13a6930..23fb9979ba0 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue @@ -149,6 +149,7 @@ export default { data-testid="add_button" class="gl-my-3" variant="dashed" + data-qa-selector="add_file_button" @click="addBlob" >{{ addLabel }}</gl-button > diff --git a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue index f3f894ed649..ab7ef0d50a5 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue @@ -69,7 +69,7 @@ export default { }; </script> <template> - <div class="file-holder snippet"> + <div class="file-holder snippet" data-qa-selector="file_holder_container"> <blob-header-edit :id="inputId" :value="blob.path" diff --git a/app/assets/javascripts/snippets/components/snippet_blob_view.vue b/app/assets/javascripts/snippets/components/snippet_blob_view.vue index b38be5bb9a4..e88126ea56a 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_view.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_view.vue @@ -23,6 +23,7 @@ export default { return { ids: this.snippet.id, rich: this.activeViewerType === RICH_BLOB_VIEWER, + paths: [this.blob.path], }; }, update(data) { @@ -79,8 +80,10 @@ export default { }, onContentUpdate(data) { const { path: blobPath } = this.blob; - const { blobs } = data.snippets.edges[0].node; - const updatedBlobData = blobs.find(blob => blob.path === blobPath); + const { + blobs: { nodes: dataBlobs }, + } = data.snippets.nodes[0]; + const updatedBlobData = dataBlobs.find(blob => blob.path === blobPath); return updatedBlobData.richData || updatedBlobData.plainData; }, }, diff --git a/app/assets/javascripts/snippets/components/snippet_description_edit.vue b/app/assets/javascripts/snippets/components/snippet_description_edit.vue index 737845d09b8..5e6caf27bdd 100644 --- a/app/assets/javascripts/snippets/components/snippet_description_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_description_edit.vue @@ -49,6 +49,7 @@ export default { :add-spacing-classes="false" :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" + :textarea-value="value" > <template #textarea> <textarea diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue index 0ca69f3161a..30de5a9d0e0 100644 --- a/app/assets/javascripts/snippets/components/snippet_header.vue +++ b/app/assets/javascripts/snippets/components/snippet_header.vue @@ -18,6 +18,7 @@ import DeleteSnippetMutation from '../mutations/deleteSnippet.mutation.graphql'; import CanCreatePersonalSnippet from '../queries/userPermissions.query.graphql'; import CanCreateProjectSnippet from '../queries/projectPermissions.query.graphql'; import { joinPaths } from '~/lib/utils/url_utility'; +import { fetchPolicies } from '~/lib/graphql'; export default { components: { @@ -37,6 +38,7 @@ export default { }, apollo: { canCreateSnippet: { + fetchPolicy: fetchPolicies.NO_CACHE, query() { return this.snippet.project ? CanCreateProjectSnippet : CanCreatePersonalSnippet; }, diff --git a/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql b/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql index 2cca71708ca..d75b4011d1c 100644 --- a/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql +++ b/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql @@ -12,18 +12,20 @@ fragment SnippetBase on Snippet { httpUrlToRepo sshUrlToRepo blobs { - binary - name - path - rawPath - size - externalStorage - renderedAsText - simpleViewer { - ...BlobViewer - } - richViewer { - ...BlobViewer + nodes { + binary + name + path + rawPath + size + externalStorage + renderedAsText + simpleViewer { + ...BlobViewer + } + richViewer { + ...BlobViewer + } } } userPermissions { diff --git a/app/assets/javascripts/snippets/index.js b/app/assets/javascripts/snippets/index.js index c70ad9b95f8..d3caec42ce7 100644 --- a/app/assets/javascripts/snippets/index.js +++ b/app/assets/javascripts/snippets/index.js @@ -3,8 +3,6 @@ import VueApollo from 'vue-apollo'; import Translate from '~/vue_shared/translate'; import createDefaultClient from '~/lib/graphql'; -import SnippetsShow from './components/show.vue'; -import SnippetsEdit from './components/edit.vue'; import { SNIPPET_LEVELS_MAP, SNIPPET_VISIBILITY_PRIVATE } from '~/snippets/constants'; Vue.use(VueApollo); @@ -16,7 +14,7 @@ function appFactory(el, Component) { } const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), + defaultClient: createDefaultClient({}, { batchMax: 1 }), }); const { @@ -48,9 +46,17 @@ function appFactory(el, Component) { } export const SnippetShowInit = () => { - appFactory(document.getElementById('js-snippet-view'), SnippetsShow); + import('./components/show.vue') + .then(({ default: SnippetsShow }) => { + appFactory(document.getElementById('js-snippet-view'), SnippetsShow); + }) + .catch(() => {}); }; export const SnippetEditInit = () => { - appFactory(document.getElementById('js-snippet-edit'), SnippetsEdit); + import('./components/edit.vue') + .then(({ default: SnippetsEdit }) => { + appFactory(document.getElementById('js-snippet-edit'), SnippetsEdit); + }) + .catch(() => {}); }; diff --git a/app/assets/javascripts/snippets/mixins/snippets.js b/app/assets/javascripts/snippets/mixins/snippets.js index 15daaa8d84a..d5e69e2a889 100644 --- a/app/assets/javascripts/snippets/mixins/snippets.js +++ b/app/assets/javascripts/snippets/mixins/snippets.js @@ -11,9 +11,16 @@ export const getSnippetMixin = { ids: this.snippetGid, }; }, - update: data => data.snippets.edges[0]?.node, + update: data => { + const res = data.snippets.nodes[0]; + if (res) { + res.blobs = res.blobs.nodes; + } + + return res; + }, result(res) { - this.blobs = res.data.snippets.edges[0]?.node?.blobs || blobsDefault; + this.blobs = res.data.snippets.nodes[0]?.blobs || blobsDefault; if (this.onSnippetFetch) { this.onSnippetFetch(res); } diff --git a/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql b/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql index 8f1f16b76c2..0e04ee9b7b8 100644 --- a/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql +++ b/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql @@ -1,9 +1,9 @@ -query SnippetBlobContent($ids: [ID!], $rich: Boolean!) { +query SnippetBlobContent($ids: [ID!], $rich: Boolean!, $paths: [String!]) { snippets(ids: $ids) { - edges { - node { - id - blobs { + nodes { + id + blobs(paths: $paths) { + nodes { path richData @include(if: $rich) plainData @skip(if: $rich) diff --git a/app/assets/javascripts/snippets/queries/snippet.query.graphql b/app/assets/javascripts/snippets/queries/snippet.query.graphql index b23ab862439..2f385050d89 100644 --- a/app/assets/javascripts/snippets/queries/snippet.query.graphql +++ b/app/assets/javascripts/snippets/queries/snippet.query.graphql @@ -4,13 +4,11 @@ query GetSnippetQuery($ids: [ID!]) { snippets(ids: $ids) { - edges { - node { - ...SnippetBase - ...SnippetProject - author { - ...Author - } + nodes { + ...SnippetBase + ...SnippetProject + author { + ...Author } } } diff --git a/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue b/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue index 2d62964cb3b..5f00f9f22f3 100644 --- a/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue +++ b/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue @@ -41,7 +41,7 @@ export default { :disabled="savingChanges" @click="$emit('editSettings')" > - {{ __('Settings') }} + {{ __('Page settings') }} </gl-button> <gl-button ref="submit" diff --git a/app/assets/javascripts/static_site_editor/graphql/index.js b/app/assets/javascripts/static_site_editor/graphql/index.js index 0a5d8c07ad9..fbb3d7fbfcc 100644 --- a/app/assets/javascripts/static_site_editor/graphql/index.js +++ b/app/assets/javascripts/static_site_editor/graphql/index.js @@ -4,6 +4,7 @@ import createDefaultClient from '~/lib/graphql'; import typeDefs from './typedefs.graphql'; import fileResolver from './resolvers/file'; import submitContentChangesResolver from './resolvers/submit_content_changes'; +import hasSubmittedChangesResolver from './resolvers/has_submitted_changes'; Vue.use(VueApollo); @@ -15,6 +16,7 @@ const createApolloProvider = appData => { }, Mutation: { submitContentChanges: submitContentChangesResolver, + hasSubmittedChanges: hasSubmittedChangesResolver, }, }, { diff --git a/app/assets/javascripts/static_site_editor/graphql/mutations/has_submitted_changes.mutation.graphql b/app/assets/javascripts/static_site_editor/graphql/mutations/has_submitted_changes.mutation.graphql new file mode 100644 index 00000000000..1f47929556a --- /dev/null +++ b/app/assets/javascripts/static_site_editor/graphql/mutations/has_submitted_changes.mutation.graphql @@ -0,0 +1,5 @@ +mutation hasSubmittedChanges($input: HasSubmittedChangesInput) { + hasSubmittedChanges(input: $input) @client { + hasSubmittedChanges + } +} 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 946d80efff0..9f4b0afe55f 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 @@ -1,6 +1,7 @@ query appData { appData @client { isSupportedContent + hasSubmittedChanges project sourcePath username diff --git a/app/assets/javascripts/static_site_editor/graphql/resolvers/has_submitted_changes.js b/app/assets/javascripts/static_site_editor/graphql/resolvers/has_submitted_changes.js new file mode 100644 index 00000000000..ce55db7f3e5 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/graphql/resolvers/has_submitted_changes.js @@ -0,0 +1,17 @@ +import query from '../queries/app_data.query.graphql'; + +const hasSubmittedChangesResolver = (_, { input: { hasSubmittedChanges } }, { cache }) => { + const { appData } = cache.readQuery({ query }); + cache.writeQuery({ + query, + data: { + appData: { + __typename: 'AppData', + ...appData, + hasSubmittedChanges, + }, + }, + }); +}; + +export default hasSubmittedChangesResolver; 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 0cb26f88785..694cf762e51 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,22 +3,27 @@ import savedContentMetaQuery from '../queries/saved_content_meta.query.graphql'; const submitContentChangesResolver = ( _, - { input: { project: projectId, username, sourcePath, content, images } }, + { input: { project: projectId, username, sourcePath, content, images, mergeRequestMeta } }, { cache }, ) => { - return submitContentChanges({ projectId, username, sourcePath, content, images }).then( - savedContentMeta => { - cache.writeQuery({ - query: savedContentMetaQuery, - data: { - savedContentMeta: { - __typename: 'SavedContentMeta', - ...savedContentMeta, - }, + return submitContentChanges({ + projectId, + username, + sourcePath, + content, + images, + mergeRequestMeta, + }).then(savedContentMeta => { + cache.writeQuery({ + query: savedContentMetaQuery, + data: { + savedContentMeta: { + __typename: 'SavedContentMeta', + ...savedContentMeta, }, - }); - }, - ); + }, + }); + }); }; export default submitContentChangesResolver; diff --git a/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql b/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql index 78cc1746cdb..0ded1722d26 100644 --- a/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql +++ b/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql @@ -16,12 +16,17 @@ type SavedContentMeta { type AppData { isSupportedContent: Boolean! + hasSubmittedChanges: Boolean! project: String! returnUrl: String sourcePath: String! username: String! } +input HasSubmittedChangesInput { + hasSubmittedChanges: Boolean! +} + input SubmitContentChangesInput { project: String! sourcePath: String! @@ -40,4 +45,5 @@ extend type Query { extend type Mutation { submitContentChanges(input: SubmitContentChangesInput!): SavedContentMeta + hasSubmittedChanges(input: HasSubmittedChangesInput!): AppData } diff --git a/app/assets/javascripts/static_site_editor/index.js b/app/assets/javascripts/static_site_editor/index.js index b7e5ea4eee3..fceef8f9084 100644 --- a/app/assets/javascripts/static_site_editor/index.js +++ b/app/assets/javascripts/static_site_editor/index.js @@ -12,13 +12,23 @@ const initStaticSiteEditor = el => { namespace, project, mergeRequestsIllustrationPath, + // NOTE: The following variables are not yet used, but are supported by the config file, + // so we are adding them here as a convenience for future use. + // eslint-disable-next-line no-unused-vars + staticSiteGenerator, + // eslint-disable-next-line no-unused-vars + imageUploadPath, + mounts, } = el.dataset; + // NOTE that the object in 'mounts' is a JSON string from the data attribute, so it must be parsed into an object. + // eslint-disable-next-line no-unused-vars + const mountsObject = JSON.parse(mounts); const { current_username: username } = window.gon; const returnUrl = el.dataset.returnUrl || null; - const router = createRouter(baseUrl); const apolloProvider = createApolloProvider({ isSupportedContent: parseBoolean(isSupportedContent), + hasSubmittedChanges: false, project: `${namespace}/${project}`, returnUrl, sourcePath, diff --git a/app/assets/javascripts/static_site_editor/pages/home.vue b/app/assets/javascripts/static_site_editor/pages/home.vue index eef2bd88f0e..d48917e8f36 100644 --- a/app/assets/javascripts/static_site_editor/pages/home.vue +++ b/app/assets/javascripts/static_site_editor/pages/home.vue @@ -1,13 +1,16 @@ <script> +import { deprecatedCreateFlash as createFlash } from '~/flash'; +import { s__, sprintf } from '~/locale'; +import Tracking from '~/tracking'; + import SkeletonLoader from '../components/skeleton_loader.vue'; import EditArea from '../components/edit_area.vue'; import InvalidContentMessage from '../components/invalid_content_message.vue'; import SubmitChangesError from '../components/submit_changes_error.vue'; import appDataQuery from '../graphql/queries/app_data.query.graphql'; import sourceContentQuery from '../graphql/queries/source_content.query.graphql'; +import hasSubmittedChangesMutation from '../graphql/mutations/has_submitted_changes.mutation.graphql'; import submitContentChangesMutation from '../graphql/mutations/submit_content_changes.mutation.graphql'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; -import Tracking from '~/tracking'; import { LOAD_CONTENT_ERROR, TRACKING_ACTION_INITIALIZE_EDITOR } from '../constants'; import { SUCCESS_ROUTE } from '../router/constants'; @@ -74,6 +77,20 @@ export default { submitChanges(images) { this.isSavingChanges = true; + // eslint-disable-next-line promise/catch-or-return + this.$apollo + .mutate({ + mutation: hasSubmittedChangesMutation, + variables: { + input: { + hasSubmittedChanges: true, + }, + }, + }) + .finally(() => { + this.$router.push(SUCCESS_ROUTE); + }); + this.$apollo .mutate({ mutation: submitContentChangesMutation, @@ -84,12 +101,15 @@ export default { sourcePath: this.appData.sourcePath, content: this.content, images, + mergeRequestMeta: { + title: sprintf(s__(`StaticSiteEditor|Update %{sourcePath} file`), { + sourcePath: this.appData.sourcePath, + }), + description: s__('StaticSiteEditor|Copy update'), + }, }, }, }) - .then(() => { - this.$router.push(SUCCESS_ROUTE); - }) .catch(e => { this.submitChangesError = e.message; }) diff --git a/app/assets/javascripts/static_site_editor/pages/success.vue b/app/assets/javascripts/static_site_editor/pages/success.vue index f0d597d7c9b..5b013c27c35 100644 --- a/app/assets/javascripts/static_site_editor/pages/success.vue +++ b/app/assets/javascripts/static_site_editor/pages/success.vue @@ -1,5 +1,5 @@ <script> -import { GlEmptyState, GlButton } from '@gitlab/ui'; +import { GlButton, GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; import { s__, __, sprintf } from '~/locale'; import savedContentMetaQuery from '../graphql/queries/saved_content_meta.query.graphql'; @@ -8,8 +8,9 @@ import { HOME_ROUTE } from '../router/constants'; export default { components: { - GlEmptyState, GlButton, + GlEmptyState, + GlLoadingIcon, }, props: { mergeRequestsIllustrationPath: { @@ -33,7 +34,7 @@ export default { }, }, created() { - if (!this.savedContentMeta) { + if (!this.appData.hasSubmittedChanges) { this.$router.push(HOME_ROUTE); } }, @@ -50,14 +51,21 @@ export default { assignMergeRequestInstruction: s__( 'StaticSiteEditor|3. Assign a person to review and accept the merge request.', ), + submittingTitle: s__('StaticSiteEditor|Creating your merge request'), + submittingNotePrimary: s__( + 'StaticSiteEditor|You can set an assignee to get your changes reviewed and deployed once your merge request is created.', + ), + submittingNoteSecondary: s__( + 'StaticSiteEditor|A link to view the merge request will appear once ready.', + ), }; </script> <template> - <div - v-if="savedContentMeta" - class="container gl-flex-grow-1 gl-display-flex gl-flex-direction-column" - > - <div class="gl-fixed gl-left-0 gl-right-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-100"> + <div class="container gl-flex-grow-1 gl-display-flex gl-flex-direction-column"> + <div + v-if="savedContentMeta" + class="gl-fixed gl-left-0 gl-right-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-100" + > <div class="container gl-py-4"> <gl-button v-if="appData.returnUrl" @@ -73,16 +81,24 @@ export default { </div> <gl-empty-state class="gl-my-9" - :primary-button-text="$options.primaryButtonText" - :title="$options.title" - :primary-button-link="savedContentMeta.mergeRequest.url" + :title="savedContentMeta ? $options.title : $options.submittingTitle" + :primary-button-text="savedContentMeta && $options.primaryButtonText" + :primary-button-link="savedContentMeta && savedContentMeta.mergeRequest.url" :svg-path="mergeRequestsIllustrationPath" + :svg-height="146" > <template #description> - <p>{{ $options.mergeRequestInstructionsHeading }}</p> - <p>{{ $options.addTitleInstruction }}</p> - <p>{{ $options.addDescriptionInstruction }}</p> - <p>{{ $options.assignMergeRequestInstruction }}</p> + <div v-if="savedContentMeta"> + <p>{{ $options.mergeRequestInstructionsHeading }}</p> + <p>{{ $options.addTitleInstruction }}</p> + <p>{{ $options.addDescriptionInstruction }}</p> + <p>{{ $options.assignMergeRequestInstruction }}</p> + </div> + <div v-else> + <p>{{ $options.submittingNotePrimary }}</p> + <p>{{ $options.submittingNoteSecondary }}</p> + <gl-loading-icon size="xl" /> + </div> </template> </gl-empty-state> </div> diff --git a/app/assets/javascripts/static_site_editor/services/front_matterify.js b/app/assets/javascripts/static_site_editor/services/front_matterify.js new file mode 100644 index 00000000000..cbf0fffd515 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/services/front_matterify.js @@ -0,0 +1,73 @@ +import jsYaml from 'js-yaml'; + +const NEW_LINE = '\n'; + +const hasMatter = (firstThreeChars, fourthChar) => { + const isYamlDelimiter = firstThreeChars === '---'; + const isFourthCharNewline = fourthChar === NEW_LINE; + return isYamlDelimiter && isFourthCharNewline; +}; + +export const frontMatterify = source => { + let index = 3; + let offset; + const delimiter = source.slice(0, index); + const type = 'yaml'; + const NO_FRONTMATTER = { + source, + matter: null, + spacing: null, + content: source, + delimiter: null, + type: null, + }; + + if (!hasMatter(delimiter, source.charAt(index))) { + return NO_FRONTMATTER; + } + + offset = source.indexOf(delimiter, index); + + // Finds the end delimiter that starts at a new line + while (offset !== -1 && source.charAt(offset - 1) !== NEW_LINE) { + index = offset + delimiter.length; + offset = source.indexOf(delimiter, index); + } + + if (offset === -1) { + return NO_FRONTMATTER; + } + + const matterStr = source.slice(index, offset); + const matter = jsYaml.safeLoad(matterStr); + + let content = source.slice(offset + delimiter.length); + let spacing = ''; + let idx = 0; + while (content.charAt(idx).match(/(\s|\n)/)) { + spacing += content.charAt(idx); + idx += 1; + } + content = content.replace(spacing, ''); + + return { + source, + matter, + spacing, + content, + delimiter, + type, + }; +}; + +export const stringify = ({ matter, spacing, content, delimiter }, newMatter) => { + const matterObj = newMatter || matter; + + if (!matterObj) { + return content; + } + + const header = `${delimiter}${NEW_LINE}${jsYaml.safeDump(matterObj)}${delimiter}`; + const body = `${spacing}${content}`; + return `${header}${body}`; +}; 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 640186ee1d0..d4fc8b2edb6 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 @@ -1,7 +1,7 @@ -import grayMatter from 'gray-matter'; +import { frontMatterify, stringify } from './front_matterify'; const parseSourceFile = raw => { - const remake = source => grayMatter(source, {}); + const remake = source => frontMatterify(source); let editable = remake(raw); @@ -13,20 +13,17 @@ const parseSourceFile = raw => { } }; - const trimmedEditable = () => grayMatter.stringify(editable).trim(); + const content = (isBody = false) => (isBody ? editable.content : stringify(editable)); - const content = (isBody = false) => (isBody ? editable.content.trim() : trimmedEditable()); // gray-matter internally adds an eof newline so we trim to bypass, open issue: https://github.com/jonschlinkert/gray-matter/issues/96 - - const matter = () => editable.data; + const matter = () => editable.matter; const syncMatter = settings => { - const source = grayMatter.stringify(editable.content, settings); - syncContent(source); + editable.matter = settings; }; - const isModified = () => trimmedEditable() !== raw; + const isModified = () => stringify(editable) !== raw; - const hasMatter = () => editable.matter.length > 0; + const hasMatter = () => Boolean(editable.matter); return { matter, 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 da62d3fa4fc..8623a671a7d 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 @@ -1,6 +1,5 @@ import Api from '~/api'; import Tracking from '~/tracking'; -import { s__, sprintf } from '~/locale'; import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils'; import generateBranchName from '~/static_site_editor/services/generate_branch_name'; @@ -71,6 +70,7 @@ const commitContent = (projectId, message, branch, sourcePath, content, images) const createMergeRequest = ( projectId, title, + description, sourceBranch, targetBranch = DEFAULT_TARGET_BRANCH, ) => { @@ -80,6 +80,7 @@ const createMergeRequest = ( projectId, convertObjectPropsToSnakeCase({ title, + description, sourceBranch, targetBranch, }), @@ -88,11 +89,16 @@ const createMergeRequest = ( }); }; -const submitContentChanges = ({ username, projectId, sourcePath, content, images }) => { +const submitContentChanges = ({ + username, + projectId, + sourcePath, + content, + images, + mergeRequestMeta, +}) => { const branch = generateBranchName(username); - const mergeRequestTitle = sprintf(s__(`StaticSiteEditor|Update %{sourcePath} file`), { - sourcePath, - }); + const { title: mergeRequestTitle, description: mergeRequestDescription } = mergeRequestMeta; const meta = {}; return createBranch(projectId, branch) @@ -104,7 +110,7 @@ const submitContentChanges = ({ username, projectId, sourcePath, content, images .then(({ data: { short_id: label, web_url: url } }) => { Object.assign(meta, { commit: { label, url } }); - return createMergeRequest(projectId, mergeRequestTitle, branch); + return createMergeRequest(projectId, mergeRequestTitle, mergeRequestDescription, branch); }) .then(({ data: { iid: label, web_url: url } }) => { Object.assign(meta, { mergeRequest: { label: label.toString(), url } }); diff --git a/app/assets/javascripts/static_site_editor/services/templater.js b/app/assets/javascripts/static_site_editor/services/templater.js index a1c1bb6b8d6..318f2099064 100644 --- a/app/assets/javascripts/static_site_editor/services/templater.js +++ b/app/assets/javascripts/static_site_editor/services/templater.js @@ -15,7 +15,7 @@ const markPrefix = `${marker}-${Date.now()}`; const reHelpers = { template: `.| |\\t|\\n(?!(\\n|${markPrefix}))`, - openTag: '<[a-zA-Z]+.*?>', + openTag: '<(?!iframe)[a-zA-Z]+.*?>', closeTag: '</.+>', }; const reTemplated = new RegExp(`(^${wrapPrefix}(${reHelpers.template})+?${wrapPostfix}$)`, 'gm'); diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js index f2b05946a08..b51951674d5 100644 --- a/app/assets/javascripts/task_list.js +++ b/app/assets/javascripts/task_list.js @@ -31,6 +31,15 @@ export default class TaskList { init() { this.disable(); // Prevent duplicate event bindings + const taskListFields = document.querySelectorAll( + `${this.taskListContainerSelector} .js-task-list-field[data-value]`, + ); + + taskListFields.forEach(taskListField => { + // eslint-disable-next-line no-param-reassign + taskListField.value = taskListField.dataset.value; + }); + $(this.taskListContainerSelector).taskList('enable'); $(document).on('tasklist:changed', this.taskListContainerSelector, this.updateHandler); } diff --git a/app/assets/javascripts/tooltips/index.js b/app/assets/javascripts/tooltips/index.js index cfbd88d6c40..debb36dc53f 100644 --- a/app/assets/javascripts/tooltips/index.js +++ b/app/assets/javascripts/tooltips/index.js @@ -58,6 +58,8 @@ const applyToElements = (elements, handler) => toArray(elements).forEach(handler const invokeBootstrapApi = (elements, method) => { if (isFunction(elements.tooltip)) { + elements.tooltip(method); + } else { jQuery(elements).tooltip(method); } }; diff --git a/app/assets/javascripts/user_lists/components/add_user_modal.vue b/app/assets/javascripts/user_lists/components/add_user_modal.vue new file mode 100644 index 00000000000..a8dde1f681e --- /dev/null +++ b/app/assets/javascripts/user_lists/components/add_user_modal.vue @@ -0,0 +1,72 @@ +<script> +import { GlModal, GlFormGroup, GlFormTextarea } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { ADD_USER_MODAL_ID } from '../constants/show'; + +export default { + components: { + GlFormGroup, + GlFormTextarea, + GlModal, + }, + props: { + visible: { + type: Boolean, + required: false, + default: false, + }, + }, + modalOptions: { + actionPrimary: { + text: s__('UserLists|Add'), + attributes: [{ 'data-testid': 'confirm-add-user-ids' }], + }, + actionCancel: { + text: s__('UserLists|Cancel'), + attributes: [{ 'data-testid': 'cancel-add-user-ids' }], + }, + modalId: ADD_USER_MODAL_ID, + static: true, + }, + translations: { + title: s__('UserLists|Add users'), + description: s__( + 'UserLists|Enter a comma separated list of user IDs. These IDs should be the users of the system in which the feature flag is set, not GitLab IDs', + ), + userIdsLabel: s__('UserLists|User IDs'), + }, + data() { + return { + userIds: '', + }; + }, + methods: { + submitUsers() { + this.$emit('addUsers', this.userIds); + this.clearInput(); + }, + clearInput() { + this.userIds = ''; + }, + }, +}; +</script> +<template> + <gl-modal + v-bind="$options.modalOptions" + :visible="visible" + data-testid="add-users-modal" + @primary="submitUsers" + @canceled="clearInput" + > + <template #modal-title> + {{ $options.translations.title }} + </template> + <template #default> + <p data-testid="add-userids-description">{{ $options.translations.description }}</p> + <gl-form-group label-for="add-user-ids" :label="$options.translations.userIdsLabel"> + <gl-form-textarea id="add-user-ids" v-model="userIds" /> + </gl-form-group> + </template> + </gl-modal> +</template> diff --git a/app/assets/javascripts/user_lists/components/edit_user_list.vue b/app/assets/javascripts/user_lists/components/edit_user_list.vue new file mode 100644 index 00000000000..d56c3d61027 --- /dev/null +++ b/app/assets/javascripts/user_lists/components/edit_user_list.vue @@ -0,0 +1,74 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; +import statuses from '../constants/edit'; +import UserListForm from './user_list_form.vue'; + +export default { + components: { + GlAlert, + GlLoadingIcon, + UserListForm, + }, + inject: ['userListsDocsPath'], + translations: { + saveButtonLabel: s__('UserLists|Save'), + }, + computed: { + ...mapState(['userList', 'status', 'errorMessage']), + title() { + return sprintf(s__('UserLists|Edit %{name}'), { name: this.userList?.name }); + }, + isLoading() { + return this.status === statuses.LOADING; + }, + isError() { + return this.status === statuses.ERROR; + }, + hasUserList() { + return Boolean(this.userList); + }, + }, + mounted() { + this.fetchUserList(); + }, + methods: { + ...mapActions(['fetchUserList', 'updateUserList', 'dismissErrorAlert']), + }, +}; +</script> +<template> + <div> + <gl-alert + v-if="isError" + :dismissible="hasUserList" + variant="danger" + @dismiss="dismissErrorAlert" + > + <ul class="gl-mb-0"> + <li v-for="(message, index) in errorMessage" :key="index"> + {{ message }} + </li> + </ul> + </gl-alert> + + <gl-loading-icon v-if="isLoading" size="xl" /> + + <template v-else-if="hasUserList"> + <h3 + data-testid="user-list-title" + class="gl-font-weight-bold gl-pb-5 gl-border-b-solid gl-border-gray-100 gl-border-1" + > + {{ title }} + </h3> + <user-list-form + :cancel-path="userList.path" + :save-button-label="$options.translations.saveButtonLabel" + :user-lists-docs-path="userListsDocsPath" + :user-list="userList" + @submit="updateUserList" + /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/user_lists/components/new_user_list.vue b/app/assets/javascripts/user_lists/components/new_user_list.vue new file mode 100644 index 00000000000..522e077fb25 --- /dev/null +++ b/app/assets/javascripts/user_lists/components/new_user_list.vue @@ -0,0 +1,50 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { GlAlert } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import UserListForm from './user_list_form.vue'; + +export default { + components: { + GlAlert, + UserListForm, + }, + inject: ['userListsDocsPath', 'featureFlagsPath'], + translations: { + pageTitle: s__('UserLists|New list'), + createButtonLabel: s__('UserLists|Create'), + }, + computed: { + ...mapState(['userList', 'errorMessage']), + isError() { + return Array.isArray(this.errorMessage) && this.errorMessage.length > 0; + }, + }, + methods: { + ...mapActions(['createUserList', 'dismissErrorAlert']), + }, +}; +</script> +<template> + <div> + <gl-alert v-if="isError" variant="danger" @dismiss="dismissErrorAlert"> + <ul class="gl-mb-0"> + <li v-for="(message, index) in errorMessage" :key="index"> + {{ message }} + </li> + </ul> + </gl-alert> + + <h3 class="gl-font-weight-bold gl-pb-5 gl-border-b-solid gl-border-gray-100 gl-border-1"> + {{ $options.translations.pageTitle }} + </h3> + + <user-list-form + :cancel-path="featureFlagsPath" + :save-button-label="$options.translations.createButtonLabel" + :user-lists-docs-path="userListsDocsPath" + :user-list="userList" + @submit="createUserList" + /> + </div> +</template> diff --git a/app/assets/javascripts/user_lists/components/user_list.vue b/app/assets/javascripts/user_lists/components/user_list.vue new file mode 100644 index 00000000000..0e2b72c1423 --- /dev/null +++ b/app/assets/javascripts/user_lists/components/user_list.vue @@ -0,0 +1,142 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { + GlAlert, + GlButton, + GlEmptyState, + GlLoadingIcon, + GlModalDirective as GlModal, +} from '@gitlab/ui'; +import { s__, __ } from '~/locale'; +import { states, ADD_USER_MODAL_ID } from '../constants/show'; +import AddUserModal from './add_user_modal.vue'; + +const commonTableClasses = ['gl-py-5', 'gl-border-b-1', 'gl-border-b-solid', 'gl-border-gray-100']; + +export default { + components: { + GlAlert, + GlButton, + GlEmptyState, + GlLoadingIcon, + AddUserModal, + }, + directives: { + GlModal, + }, + props: { + emptyStatePath: { + required: true, + type: String, + }, + }, + translations: { + addUserButtonLabel: s__('UserLists|Add Users'), + emptyStateTitle: s__('UserLists|There are no users'), + emptyStateDescription: s__( + 'UserLists|Define a set of users to be used within feature flag strategies', + ), + userIdLabel: s__('UserLists|User IDs'), + userIdColumnHeader: s__('UserLists|User ID'), + errorMessage: __('Something went wrong on our end. Please try again!'), + editButtonLabel: s__('UserLists|Edit'), + }, + classes: { + headerClasses: [ + 'gl-display-flex', + 'gl-justify-content-space-between', + 'gl-pb-5', + 'gl-border-b-1', + 'gl-border-b-solid', + 'gl-border-gray-100', + ].join(' '), + tableHeaderClasses: commonTableClasses.join(' '), + tableRowClasses: [ + ...commonTableClasses, + 'gl-display-flex', + 'gl-justify-content-space-between', + 'gl-align-items-center', + ].join(' '), + }, + ADD_USER_MODAL_ID, + computed: { + ...mapState(['userList', 'userIds', 'state']), + name() { + return this.userList?.name ?? ''; + }, + hasUserIds() { + return this.userIds.length > 0; + }, + isLoading() { + return this.state === states.LOADING; + }, + hasError() { + return this.state === states.ERROR; + }, + editPath() { + return this.userList?.edit_path; + }, + }, + mounted() { + this.fetchUserList(); + }, + methods: { + ...mapActions(['fetchUserList', 'dismissErrorAlert', 'removeUserId', 'addUserIds']), + }, +}; +</script> +<template> + <div> + <gl-alert v-if="hasError" variant="danger" @dismiss="dismissErrorAlert"> + {{ $options.translations.errorMessage }} + </gl-alert> + <gl-loading-icon v-if="isLoading" size="xl" class="gl-mt-6" /> + <div v-else> + <add-user-modal @addUsers="addUserIds" /> + <div :class="$options.classes.headerClasses"> + <div> + <h3>{{ name }}</h3> + <h4 class="gl-text-gray-500">{{ $options.translations.userIdLabel }}</h4> + </div> + <div class="gl-mt-6"> + <gl-button v-if="editPath" :href="editPath" data-testid="edit-user-list" class="gl-mr-3"> + {{ $options.translations.editButtonLabel }} + </gl-button> + <gl-button + v-gl-modal="$options.ADD_USER_MODAL_ID" + data-testid="add-users" + variant="success" + > + {{ $options.translations.addUserButtonLabel }} + </gl-button> + </div> + </div> + <div v-if="hasUserIds"> + <div :class="$options.classes.tableHeaderClasses"> + {{ $options.translations.userIdColumnHeader }} + </div> + <div + v-for="id in userIds" + :key="id" + data-testid="user-id-row" + :class="$options.classes.tableRowClasses" + > + <span data-testid="user-id">{{ id }}</span> + <gl-button + category="secondary" + variant="danger" + icon="remove" + data-testid="delete-user-id" + @click="removeUserId(id)" + /> + </div> + </div> + <gl-empty-state + v-else + :title="$options.translations.emptyStateTitle" + :description="$options.translations.emptyStateDescription" + :svg-path="emptyStatePath" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/user_lists/components/user_list_form.vue b/app/assets/javascripts/user_lists/components/user_list_form.vue new file mode 100644 index 00000000000..657acb51fee --- /dev/null +++ b/app/assets/javascripts/user_lists/components/user_list_form.vue @@ -0,0 +1,97 @@ +<script> +import { GlButton, GlFormGroup, GlFormInput, GlLink, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + components: { + GlButton, + GlFormGroup, + GlFormInput, + GlLink, + GlSprintf, + }, + props: { + cancelPath: { + type: String, + required: true, + }, + saveButtonLabel: { + type: String, + required: true, + }, + userListsDocsPath: { + type: String, + required: true, + }, + userList: { + type: Object, + required: true, + }, + }, + classes: { + actionContainer: [ + 'gl-py-5', + 'gl-display-flex', + 'gl-justify-content-space-between', + 'gl-px-4', + 'gl-border-t-solid', + 'gl-border-gray-100', + 'gl-border-1', + 'gl-bg-gray-10', + ], + }, + translations: { + formLabel: s__('UserLists|Feature flag list'), + formSubtitle: s__( + 'UserLists|Lists allow you to define a set of users to be used with feature flags. %{linkStart}Read more about feature flag lists.%{linkEnd}', + ), + nameLabel: s__('UserLists|Name'), + cancelButtonLabel: s__('UserLists|Cancel'), + }, + data() { + return { + name: this.userList.name, + }; + }, + methods: { + submit() { + this.$emit('submit', { ...this.userList, name: this.name }); + }, + }, +}; +</script> +<template> + <div> + <div class="gl-display-flex gl-mt-7"> + <div class="gl-flex-basis-0 gl-mr-7"> + <h4 class="gl-min-width-fit-content gl-white-space-nowrap"> + {{ $options.translations.formLabel }} + </h4> + <gl-sprintf :message="$options.translations.formSubtitle" class="gl-text-gray-500"> + <template #link="{ content }"> + <gl-link :href="userListsDocsPath" data-testid="user-list-docs-link"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </div> + <div class="gl-flex-fill-1 gl-ml-7"> + <gl-form-group + label-for="user-list-name" + :label="$options.translations.nameLabel" + class="gl-mb-7" + > + <gl-form-input id="user-list-name" v-model="name" data-testid="user-list-name" required /> + </gl-form-group> + <div :class="$options.classes.actionContainer"> + <gl-button variant="success" data-testid="save-user-list" @click="submit"> + {{ saveButtonLabel }} + </gl-button> + <gl-button :href="cancelPath" data-testid="user-list-cancel"> + {{ $options.translations.cancelButtonLabel }} + </gl-button> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/user_lists/constants/edit.js b/app/assets/javascripts/user_lists/constants/edit.js new file mode 100644 index 00000000000..33378f0d39f --- /dev/null +++ b/app/assets/javascripts/user_lists/constants/edit.js @@ -0,0 +1,6 @@ +export default Object.freeze({ + LOADING: 'LOADING', + SUCCESS: 'SUCCESS', + ERROR: 'ERROR', + UNSYNCED: 'UNSYNCED', +}); diff --git a/app/assets/javascripts/user_lists/constants/show.js b/app/assets/javascripts/user_lists/constants/show.js new file mode 100644 index 00000000000..045375d5900 --- /dev/null +++ b/app/assets/javascripts/user_lists/constants/show.js @@ -0,0 +1,8 @@ +export const states = Object.freeze({ + LOADING: 'LOADING', + SUCCESS: 'SUCCESS', + ERROR: 'ERROR', + ERROR_DISMISSED: 'ERROR_DISMISSED', +}); + +export const ADD_USER_MODAL_ID = 'add-userids-modal'; diff --git a/app/assets/javascripts/user_lists/store/edit/actions.js b/app/assets/javascripts/user_lists/store/edit/actions.js new file mode 100644 index 00000000000..8f0a2bafec7 --- /dev/null +++ b/app/assets/javascripts/user_lists/store/edit/actions.js @@ -0,0 +1,22 @@ +import Api from '~/api'; +import { redirectTo } from '~/lib/utils/url_utility'; +import { getErrorMessages } from '../utils'; +import * as types from './mutation_types'; + +export const fetchUserList = ({ commit, state }) => { + commit(types.REQUEST_USER_LIST); + return Api.fetchFeatureFlagUserList(state.projectId, state.userListIid) + .then(({ data }) => commit(types.RECEIVE_USER_LIST_SUCCESS, data)) + .catch(response => commit(types.RECEIVE_USER_LIST_ERROR, getErrorMessages(response))); +}; + +export const dismissErrorAlert = ({ commit }) => commit(types.DISMISS_ERROR_ALERT); + +export const updateUserList = ({ commit, state }, userList) => { + return Api.updateFeatureFlagUserList(state.projectId, { + iid: userList.iid, + name: userList.name, + }) + .then(({ data }) => redirectTo(data.path)) + .catch(response => commit(types.RECEIVE_USER_LIST_ERROR, getErrorMessages(response))); +}; diff --git a/app/assets/javascripts/user_lists/store/edit/index.js b/app/assets/javascripts/user_lists/store/edit/index.js new file mode 100644 index 00000000000..b30b0b04b9e --- /dev/null +++ b/app/assets/javascripts/user_lists/store/edit/index.js @@ -0,0 +1,11 @@ +import Vuex from 'vuex'; +import createState from './state'; +import * as actions from './actions'; +import mutations from './mutations'; + +export default initialState => + new Vuex.Store({ + actions, + mutations, + state: createState(initialState), + }); diff --git a/app/assets/javascripts/user_lists/store/edit/mutation_types.js b/app/assets/javascripts/user_lists/store/edit/mutation_types.js new file mode 100644 index 00000000000..8b572e36839 --- /dev/null +++ b/app/assets/javascripts/user_lists/store/edit/mutation_types.js @@ -0,0 +1,5 @@ +export const REQUEST_USER_LIST = 'REQUEST_USER_LIST'; +export const RECEIVE_USER_LIST_SUCCESS = 'RECEIVE_USER_LIST_SUCCESS'; +export const RECEIVE_USER_LIST_ERROR = 'RECEIVE_USER_LIST_ERROR'; + +export const DISMISS_ERROR_ALERT = 'DISMISS_ERROR_ALERT'; diff --git a/app/assets/javascripts/user_lists/store/edit/mutations.js b/app/assets/javascripts/user_lists/store/edit/mutations.js new file mode 100644 index 00000000000..8a202885069 --- /dev/null +++ b/app/assets/javascripts/user_lists/store/edit/mutations.js @@ -0,0 +1,19 @@ +import statuses from '../../constants/edit'; +import * as types from './mutation_types'; + +export default { + [types.REQUEST_USER_LIST](state) { + state.status = statuses.LOADING; + }, + [types.RECEIVE_USER_LIST_SUCCESS](state, userList) { + state.status = statuses.SUCCESS; + state.userList = userList; + }, + [types.RECEIVE_USER_LIST_ERROR](state, error) { + state.status = statuses.ERROR; + state.errorMessage = error; + }, + [types.DISMISS_ERROR_ALERT](state) { + state.status = statuses.UNSYNCED; + }, +}; diff --git a/app/assets/javascripts/user_lists/store/edit/state.js b/app/assets/javascripts/user_lists/store/edit/state.js new file mode 100644 index 00000000000..66fbe3c2ba9 --- /dev/null +++ b/app/assets/javascripts/user_lists/store/edit/state.js @@ -0,0 +1,9 @@ +import statuses from '../../constants/edit'; + +export default ({ projectId = '', userListIid = '' }) => ({ + status: statuses.LOADING, + projectId, + userListIid, + userList: null, + errorMessage: [], +}); diff --git a/app/assets/javascripts/user_lists/store/new/actions.js b/app/assets/javascripts/user_lists/store/new/actions.js new file mode 100644 index 00000000000..185508bcfbc --- /dev/null +++ b/app/assets/javascripts/user_lists/store/new/actions.js @@ -0,0 +1,15 @@ +import Api from '~/api'; +import { redirectTo } from '~/lib/utils/url_utility'; +import { getErrorMessages } from '../utils'; +import * as types from './mutation_types'; + +export const dismissErrorAlert = ({ commit }) => commit(types.DISMISS_ERROR_ALERT); + +export const createUserList = ({ commit, state }, userList) => { + return Api.createFeatureFlagUserList(state.projectId, { + ...state.userList, + ...userList, + }) + .then(({ data }) => redirectTo(data.path)) + .catch(response => commit(types.RECEIVE_CREATE_USER_LIST_ERROR, getErrorMessages(response))); +}; diff --git a/app/assets/javascripts/user_lists/store/new/index.js b/app/assets/javascripts/user_lists/store/new/index.js new file mode 100644 index 00000000000..b30b0b04b9e --- /dev/null +++ b/app/assets/javascripts/user_lists/store/new/index.js @@ -0,0 +1,11 @@ +import Vuex from 'vuex'; +import createState from './state'; +import * as actions from './actions'; +import mutations from './mutations'; + +export default initialState => + new Vuex.Store({ + actions, + mutations, + state: createState(initialState), + }); diff --git a/app/assets/javascripts/user_lists/store/new/mutation_types.js b/app/assets/javascripts/user_lists/store/new/mutation_types.js new file mode 100644 index 00000000000..9a5ce6e99f5 --- /dev/null +++ b/app/assets/javascripts/user_lists/store/new/mutation_types.js @@ -0,0 +1,3 @@ +export const RECEIVE_CREATE_USER_LIST_ERROR = 'RECEIVE_CREATE_USER_LIST_ERROR'; + +export const DISMISS_ERROR_ALERT = 'DISMISS_ERROR_ALERT'; diff --git a/app/assets/javascripts/user_lists/store/new/mutations.js b/app/assets/javascripts/user_lists/store/new/mutations.js new file mode 100644 index 00000000000..d7c1276bd72 --- /dev/null +++ b/app/assets/javascripts/user_lists/store/new/mutations.js @@ -0,0 +1,10 @@ +import * as types from './mutation_types'; + +export default { + [types.RECEIVE_CREATE_USER_LIST_ERROR](state, error) { + state.errorMessage = error; + }, + [types.DISMISS_ERROR_ALERT](state) { + state.errorMessage = ''; + }, +}; diff --git a/app/assets/javascripts/user_lists/store/new/state.js b/app/assets/javascripts/user_lists/store/new/state.js new file mode 100644 index 00000000000..0fa73b4ffc1 --- /dev/null +++ b/app/assets/javascripts/user_lists/store/new/state.js @@ -0,0 +1,5 @@ +export default ({ projectId = '' }) => ({ + projectId, + userList: { name: '', user_xids: '' }, + errorMessage: [], +}); diff --git a/app/assets/javascripts/user_lists/store/show/actions.js b/app/assets/javascripts/user_lists/store/show/actions.js new file mode 100644 index 00000000000..15b971aa5e8 --- /dev/null +++ b/app/assets/javascripts/user_lists/store/show/actions.js @@ -0,0 +1,32 @@ +import Api from '~/api'; +import { stringifyUserIds } from '../utils'; +import * as types from './mutation_types'; + +export const fetchUserList = ({ commit, state }) => { + commit(types.REQUEST_USER_LIST); + return Api.fetchFeatureFlagUserList(state.projectId, state.userListIid) + .then(response => commit(types.RECEIVE_USER_LIST_SUCCESS, response.data)) + .catch(() => commit(types.RECEIVE_USER_LIST_ERROR)); +}; + +export const dismissErrorAlert = ({ commit }) => commit(types.DISMISS_ERROR_ALERT); +export const addUserIds = ({ dispatch, commit }, userIds) => { + commit(types.ADD_USER_IDS, userIds); + return dispatch('updateUserList'); +}; + +export const removeUserId = ({ commit, dispatch }, userId) => { + commit(types.REMOVE_USER_ID, userId); + return dispatch('updateUserList'); +}; + +export const updateUserList = ({ commit, state }) => { + commit(types.REQUEST_USER_LIST); + + return Api.updateFeatureFlagUserList(state.projectId, { + ...state.userList, + user_xids: stringifyUserIds(state.userIds), + }) + .then(response => commit(types.RECEIVE_USER_LIST_SUCCESS, response.data)) + .catch(() => commit(types.RECEIVE_USER_LIST_ERROR)); +}; diff --git a/app/assets/javascripts/user_lists/store/show/index.js b/app/assets/javascripts/user_lists/store/show/index.js new file mode 100644 index 00000000000..b30b0b04b9e --- /dev/null +++ b/app/assets/javascripts/user_lists/store/show/index.js @@ -0,0 +1,11 @@ +import Vuex from 'vuex'; +import createState from './state'; +import * as actions from './actions'; +import mutations from './mutations'; + +export default initialState => + new Vuex.Store({ + actions, + mutations, + state: createState(initialState), + }); diff --git a/app/assets/javascripts/user_lists/store/show/mutation_types.js b/app/assets/javascripts/user_lists/store/show/mutation_types.js new file mode 100644 index 00000000000..fb967f06beb --- /dev/null +++ b/app/assets/javascripts/user_lists/store/show/mutation_types.js @@ -0,0 +1,8 @@ +export const REQUEST_USER_LIST = 'REQUEST_USER_LIST'; +export const RECEIVE_USER_LIST_SUCCESS = 'RECEIVE_USER_LIST_SUCCESS'; +export const RECEIVE_USER_LIST_ERROR = 'RECEIVE_USER_LIST_ERROR'; + +export const DISMISS_ERROR_ALERT = 'DISMISS_ERROR_ALERT'; + +export const ADD_USER_IDS = 'ADD_USER_IDS'; +export const REMOVE_USER_ID = 'REMOVE_USER_ID'; diff --git a/app/assets/javascripts/user_lists/store/show/mutations.js b/app/assets/javascripts/user_lists/store/show/mutations.js new file mode 100644 index 00000000000..c3e766465a7 --- /dev/null +++ b/app/assets/javascripts/user_lists/store/show/mutations.js @@ -0,0 +1,29 @@ +import { states } from '../../constants/show'; +import * as types from './mutation_types'; +import { parseUserIds } from '../utils'; + +export default { + [types.REQUEST_USER_LIST](state) { + state.state = states.LOADING; + }, + [types.RECEIVE_USER_LIST_SUCCESS](state, userList) { + state.state = states.SUCCESS; + state.userIds = userList.user_xids?.length > 0 ? parseUserIds(userList.user_xids) : []; + state.userList = userList; + }, + [types.RECEIVE_USER_LIST_ERROR](state) { + state.state = states.ERROR; + }, + [types.DISMISS_ERROR_ALERT](state) { + state.state = states.ERROR_DISMISSED; + }, + [types.ADD_USER_IDS](state, ids) { + state.userIds = [ + ...state.userIds, + ...parseUserIds(ids).filter(id => id && !state.userIds.includes(id)), + ]; + }, + [types.REMOVE_USER_ID](state, id) { + state.userIds = state.userIds.filter(uid => uid !== id); + }, +}; diff --git a/app/assets/javascripts/user_lists/store/show/state.js b/app/assets/javascripts/user_lists/store/show/state.js new file mode 100644 index 00000000000..a5780893ccb --- /dev/null +++ b/app/assets/javascripts/user_lists/store/show/state.js @@ -0,0 +1,9 @@ +import { states } from '../../constants/show'; + +export default ({ projectId = '', userListIid = '' }) => ({ + state: states.LOADING, + projectId, + userListIid, + userIds: [], + userList: null, +}); diff --git a/app/assets/javascripts/user_lists/store/utils.js b/app/assets/javascripts/user_lists/store/utils.js new file mode 100644 index 00000000000..f4e46947759 --- /dev/null +++ b/app/assets/javascripts/user_lists/store/utils.js @@ -0,0 +1,5 @@ +export const parseUserIds = userIds => userIds.split(/\s*,\s*/g); + +export const stringifyUserIds = userIds => userIds.join(','); + +export const getErrorMessages = error => [].concat(error?.response?.data?.message ?? error.message); diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js index c8f95dac48e..0636d79e6f2 100644 --- a/app/assets/javascripts/user_popovers.js +++ b/app/assets/javascripts/user_popovers.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import { sanitize } from 'dompurify'; +import { sanitize } from '~/lib/dompurify'; import UsersCache from './lib/utils/users_cache'; import UserPopover from './vue_shared/components/user_popover/user_popover.vue'; diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js index 5f4260f26ff..20d1a3c1fcd 100644 --- a/app/assets/javascripts/users_select/index.js +++ b/app/assets/javascripts/users_select/index.js @@ -19,6 +19,7 @@ import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; window.emitSidebarEvent = window.emitSidebarEvent || $.noop; function UsersSelect(currentUser, els, options = {}) { + const elsClassName = els?.toString().match('.(.+$)')[1]; const $els = $(els || '.js-user-search'); this.users = this.users.bind(this); this.user = this.user.bind(this); @@ -127,9 +128,16 @@ function UsersSelect(currentUser, els, options = {}) { .find(`input[name='${$dropdown.data('fieldName')}'][value=${firstSelectedId}]`); firstSelected.remove(); - emitSidebarEvent('sidebar.removeAssignee', { - id: firstSelectedId, - }); + + if ($dropdown.hasClass(elsClassName)) { + emitSidebarEvent('sidebar.removeReviewer', { + id: firstSelectedId, + }); + } else { + emitSidebarEvent('sidebar.removeAssignee', { + id: firstSelectedId, + }); + } } } }; @@ -392,7 +400,11 @@ function UsersSelect(currentUser, els, options = {}) { defaultLabel, hidden() { if ($dropdown.hasClass('js-multiselect')) { - emitSidebarEvent('sidebar.saveAssignees'); + if ($dropdown.hasClass(elsClassName)) { + emitSidebarEvent('sidebar.saveReviewers'); + } else { + emitSidebarEvent('sidebar.saveAssignees'); + } } if (!$dropdown.data('alwaysShowSelectbox')) { @@ -428,10 +440,18 @@ function UsersSelect(currentUser, els, options = {}) { previouslySelected.each((index, element) => { element.remove(); }); - emitSidebarEvent('sidebar.removeAllAssignees'); + if ($dropdown.hasClass(elsClassName)) { + emitSidebarEvent('sidebar.removeAllReviewers'); + } else { + emitSidebarEvent('sidebar.removeAllAssignees'); + } } else if (isActive) { // user selected - emitSidebarEvent('sidebar.addAssignee', user); + if ($dropdown.hasClass(elsClassName)) { + emitSidebarEvent('sidebar.addReviewer', user); + } else { + emitSidebarEvent('sidebar.addAssignee', user); + } // Remove unassigned selection (if it was previously selected) const unassignedSelected = $dropdown @@ -448,7 +468,11 @@ function UsersSelect(currentUser, els, options = {}) { } // User unselected - emitSidebarEvent('sidebar.removeAssignee', user); + if ($dropdown.hasClass(elsClassName)) { + emitSidebarEvent('sidebar.removeReviewer', user); + } else { + emitSidebarEvent('sidebar.removeAssignee', user); + } } if (getSelected().find(u => u === gon.current_user_id)) { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue index 208df03b6a4..b90cbfd1a1a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue @@ -74,9 +74,6 @@ export default { canBeManuallyRedeployed() { return this.computedDeploymentStatus === FAILED && Boolean(this.redeployPath); }, - shouldShowManualButtons() { - return this.glFeatures.deployFromFooter; - }, hasExternalUrls() { return Boolean(this.deployment.external_url && this.deployment.external_url_formatted); }, @@ -154,7 +151,7 @@ export default { <template> <div> <deployment-action-button - v-if="shouldShowManualButtons && canBeManuallyDeployed" + v-if="canBeManuallyDeployed" :action-in-progress="actionInProgress" :actions-configuration="$options.actionsConfiguration[constants.DEPLOYING]" :computed-deployment-status="computedDeploymentStatus" @@ -165,7 +162,7 @@ export default { <span>{{ $options.actionsConfiguration[constants.DEPLOYING].buttonText }}</span> </deployment-action-button> <deployment-action-button - v-if="shouldShowManualButtons && canBeManuallyRedeployed" + v-if="canBeManuallyRedeployed" :action-in-progress="actionInProgress" :actions-configuration="$options.actionsConfiguration[constants.REDEPLOYING]" :computed-deployment-status="computedDeploymentStatus" 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 814d4e8341e..eb8989adb2a 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 @@ -111,9 +111,10 @@ export default { v-html="mr.sourceBranchLink" /><clipboard-button ref="copyBranchNameButton" + data-testid="mr-widget-copy-clipboard" :text="branchNameClipboardData" :title="__('Copy branch name')" - css-class="btn-default btn-transparent btn-clipboard" + category="tertiary" /> {{ s__('mrWidget|into') }} <tooltip-on-truncate diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue index 83e7d6db9fa..30da9947859 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue @@ -1,5 +1,5 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlButton } from '@gitlab/ui'; import eventHub from '../../event_hub'; import statusIcon from '../mr_widget_status_icon.vue'; @@ -8,6 +8,7 @@ export default { components: { statusIcon, GlLoadingIcon, + GlButton, }, props: { mr: { @@ -33,20 +34,21 @@ export default { <template> <div class="mr-widget-body media"> <status-icon status="warning" /> - <div class="media-body space-children"> + <div class="media-body space-children gl-display-flex gl-flex-wrap gl-align-items-center"> <span class="bold"> <template v-if="mr.mergeError">{{ mr.mergeError }}</template> {{ s__('mrWidget|This merge request failed to be merged automatically') }} </span> - <button + <gl-button :disabled="isRefreshing" - type="button" - class="btn btn-sm btn-default" + category="secondary" + variant="default" + size="small" @click="refreshWidget" > <gl-loading-icon v-if="isRefreshing" :inline="true" /> {{ s__('mrWidget|Refresh') }} - </button> + </gl-button> </div> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue index 58839251edc..543d70cbdbe 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue @@ -177,7 +177,9 @@ export default { <clipboard-button :title="__('Copy commit SHA')" :text="mr.mergeCommitSha" - css-class="btn-default btn-transparent btn-clipboard js-mr-merged-copy-sha" + css-class="js-mr-merged-copy-sha" + category="tertiary" + size="small" /> </template> </p> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue index 83783528cc1..6489569cf68 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue @@ -1,13 +1,12 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { sprintf, s__ } from '~/locale'; -import tooltip from '~/vue_shared/directives/tooltip'; import statusIcon from '../mr_widget_status_icon.vue'; export default { name: 'MRWidgetMissingBranch', directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, components: { GlIcon, @@ -52,7 +51,7 @@ export default { <span class="bold js-branch-text"> <span class="capitalize"> {{ missingBranchName }} </span> {{ s__('mrWidget|branch does not exist.') }} {{ missingBranchNameMessage }} - <gl-icon v-tooltip :title="message" :aria-label="message" name="question-o" /> + <gl-icon v-gl-tooltip :title="message" :aria-label="message" name="question-o" /> </span> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue index ec0934c5b4b..14c2e9fa828 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue @@ -1,6 +1,6 @@ <script> /* eslint-disable vue/no-v-html */ -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { escape } from 'lodash'; import simplePoll from '../../../lib/utils/simple_poll'; import eventHub from '../../event_hub'; @@ -12,7 +12,7 @@ export default { name: 'MRWidgetRebase', components: { statusIcon, - GlLoadingIcon, + GlButton, }, props: { mr: { @@ -109,29 +109,29 @@ export default { <div class="rebase-state-find-class-convention media media-body space-children"> <template v-if="mr.rebaseInProgress || isMakingRequest"> - <span class="bold">{{ __('Rebase in progress') }}</span> + <span class="bold" data-testid="rebase-message">{{ __('Rebase in progress') }}</span> </template> <template v-if="!mr.rebaseInProgress && !mr.canPushToSourceBranch"> - <span class="bold" v-html="fastForwardMergeText"></span> + <span class="bold" data-testid="rebase-message" v-html="fastForwardMergeText"></span> </template> <template v-if="!mr.rebaseInProgress && mr.canPushToSourceBranch && !isMakingRequest"> <div class="accept-merge-holder clearfix js-toggle-container accept-action media space-children" > - <button - :disabled="isMakingRequest" - type="button" - class="btn btn-sm btn-reopen btn-success qa-mr-rebase-button" + <gl-button + :loading="isMakingRequest" + variant="success" + class="qa-mr-rebase-button" @click="rebase" > - <gl-loading-icon v-if="isMakingRequest" />{{ __('Rebase') }} - </button> - <span v-if="!rebasingError" class="bold">{{ + {{ __('Rebase') }} + </gl-button> + <span v-if="!rebasingError" class="bold" data-testid="rebase-message">{{ __( 'Fast-forward merge is not possible. Rebase the source branch onto the target branch.', ) }}</span> - <span v-else class="bold danger">{{ rebasingError }}</span> + <span v-else class="bold danger" data-testid="rebase-message">{{ rebasingError }}</span> </div> </template> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index 240bab58297..835f7b9e9a9 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 @@ -1,12 +1,9 @@ <script> -/* eslint-disable vue/no-v-html */ import { isEmpty } from 'lodash'; import { GlIcon, GlButton, GlSprintf, GlLink } from '@gitlab/ui'; -import successSvg from 'icons/_icon_status_success.svg'; -import warningSvg from 'icons/_icon_status_warning.svg'; import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge'; import simplePoll from '~/lib/utils/simple_poll'; -import { __, sprintf } from '~/locale'; +import { __ } from '~/locale'; import MergeRequest from '../../../merge_request'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { deprecatedCreateFlash as Flash } from '../../../flash'; @@ -59,8 +56,6 @@ export default { commitMessage: this.mr.commitMessage, squashBeforeMerge: this.mr.squashIsSelected, isSquashReadOnly: this.mr.squashIsReadonly, - successSvg, - warningSvg, squashCommitMessage: this.mr.squashCommitMessage, }; }, @@ -147,16 +142,7 @@ export default { return !this.mr.ffOnlyEnabled; }, shaMismatchLink() { - const href = this.mr.mergeRequestDiffsPath; - - return sprintf( - __('New changes were added. %{linkStart}Reload the page to review them%{linkEnd}'), - { - linkStart: `<a href="${href}">`, - linkEnd: '</a>', - }, - false, - ); + return this.mr.mergeRequestDiffsPath; }, }, methods: { @@ -331,7 +317,7 @@ export default { @click.prevent="handleMergeButtonClick(true)" > <span class="media"> - <span class="merge-opt-icon" aria-hidden="true" v-html="successSvg"></span> + <gl-icon name="status_success" class="merge-opt-icon" aria-hidden="true" /> <span class="media-body merge-opt-title">{{ autoMergeText }}</span> </span> </a> @@ -349,7 +335,7 @@ export default { @click.prevent="handleMergeImmediatelyButtonClick" > <span class="media"> - <span class="merge-opt-icon" aria-hidden="true" v-html="warningSvg"></span> + <gl-icon name="status_warning" class="merge-opt-icon" aria-hidden="true" /> <span class="media-body merge-opt-title">{{ __('Merge immediately') }}</span> </span> </a> @@ -400,7 +386,17 @@ export default { </div> <div v-if="mr.isSHAMismatch" class="d-flex align-items-center mt-2 js-sha-mismatch"> <gl-icon name="warning-solid" class="text-warning mr-1" /> - <span class="text-warning" v-html="shaMismatchLink"></span> + <span class="text-warning"> + <gl-sprintf + :message=" + __('New changes were added. %{linkStart}Reload the page to review them%{linkEnd}') + " + > + <template #link="{ content }"> + <gl-link :href="mr.mergeRequestDiffsPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </span> </div> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue index 61cc950f058..be9d37e4531 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue @@ -3,6 +3,7 @@ import $ from 'jquery'; import { GlButton } from '@gitlab/ui'; import { __ } from '~/locale'; import { deprecatedCreateFlash as createFlash } from '~/flash'; +import MergeRequest from '~/merge_request'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; import getStateQuery from '../../queries/get_state.query.graphql'; @@ -128,8 +129,7 @@ export default { .then(res => res.data) .then(data => { eventHub.$emit('UpdateWidgetData', data); - createFlash(__('The merge request can now be merged.'), 'notice'); - $('.merge-request .detail-page-description .title').text(this.mr.title); + MergeRequest.toggleDraftStatus(this.mr.title, true); }) .catch(() => { this.isMakingRequest = false; 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 43ce748b41d..78ac9b6ac76 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 @@ -45,6 +45,7 @@ import GroupedTestReportsApp from '../reports/components/grouped_test_reports_ap import { setFaviconOverlay } from '../lib/utils/common_utils'; import GroupedAccessibilityReportsApp from '../reports/accessibility_report/grouped_accessibility_reports_app.vue'; import getStateQuery from './queries/get_state.query.graphql'; +import { isExperimentEnabled } from '~/lib/utils/experimentation'; export default { el: '#js-vue-mr-widget', @@ -148,7 +149,7 @@ export default { }, shouldSuggestPipelines() { return ( - gon.features?.suggestPipeline && + isExperimentEnabled('suggestPipeline') && !this.mr.hasCI && this.mr.mergeRequestAddCiConfigPath && !this.mr.isDismissedSuggestPipeline diff --git a/app/assets/javascripts/vue_shared/components/alert_details_table.vue b/app/assets/javascripts/vue_shared/components/alert_details_table.vue index c94e784c01e..a70b8e11a83 100644 --- a/app/assets/javascripts/vue_shared/components/alert_details_table.vue +++ b/app/assets/javascripts/vue_shared/components/alert_details_table.vue @@ -1,5 +1,6 @@ <script> import { GlLoadingIcon, GlTable } from '@gitlab/ui'; +import { reduce } from 'lodash'; import { s__ } from '~/locale'; import { capitalizeFirstCharacter, @@ -9,6 +10,22 @@ import { const thClass = 'gl-bg-transparent! gl-border-1! gl-border-b-solid! gl-border-gray-200!'; const tdClass = 'gl-border-gray-100! gl-p-5!'; +const allowedFields = [ + 'iid', + 'title', + 'severity', + 'status', + 'startedAt', + 'eventCount', + 'monitoringTool', + 'service', + 'description', + 'endedAt', + 'details', + 'environment', +]; + +const isAllowed = fieldName => allowedFields.includes(fieldName); export default { components: { @@ -46,10 +63,16 @@ export default { if (!this.alert) { return []; } - return Object.entries(this.alert).map(([fieldName, value]) => ({ - fieldName, - value, - })); + return reduce( + this.alert, + (allowedItems, value, fieldName) => { + if (isAllowed(fieldName)) { + return [...allowedItems, { fieldName, value }]; + } + return allowedItems; + }, + [], + ); }, }, }; diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue index d7af3b3298e..1b7e51b7d02 100644 --- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue +++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue @@ -7,7 +7,7 @@ import CiIcon from './ci_icon.vue'; * * Receives status object containing: * status: { - * details_path: "/gitlab-org/gitlab-foss/pipelines/8150156" // url + * details_path or detailsPath: "/gitlab-org/gitlab-foss/pipelines/8150156" // url * group:"running" // used for CSS class * icon: "icon_status_running" // used to render the icon * label:"running" // used for potential tooltip @@ -46,6 +46,13 @@ export default { }, }, computed: { + title() { + return !this.showText ? this.status?.text : ''; + }, + detailsPath() { + // For now, this can either come from graphQL with camelCase or REST API in snake_case + return this.status.detailsPath || this.status.details_path; + }, cssClass() { const className = this.status.group; return className ? `ci-status ci-${className} qa-status-badge` : 'ci-status qa-status-badge'; @@ -54,12 +61,7 @@ export default { }; </script> <template> - <a - v-gl-tooltip - :href="status.details_path" - :class="cssClass" - :title="!showText ? status.text : ''" - > + <a v-gl-tooltip :href="detailsPath" :class="cssClass" :title="title"> <ci-icon :status="status" :css-classes="iconClasses" /> <template v-if="showText"> diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue index 0234b6bf848..960551fae91 100644 --- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue +++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue @@ -12,7 +12,7 @@ * css-class="btn-transparent" * /> */ -import { GlDeprecatedButton, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; export default { name: 'ClipboardButton', @@ -20,8 +20,7 @@ export default { GlTooltip: GlTooltipDirective, }, components: { - GlDeprecatedButton, - GlIcon, + GlButton, }, props: { text: { @@ -50,7 +49,17 @@ export default { cssClass: { type: String, required: false, - default: 'btn-default', + default: null, + }, + category: { + type: String, + required: false, + default: 'secondary', + }, + size: { + type: String, + required: false, + default: 'medium', }, }, computed: { @@ -65,13 +74,15 @@ export default { </script> <template> - <gl-deprecated-button + <gl-button v-gl-tooltip="{ placement: tooltipPlacement, container: tooltipContainer }" v-gl-tooltip.hover.blur :class="cssClass" :title="title" :data-clipboard-text="clipboardText" - > - <gl-icon name="copy-to-clipboard" /> - </gl-deprecated-button> + :category="category" + :size="size" + icon="copy-to-clipboard" + :aria-label="__('Copy this value')" + /> </template> diff --git a/app/assets/javascripts/vue_shared/components/commit.vue b/app/assets/javascripts/vue_shared/components/commit.vue index c1c8fb3a6e2..e01a651806d 100644 --- a/app/assets/javascripts/vue_shared/components/commit.vue +++ b/app/assets/javascripts/vue_shared/components/commit.vue @@ -139,7 +139,7 @@ export default { <template> <div class="branch-commit cgray"> <template v-if="shouldShowRefInfo"> - <div class="icon-container"> + <div class="icon-container gl-display-inline-block"> <gl-icon v-if="tag" name="tag" /> <gl-icon v-else-if="mergeRequestRef" name="git-merge" /> <gl-icon v-else name="branch" /> diff --git a/app/assets/javascripts/vue_shared/components/confirm_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_modal.vue index e7f6cc1abc0..a42a606d446 100644 --- a/app/assets/javascripts/vue_shared/components/confirm_modal.vue +++ b/app/assets/javascripts/vue_shared/components/confirm_modal.vue @@ -12,6 +12,11 @@ export default { type: String, required: true, }, + handleSubmit: { + type: Function, + required: false, + default: null, + }, }, data() { return { @@ -41,7 +46,11 @@ export default { this.$refs.modal.hide(); }, submitModal() { - this.$refs.form.submit(); + if (this.handleSubmit) { + this.handleSubmit(this.path); + } else { + this.$refs.form.submit(); + } }, }, csrf, diff --git a/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue b/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue index c7d7c3a1d24..2a28b13e7bf 100644 --- a/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue +++ b/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue @@ -22,7 +22,7 @@ export default { }, data() { return { - isDismissed: 'false', + isDismissed: false, }; }, computed: { @@ -30,12 +30,12 @@ export default { return `${slugifyWithUnderscore(this.featureName)}_feedback_dismissed`; }, showAlert() { - return this.isDismissed === 'false'; + return !this.isDismissed; }, }, methods: { dismissFeedbackAlert() { - this.isDismissed = 'true'; + this.isDismissed = true; }, }, }; @@ -43,16 +43,12 @@ export default { <template> <div v-show="showAlert"> - <local-storage-sync - :value="isDismissed" - :storage-key="storageKey" - @input="dismissFeedbackAlert" - /> + <local-storage-sync v-model="isDismissed" :storage-key="storageKey" as-json /> <gl-alert v-if="showAlert" class="gl-mt-5" @dismiss="dismissFeedbackAlert"> <gl-sprintf :message=" __( - 'We’ve been making changes to %{featureName} and we’d love your feedback %{linkStart}in this issue%{linkEnd} to help us improve the experience.', + 'Please share your feedback about %{featureName} %{linkStart}in this issue%{linkEnd} to help us improve the experience.', ) " > diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue index 7157337f8f3..300046dbb85 100644 --- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue @@ -1,7 +1,11 @@ <script> +import { GlIcon } from '@gitlab/ui'; import { __ } from '~/locale'; export default { + components: { + GlIcon, + }, props: { placeholderText: { type: String, @@ -41,5 +45,6 @@ export default { autocomplete="off" /> <i class="fa fa-search dropdown-input-search" aria-hidden="true" data-hidden="true"> </i> + <gl-icon name="search" class="dropdown-input-search" aria-hidden="true" data-hidden="true" /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue b/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue deleted file mode 100644 index 4d85726065b..00000000000 --- a/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue +++ /dev/null @@ -1,92 +0,0 @@ -<script> -import { GlDeprecatedButton, GlIcon } from '@gitlab/ui'; - -export default { - components: { - GlIcon, - GlDeprecatedButton, - }, - props: { - size: { - type: String, - required: false, - default: '', - }, - primaryButtonClass: { - type: String, - required: false, - default: '', - }, - dropdownClass: { - type: String, - required: false, - default: '', - }, - actions: { - type: Array, - required: true, - }, - defaultAction: { - type: Number, - required: true, - }, - }, - data() { - return { - selectedAction: this.defaultAction, - }; - }, - computed: { - selectedActionTitle() { - return this.actions[this.selectedAction].title; - }, - buttonSizeClass() { - return `btn-${this.size}`; - }, - }, - methods: { - handlePrimaryActionClick() { - this.$emit('onActionClick', this.actions[this.selectedAction]); - }, - handleActionClick(selectedAction) { - this.selectedAction = selectedAction; - this.$emit('onActionSelect', selectedAction); - }, - }, -}; -</script> - -<template> - <div class="btn-group droplab-dropdown comment-type-dropdown"> - <gl-deprecated-button - :class="primaryButtonClass" - :size="size" - @click.prevent="handlePrimaryActionClick" - > - {{ selectedActionTitle }} - </gl-deprecated-button> - <button - :class="buttonSizeClass" - type="button" - class="btn dropdown-toggle pl-2 pr-2" - data-display="static" - data-toggle="dropdown" - > - <gl-icon name="chevron-down" :aria-label="__('toggle dropdown')" /> - </button> - <ul :class="dropdownClass" class="dropdown-menu dropdown-open-top"> - <template v-for="(action, index) in actions"> - <li :key="index" :class="{ 'droplab-item-selected': selectedAction === index }"> - <gl-deprecated-button class="btn-transparent" @click.prevent="handleActionClick(index)"> - <i aria-hidden="true" class="fa fa-check icon"> </i> - <div class="description"> - <strong>{{ action.title }}</strong> - <p>{{ action.description }}</p> - </div> - </gl-deprecated-button> - </li> - <li v-if="index === 0" :key="`${index}-separator`" class="divider droplab-item-ignore"></li> - </template> - </ul> - </div> -</template> 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 012aca8105a..386df617d47 100644 --- a/app/assets/javascripts/vue_shared/components/file_finder/index.vue +++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue @@ -230,13 +230,12 @@ export default { @keydown="onKeydown($event)" @keyup="onKeyup($event)" /> - <i - :class="{ - hidden: showClearInputButton, - }" + <gl-icon + name="search" + class="dropdown-input-search" + :class="{ hidden: showClearInputButton }" aria-hidden="true" - class="fa fa-search dropdown-input-search" - ></i> + /> <gl-icon name="close" class="dropdown-input-clear" diff --git a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js index b70f093e930..91a0ac3aa92 100644 --- a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js +++ b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js @@ -9,6 +9,12 @@ const fileExtensionIcons = { 'md.rendered': 'markdown', markdown: 'markdown', 'markdown.rendered': 'markdown', + mdown: 'markdown', + 'mdown.rendered': 'markdown', + mkd: 'markdown', + 'mkd.rendered': 'markdown', + mkdn: 'markdown', + 'mkdn.rendered': 'markdown', rst: 'markdown', blink: 'blink', css: 'css', diff --git a/app/assets/javascripts/vue_shared/components/gl_mentions.vue b/app/assets/javascripts/vue_shared/components/gl_mentions.vue index da4b0aedef5..e895a7a52ab 100644 --- a/app/assets/javascripts/vue_shared/components/gl_mentions.vue +++ b/app/assets/javascripts/vue_shared/components/gl_mentions.vue @@ -1,5 +1,5 @@ <script> -import { escape } from 'lodash'; +import { escape, last } from 'lodash'; import Tribute from 'tributejs'; import axios from '~/lib/utils/axios_utils'; import { spriteIcon } from '~/lib/utils/common_utils'; @@ -12,6 +12,8 @@ const AutoComplete = { MergeRequests: 'mergeRequests', }; +const groupType = 'Group'; // eslint-disable-line @gitlab/require-i18n-strings + function doesCurrentLineStartWith(searchString, fullText, selectionStart) { const currentLineNumber = fullText.slice(0, selectionStart).split('\n').length; const currentLine = fullText.split('\n')[currentLineNumber - 1]; @@ -74,30 +76,40 @@ const autoCompleteMap = { return this.members; }, 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`; - - const avatarTag = original.avatar_url - ? `<img - src="${original.avatar_url}" - alt="${original.username}'s avatar" - class="${avatarClasses}"/>` - : `<div class="${avatarClasses}">${original.username.charAt(0).toUpperCase()}</div>`; - - const name = escape(original.name); + const commonClasses = 'gl-avatar gl-avatar-s24 gl-flex-shrink-0'; + const noAvatarClasses = `${commonClasses} gl-rounded-small + gl-display-flex gl-align-items-center gl-justify-content-center`; + + const avatar = original.avatar_url + ? `<img class="${commonClasses} gl-avatar-circle" src="${original.avatar_url}" alt="" />` + : `<div class="${noAvatarClasses}" aria-hidden="true"> + ${original.username.charAt(0).toUpperCase()}</div>`; + + let displayName = original.name; + let parentGroupOrUsername = `@${original.username}`; + + if (original.type === groupType) { + const splitName = original.name.split(' / '); + displayName = splitName.pop(); + parentGroupOrUsername = splitName.pop(); + } const count = original.count && !original.mentionsDisabled ? ` (${original.count})` : ''; - const icon = original.mentionsDisabled - ? spriteIcon('notifications-off', 's16 gl-vertical-align-middle gl-ml-3') + const disabledMentionsIcon = original.mentionsDisabled + ? spriteIcon('notifications-off', 's16 gl-ml-3') : ''; - return `${avatarTag} - ${original.username} - <small class="gl-text-small gl-font-weight-normal gl-reset-color">${name}${count}</small> - ${icon}`; + return ` + <div class="gl-display-flex gl-align-items-center"> + ${avatar} + <div class="gl-font-sm gl-line-height-normal gl-ml-3"> + <div>${escape(displayName)}${count}</div> + <div class="gl-text-gray-700">${escape(parentGroupOrUsername)}</div> + </div> + ${disabledMentionsIcon} + </div> + `; }, }, [AutoComplete.MergeRequests]: { @@ -134,7 +146,8 @@ export default { { trigger: '@', fillAttr: 'username', - lookup: value => value.name + value.username, + lookup: value => + value.type === groupType ? last(value.name.split(' / ')) : value.name + value.username, menuItemTemplate: autoCompleteMap[AutoComplete.Members].menuItemTemplate, values: this.getValues(AutoComplete.Members), }, diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue index 6ff6f10f786..4679d922861 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -1,10 +1,11 @@ <script> /* eslint-disable vue/no-v-html */ -import { GlTooltipDirective, GlLink, GlDeprecatedButton } from '@gitlab/ui'; -import { __, sprintf } from '~/locale'; +import { GlTooltipDirective, GlLink, GlDeprecatedButton, GlTooltip } from '@gitlab/ui'; import CiIconBadge from './ci_badge_link.vue'; import TimeagoTooltip from './time_ago_tooltip.vue'; import UserAvatarImage from './user_avatar/user_avatar_image.vue'; +import { glEmojiTag } from '../../emoji'; +import { __, sprintf } from '../../locale'; /** * Renders header component for job and pipeline page based on UI mockups @@ -20,10 +21,12 @@ export default { UserAvatarImage, GlLink, GlDeprecatedButton, + GlTooltip, }, directives: { GlTooltip: GlTooltipDirective, }, + EMOJI_REF: 'EMOJI_REF', props: { status: { type: Object, @@ -62,6 +65,27 @@ export default { userAvatarAltText() { return sprintf(__(`%{username}'s avatar`), { username: this.user.name }); }, + userPath() { + // GraphQL returns `webPath` and Rest `path` + return this.user?.webPath || this.user?.path; + }, + avatarUrl() { + // GraphQL returns `avatarUrl` and Rest `avatar_url` + return this.user?.avatarUrl || this.user?.avatar_url; + }, + statusTooltipHTML() { + // Rest `status_tooltip_html` which is a ready to work + // html for the emoji and the status text inside a tooltip. + // GraphQL returns `status.emoji` and `status.message` which + // needs to be combined to make the html we want. + const { emoji } = this.user?.status || {}; + const emojiHtml = emoji ? glEmojiTag(emoji) : ''; + + return emojiHtml || this.user?.status_tooltip_html; + }, + message() { + return this.user?.status?.message; + }, }, methods: { @@ -73,7 +97,7 @@ export default { </script> <template> - <header class="page-content-header ci-header-container"> + <header class="page-content-header ci-header-container" data-testid="pipeline-header-content"> <section class="header-main-content"> <ci-icon-badge :status="status" /> @@ -89,12 +113,12 @@ export default { <template v-if="user"> <gl-link v-gl-tooltip - :href="user.path" + :href="userPath" :title="user.email" class="js-user-link commit-committer-link" > <user-avatar-image - :img-src="user.avatar_url" + :img-src="avatarUrl" :img-alt="userAvatarAltText" :tooltip-text="user.name" :img-size="24" @@ -102,7 +126,15 @@ export default { {{ user.name }} </gl-link> - <span v-if="user.status_tooltip_html" v-html="user.status_tooltip_html"></span> + <gl-tooltip v-if="message" :target="() => $refs[$options.EMOJI_REF]"> + {{ message }} + </gl-tooltip> + <span + v-if="statusTooltipHTML" + :ref="$options.EMOJI_REF" + :data-testid="message" + v-html="statusTooltipHTML" + ></span> </template> </section> diff --git a/app/assets/javascripts/vue_shared/components/local_storage_sync.vue b/app/assets/javascripts/vue_shared/components/local_storage_sync.vue index b5d6b872547..59155bd4ddc 100644 --- a/app/assets/javascripts/vue_shared/components/local_storage_sync.vue +++ b/app/assets/javascripts/vue_shared/components/local_storage_sync.vue @@ -1,4 +1,6 @@ <script> +import { isEqual } from 'lodash'; + export default { props: { storageKey: { @@ -6,31 +8,58 @@ export default { required: true, }, value: { - type: String, + type: [String, Number, Boolean, Array, Object], required: false, default: '', }, + asJson: { + type: Boolean, + required: false, + default: false, + }, }, watch: { value(newVal) { - this.saveValue(newVal); + this.saveValue(this.serialize(newVal)); }, }, mounted() { // On mount, trigger update if we actually have a localStorageValue - const value = this.getValue(); + const { exists, value } = this.getStorageValue(); - if (value && this.value !== value) { + if (exists && !isEqual(value, this.value)) { this.$emit('input', value); } }, methods: { - getValue() { - return localStorage.getItem(this.storageKey); + getStorageValue() { + const value = localStorage.getItem(this.storageKey); + + if (value === null) { + return { exists: false }; + } + + try { + return { exists: true, value: this.deserialize(value) }; + } catch { + // eslint-disable-next-line no-console + console.warn( + `[gitlab] Failed to deserialize value from localStorage (key=${this.storageKey})`, + value, + ); + // default to "don't use localStorage value" + return { exists: false }; + } }, saveValue(val) { localStorage.setItem(this.storageKey, val); }, + serialize(val) { + return this.asJson ? JSON.stringify(val) : val; + }, + deserialize(val) { + return this.asJson ? JSON.parse(val) : val; + }, }, render() { return this.$slots.default; diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index a48c279d0e3..9dd2d5402c3 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -25,6 +25,18 @@ export default { }, mixins: [glFeatureFlagsMixin()], props: { + /** + * This prop should be bound to the value of the `<textarea>` element + * that is rendered as a child of this component (in the `textarea` slot) + */ + textareaValue: { + type: String, + required: true, + }, + markdownDocsPath: { + type: String, + required: true, + }, isSubmitting: { type: Boolean, required: false, @@ -35,10 +47,6 @@ export default { required: false, default: '', }, - markdownDocsPath: { - type: String, - required: true, - }, addSpacingClasses: { type: Boolean, required: false, @@ -84,12 +92,6 @@ export default { required: false, default: false, }, - // This prop is used as a fallback in case if textarea.elm is undefined - textareaValue: { - type: String, - required: false, - default: '', - }, }, data() { return { @@ -189,17 +191,11 @@ export default { this.previewMarkdown = true; - /* - Can't use `$refs` as the component is technically in the parent component - so we access the VNode & then get the element - */ - const text = this.$slots.textarea[0]?.elm?.value || this.textareaValue; - - if (text) { + if (this.textareaValue) { this.markdownPreviewLoading = true; this.markdownPreview = __('Loading…'); axios - .post(this.markdownPreviewPath, { text }) + .post(this.markdownPreviewPath, { text: this.textareaValue }) .then(response => this.renderMarkdown(response.data)) .catch(() => new Flash(__('Error loading markdown preview'))); } else { 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 13c42d35b04..13ec7a6ada9 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue @@ -27,6 +27,11 @@ export default { type: String, required: true, }, + suggestionsCount: { + type: Number, + required: false, + default: 0, + }, }, computed: { batchSuggestionsCount() { @@ -62,6 +67,7 @@ export default { <div class="md-suggestion"> <suggestion-diff-header class="qa-suggestion-diff-header js-suggestion-diff-header" + :suggestions-count="suggestionsCount" :can-apply="suggestion.appliable && suggestion.current_user.can_apply && !disabled" :is-applied="suggestion.applied" :is-batched="isBatched" 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 1fc54d2f52e..fb9636ba734 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 @@ -42,6 +42,11 @@ export default { required: false, default: null, }, + suggestionsCount: { + type: Number, + required: false, + default: 0, + }, }, data() { return { @@ -127,7 +132,7 @@ export default { </div> <div v-else class="d-flex align-items-center"> <gl-button - v-if="canBeBatched && !isDisableButton" + v-if="suggestionsCount > 1 && canBeBatched && !isDisableButton" class="btn-inverted js-add-to-batch-btn btn-grouped" :disabled="isDisableButton" @click="addSuggestionToBatch" diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue index 083f581af05..927a93487e6 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue @@ -38,6 +38,11 @@ export default { type: String, required: true, }, + suggestionsCount: { + type: Number, + required: false, + default: 0, + }, }, data() { return { @@ -77,12 +82,12 @@ export default { this.isRendered = true; }, generateDiff(suggestionIndex) { - const { suggestions, disabled, batchSuggestionsInfo, helpPagePath } = this; + const { suggestions, disabled, batchSuggestionsInfo, helpPagePath, suggestionsCount } = this; const suggestion = suggestions && suggestions[suggestionIndex] ? suggestions[suggestionIndex] : {}; const SuggestionDiffComponent = Vue.extend(SuggestionDiff); const suggestionDiff = new SuggestionDiffComponent({ - propsData: { disabled, suggestion, batchSuggestionsInfo, helpPagePath }, + propsData: { disabled, suggestion, batchSuggestionsInfo, helpPagePath, suggestionsCount }, }); suggestionDiff.$on('apply', ({ suggestionId, callback }) => { diff --git a/app/assets/javascripts/vue_shared/components/members/avatars/group_avatar.vue b/app/assets/javascripts/vue_shared/components/members/avatars/group_avatar.vue new file mode 100644 index 00000000000..12b748f9ab6 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/avatars/group_avatar.vue @@ -0,0 +1,34 @@ +<script> +import { GlAvatarLink, GlAvatarLabeled } from '@gitlab/ui'; +import { AVATAR_SIZE } from '../constants'; + +export default { + name: 'GroupAvatar', + avatarSize: AVATAR_SIZE, + components: { GlAvatarLink, GlAvatarLabeled }, + props: { + member: { + type: Object, + required: true, + }, + }, + computed: { + group() { + return this.member.sharedWithGroup; + }, + }, +}; +</script> + +<template> + <gl-avatar-link :href="group.webUrl"> + <gl-avatar-labeled + :label="group.fullName" + :src="group.avatarUrl" + :alt="group.fullName" + :size="$options.avatarSize" + :entity-name="group.name" + :entity-id="group.id" + /> + </gl-avatar-link> +</template> diff --git a/app/assets/javascripts/vue_shared/components/members/avatars/invite_avatar.vue b/app/assets/javascripts/vue_shared/components/members/avatars/invite_avatar.vue new file mode 100644 index 00000000000..28654a60860 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/avatars/invite_avatar.vue @@ -0,0 +1,32 @@ +<script> +import { GlAvatarLabeled } from '@gitlab/ui'; +import { AVATAR_SIZE } from '../constants'; + +export default { + name: 'InviteAvatar', + avatarSize: AVATAR_SIZE, + components: { GlAvatarLabeled }, + props: { + member: { + type: Object, + required: true, + }, + }, + computed: { + invite() { + return this.member.invite; + }, + }, +}; +</script> + +<template> + <gl-avatar-labeled + :label="invite.email" + :src="invite.avatarUrl" + :alt="invite.email" + :size="$options.avatarSize" + :entity-name="invite.email" + :entity-id="member.id" + /> +</template> diff --git a/app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue b/app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue new file mode 100644 index 00000000000..4cd74305450 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue @@ -0,0 +1,80 @@ +<script> +import { + GlAvatarLink, + GlAvatarLabeled, + GlBadge, + GlSafeHtmlDirective as SafeHtml, +} from '@gitlab/ui'; +import { generateBadges } from 'ee_else_ce/vue_shared/components/members/utils'; +import { __ } from '~/locale'; +import { AVATAR_SIZE } from '../constants'; + +export default { + name: 'UserAvatar', + avatarSize: AVATAR_SIZE, + orphanedUserLabel: __('Orphaned member'), + components: { + GlAvatarLink, + GlAvatarLabeled, + GlBadge, + }, + directives: { + SafeHtml, + }, + props: { + member: { + type: Object, + required: true, + }, + isCurrentUser: { + type: Boolean, + required: true, + }, + }, + computed: { + user() { + return this.member.user; + }, + badges() { + return generateBadges(this.member, this.isCurrentUser).filter(badge => badge.show); + }, + }, +}; +</script> + +<template> + <gl-avatar-link + v-if="user" + class="js-user-link" + :href="user.webUrl" + :data-user-id="user.id" + :data-username="user.username" + > + <gl-avatar-labeled + :label="user.name" + :sub-label="`@${user.username}`" + :src="user.avatarUrl" + :alt="user.name" + :size="$options.avatarSize" + :entity-name="user.name" + :entity-id="user.id" + > + <template #meta> + <div v-for="badge in badges" :key="badge.text" class="gl-p-1"> + <gl-badge size="sm" :variant="badge.variant"> + {{ badge.text }} + </gl-badge> + </div> + </template> + </gl-avatar-labeled> + </gl-avatar-link> + + <gl-avatar-labeled + v-else + :label="$options.orphanedUserLabel" + :alt="$options.orphanedUserLabel" + :size="$options.avatarSize" + :entity-name="$options.orphanedUserLabel" + :entity-id="member.id" + /> +</template> diff --git a/app/assets/javascripts/vue_shared/components/members/constants.js b/app/assets/javascripts/vue_shared/components/members/constants.js new file mode 100644 index 00000000000..9dc0ec97ce6 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/constants.js @@ -0,0 +1,66 @@ +import { __ } from '~/locale'; + +export const FIELDS = [ + { + key: 'account', + label: __('Account'), + }, + { + key: 'source', + label: __('Source'), + thClass: 'col-meta', + tdClass: 'col-meta', + }, + { + key: 'granted', + label: __('Access granted'), + thClass: 'col-meta', + tdClass: 'col-meta', + }, + { + key: 'invited', + label: __('Invited'), + thClass: 'col-meta', + tdClass: 'col-meta', + }, + { + key: 'requested', + label: __('Requested'), + thClass: 'col-meta', + tdClass: 'col-meta', + }, + { + key: 'expires', + label: __('Access expires'), + thClass: 'col-meta', + tdClass: 'col-meta', + }, + { + key: 'maxRole', + label: __('Max role'), + thClass: 'col-meta', + tdClass: 'col-meta', + }, + { + key: 'expiration', + label: __('Expiration'), + thClass: 'col-expiration', + tdClass: 'col-expiration', + }, + { + key: 'actions', + thClass: 'col-actions', + tdClass: 'col-actions', + }, +]; + +export const AVATAR_SIZE = 48; + +export const MEMBER_TYPES = { + user: 'user', + group: 'group', + invite: 'invite', + accessRequest: 'accessRequest', +}; + +export const DAYS_TO_EXPIRE_SOON = 7; diff --git a/app/assets/javascripts/vue_shared/components/members/table/created_at.vue b/app/assets/javascripts/vue_shared/components/members/table/created_at.vue new file mode 100644 index 00000000000..0bad70894f9 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/table/created_at.vue @@ -0,0 +1,40 @@ +<script> +import { GlSprintf } from '@gitlab/ui'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + name: 'CreatedAt', + components: { GlSprintf, TimeAgoTooltip }, + props: { + date: { + type: String, + required: false, + default: null, + }, + createdBy: { + type: Object, + required: false, + default: null, + }, + }, + computed: { + showCreatedBy() { + return this.createdBy?.name && this.createdBy?.webUrl; + }, + }, +}; +</script> + +<template> + <span> + <gl-sprintf v-if="showCreatedBy" :message="s__('Members|%{time} by %{user}')"> + <template #time> + <time-ago-tooltip :time="date" /> + </template> + <template #user> + <a :href="createdBy.webUrl">{{ createdBy.name }}</a> + </template> + </gl-sprintf> + <time-ago-tooltip v-else :time="date" /> + </span> +</template> diff --git a/app/assets/javascripts/vue_shared/components/members/table/expires_at.vue b/app/assets/javascripts/vue_shared/components/members/table/expires_at.vue new file mode 100644 index 00000000000..de65e3fb10f --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/table/expires_at.vue @@ -0,0 +1,66 @@ +<script> +import { GlSprintf, GlTooltipDirective } from '@gitlab/ui'; +import { + approximateDuration, + differenceInSeconds, + formatDate, + getDayDifference, +} from '~/lib/utils/datetime_utility'; +import { DAYS_TO_EXPIRE_SOON } from '../constants'; + +export default { + name: 'ExpiresAt', + components: { GlSprintf }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + date: { + type: String, + required: false, + default: null, + }, + }, + computed: { + noExpirationSet() { + return this.date === null; + }, + parsed() { + return new Date(this.date); + }, + differenceInSeconds() { + return differenceInSeconds(new Date(), this.parsed); + }, + isExpired() { + return this.differenceInSeconds <= 0; + }, + inWords() { + return approximateDuration(this.differenceInSeconds); + }, + formatted() { + return formatDate(this.parsed); + }, + expiresSoon() { + return getDayDifference(new Date(), this.parsed) < DAYS_TO_EXPIRE_SOON; + }, + cssClass() { + return { + 'gl-text-red-500': this.isExpired, + 'gl-text-orange-500': this.expiresSoon, + }; + }, + }, +}; +</script> + +<template> + <span v-if="noExpirationSet">{{ s__('Members|No expiration set') }}</span> + <span v-else v-gl-tooltip.hover :title="formatted" :class="cssClass"> + <template v-if="isExpired">{{ s__('Members|Expired') }}</template> + <gl-sprintf v-else :message="s__('Members|in %{time}')"> + <template #time> + {{ inWords }} + </template> + </gl-sprintf> + </span> +</template> diff --git a/app/assets/javascripts/vue_shared/components/members/table/member_avatar.vue b/app/assets/javascripts/vue_shared/components/members/table/member_avatar.vue new file mode 100644 index 00000000000..a1f98d4008a --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/table/member_avatar.vue @@ -0,0 +1,35 @@ +<script> +import { kebabCase } from 'lodash'; +import UserAvatar from '../avatars/user_avatar.vue'; +import InviteAvatar from '../avatars/invite_avatar.vue'; +import GroupAvatar from '../avatars/group_avatar.vue'; + +export default { + name: 'MemberAvatar', + components: { UserAvatar, InviteAvatar, GroupAvatar, AccessRequestAvatar: UserAvatar }, + props: { + memberType: { + type: String, + required: true, + }, + isCurrentUser: { + type: Boolean, + required: true, + }, + member: { + type: Object, + required: true, + }, + }, + computed: { + avatarComponent() { + // eslint-disable-next-line @gitlab/require-i18n-strings + return `${kebabCase(this.memberType)}-avatar`; + }, + }, +}; +</script> + +<template> + <component :is="avatarComponent" :member="member" :is-current-user="isCurrentUser" /> +</template> diff --git a/app/assets/javascripts/vue_shared/components/members/table/member_source.vue b/app/assets/javascripts/vue_shared/components/members/table/member_source.vue new file mode 100644 index 00000000000..030d72c3420 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/table/member_source.vue @@ -0,0 +1,27 @@ +<script> +import { GlTooltipDirective } from '@gitlab/ui'; + +export default { + name: 'MemberSource', + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + memberSource: { + type: Object, + required: true, + }, + isDirectMember: { + type: Boolean, + required: true, + }, + }, +}; +</script> + +<template> + <span v-if="isDirectMember">{{ __('Direct member') }}</span> + <a v-else v-gl-tooltip.hover :title="__('Inherited')" :href="memberSource.webUrl">{{ + memberSource.name + }}</a> +</template> diff --git a/app/assets/javascripts/vue_shared/components/members/table/members_table.vue b/app/assets/javascripts/vue_shared/components/members/table/members_table.vue new file mode 100644 index 00000000000..b72633f0cee --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/table/members_table.vue @@ -0,0 +1,82 @@ +<script> +import { mapState } from 'vuex'; +import { GlTable } from '@gitlab/ui'; +import { FIELDS } from '../constants'; +import initUserPopovers from '~/user_popovers'; +import MemberAvatar from './member_avatar.vue'; +import MemberSource from './member_source.vue'; +import CreatedAt from './created_at.vue'; +import ExpiresAt from './expires_at.vue'; +import MembersTableCell from './members_table_cell.vue'; + +export default { + name: 'MembersTable', + components: { + GlTable, + MemberAvatar, + CreatedAt, + ExpiresAt, + MembersTableCell, + MemberSource, + }, + computed: { + ...mapState(['members', 'tableFields']), + filteredFields() { + return FIELDS.filter(field => this.tableFields.includes(field.key)); + }, + }, + mounted() { + initUserPopovers(this.$el.querySelectorAll('.js-user-link')); + }, +}; +</script> + +<template> + <gl-table + class="members-table" + head-variant="white" + stacked="lg" + :fields="filteredFields" + :items="members" + primary-key="id" + thead-class="border-bottom" + :empty-text="__('No members found')" + show-empty + > + <template #cell(account)="{ item: member }"> + <members-table-cell #default="{ memberType, isCurrentUser }" :member="member"> + <member-avatar + :member-type="memberType" + :is-current-user="isCurrentUser" + :member="member" + /> + </members-table-cell> + </template> + + <template #cell(source)="{ item: member }"> + <members-table-cell #default="{ isDirectMember }" :member="member"> + <member-source :is-direct-member="isDirectMember" :member-source="member.source" /> + </members-table-cell> + </template> + + <template #cell(granted)="{ item: { createdAt, createdBy } }"> + <created-at :date="createdAt" :created-by="createdBy" /> + </template> + + <template #cell(invited)="{ item: { createdAt, createdBy } }"> + <created-at :date="createdAt" :created-by="createdBy" /> + </template> + + <template #cell(requested)="{ item: { createdAt } }"> + <created-at :date="createdAt" /> + </template> + + <template #cell(expires)="{ item: { expiresAt } }"> + <expires-at :date="expiresAt" /> + </template> + + <template #head(actions)="{ label }"> + <span data-testid="col-actions" class="gl-sr-only">{{ label }}</span> + </template> + </gl-table> +</template> diff --git a/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue b/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue new file mode 100644 index 00000000000..0688c5d3c9d --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue @@ -0,0 +1,50 @@ +<script> +import { mapState } from 'vuex'; +import { MEMBER_TYPES } from '../constants'; + +export default { + name: 'MembersTableCell', + props: { + member: { + type: Object, + required: true, + }, + }, + computed: { + ...mapState(['sourceId', 'currentUserId']), + isGroup() { + return Boolean(this.member.sharedWithGroup); + }, + isInvite() { + return Boolean(this.member.invite); + }, + isAccessRequest() { + return Boolean(this.member.requestedAt); + }, + memberType() { + if (this.isGroup) { + return MEMBER_TYPES.group; + } else if (this.isInvite) { + return MEMBER_TYPES.invite; + } else if (this.isAccessRequest) { + return MEMBER_TYPES.accessRequest; + } + + return MEMBER_TYPES.user; + }, + isDirectMember() { + return this.member.source?.id === this.sourceId; + }, + isCurrentUser() { + return this.member.user?.id === this.currentUserId; + }, + }, + render() { + return this.$scopedSlots.default({ + memberType: this.memberType, + isDirectMember: this.isDirectMember, + isCurrentUser: this.isCurrentUser, + }); + }, +}; +</script> diff --git a/app/assets/javascripts/vue_shared/components/members/utils.js b/app/assets/javascripts/vue_shared/components/members/utils.js new file mode 100644 index 00000000000..782a0b7f96b --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/utils.js @@ -0,0 +1,19 @@ +import { __ } from '~/locale'; + +export const generateBadges = (member, isCurrentUser) => [ + { + show: isCurrentUser, + text: __("It's you"), + variant: 'success', + }, + { + show: member.user?.blocked, + text: __('Blocked'), + variant: 'danger', + }, + { + show: member.user?.twoFactorEnabled, + text: __('2FA'), + variant: 'info', + }, +]; diff --git a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue index 35ba7c665d5..cad4439ecea 100644 --- a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue +++ b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue @@ -1,19 +1,16 @@ <script> import $ from 'jquery'; -import { GlDeprecatedButton, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import Clipboard from 'clipboard'; import { __ } from '~/locale'; export default { components: { - GlDeprecatedButton, - GlIcon, + GlButton, }, - directives: { GlTooltip: GlTooltipDirective, }, - props: { text: { type: String, @@ -55,15 +52,12 @@ export default { default: null, }, }, - copySuccessText: __('Copied'), - computed: { modalDomId() { return this.modalId ? `#${this.modalId}` : ''; }, }, - mounted() { this.$nextTick(() => { this.clipboard = new Clipboard(this.$el, { @@ -83,13 +77,11 @@ export default { .on('error', e => this.$emit('error', e)); }); }, - destroyed() { if (this.clipboard) { this.clipboard.destroy(); } }, - methods: { updateTooltip(target) { const $target = $(target); @@ -112,15 +104,12 @@ export default { }; </script> <template> - <gl-deprecated-button + <gl-button v-gl-tooltip="{ placement: tooltipPlacement, container: tooltipContainer }" :class="cssClasses" :data-clipboard-target="target" :data-clipboard-text="text" :title="title" - > - <slot> - <gl-icon name="copy-to-clipboard" /> - </slot> - </gl-deprecated-button> + icon="copy-to-clipboard" + /> </template> diff --git a/app/assets/javascripts/vue_shared/components/navigation_tabs.vue b/app/assets/javascripts/vue_shared/components/navigation_tabs.vue index f8983a3d29a..3749888ee36 100644 --- a/app/assets/javascripts/vue_shared/components/navigation_tabs.vue +++ b/app/assets/javascripts/vue_shared/components/navigation_tabs.vue @@ -58,7 +58,12 @@ export default { active: tab.isActive, }" > - <a :class="`js-${scope}-tab-${tab.scope}`" role="button" @click="onTabClick(tab)"> + <a + :class="`js-${scope}-tab-${tab.scope}`" + :data-testid="`${scope}-tab-${tab.scope}`" + role="button" + @click="onTabClick(tab)" + > {{ tab.name }} <span v-if="shouldRenderBadge(tab.count)" class="badge badge-pill"> {{ tab.count }} </span> diff --git a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue index 53dbae39608..3aca068c074 100644 --- a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue @@ -12,7 +12,7 @@ export default { </script> <template> - <timeline-entry-item class="note note-wrapper" data-qa-selector="skeleton_note"> + <timeline-entry-item class="note note-wrapper" data-qa-selector="skeleton_note_placeholder"> <div class="timeline-icon"></div> <div class="timeline-content"> <div class="note-header"></div> diff --git a/app/assets/javascripts/vue_shared/components/registry/title_area.vue b/app/assets/javascripts/vue_shared/components/registry/title_area.vue index cc33b8f85cd..197671b47d6 100644 --- a/app/assets/javascripts/vue_shared/components/registry/title_area.vue +++ b/app/assets/javascripts/vue_shared/components/registry/title_area.vue @@ -1,10 +1,12 @@ <script> -import { GlAvatar } from '@gitlab/ui'; +import { GlAvatar, GlSprintf, GlLink } from '@gitlab/ui'; export default { name: 'TitleArea', components: { GlAvatar, + GlSprintf, + GlLink, }, props: { avatar: { @@ -17,6 +19,11 @@ export default { default: null, required: false, }, + infoMessages: { + type: Array, + default: () => [], + required: false, + }, }, data() { return { @@ -30,37 +37,58 @@ export default { </script> <template> - <div class="gl-display-flex gl-justify-content-space-between gl-py-3"> - <div class="gl-flex-direction-column"> - <div class="gl-display-flex"> - <gl-avatar v-if="avatar" :src="avatar" shape="rect" class="gl-align-self-center gl-mr-4" /> + <div class="gl-display-flex gl-flex-direction-column"> + <div class="gl-display-flex gl-justify-content-space-between gl-py-3"> + <div class="gl-flex-direction-column"> + <div class="gl-display-flex"> + <gl-avatar + v-if="avatar" + :src="avatar" + shape="rect" + class="gl-align-self-center gl-mr-4" + /> - <div class="gl-display-flex gl-flex-direction-column"> - <h1 class="gl-font-size-h1 gl-mt-3 gl-mb-2" data-testid="title"> - <slot name="title">{{ title }}</slot> - </h1> + <div class="gl-display-flex gl-flex-direction-column"> + <h1 class="gl-font-size-h1 gl-mt-3 gl-mb-2" data-testid="title"> + <slot name="title">{{ title }}</slot> + </h1> + + <div + v-if="$slots['sub-header']" + class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1" + > + <slot name="sub-header"></slot> + </div> + </div> + </div> + <div class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3"> <div - v-if="$slots['sub-header']" - class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1" + v-for="(row, metadataIndex) in metadataSlots" + :key="metadataIndex" + class="gl-display-flex gl-align-items-center gl-mr-5" > - <slot name="sub-header"></slot> + <slot :name="row"></slot> </div> </div> </div> - - <div class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3"> - <div - v-for="(row, metadataIndex) in metadataSlots" - :key="metadataIndex" - class="gl-display-flex gl-align-items-center gl-mr-5" - > - <slot :name="row"></slot> - </div> + <div v-if="$slots['right-actions']" class="gl-mt-3"> + <slot name="right-actions"></slot> </div> </div> - <div v-if="$slots['right-actions']" class="gl-mt-3"> - <slot name="right-actions"></slot> - </div> + <p> + <span + v-for="(message, index) in infoMessages" + :key="index" + class="gl-mr-2" + data-testid="info-message" + > + <gl-sprintf :message="message.text"> + <template #docLink="{content}"> + <gl-link :href="message.link" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </span> + </p> </div> </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 c08659919fa..44d43ca8f69 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 @@ -4,6 +4,8 @@ export const CUSTOM_EVENTS = { openAddImageModal: 'gl_openAddImageModal', }; +export const ALLOWED_VIDEO_ORIGINS = ['https://www.youtube.com']; + /* eslint-disable @gitlab/require-i18n-strings */ export const TOOLBAR_ITEM_CONFIGS = [ { icon: 'heading', event: 'openHeadingSelect', classes: 'tui-heading', tooltip: __('Headings') }, diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js index 51ba033dff0..bbe3825138c 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js @@ -4,6 +4,7 @@ import ToolbarItem from '../toolbar_item.vue'; import buildHtmlToMarkdownRenderer from './build_html_to_markdown_renderer'; import buildCustomHTMLRenderer from './build_custom_renderer'; import { TOOLBAR_ITEM_CONFIGS } from '../constants'; +import sanitizeHTML from './sanitize_html'; const buildWrapper = propsData => { const instance = new Vue({ @@ -62,5 +63,6 @@ export const getEditorOptions = externalOptions => { return defaults({ customHTMLRenderer: buildCustomHTMLRenderer(externalOptions?.customRenderers), toolbarItems: TOOLBAR_ITEM_CONFIGS.map(toolbarItem => generateToolbarItem(toolbarItem)), + customHTMLSanitizer: html => sanitizeHTML(html), }); }; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js index b179ca61dba..18bd17d43d9 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js @@ -1,7 +1,21 @@ import { buildUneditableHtmlAsTextTokens } from './build_uneditable_token'; +import { ALLOWED_VIDEO_ORIGINS } from '../../constants'; +import { getURLOrigin } from '~/lib/utils/url_utility'; -const canRender = ({ type }) => { - return type === 'htmlBlock'; +const isVideoFrame = html => { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const { + children: { length }, + } = doc; + const iframe = doc.querySelector('iframe'); + const origin = iframe && getURLOrigin(iframe.getAttribute('src')); + + return length === 1 && ALLOWED_VIDEO_ORIGINS.includes(origin); +}; + +const canRender = ({ type, literal }) => { + return type === 'htmlBlock' && !isVideoFrame(literal); }; const render = node => buildUneditableHtmlAsTextTokens(node); diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/sanitize_html.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/sanitize_html.js new file mode 100644 index 00000000000..eae2e0335c1 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/sanitize_html.js @@ -0,0 +1,22 @@ +import createSanitizer from 'dompurify'; +import { ALLOWED_VIDEO_ORIGINS } from '../constants'; +import { getURLOrigin } from '~/lib/utils/url_utility'; + +const sanitizer = createSanitizer(window); +const ADD_TAGS = ['iframe']; + +sanitizer.addHook('uponSanitizeElement', node => { + if (node.tagName !== 'IFRAME') { + return; + } + + const origin = getURLOrigin(node.getAttribute('src')); + + if (!ALLOWED_VIDEO_ORIGINS.includes(origin)) { + node.remove(); + } +}); + +const sanitize = content => sanitizer.sanitize(content, { ADD_TAGS }); + +export default sanitize; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue index 6839354fb3a..267c3be5f50 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue @@ -38,6 +38,7 @@ export default { <template> <div class="labels-select-dropdown-contents w-100 mt-1 mb-3 py-2 rounded-top rounded-bottom position-absolute" + data-qa-selector="labels_dropdown_content" :style="directionStyle" > <component :is="dropdownContentsView" /> 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 0b763aa4b72..c8dee81d746 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 @@ -1,6 +1,7 @@ <script> import { mapState, mapGetters, mapActions } from 'vuex'; import { GlLoadingIcon, GlButton, GlSearchBoxByType, GlLink } from '@gitlab/ui'; +import fuzzaldrinPlus from 'fuzzaldrin-plus'; import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; @@ -39,9 +40,9 @@ export default { ...mapGetters(['selectedLabelsList', 'isDropdownVariantSidebar', 'isDropdownVariantEmbedded']), visibleLabels() { if (this.searchKey) { - return this.labels.filter(label => - label.title.toLowerCase().includes(this.searchKey.toLowerCase()), - ); + return fuzzaldrinPlus.filter(this.labels, this.searchKey, { + key: ['title'], + }); } return this.labels; }, @@ -112,6 +113,7 @@ export default { this.currentHighlightItem += 1; } else if (e.keyCode === ENTER_KEY_CODE && this.currentHighlightItem > -1) { this.updateSelectedLabels([this.visibleLabels[this.currentHighlightItem]]); + this.searchKey = ''; } else if (e.keyCode === ESC_KEY_CODE) { this.toggleDropdownContents(); } @@ -155,7 +157,11 @@ export default { /> </div> <div class="dropdown-input" @click.stop="() => {}"> - <gl-search-box-by-type v-model="searchKey" :autofocus="true" /> + <gl-search-box-by-type + v-model="searchKey" + :autofocus="true" + data-qa-selector="dropdown_input_field" + /> </div> <div v-show="showListContainer" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue index 12ad2acf308..286067a0d0f 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue @@ -35,6 +35,8 @@ export default { <template v-for="label in selectedLabels" v-else> <gl-label :key="label.id" + data-qa-selector="selected_label_content" + :data-qa-label-name="label.title" :title="label.title" :description="label.description" :background-color="label.color" diff --git a/app/assets/javascripts/vue_shared/components/todo_button.vue b/app/assets/javascripts/vue_shared/components/todo_button.vue index debf19ccca6..a9d4f8403fa 100644 --- a/app/assets/javascripts/vue_shared/components/todo_button.vue +++ b/app/assets/javascripts/vue_shared/components/todo_button.vue @@ -15,7 +15,7 @@ export default { }, computed: { buttonLabel() { - return this.isTodo ? __('Mark as done') : __('Add a To-Do'); + return this.isTodo ? __('Mark as done') : __('Add a To Do'); }, }, }; diff --git a/app/assets/javascripts/vue_shared/components/web_ide_link.vue b/app/assets/javascripts/vue_shared/components/web_ide_link.vue index 8307c6d3b55..b9c25bdc2e8 100644 --- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue +++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue @@ -15,7 +15,13 @@ export default { props: { webIdeUrl: { type: String, - required: true, + required: false, + default: '', + }, + webIdeIsFork: { + type: Boolean, + required: false, + default: false, }, needsToFork: { type: Boolean, @@ -61,9 +67,11 @@ export default { ? { href: '#modal-confirm-fork', handle: () => this.showModal('#modal-confirm-fork') } : { href: this.webIdeUrl }; + const text = this.webIdeIsFork ? __('Edit fork in Web IDE') : __('Web IDE'); + return { key: KEY_WEB_IDE, - text: __('Web IDE'), + text, secondaryText: __('Quickly and easily edit multiple files in your project.'), tooltip: '', attrs: { diff --git a/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js b/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js index a740a3fa6b9..cdbde55901d 100644 --- a/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js +++ b/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js @@ -10,6 +10,10 @@ import { validateParams } from '~/pipelines/utils'; export default { methods: { onChangeTab(scope) { + if (this.scope === scope) { + return; + } + let params = { scope, page: '1', diff --git a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js index be5f55a5220..c0fc055a01b 100644 --- a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js +++ b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js @@ -111,7 +111,7 @@ const mixins = { return this.isMergeRequest && this.pipelineStatus && Object.keys(this.pipelineStatus).length; }, isOpen() { - return this.state === 'opened'; + return this.state === 'opened' || this.state === 'reopened'; }, isClosed() { return this.state === 'closed'; diff --git a/app/assets/javascripts/vuex_shared/modules/members/state.js b/app/assets/javascripts/vuex_shared/modules/members/state.js index 1511961245c..244a54d74d5 100644 --- a/app/assets/javascripts/vuex_shared/modules/members/state.js +++ b/app/assets/javascripts/vuex_shared/modules/members/state.js @@ -1,5 +1,6 @@ -export default ({ members, sourceId, currentUserId }) => ({ +export default ({ members, sourceId, currentUserId, tableFields }) => ({ members, sourceId, currentUserId, + tableFields, }); diff --git a/app/assets/javascripts/whats_new/components/app.vue b/app/assets/javascripts/whats_new/components/app.vue index a00661c214d..ed17927c5b2 100644 --- a/app/assets/javascripts/whats_new/components/app.vue +++ b/app/assets/javascripts/whats_new/components/app.vue @@ -1,6 +1,9 @@ <script> import { mapState, mapActions } from 'vuex'; import { GlDrawer, GlBadge, GlIcon, GlLink } from '@gitlab/ui'; +import Tracking from '~/tracking'; + +const trackingMixin = Tracking.mixin(); export default { components: { @@ -9,12 +12,18 @@ export default { GlIcon, GlLink, }, + mixins: [trackingMixin], props: { features: { type: String, required: false, default: null, }, + storageKey: { + type: String, + required: true, + default: null, + }, }, computed: { ...mapState(['open']), @@ -31,7 +40,12 @@ export default { }, }, mounted() { - this.openDrawer(); + this.openDrawer(this.storageKey); + + const body = document.querySelector('body'); + const namespaceId = body.getAttribute('data-namespace-id'); + + this.track('click_whats_new_drawer', { label: 'namespace_id', value: namespaceId }); }, methods: { ...mapActions(['openDrawer', 'closeDrawer']), @@ -41,13 +55,20 @@ export default { <template> <div> - <gl-drawer class="mt-6" :open="open" @close="closeDrawer"> + <gl-drawer class="whats-new-drawer" :open="open" @close="closeDrawer"> <template #header> <h4 class="page-title my-2">{{ __("What's new at GitLab") }}</h4> </template> <div class="pb-6"> <div v-for="feature in parsedFeatures" :key="feature.title" class="mb-6"> - <gl-link :href="feature.url" target="_blank"> + <gl-link + :href="feature.url" + target="_blank" + data-testid="whats-new-title-link" + data-track-event="click_whats_new_item" + :data-track-label="feature.title" + :data-track-property="feature.url" + > <h5 class="gl-font-base">{{ feature.title }}</h5> </gl-link> <div class="mb-2"> @@ -57,7 +78,13 @@ export default { </gl-badge> </template> </div> - <gl-link :href="feature.url" target="_blank"> + <gl-link + :href="feature.url" + target="_blank" + data-track-event="click_whats_new_item" + :data-track-label="feature.title" + :data-track-property="feature.url" + > <img :alt="feature.title" :src="feature.image_url" @@ -65,9 +92,17 @@ export default { /> </gl-link> <p class="pt-2">{{ feature.body }}</p> - <gl-link :href="feature.url" target="_blank">{{ __('Learn more') }}</gl-link> + <gl-link + :href="feature.url" + target="_blank" + data-track-event="click_whats_new_item" + :data-track-label="feature.title" + :data-track-property="feature.url" + >{{ __('Learn more') }}</gl-link + > </div> </div> </gl-drawer> + <div v-if="open" class="whats-new-modal-backdrop modal-backdrop"></div> </div> </template> diff --git a/app/assets/javascripts/whats_new/index.js b/app/assets/javascripts/whats_new/index.js index 19cdb590ae2..dc2e9eb7ea3 100644 --- a/app/assets/javascripts/whats_new/index.js +++ b/app/assets/javascripts/whats_new/index.js @@ -20,6 +20,7 @@ export default () => { return createElement('app', { props: { features: whatsNewElm.getAttribute('data-features'), + storageKey: whatsNewElm.getAttribute('data-storage-key'), }, }); }, diff --git a/app/assets/javascripts/whats_new/store/actions.js b/app/assets/javascripts/whats_new/store/actions.js index 53488413d9e..f4229598cb3 100644 --- a/app/assets/javascripts/whats_new/store/actions.js +++ b/app/assets/javascripts/whats_new/store/actions.js @@ -4,7 +4,11 @@ export default { closeDrawer({ commit }) { commit(types.CLOSE_DRAWER); }, - openDrawer({ commit }) { + openDrawer({ commit }, storageKey) { commit(types.OPEN_DRAWER); + + if (storageKey) { + localStorage.setItem(storageKey, JSON.stringify(false)); + } }, }; diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss index f706b615e7e..e8899b0a430 100644 --- a/app/assets/stylesheets/_page_specific_files.scss +++ b/app/assets/stylesheets/_page_specific_files.scss @@ -1,13 +1,11 @@ @import './pages/admin'; @import './pages/alert_management/details'; @import './pages/alert_management/severity-icons'; -@import './pages/boards'; @import './pages/branches'; @import './pages/builds'; @import './pages/ci_projects'; @import './pages/clusters'; @import './pages/commits'; -@import './pages/cycle_analytics'; @import './pages/deploy_keys'; @import './pages/detail_page'; @import './pages/dev_ops_report'; @@ -35,7 +33,6 @@ @import './pages/members'; @import './pages/merge_conflicts'; @import './pages/merge_requests'; -@import './pages/milestone'; @import './pages/monitor'; @import './pages/note_form'; @import './pages/notes'; @@ -57,9 +54,7 @@ @import './pages/sherlock'; @import './pages/status'; @import './pages/storage_quota'; -@import './pages/tags'; @import './pages/tree'; @import './pages/trials'; -@import './pages/ui_dev_kit'; @import './pages/users'; @import './pages/wiki'; diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 8acd338fff8..cae886bf846 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -1,11 +1,3 @@ -/* - * This is a manifest file that'll automatically include all the stylesheets available in this directory - * and any sub-directories. You're free to add application-wide styles to this file and they'll appear at - * the top of the compiled file, but it's generally better to create a new file per style scope. - *= require_self - *= require cropper.css -*/ - // Welcome to GitLab css! // If you need to add or modify UI component that is common for many pages // like a table or typography then make changes in the framework/ directory. @@ -14,6 +6,7 @@ @import '@gitlab/at.js/dist/css/jquery.atwho'; @import 'dropzone/dist/basic'; @import 'select2'; +@import 'cropper'; // GitLab UI framework @import 'framework'; @@ -36,17 +29,6 @@ // EE-only stylesheets @import 'application_ee'; -// CSS util classes -/** - These are deprecated in favor of the Gitlab UI utilities imported below. - Please check https://unpkg.com/browse/@gitlab/ui/src/scss/utilities.scss - to see the available utility classes. -**/ -@import 'utilities'; - -// Gitlab UI util classes -@import '@gitlab/ui/src/scss/utilities'; - /* print styles */ @media print { @import 'print'; diff --git a/app/assets/stylesheets/application_utilities.scss b/app/assets/stylesheets/application_utilities.scss new file mode 100644 index 00000000000..817e983a0ec --- /dev/null +++ b/app/assets/stylesheets/application_utilities.scss @@ -0,0 +1,12 @@ +@import 'page_bundles/mixins_and_variables_and_functions'; + +// CSS util classes +/** + These are deprecated in favor of the Gitlab UI utilities imported below. + Please check https://unpkg.com/browse/@gitlab/ui/src/scss/utilities.scss + to see the available utility classes. +**/ +@import 'utilities'; + +// Gitlab UI util classes +@import '@gitlab/ui/src/scss/utilities'; diff --git a/app/assets/stylesheets/application_utilities_dark.scss b/app/assets/stylesheets/application_utilities_dark.scss new file mode 100644 index 00000000000..eb32cdfc444 --- /dev/null +++ b/app/assets/stylesheets/application_utilities_dark.scss @@ -0,0 +1,3 @@ +@import './themes/dark'; + +@import 'application_utilities'; diff --git a/app/assets/stylesheets/components/design_management/design.scss b/app/assets/stylesheets/components/design_management/design.scss index 21133316291..f198c06c2df 100644 --- a/app/assets/stylesheets/components/design_management/design.scss +++ b/app/assets/stylesheets/components/design_management/design.scss @@ -152,6 +152,10 @@ } } +.design-card-header { + background: transparent; +} + .design-dropzone-border { border: 2px dashed $gray-100; } diff --git a/app/assets/stylesheets/components/whats_new.scss b/app/assets/stylesheets/components/whats_new.scss index 4fff900f5a5..6c58346b750 100644 --- a/app/assets/stylesheets/components/whats_new.scss +++ b/app/assets/stylesheets/components/whats_new.scss @@ -1,9 +1,32 @@ +.whats-new-drawer { + margin-top: $header-height; + @include gl-shadow-none; +} + +.with-performance-bar .whats-new-drawer { + margin-top: calc(#{$performance-bar-height} + #{$header-height}); +} + .gl-badge.whats-new-item-badge { background-color: $purple-light; color: $purple; - font-weight: bold; + @include gl-font-weight-bold; } .whats-new-item-image { border-color: $gray-50; } + +.whats-new-modal-backdrop { + z-index: 9; +} + +.whats-new-notification-count { + @include gl-bg-gray-900; + @include gl-font-sm; + @include gl-line-height-normal; + @include gl-text-white; + @include gl-vertical-align-top; + border-radius: 20px; + padding: 3px 10px; +} diff --git a/app/assets/stylesheets/fontawesome_custom.scss b/app/assets/stylesheets/fontawesome_custom.scss index 46e5e5a28ea..7ea03c4127b 100644 --- a/app/assets/stylesheets/fontawesome_custom.scss +++ b/app/assets/stylesheets/fontawesome_custom.scss @@ -88,11 +88,6 @@ content: '\f078'; } -.fa-remove::before, -.fa-times::before { - content: '\f00d'; -} - .fa-caret-down::before { content: '\f0d7'; } @@ -258,10 +253,6 @@ content: '\f081'; } -.fa-unlink::before { - content: '\f127'; -} - .fa-file-pdf-o::before { content: '\f1c1'; } diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss index 136ff82e0f8..196fb3a7088 100644 --- a/app/assets/stylesheets/framework/animations.scss +++ b/app/assets/stylesheets/framework/animations.scss @@ -112,8 +112,7 @@ a { } .dropdown-menu a, -.dropdown-menu button, -.dropdown-menu-nav a { +.dropdown-menu button { transition: none; } diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index a9c1652d00d..a8cc685d880 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -417,12 +417,6 @@ } } -@include media-breakpoint-down(xs) { - .btn-wide-on-xs { - width: 100%; - } -} - .btn-blank { padding: 0; background: transparent; diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index ad5864ef6d9..e8d37fcf40b 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -33,8 +33,7 @@ } .show.dropdown { - .dropdown-menu, - .dropdown-menu-nav { + .dropdown-menu { @include set-visible; min-height: $dropdown-min-height; max-height: $dropdown-max-height; @@ -190,15 +189,6 @@ background-color: $gray-darker; color: $gl-text-color; outline: 0; - - // make sure the text color is not overridden - &.text-danger { - color: $brand-danger; - } - - .avatar { - border-color: $white; - } } @mixin dropdown-link { @@ -217,11 +207,6 @@ text-align: left; width: 100%; - // make sure the text color is not overridden - &.text-danger { - color: $brand-danger; - } - &.disable-hover { text-decoration: none; } @@ -233,10 +218,6 @@ @include dropdown-item-hover; text-decoration: none; - - .badge.badge-pill { - background-color: darken($blue-50, 5%); - } } &.dropdown-menu-user-link { @@ -258,8 +239,7 @@ } } -.dropdown-menu, -.dropdown-menu-nav { +.dropdown-menu { display: none; position: absolute; width: auto; @@ -393,49 +373,56 @@ pointer-events: none; } - .dropdown-menu li { - cursor: pointer; + .dropdown-menu { + display: none; + opacity: 1; + visibility: visible; + transform: translateY(0); - &.droplab-item-active button { - @include dropdown-item-hover; - } + li { + cursor: pointer; - > a, - > button { - display: flex; - margin: 0; - text-overflow: inherit; - text-align: left; + &.droplab-item-active button { + @include dropdown-item-hover; + } - &.btn .fa:not(:last-child) { - margin-left: 5px; + > a, + > button { + display: flex; + margin: 0; + text-overflow: inherit; + text-align: left; + + &.btn .fa:not(:last-child) { + margin-left: 5px; + } } - } - > button.dropdown-epic-button { - flex-direction: column; + > button.dropdown-epic-button { + flex-direction: column; - .reference { - color: $gray-300; - margin-top: $gl-padding-4; + .reference { + color: $gray-300; + margin-top: $gl-padding-4; + } } - } - &.droplab-item-selected i { - visibility: visible; - } + &.droplab-item-selected i { + visibility: visible; + } - .icon { - visibility: hidden; - } + .icon { + visibility: hidden; + } - .description { - display: inline-block; - white-space: normal; - margin-left: 5px; + .description { + display: inline-block; + white-space: normal; + margin-left: 5px; - p { - margin-bottom: 0; + p { + margin-bottom: 0; + } } } } @@ -447,21 +434,12 @@ } } -.droplab-dropdown .dropdown-menu, -.droplab-dropdown .dropdown-menu-nav { - display: none; - opacity: 1; - visibility: visible; - transform: translateY(0); -} - .comment-type-dropdown.show .dropdown-menu { display: block; } .filtered-search-box-input-container { - .dropdown-menu, - .dropdown-menu-nav { + .dropdown-menu { max-width: 280px; } } @@ -850,8 +828,7 @@ } header.navbar-gitlab .dropdown { - .dropdown-menu, - .dropdown-menu-nav { + .dropdown-menu { width: 100%; min-width: 100%; } diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 76c6e03377c..f8710cc1346 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -50,7 +50,7 @@ right: 15px; margin-left: auto; - .btn { + .btn:not(.btn-icon) { padding: 0 10px; font-size: 13px; line-height: 28px; @@ -372,7 +372,7 @@ span.idiff { color: $gl-text-color; } - .file-actions .btn { + .file-actions .btn:not(.btn-icon) { padding: 0 10px; font-size: 13px; line-height: 28px; diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index cf21c23cb17..52319d9658b 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -203,18 +203,6 @@ margin-right: 0; } } - - &:hover, - &:focus { - text-decoration: none; - outline: 0; - opacity: 1; - color: $white; - - &.header-user-dropdown-toggle .header-user-avatar { - border-color: $white; - } - } } .header-new-dropdown-toggle { diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss index ec0755b1614..5623d38d66e 100644 --- a/app/assets/stylesheets/framework/icons.scss +++ b/app/assets/stylesheets/framework/icons.scss @@ -9,6 +9,7 @@ } } +.ci-status-icon-error, .ci-status-icon-failed { svg { fill: $red-500; diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss index 292d57f132c..bbfe65e6eda 100644 --- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss +++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss @@ -28,10 +28,6 @@ text-decoration: none; color: $black; border-bottom: 2px solid $gray-darkest; - - .badge.badge-pill { - color: $black; - } } } diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 8b5fa6c1b6c..c15d46d43b2 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -439,10 +439,6 @@ content: '\f0c6'; } - &:hover::before { - text-decoration: none; - } - &.no-attachment-icon { &::before { display: none; diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 8cebfc430e0..66267d8a8bc 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -819,7 +819,6 @@ $pipeline-dropdown-line-height: 20px; $pipeline-dropdown-status-icon-size: 18px; $ci-action-dropdown-button-size: 24px; $ci-action-dropdown-svg-size: 12px; -$pipelines-table-header-height: 40px; /* CI variable lists @@ -868,9 +867,6 @@ $add-to-slack-popup-max-width: 400px; $add-to-slack-gif-max-width: 850px; $add-to-slack-well-max-width: 750px; $add-to-slack-logo-size: 100px; -$double-headed-arrow-width: 100px; -$double-headed-arrow-height: 25px; -$right-arrow-size: 16px; /* Popup diff --git a/app/assets/stylesheets/framework/wells.scss b/app/assets/stylesheets/framework/wells.scss index 55996a074c6..d550a1faa18 100644 --- a/app/assets/stylesheets/framework/wells.scss +++ b/app/assets/stylesheets/framework/wells.scss @@ -3,7 +3,6 @@ color: $gl-text-color; border: 1px solid $border-color; border-radius: $border-radius-default; - margin-bottom: $gl-padding-8; .card.card-body-segment { padding: $gl-padding; @@ -29,11 +28,6 @@ .ref-name { font-size: 12px; - - &:hover { - text-decoration: underline; - color: $gl-text-color; - } } } diff --git a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss index 1e239877428..93cb9be4a8f 100644 --- a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss +++ b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss @@ -6,9 +6,10 @@ $bs-input-focus-box-shadow: rgba(0, 123, 255, 0.25); a:not(.btn), - .btn-link:hover, - .btn-link:focus, - .btn-link:active { + .gl-button.btn-link, + .gl-button.btn-link:hover, + .gl-button.btn-link:focus, + .gl-button.btn-link:active { color: var(--ide-link-color, $blue-600); } diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss index c4852974a4d..ffa034a2495 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/page_bundles/boards.scss @@ -1,3 +1,5 @@ +@import 'mixins_and_variables_and_functions'; + .user-can-drag { cursor: grab; } @@ -356,8 +358,6 @@ } .avatar { - margin: 0; - @include media-breakpoint-down(md) { width: $gl-padding; height: $gl-padding; diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/page_bundles/cycle_analytics.scss index c509bf121bc..3a5e2e4159d 100644 --- a/app/assets/stylesheets/pages/cycle_analytics.scss +++ b/app/assets/stylesheets/page_bundles/cycle_analytics.scss @@ -1,3 +1,5 @@ +@import 'mixins_and_variables_and_functions'; + #cycle-analytics, .cycle-analytics { margin: 24px auto 0; @@ -84,7 +86,7 @@ } .text { - color: $layout-link-gray; + color: var(--gray-500, $gray-500); margin: 0; } @@ -127,14 +129,14 @@ line-height: 65px; &.active { - background: $blue-50; - border-color: $blue-300; - box-shadow: inset 4px 0 0 0 $blue-500; + background: var(--blue-50, $blue-50); + border-color: var(--blue-300, $blue-300); + box-shadow: inset 4px 0 0 0 var(--blue-500, $blue-500); } &:hover:not(.active) { - background-color: $gray-lightest; - box-shadow: inset 2px 0 0 0 $border-color; + background-color: var(--gray-10, $gray-10); + box-shadow: inset 2px 0 0 0 var(--border-color, $border-color); cursor: pointer; } @@ -148,7 +150,7 @@ .stage-empty, .not-available { - color: $gl-text-color-secondary; + color: var(--gray-500, $gray-500); } } } @@ -172,7 +174,7 @@ } .events-info { - color: $gl-text-color-secondary; + color: var(--gray-500, $gray-500); } } @@ -191,7 +193,7 @@ list-style-type: none; padding: 0 0 $gl-padding; margin: 0 $gl-padding $gl-padding; - border-bottom: 1px solid $gray-darker; + border-bottom: 1px solid var(--gray-50, $gray-50); &:last-child { border-bottom: 0; @@ -220,7 +222,7 @@ display: block; a { - color: $gl-text-color; + color: var(--gl-text-color, $gl-text-color); } } } @@ -232,24 +234,24 @@ .total-time { font-size: $cycle-analytics-big-font; - color: $gl-text-color; + color: var(--gl-text-color, $gl-text-color); span { - color: $gl-text-color; + color: var(--gl-text-color, $gl-text-color); font-size: $gl-font-size; } } .issue-date, .build-date { - color: $gl-text-color; + color: var(--gl-text-color, $gl-text-color); } .mr-link, .issue-link, .commit-author-link, .issue-author-link { - color: $gl-text-color; + color: var(--gl-text-color, $gl-text-color); } // Custom CSS for components @@ -287,16 +289,16 @@ } .item-build-name { - color: $gl-text-color; + color: var(--gl-text-color, $gl-text-color); } .pipeline-id { - color: $gl-text-color; + color: var(--gl-text-color, $gl-text-color); padding: 0 3px 0 0; } .ref-name { - color: $black; + color: var(--gray-900, $gray-900); display: inline-block; max-width: 180px; text-overflow: ellipsis; @@ -307,14 +309,14 @@ } .commit-sha { - color: $blue-600; + color: var(--blue-600, $blue-600); line-height: 1.3; vertical-align: top; font-weight: $gl-font-weight-normal; } .fa { - color: $gl-text-color-secondary; + color: var(--gray-500, $gray-500); font-size: $code-font-size; } } @@ -326,10 +328,10 @@ width: 75%; margin: 0 auto; padding-top: 130px; - color: $gl-text-color-secondary; + color: var(--gray-500, $gray-500); h4 { - color: $gl-text-color; + color: var(--gl-text-color, $gl-text-color); } } diff --git a/app/assets/stylesheets/page_bundles/issues.scss b/app/assets/stylesheets/page_bundles/issues.scss new file mode 100644 index 00000000000..705583c74ae --- /dev/null +++ b/app/assets/stylesheets/page_bundles/issues.scss @@ -0,0 +1,8 @@ +.user-can-drag { + cursor: grab; +} + +.is-ghost { + opacity: 0.3; + pointer-events: none; +} diff --git a/app/assets/stylesheets/pages/milestone.scss b/app/assets/stylesheets/page_bundles/milestone.scss index e9eb79b071c..c1d7d86e3f9 100644 --- a/app/assets/stylesheets/pages/milestone.scss +++ b/app/assets/stylesheets/page_bundles/milestone.scss @@ -1,3 +1,5 @@ +@import 'mixins_and_variables_and_functions'; + $status-box-line-height: 26px; .issues-sortable-list .str-truncated { diff --git a/app/assets/stylesheets/pages/alert_management/details.scss b/app/assets/stylesheets/pages/alert_management/details.scss index a104c06c853..514f228e223 100644 --- a/app/assets/stylesheets/pages/alert_management/details.scss +++ b/app/assets/stylesheets/pages/alert_management/details.scss @@ -33,7 +33,7 @@ } .main-notes-list::before { - left: 15px !important; + left: $gl-spacing-scale-5 !important; } .note-header-info { diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 04167cbee1b..d7b4db3840e 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -123,20 +123,13 @@ } .build-header { - .ci-header-container, - .header-action-buttons { - display: flex; - } - - .ci-header-container { - min-height: 54px; - } - .page-content-header { padding: 10px 0 9px; } .header-action-buttons { + display: flex; + @include media-breakpoint-down(xs) { .sidebar-toggle-btn { margin-top: 0; diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index e6378fd9168..c55bfeb7b15 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -306,7 +306,6 @@ .commit, .generic-commit-status, .branch-commit { - .autodevops-link, .commit-sha { color: $blue-600; } diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 62af7103b39..3c432fe09c0 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -13,6 +13,21 @@ box-shadow: 0 -2px 0 0 var(--white); cursor: pointer; + .dropdown-menu { + cursor: auto; + } + + @media (max-width: map-get($grid-breakpoints, sm)-1) { + .file-header-content { + width: 0; + flex: 1; + } + + .file-actions { + margin-left: $gl-spacing-scale-2; + } + } + @media (min-width: map-get($grid-breakpoints, md)) { // The `+11` is to ensure the file header border shows when scrolled - // the bottom of the compare-versions header and the top of the file header @@ -55,10 +70,6 @@ } } - a:hover { - text-decoration: none; - } - &:hover { background-color: $gray-normal; } diff --git a/app/assets/stylesheets/pages/experimental_separate_sign_up.scss b/app/assets/stylesheets/pages/experimental_separate_sign_up.scss index dfc56654229..415ff01bc33 100644 --- a/app/assets/stylesheets/pages/experimental_separate_sign_up.scss +++ b/app/assets/stylesheets/pages/experimental_separate_sign_up.scss @@ -57,4 +57,8 @@ height: $default-icon-size; } } + + .decline-page { + width: 350px; + } } diff --git a/app/assets/stylesheets/pages/incident_management_list.scss b/app/assets/stylesheets/pages/incident_management_list.scss index 316066694a8..4aa6b2492a2 100644 --- a/app/assets/stylesheets/pages/incident_management_list.scss +++ b/app/assets/stylesheets/pages/incident_management_list.scss @@ -127,9 +127,4 @@ @include gl-w-full; } } - - // TODO: Abstract to `@gitlab/ui` utility set: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/921 - .gl-fill-green-500 { - fill: $green-500; - } } diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 53525a4d877..7097c2b10c4 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -117,7 +117,8 @@ } } -.assignee { +.assignee, +.reviewer { .merge-icon { color: $orange-400; position: absolute; @@ -240,16 +241,6 @@ .avatar { margin-left: 0; } - - a.edit-link:not([href]):hover { - color: rgba($gray-normal, 0.2); - } - - .confidential-edit, - .lock-edit, - .edit-link { - @extend .btn-link; - } } .cross-project-reference, diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index e37b26187e7..80cb6ec89ce 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -134,11 +134,6 @@ } } -.label-description-wrapper { - margin-right: 8px; - margin-left: 8px; -} - .prioritized-labels { margin-bottom: 30px; @@ -201,10 +196,6 @@ } } -.label-options-toggle { - width: 100%; -} - .label-subscription { vertical-align: middle; @@ -276,35 +267,6 @@ font-size: $label-font-size; } -.label-badge-blue { - background-color: $theme-blue-100; -} - -.label-badge-gray { - background-color: $gray-50; -} - -.label-links { - list-style: none; - margin: 0; - padding: 0; - white-space: nowrap; -} - -.label-link-item { - padding: 0; -} - -.label-description { - .description-text { - margin-bottom: 10px; - - .admin-labels & { - margin-bottom: 0; - } - } -} - .label-list-item { .content-list &::before, .content-list &::after { @@ -313,21 +275,12 @@ .label-name { width: 200px; - flex-shrink: 0; .gl-label { line-height: $gl-line-height; } } - .label-description { - flex-grow: 1; - - a { - color: $blue-600; - } - } - .label { padding: 4px $grid-size; font-size: $label-font-size; @@ -382,31 +335,8 @@ text-align: left; } - .label-links { - white-space: normal; - } - .label-description { order: 3; - width: 100%; - - > .label-description-wrapper { - margin-left: 0; - margin-right: 0; - } - } - } -} - -@media (max-width: 910px) { - .priority-badge { - display: block; - width: 100%; - margin-left: 0; - margin-top: $gl-padding; - - .label-badge { - display: inline-block; } } } diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss index 2d9a9f3029f..11d5104f64d 100644 --- a/app/assets/stylesheets/pages/members.scss +++ b/app/assets/stylesheets/pages/members.scss @@ -209,6 +209,23 @@ } } + +.members-table { + @include media-breakpoint-up(lg) { + .col-meta { + width: px-to-rem(150px); + } + + .col-expiration { + width: px-to-rem(200px); + } + + .col-actions { + width: px-to-rem(50px); + } + } +} + .card-mobile { .content-list.members-list li { display: block; diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 8aaeb92eb7a..ddec04b1b0c 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -770,8 +770,6 @@ $mr-widget-min-height: 69px; position: -webkit-sticky; position: sticky; top: $header-height + $mr-tabs-height; - margin-left: -16px; - width: calc(100% + 32px); .mr-version-menus-container { flex-wrap: nowrap; @@ -868,6 +866,13 @@ $mr-widget-min-height: 69px; } } +.container-fluid { + // Negative margins for mobile/tablet screen + .diffs.tab-pane { + margin: 0 (-$gl-padding); + } +} + // Wrap MR tabs/buttons so you don't have to scroll on desktop @include media-breakpoint-down(md) { .merge-request-tabs-container, diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index c144fb13322..b510822a20a 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -858,68 +858,28 @@ $note-form-margin-left: 72px; } .line-resolve-all-container { - margin: $gl-padding-4; - > div { white-space: nowrap; } - .discussion-next-btn { - border-radius: 0; - } - - .toggle-all-discussions-btn { + .btn-group .btn:first-child { border-top-left-radius: 0; border-bottom-left-radius: 0; } - - .btn { - line-height: $gl-line-height; - - svg { - fill: $gray-500; - } - - &.discussion-create-issue-btn { - border-radius: 0; - border-right: 0; - - a { - padding: 0; - line-height: 0; - - &:hover { - text-decoration: none; - border: 0; - } - } - } - - &.discussion-next-btn { - border-right: 0; - } - } } .line-resolve-all { vertical-align: middle; display: inline-block; - padding: $gl-padding-4 10px; + padding: $gl-padding-8 $gl-padding-12; background-color: $gray-light; border: 1px solid $border-color; + border-right: 0; border-radius: $border-radius-default; - font-size: $gl-btn-small-font-size; border-top-right-radius: 0; border-bottom-right-radius: 0; - border-right: 0; - - .line-resolve-btn { - color: $gray-500; - - svg { - vertical-align: text-top; - } - } + font-size: $gl-font-size; + line-height: 1rem; @include media-breakpoint-down(xs) { flex: 1; diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 8b104ce9017..d382fc6241f 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -26,10 +26,6 @@ } .pipelines { - .negative-margin-top { - margin-top: -$pipelines-table-header-height; - } - .stage { max-width: 90px; width: 90px; @@ -111,6 +107,10 @@ white-space: nowrap; } } + + .pipeline-tags .label-container { + white-space: normal; + } } } @@ -124,22 +124,6 @@ } .ci-table { - .build.retried { - background-color: $gray-lightest; - } - - .commit-link { - a { - &:focus { - text-decoration: none; - } - } - - a:hover { - text-decoration: none; - } - } - .avatar { margin-left: 0; float: none; @@ -191,45 +175,12 @@ } } - .icon-container { - display: inline-block; - - &.commit-icon { - width: 15px; - text-align: center; - } - } - - /** - * Play button with icon in dropdowns - */ - .no-btn { - border: 0; - background: none; - outline: none; - width: 100%; - text-align: left; - - .icon-play { - position: relative; - top: 2px; - margin-right: 5px; - height: 13px; - width: 12px; - } - } - .duration, .finished-at { color: $gl-text-color-secondary; margin: 0; white-space: nowrap; - .fa { - font-size: 12px; - margin-right: 4px; - } - svg { width: 12px; height: 12px; @@ -241,14 +192,6 @@ .build-link a { color: $gl-text-color; } - - .btn-group.open .dropdown-toggle { - box-shadow: none; - } - - .pipeline-tags .label-container { - white-space: normal; - } } .stage-cell { @@ -322,9 +265,11 @@ } } -.admin-builds-table { - .ci-table td:last-child { - min-width: 120px; +[data-page='admin:jobs:index'] { + .admin-builds-table { + td:last-child { + min-width: 120px; + } } } @@ -333,377 +278,376 @@ border-bottom: 0; } -.tab-pane { - &.builds .ci-table tr { - height: 71px; - } - - .ci-table { - thead th { - border-top: 0; +[data-page='projects:pipelines:show'] { + .tab-pane { + .ci-table { + thead th { + border-top: 0; + } } } -} -.build-failures { - .build-state { - padding: 20px 2px; + .build-failures { + .build-state { + padding: 20px 2px; - .build-name { - font-weight: $gl-font-weight-normal; - } + .build-name { + font-weight: $gl-font-weight-normal; + } - .stage { - color: $gl-text-color-secondary; - font-weight: $gl-font-weight-normal; - vertical-align: middle; + .stage { + color: $gl-text-color-secondary; + font-weight: $gl-font-weight-normal; + vertical-align: middle; + } } - } - .build-log { - border: 0; - line-height: initial; - } + .build-log { + border: 0; + line-height: initial; + } - .build-trace-row td { - border-top: 0; - border-bottom-width: 1px; - border-bottom-style: solid; - padding-top: 0; - } + .build-trace-row td { + border-top: 0; + border-bottom-width: 1px; + border-bottom-style: solid; + padding-top: 0; + } - .build-trace { - width: 100%; - text-align: left; - margin-top: $gl-padding; - } + .build-trace { + width: 100%; + text-align: left; + margin-top: $gl-padding; + } - .build-name { - width: 196px; + .build-name { + width: 196px; - a { - font-weight: $gl-font-weight-bold; - color: $gl-text-color; - text-decoration: none; + a { + font-weight: $gl-font-weight-bold; + color: $gl-text-color; + text-decoration: none; - &:focus, - &:hover { - text-decoration: underline; + &:focus, + &:hover { + text-decoration: underline; + } } } - } - - .build-actions { - width: 70px; - text-align: right; - } - - .build-stage { - width: 140px; - } - - .ci-status-icon-failed { - padding: 10px 0 10px 12px; - width: 12px + 24px; // padding-left + svg width - } - .build-icon svg { - width: 24px; - height: 24px; - vertical-align: middle; - } - - .build-state, - .build-trace-row { - > td:last-child { - padding-right: 0; + .build-actions { + width: 70px; + text-align: right; } - } - @include media-breakpoint-down(sm) { - td:empty { - display: none; + .build-stage { + width: 140px; } - .ci-table { - margin-top: 2 * $gl-padding; + .ci-status-icon-failed { + padding: 10px 0 10px 12px; + width: 12px + 24px; // padding-left + svg width } - .build-trace-container { - padding-top: $gl-padding; - padding-bottom: $gl-padding; + .build-icon svg { + width: 24px; + height: 24px; + vertical-align: middle; } - .build-trace { - margin-bottom: 0; - margin-top: 0; + .build-state, + .build-trace-row { + > td:last-child { + padding-right: 0; + } } - } -} -.pipeline-tab-content { - display: flex; - width: 100%; - min-height: $dropdown-max-height-lg; - background-color: $gray-light; - padding: $gl-padding 0; - overflow: auto; -} + @include media-breakpoint-down(sm) { + td:empty { + display: none; + } -// Pipeline graph -.pipeline-graph { - white-space: nowrap; - transition: max-height 0.3s, padding 0.3s; + .ci-table { + margin-top: 2 * $gl-padding; + } - .stage-column-list, - .builds-container > ul { - padding: 0; - } + .build-trace-container { + padding-top: $gl-padding; + padding-bottom: $gl-padding; + } - a { - text-decoration: none; - color: $gl-text-color; + .build-trace { + margin-bottom: 0; + margin-top: 0; + } + } } - svg { - vertical-align: middle; + .pipeline-tab-content { + display: flex; + width: 100%; + min-height: $dropdown-max-height-lg; + background-color: $gray-light; + padding: $gl-padding 0; + overflow: auto; } - .stage-column { - display: inline-block; - vertical-align: top; - - &.left-margin { - &:not(:first-child) { - margin-left: 44px; + // Pipeline graph, used at + // app/assets/javascripts/pipelines/components/graph/graph_component.vue + .pipeline-graph { + white-space: nowrap; + transition: max-height 0.3s, padding 0.3s; - .left-connector { - @include flat-connector-before; - } - } + .stage-column-list, + .builds-container > ul { + padding: 0; } - &.no-margin { - margin: 0; + a { + text-decoration: none; + color: $gl-text-color; } - li { - list-style: none; + svg { + vertical-align: middle; } - // when downstream pipelines are present, the last stage isn't the last column - &:last-child:not(.has-downstream) { - .build { - // Remove right connecting horizontal line from first build in last stage - &:first-child::after { - border: 0; - } - // Remove right curved connectors from all builds in last stage - &:not(:first-child)::after { - border: 0; - } - // Remove opposite curve - .curve::before { - display: none; - } - } - } + .stage-column { + display: inline-block; + vertical-align: top; - // when upstream pipelines are present, the first stage isn't the first column - &:first-child:not(.has-upstream) { - .build { - // Remove left curved connectors from all builds in first stage - &:not(:first-child)::before { - border: 0; - } - // Remove opposite curve - .curve::after { - display: none; + &.left-margin { + &:not(:first-child) { + margin-left: 44px; + + .left-connector { + @include flat-connector-before; + } } } - } - - // Curve first child connecting lines in opposite direction - .curve { - display: none; - &::before, - &::after { - content: ''; - width: 21px; - height: 25px; - position: absolute; - top: -31px; - border-top: 2px solid $border-color; + &.no-margin { + margin: 0; } - &::after { - left: -44px; - border-right: 2px solid $border-color; - border-radius: 0 20px; + li { + list-style: none; } - &::before { - right: -44px; - border-left: 2px solid $border-color; - border-radius: 20px 0 0; + // when downstream pipelines are present, the last stage isn't the last column + &:last-child:not(.has-downstream) { + .build { + // Remove right connecting horizontal line from first build in last stage + &:first-child::after { + border: 0; + } + // Remove right curved connectors from all builds in last stage + &:not(:first-child)::after { + border: 0; + } + // Remove opposite curve + .curve::before { + display: none; + } + } } - } - } - .stage-name { - margin: 0 0 15px 10px; - font-weight: $gl-font-weight-bold; - width: 176px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - line-height: 2.2em; - } - - .build { - position: relative; - width: 186px; - margin-bottom: 10px; - white-space: normal; - - .ci-job-dropdown-container { - // override dropdown.scss - .dropdown-menu li button { - padding: 0; - text-align: center; + // when upstream pipelines are present, the first stage isn't the first column + &:first-child:not(.has-upstream) { + .build { + // Remove left curved connectors from all builds in first stage + &:not(:first-child)::before { + border: 0; + } + // Remove opposite curve + .curve::after { + display: none; + } + } } - } - // ensure .build-content has hover style when action-icon is hovered - .ci-job-dropdown-container:hover .build-content { - @extend .build-content:hover; - } + // Curve first child connecting lines in opposite direction + .curve { + display: none; - .ci-status-icon svg { - height: 24px; - width: 24px; - } + &::before, + &::after { + content: ''; + width: 21px; + height: 25px; + position: absolute; + top: -31px; + border-top: 2px solid $border-color; + } - .dropdown-menu-toggle { - background-color: transparent; - border: 0; - padding: 0; + &::after { + left: -44px; + border-right: 2px solid $border-color; + border-radius: 0 20px; + } - &:focus { - outline: none; + &::before { + right: -44px; + border-left: 2px solid $border-color; + border-radius: 20px 0 0; + } } } - .build-content { - @include build-content(); + .stage-name { + margin: 0 0 15px 10px; + font-weight: $gl-font-weight-bold; + width: 176px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 2.2em; } - a.build-content:hover, - button.build-content:hover { - background-color: $gray-darker; - border: 1px solid $dropdown-toggle-active-border-color; - } + .build { + position: relative; + width: 186px; + margin-bottom: 10px; + white-space: normal; + + .ci-job-dropdown-container { + // override dropdown.scss + .dropdown-menu li button { + padding: 0; + text-align: center; + } + } - // Connect first build in each stage with right horizontal line - &:first-child { - &::after { - content: ''; - position: absolute; - top: 48%; - right: -48px; - border-top: 2px solid $border-color; - width: 48px; - height: 1px; + // ensure .build-content has hover style when action-icon is hovered + .ci-job-dropdown-container:hover .build-content { + @extend .build-content:hover; } - } - // Connect each build (except for first) with curved lines - &:not(:first-child) { - &::after, - &::before { - content: ''; - top: -49px; - position: absolute; - border-bottom: 2px solid $border-color; - width: 25px; - height: 69px; + .ci-status-icon svg { + height: 24px; + width: 24px; } - // Right connecting curves - &::after { - right: -25px; - border-right: 2px solid $border-color; - border-radius: 0 0 20px; + .dropdown-menu-toggle { + background-color: transparent; + border: 0; + padding: 0; + + &:focus { + outline: none; + } } - // Left connecting curves - &::before { - left: -25px; - border-left: 2px solid $border-color; - border-radius: 0 0 0 20px; + .build-content { + @include build-content(); } - } - // Connect second build to first build with smaller curved line - &:nth-child(2) { - &::after, - &::before { - height: 29px; - top: -9px; + a.build-content:hover, + button.build-content:hover { + background-color: $gray-darker; + border: 1px solid $dropdown-toggle-active-border-color; } - .curve { - display: block; + // Connect first build in each stage with right horizontal line + &:first-child { + &::after { + content: ''; + position: absolute; + top: 48%; + right: -48px; + border-top: 2px solid $border-color; + width: 48px; + height: 1px; + } } - } - } - .ci-action-icon-container { - position: absolute; - right: 5px; - top: 50%; - transform: translateY(-50%); + // Connect each build (except for first) with curved lines + &:not(:first-child) { + &::after, + &::before { + content: ''; + top: -49px; + position: absolute; + border-bottom: 2px solid $border-color; + width: 25px; + height: 69px; + } - // Action Icons in big pipeline-graph nodes - &.ci-action-icon-wrapper { - height: 30px; - width: 30px; - border-radius: 100%; - display: block; - padding: 0; - line-height: 0; + // Right connecting curves + &::after { + right: -25px; + border-right: 2px solid $border-color; + border-radius: 0 0 20px; + } - svg { - fill: $gl-text-color-secondary; + // Left connecting curves + &::before { + left: -25px; + border-left: 2px solid $border-color; + border-radius: 0 0 0 20px; + } } - .spinner { - top: 2px; + // Connect second build to first build with smaller curved line + &:nth-child(2) { + &::after, + &::before { + height: 29px; + top: -9px; + } + + .curve { + display: block; + } } + } + + .ci-action-icon-container { + position: absolute; + right: 5px; + top: 50%; + transform: translateY(-50%); + + // Action Icons in big pipeline-graph nodes + &.ci-action-icon-wrapper { + height: 30px; + width: 30px; + border-radius: 100%; + display: block; + padding: 0; + line-height: 0; - &.play { svg { - left: 1px; - top: 1px; + fill: $gl-text-color-secondary; + } + + .spinner { + top: 2px; + } + + &.play { + svg { + left: 1px; + top: 1px; + } } } } - } - .stage-action svg { - left: 1px; - top: -2px; + .stage-action svg { + left: 1px; + top: -2px; + } } -} -// Triggers the dropdown in the big pipeline graph -.dropdown-counter-badge { - font-weight: 100; - font-size: 15px; - position: absolute; - right: 13px; - top: 8px; + // Triggers the dropdown in the big pipeline graph + .dropdown-counter-badge { + font-weight: 100; + font-size: 15px; + position: absolute; + right: 13px; + top: 8px; + } } .ci-build-text, @@ -1013,31 +957,35 @@ button.mini-pipeline-graph-dropdown-toggle { /** * Terminal */ -.terminal-icon { - margin-left: 3px; -} - -.terminal-container { - .content-block { - border-bottom: 0; - } +[data-page='projects:jobs:terminal'], +[data-page='projects:environments:terminal'] { + .terminal-container { + .content-block { + border-bottom: 0; + } - #terminal { - margin-top: 10px; - min-height: 450px; - box-sizing: border-box; + #terminal { + margin-top: 10px; - > div { - min-height: 450px; + > div { + min-height: 450px; + } } } } -.ci-header-container { - min-height: 55px; - - .text-center { - padding-top: 12px; +/** + * Pipelines / Jobs header + */ +[data-page='projects:pipelines:show'], +[data-page='projects:jobs:show'] { + .ci-header-container { + min-height: $gl-spacing-scale-7; + display: flex; + + .text-center { + padding-top: 12px; + } } } @@ -1045,19 +993,6 @@ button.mini-pipeline-graph-dropdown-toggle { float: none; } -.autodevops-title { - font-weight: $gl-font-weight-normal; - line-height: 1.5; -} - -.legend-all { - color: $gl-text-color-secondary; -} - -.legend-success { - color: $green-500; -} - .test-reports-table { .build-trace { @include build-trace(); diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 4dc1f2034f3..3605283245f 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -227,6 +227,10 @@ padding-left: 40px; } + .gl-label-scoped { + --label-inset-border: inset 0 0 0 1px currentColor; + } + @include media-breakpoint-up(lg) { margin-right: 5px; } @@ -443,20 +447,3 @@ table.u2f-registrations, width: 100%; max-width: $add-to-slack-popup-max-width; } - -.gitlab-slack-right-arrow svg { - fill: $white-dark; - width: $right-arrow-size; - height: $right-arrow-size; - vertical-align: text-bottom; -} - -.gitlab-slack-double-headed-arrow { - vertical-align: text-top; - - svg { - fill: $gray-darker; - width: $double-headed-arrow-width; - height: $double-headed-arrow-height; - } -} diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index a2f8447c0b6..05ade210153 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -778,7 +778,7 @@ } .btn { - margin-top: $gl-padding-8; + margin-bottom: $gl-padding-8; padding: $gl-btn-vert-padding $gl-btn-padding; line-height: $gl-btn-line-height; @@ -794,11 +794,6 @@ } .project-buttons { - .stat-text { - @extend .btn; - @extend .btn-default; - } - .nav > li:not(:last-child) { margin-right: $gl-padding-8; } diff --git a/app/assets/stylesheets/pages/settings_ci_cd.scss b/app/assets/stylesheets/pages/settings_ci_cd.scss index 239123fc3ab..ebf21f58208 100644 --- a/app/assets/stylesheets/pages/settings_ci_cd.scss +++ b/app/assets/stylesheets/pages/settings_ci_cd.scss @@ -5,6 +5,10 @@ } } +.trigger-description { + max-width: 100px; +} + .trigger-actions { white-space: nowrap; diff --git a/app/assets/stylesheets/pages/tags.scss b/app/assets/stylesheets/pages/tags.scss deleted file mode 100644 index a6d30522ff7..00000000000 --- a/app/assets/stylesheets/pages/tags.scss +++ /dev/null @@ -1,3 +0,0 @@ -.tag-release-link { - color: $blue-600 !important; -} diff --git a/app/assets/stylesheets/pages/ui_dev_kit.scss b/app/assets/stylesheets/pages/ui_dev_kit.scss deleted file mode 100644 index 288da4da5c3..00000000000 --- a/app/assets/stylesheets/pages/ui_dev_kit.scss +++ /dev/null @@ -1,17 +0,0 @@ -.gitlab-ui-dev-kit { - > h2 { - margin: 35px 0 20px; - font-weight: $gl-font-weight-bold; - } - - .example { - padding: 15px; - border: 1px dashed $gray-100; - margin-bottom: 15px; - - &::before { - content: 'Example'; - color: $ui-dev-kit-example-color; - } - } -} diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss index bfbcb8c13c6..cd607e9b247 100644 --- a/app/assets/stylesheets/themes/_dark.scss +++ b/app/assets/stylesheets/themes/_dark.scss @@ -163,6 +163,8 @@ body.gl-dark { --gl-text-color: #{$gray-900}; --border-color: #{$border-color}; + + --white: #{$white}; } $border-white-light: $gray-900; diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 9c666331c4f..0e57fc325c2 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -156,3 +156,18 @@ display: none; } } + +// This utility is used to force the z-index to match that of dropdown menu's +.gl-z-dropdown-menu\! { + z-index: 300 !important; +} + +.gl-flex-basis-quarter { + flex-basis: 25%; +} + +.gl-md-ml-3 { + @media (min-width: $breakpoint-md) { + margin-left: $gl-spacing-scale-3; + } +} diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb index b2d98d243f9..bdd9d00ca7f 100644 --- a/app/channels/application_cable/connection.rb +++ b/app/channels/application_cable/connection.rb @@ -15,12 +15,14 @@ module ApplicationCable private def find_user_from_session_store - session = ActiveSession.sessions_from_ids([session_id.private_id]).first + session = ActiveSession.sessions_from_ids(Array.wrap(session_id)).first Warden::SessionSerializer.new('rack.session' => session).fetch(:user) end def session_id - Rack::Session::SessionId.new(cookies[Gitlab::Application.config.session_options[:key]]) + session_cookie = cookies[Gitlab::Application.config.session_options[:key]] + + Rack::Session::SessionId.new(session_cookie).private_id if session_cookie.present? end def notification_payload(_) diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 73f71f7ad55..c05153921fe 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -2,6 +2,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController include InternalRedirect + include ServicesHelper # NOTE: Use @application_setting in this controller when you need to access # application_settings after it has been modified. This is because the @@ -32,6 +33,8 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController end def integrations + return not_found unless instance_level_integrations? + @integrations = Service.find_or_initialize_all(Service.for_instance).sort_by(&:title) end diff --git a/app/controllers/admin/integrations_controller.rb b/app/controllers/admin/integrations_controller.rb index 1e2a99f7078..003a5d427f5 100644 --- a/app/controllers/admin/integrations_controller.rb +++ b/app/controllers/admin/integrations_controller.rb @@ -2,6 +2,7 @@ class Admin::IntegrationsController < Admin::ApplicationController include IntegrationsActions + include ServicesHelper private @@ -10,7 +11,7 @@ class Admin::IntegrationsController < Admin::ApplicationController end def integrations_enabled? - true + instance_level_integrations? end def scoped_edit_integration_path(integration) diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb index 7a377a33d41..ba7e7d57b91 100644 --- a/app/controllers/admin/runners_controller.rb +++ b/app/controllers/admin/runners_controller.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true class Admin::RunnersController < Admin::ApplicationController - before_action :runner, except: [:index, :tag_list] + include RunnerSetupScripts + + before_action :runner, except: [:index, :tag_list, :runner_setup_scripts] def index finder = Ci::RunnersFinder.new(current_user: current_user, params: params) @@ -53,6 +55,10 @@ class Admin::RunnersController < Admin::ApplicationController render json: ActsAsTaggableOn::TagSerializer.new.represent(tags) end + def runner_setup_scripts + private_runner_setup_scripts + end + private def runner diff --git a/app/controllers/admin/sessions_controller.rb b/app/controllers/admin/sessions_controller.rb index 0c0bbaf4d93..e0bee6f48ea 100644 --- a/app/controllers/admin/sessions_controller.rb +++ b/app/controllers/admin/sessions_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Admin::SessionsController < ApplicationController - include Authenticates2FAForAdminMode + include AuthenticatesWithTwoFactorForAdminMode include InternalRedirect include RendersLdapServers diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index e19b09e1324..7f7a82a3032 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -10,6 +10,7 @@ class Admin::UsersController < Admin::ApplicationController def index @users = User.filter_items(params[:filter]).order_name_asc @users = @users.search_with_secondary_emails(params[:search_query]) if params[:search_query].present? + @users = @users.includes(:authorized_projects) # rubocop: disable CodeReuse/ActiveRecord @users = @users.sort_by_attribute(@sort = params[:sort]) @users = @users.page(params[:page]) end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 5f05337e59e..e71652faa27 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -484,7 +484,7 @@ class ApplicationController < ActionController::Base def set_page_title_header # Per https://tools.ietf.org/html/rfc5987, headers need to be ISO-8859-1, not UTF-8 - response.headers['Page-Title'] = URI.escape(page_title('GitLab')) + response.headers['Page-Title'] = Addressable::URI.encode_component(page_title('GitLab')) end def set_current_admin(&block) diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb index a18c80b996e..f5a9b9b61db 100644 --- a/app/controllers/boards/issues_controller.rb +++ b/app/controllers/boards/issues_controller.rb @@ -21,6 +21,8 @@ module Boards before_action :validate_id_list, only: [:bulk_move] before_action :can_move_issues?, only: [:bulk_move] + feature_category :boards + def index list_service = Boards::Issues::ListService.new(board_parent, current_user, filter_params) issues = issues_from(list_service) diff --git a/app/controllers/boards/lists_controller.rb b/app/controllers/boards/lists_controller.rb index 0b8469e8290..aecd287370f 100644 --- a/app/controllers/boards/lists_controller.rb +++ b/app/controllers/boards/lists_controller.rb @@ -8,6 +8,8 @@ module Boards before_action :authorize_read_list, only: [:index] skip_before_action :authenticate_user!, only: [:index] + feature_category :boards + def index lists = Boards::Lists::ListService.new(board.resource_parent, current_user).execute(board) @@ -42,7 +44,7 @@ module Boards list = board.lists.destroyable.find(params[:id]) service = Boards::Lists::DestroyService.new(board_parent, current_user) - if service.execute(list) + if service.execute(list).success? head :ok else head :unprocessable_entity diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb index 7006c23321c..52719e90e04 100644 --- a/app/controllers/clusters/clusters_controller.rb +++ b/app/controllers/clusters/clusters_controller.rb @@ -180,13 +180,20 @@ class Clusters::ClustersController < Clusters::BaseController params.permit(:cleanup) end + def base_permitted_cluster_params + [ + :enabled, + :environment_scope, + :managed, + :namespace_per_environment + ] + end + def update_params if cluster.provided_by_user? params.require(:cluster).permit( - :enabled, + *base_permitted_cluster_params, :name, - :environment_scope, - :managed, :base_domain, :management_project_id, platform_kubernetes_attributes: [ @@ -198,9 +205,7 @@ class Clusters::ClustersController < Clusters::BaseController ) else params.require(:cluster).permit( - :enabled, - :environment_scope, - :managed, + *base_permitted_cluster_params, :base_domain, :management_project_id, platform_kubernetes_attributes: [ @@ -212,10 +217,8 @@ class Clusters::ClustersController < Clusters::BaseController def create_gcp_cluster_params params.require(:cluster).permit( - :enabled, + *base_permitted_cluster_params, :name, - :environment_scope, - :managed, provider_gcp_attributes: [ :gcp_project_id, :zone, @@ -232,10 +235,8 @@ class Clusters::ClustersController < Clusters::BaseController def create_aws_cluster_params params.require(:cluster).permit( - :enabled, + *base_permitted_cluster_params, :name, - :environment_scope, - :managed, provider_aws_attributes: [ :kubernetes_version, :key_name, @@ -255,10 +256,8 @@ class Clusters::ClustersController < Clusters::BaseController def create_user_cluster_params params.require(:cluster).permit( - :enabled, + *base_permitted_cluster_params, :name, - :environment_scope, - :managed, platform_kubernetes_attributes: [ :namespace, :api_url, diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb index 9ff97f398f5..5c74d79951f 100644 --- a/app/controllers/concerns/authenticates_with_two_factor.rb +++ b/app/controllers/concerns/authenticates_with_two_factor.rb @@ -89,10 +89,7 @@ module AuthenticatesWithTwoFactor user.save! sign_in(user, message: :two_factor_authenticated, event: :authentication) else - user.increment_failed_attempts! - Gitlab::AppLogger.info("Failed Login: user=#{user.username} ip=#{request.remote_ip} method=OTP") - flash.now[:alert] = _('Invalid two-factor code.') - prompt_for_two_factor(user) + handle_two_factor_failure(user, 'OTP', _('Invalid two-factor code.')) end end @@ -101,7 +98,7 @@ module AuthenticatesWithTwoFactor if U2fRegistration.authenticate(user, u2f_app_id, user_params[:device_response], session[:challenge]) handle_two_factor_success(user) else - handle_two_factor_failure(user, 'U2F') + handle_two_factor_failure(user, 'U2F', _('Authentication via U2F device failed.')) end end @@ -109,7 +106,7 @@ module AuthenticatesWithTwoFactor if Webauthn::AuthenticateService.new(user, user_params[:device_response], session[:challenge]).execute handle_two_factor_success(user) else - handle_two_factor_failure(user, 'WebAuthn') + handle_two_factor_failure(user, 'WebAuthn', _('Authentication via WebAuthn device failed.')) end end @@ -152,13 +149,19 @@ module AuthenticatesWithTwoFactor sign_in(user, message: :two_factor_authenticated, event: :authentication) end - def handle_two_factor_failure(user, method) + def handle_two_factor_failure(user, method, message) user.increment_failed_attempts! + log_failed_two_factor(user, method, request.remote_ip) + Gitlab::AppLogger.info("Failed Login: user=#{user.username} ip=#{request.remote_ip} method=#{method}") - flash.now[:alert] = _('Authentication via %{method} device failed.') % { method: method } + flash.now[:alert] = message prompt_for_two_factor(user) end + def log_failed_two_factor(user, method, ip_address) + # overridden in EE + end + def handle_changed_user(user) clear_two_factor_attempt! @@ -173,3 +176,5 @@ module AuthenticatesWithTwoFactor Digest::SHA256.hexdigest(user.encrypted_password) != session[:user_password_hash] end end + +AuthenticatesWithTwoFactor.prepend_if_ee('EE::AuthenticatesWithTwoFactor') diff --git a/app/controllers/admin/concerns/authenticates_2fa_for_admin_mode.rb b/app/controllers/concerns/authenticates_with_two_factor_for_admin_mode.rb index 03783cd75a3..a8155f1e639 100644 --- a/app/controllers/admin/concerns/authenticates_2fa_for_admin_mode.rb +++ b/app/controllers/concerns/authenticates_with_two_factor_for_admin_mode.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module Authenticates2FAForAdminMode +module AuthenticatesWithTwoFactorForAdminMode extend ActiveSupport::Concern included do @@ -52,11 +52,7 @@ module Authenticates2FAForAdminMode # The admin user has successfully passed 2fa, enable admin mode ignoring password enable_admin_mode else - user.increment_failed_attempts! - Gitlab::AppLogger.info("Failed Admin Mode Login: user=#{user.username} ip=#{request.remote_ip} method=OTP") - flash.now[:alert] = _('Invalid two-factor code.') - - admin_mode_prompt_for_two_factor(user) + admin_handle_two_factor_failure(user, 'OTP', _('Invalid two-factor code.')) end end @@ -64,7 +60,7 @@ module Authenticates2FAForAdminMode if U2fRegistration.authenticate(user, u2f_app_id, user_params[:device_response], session[:challenge]) admin_handle_two_factor_success else - admin_handle_two_factor_failure(user, 'U2F') + admin_handle_two_factor_failure(user, 'U2F', _('Authentication via U2F device failed.')) end end @@ -72,7 +68,7 @@ module Authenticates2FAForAdminMode if Webauthn::AuthenticateService.new(user, user_params[:device_response], session[:challenge]).execute admin_handle_two_factor_success else - admin_handle_two_factor_failure(user, 'WebAuthn') + admin_handle_two_factor_failure(user, 'WebAuthn', _('Authentication via WebAuthn device failed.')) end end @@ -100,11 +96,12 @@ module Authenticates2FAForAdminMode enable_admin_mode end - def admin_handle_two_factor_failure(user, method) + def admin_handle_two_factor_failure(user, method, message) user.increment_failed_attempts! - Gitlab::AppLogger.info("Failed Admin Mode Login: user=#{user.username} ip=#{request.remote_ip} method=#{method}") - flash.now[:alert] = _('Authentication via %{method} device failed.') % { method: method } + log_failed_two_factor(user, method, request.remote_ip) + Gitlab::AppLogger.info("Failed Admin Mode Login: user=#{user.username} ip=#{request.remote_ip} method=#{method}") + flash.now[:alert] = message admin_mode_prompt_for_two_factor(user) end end diff --git a/app/controllers/concerns/boards_actions.rb b/app/controllers/concerns/boards_actions.rb index 9d40b9e8c88..b382e338a78 100644 --- a/app/controllers/concerns/boards_actions.rb +++ b/app/controllers/concerns/boards_actions.rb @@ -9,7 +9,7 @@ module BoardsActions before_action :boards, only: :index before_action :board, only: :show - before_action :push_wip_limits, only: [:index, :show] + before_action :push_licensed_features, only: [:index, :show] before_action do push_frontend_feature_flag(:not_issuable_queries, parent, default_enabled: true) end @@ -29,7 +29,7 @@ module BoardsActions private # Noop on FOSS - def push_wip_limits + def push_licensed_features end def boards diff --git a/app/controllers/concerns/controller_with_feature_category.rb b/app/controllers/concerns/controller_with_feature_category.rb index f8985cf0950..c1ff9ef2e69 100644 --- a/app/controllers/concerns/controller_with_feature_category.rb +++ b/app/controllers/concerns/controller_with_feature_category.rb @@ -5,35 +5,38 @@ module ControllerWithFeatureCategory include Gitlab::ClassAttributes class_methods do - def feature_category(category, config = {}) - validate_config!(config) + def feature_category(category, actions = []) + feature_category_configuration[category] ||= [] + feature_category_configuration[category] += actions.map(&:to_s) - 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) + validate_config!(feature_category_configuration) end def feature_category_for_action(action) - category_config = feature_category_configuration.find { |config| config.matches?(action) } + category_config = feature_category_configuration.find do |_, actions| + actions.empty? || actions.include?(action) + end - category_config&.category || superclass_feature_category_for_action(action) + category_config&.first || 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} " + empty = config.find { |_, actions| actions.empty? } + duplicate_actions = config.values.flatten.group_by(&:itself).select { |_, v| v.count > 1 }.keys + + if config.length > 1 && empty + raise ArgumentError, "#{empty.first} is defined for all actions, but other categories are set" end - if config.key?(:only) && config.key?(:except) - raise ArgumentError, "cannot configure both `only` and `except`" + if duplicate_actions.any? + raise ArgumentError, "Actions have multiple feature categories: #{duplicate_actions.join(', ')}" end end def feature_category_configuration - class_attributes[:feature_category_config] ||= [] + class_attributes[:feature_category_config] ||= {} end def superclass_feature_category_for_action(action) diff --git a/app/controllers/concerns/controller_with_feature_category/config.rb b/app/controllers/concerns/controller_with_feature_category/config.rb deleted file mode 100644 index 624691ee4f6..00000000000 --- a/app/controllers/concerns/controller_with_feature_category/config.rb +++ /dev/null @@ -1,38 +0,0 @@ -# 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/enforces_two_factor_authentication.rb b/app/controllers/concerns/enforces_two_factor_authentication.rb index 02082f81598..bf38e4ad117 100644 --- a/app/controllers/concerns/enforces_two_factor_authentication.rb +++ b/app/controllers/concerns/enforces_two_factor_authentication.rb @@ -11,7 +11,7 @@ module EnforcesTwoFactorAuthentication extend ActiveSupport::Concern included do - before_action :check_two_factor_requirement + before_action :check_two_factor_requirement, except: [:route_not_found] # to include this in controllers inheriting from `ActionController::Metal` # we need to add this block diff --git a/app/controllers/concerns/integrations_actions.rb b/app/controllers/concerns/integrations_actions.rb index 6060dc729af..39f63bbaaec 100644 --- a/app/controllers/concerns/integrations_actions.rb +++ b/app/controllers/concerns/integrations_actions.rb @@ -20,7 +20,7 @@ module IntegrationsActions respond_to do |format| format.html do if saved - PropagateIntegrationWorker.perform_async(integration.id, false) + PropagateIntegrationWorker.perform_async(integration.id) redirect_to scoped_edit_integration_path(integration), notice: success_message else render 'shared/integrations/edit' diff --git a/app/controllers/concerns/issuable_collections_action.rb b/app/controllers/concerns/issuable_collections_action.rb index e3ac117660b..7ed66027da3 100644 --- a/app/controllers/concerns/issuable_collections_action.rb +++ b/app/controllers/concerns/issuable_collections_action.rb @@ -59,6 +59,9 @@ module IssuableCollectionsAction end def finder_options - super.merge(non_archived: true) + super.merge( + non_archived: true, + issue_types: Issue::TYPES_FOR_LIST + ) end end diff --git a/app/controllers/concerns/milestone_actions.rb b/app/controllers/concerns/milestone_actions.rb index 29138e7b014..6470c75dfbd 100644 --- a/app/controllers/concerns/milestone_actions.rb +++ b/app/controllers/concerns/milestone_actions.rb @@ -3,13 +3,25 @@ module MilestoneActions extend ActiveSupport::Concern + def issues + respond_to do |format| + format.html { redirect_to milestone_redirect_path } + format.json do + render json: tabs_json("shared/milestones/_issues_tab", { + issues: @milestone.sorted_issues(current_user), # rubocop:disable Gitlab/ModuleWithInstanceVariables + show_project_name: Gitlab::Utils.to_boolean(params[:show_project_name]) + }) + end + end + end + def merge_requests respond_to do |format| format.html { redirect_to milestone_redirect_path } format.json do render json: tabs_json("shared/milestones/_merge_requests_tab", { merge_requests: @milestone.sorted_merge_requests(current_user), # rubocop:disable Gitlab/ModuleWithInstanceVariables - show_project_name: true + show_project_name: Gitlab::Utils.to_boolean(params[:show_project_name]) }) end end diff --git a/app/controllers/concerns/multiple_boards_actions.rb b/app/controllers/concerns/multiple_boards_actions.rb index 95a6800f55c..370b8c72bfe 100644 --- a/app/controllers/concerns/multiple_boards_actions.rb +++ b/app/controllers/concerns/multiple_boards_actions.rb @@ -21,11 +21,13 @@ module MultipleBoardsActions end def create - board = Boards::CreateService.new(parent, current_user, board_params).execute + response = Boards::CreateService.new(parent, current_user, board_params).execute respond_to do |format| format.json do - if board.persisted? + board = response.payload + + if response.success? extra_json = { board_path: board_path(board) } render json: serialize_as_json(board).merge(extra_json) else diff --git a/app/controllers/concerns/redis_tracking.rb b/app/controllers/concerns/redis_tracking.rb index fa5eef981d1..fdd22cc0da0 100644 --- a/app/controllers/concerns/redis_tracking.rb +++ b/app/controllers/concerns/redis_tracking.rb @@ -26,7 +26,6 @@ module RedisTracking def track_unique_redis_hll_event(event_name, feature, feature_default_enabled) return unless metric_feature_enabled?(feature, feature_default_enabled) - return unless Gitlab::CurrentSettings.usage_ping_enabled? return unless visitor_id Gitlab::UsageDataCounters::HLLRedisCounter.track_event(visitor_id, event_name) diff --git a/app/controllers/concerns/runner_setup_scripts.rb b/app/controllers/concerns/runner_setup_scripts.rb new file mode 100644 index 00000000000..c0e657a32d1 --- /dev/null +++ b/app/controllers/concerns/runner_setup_scripts.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module RunnerSetupScripts + extend ActiveSupport::Concern + + private + + def private_runner_setup_scripts(**kwargs) + instructions = Gitlab::Ci::RunnerInstructions.new(current_user: current_user, os: script_params[:os], arch: script_params[:arch], **kwargs) + output = { + install: instructions.install_script, + register: instructions.register_command + } + + if instructions.errors.any? + render json: { errors: instructions.errors }, status: :bad_request + else + render json: output + end + end + + def script_params + params.permit(:os, :arch) + end +end diff --git a/app/controllers/concerns/show_inherited_labels_checker.rb b/app/controllers/concerns/show_inherited_labels_checker.rb new file mode 100644 index 00000000000..acbea37a62e --- /dev/null +++ b/app/controllers/concerns/show_inherited_labels_checker.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module ShowInheritedLabelsChecker + extend ActiveSupport::Concern + + private + + def show_inherited_labels?(include_ancestor_groups) + Feature.enabled?(:show_inherited_labels, @project || @group) || include_ancestor_groups # rubocop:disable Gitlab/ModuleWithInstanceVariables + end +end diff --git a/app/controllers/concerns/wiki_actions.rb b/app/controllers/concerns/wiki_actions.rb index 5a5b634da40..4d1684ec3a2 100644 --- a/app/controllers/concerns/wiki_actions.rb +++ b/app/controllers/concerns/wiki_actions.rb @@ -44,7 +44,7 @@ module WikiActions wiki.list_pages(sort: params[:sort], direction: params[:direction]) ).page(params[:page]) - @wiki_entries = WikiPage.group_by_directory(@wiki_pages) + @wiki_entries = WikiDirectory.group_pages(@wiki_pages) render 'shared/wikis/pages' end diff --git a/app/controllers/confirmations_controller.rb b/app/controllers/confirmations_controller.rb index a27c4027380..c42c9827eaf 100644 --- a/app/controllers/confirmations_controller.rb +++ b/app/controllers/confirmations_controller.rb @@ -3,6 +3,8 @@ class ConfirmationsController < Devise::ConfirmationsController include AcceptsPendingInvitations + feature_category :users + def almost_there flash[:notice] = nil render layout: "devise_empty" diff --git a/app/controllers/dashboard/groups_controller.rb b/app/controllers/dashboard/groups_controller.rb index f82cde8e10a..23ffcd50369 100644 --- a/app/controllers/dashboard/groups_controller.rb +++ b/app/controllers/dashboard/groups_controller.rb @@ -5,6 +5,8 @@ class Dashboard::GroupsController < Dashboard::ApplicationController skip_cross_project_access_check :index + feature_category :subgroups + def index groups = GroupsFinder.new(current_user, all_available: false).execute render_group_tree(groups) diff --git a/app/controllers/dashboard/labels_controller.rb b/app/controllers/dashboard/labels_controller.rb index 89d87c2d5c8..e3773f65744 100644 --- a/app/controllers/dashboard/labels_controller.rb +++ b/app/controllers/dashboard/labels_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Dashboard::LabelsController < Dashboard::ApplicationController + feature_category :issue_tracking + def index respond_to do |format| format.json { render json: LabelSerializer.new.represent_appearance(labels) } diff --git a/app/controllers/dashboard/milestones_controller.rb b/app/controllers/dashboard/milestones_controller.rb index 14f9a026688..e17b16c26a2 100644 --- a/app/controllers/dashboard/milestones_controller.rb +++ b/app/controllers/dashboard/milestones_controller.rb @@ -4,6 +4,8 @@ class Dashboard::MilestonesController < Dashboard::ApplicationController before_action :projects before_action :groups, only: :index + feature_category :issue_tracking + def index respond_to do |format| format.html do diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index 2bd6fd85381..f7a74f40e4b 100644 --- a/app/controllers/dashboard/projects_controller.rb +++ b/app/controllers/dashboard/projects_controller.rb @@ -14,6 +14,8 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController before_action :projects, only: [:index] skip_cross_project_access_check :index, :starred + feature_category :projects + def index respond_to do |format| format.html do diff --git a/app/controllers/dashboard/snippets_controller.rb b/app/controllers/dashboard/snippets_controller.rb index a8ca3dbd0e7..6fe3d878639 100644 --- a/app/controllers/dashboard/snippets_controller.rb +++ b/app/controllers/dashboard/snippets_controller.rb @@ -7,6 +7,8 @@ class Dashboard::SnippetsController < Dashboard::ApplicationController skip_cross_project_access_check :index + feature_category :snippets + def index @snippet_counts = Snippets::CountService .new(current_user, author: current_user) diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb index 4fc2f7b0571..0ae326b5d94 100644 --- a/app/controllers/dashboard/todos_controller.rb +++ b/app/controllers/dashboard/todos_controller.rb @@ -9,6 +9,8 @@ class Dashboard::TodosController < Dashboard::ApplicationController before_action :authorize_read_group!, only: :index before_action :find_todos, only: [:index, :destroy_all] + feature_category :issue_tracking + 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 07cc31fb7d3..a88cf64d842 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -15,6 +15,10 @@ class DashboardController < Dashboard::ApplicationController respond_to :html + feature_category :audit_events, [:activity] + feature_category :issue_tracking, [:issues, :issues_calendar] + feature_category :code_review, [:merge_requests] + def activity respond_to do |format| format.html diff --git a/app/controllers/explore/groups_controller.rb b/app/controllers/explore/groups_controller.rb index 67db797b80a..aa4196b1c18 100644 --- a/app/controllers/explore/groups_controller.rb +++ b/app/controllers/explore/groups_controller.rb @@ -3,6 +3,8 @@ class Explore::GroupsController < Explore::ApplicationController include GroupTree + feature_category :subgroups + def index render_group_tree GroupsFinder.new(current_user).execute end diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb index b3fa089a712..42795e418a4 100644 --- a/app/controllers/explore/projects_controller.rb +++ b/app/controllers/explore/projects_controller.rb @@ -18,6 +18,8 @@ class Explore::ProjectsController < Explore::ApplicationController rescue_from PageOutOfBoundsError, with: :page_out_of_bounds + feature_category :projects + def index @projects = load_projects diff --git a/app/controllers/explore/snippets_controller.rb b/app/controllers/explore/snippets_controller.rb index 3a56a48e578..91ab18f2f55 100644 --- a/app/controllers/explore/snippets_controller.rb +++ b/app/controllers/explore/snippets_controller.rb @@ -3,6 +3,8 @@ class Explore::SnippetsController < Explore::ApplicationController include Gitlab::NoteableMetadata + feature_category :snippets + def index @snippets = SnippetsFinder.new(current_user, explore: true) .execute diff --git a/app/controllers/groups/group_links_controller.rb b/app/controllers/groups/group_links_controller.rb index c395b93f4e7..06c793b5c4c 100644 --- a/app/controllers/groups/group_links_controller.rb +++ b/app/controllers/groups/group_links_controller.rb @@ -24,6 +24,15 @@ class Groups::GroupLinksController < Groups::ApplicationController def update Groups::GroupLinks::UpdateService.new(@group_link).execute(group_link_params) + + if @group_link.expires? + render json: { + expires_in: helpers.distance_of_time_in_words_to_now(@group_link.expires_at), + expires_soon: @group_link.expires_soon? + } + else + render json: {} + end end def destroy diff --git a/app/controllers/groups/labels_controller.rb b/app/controllers/groups/labels_controller.rb index 1034ca6cd7b..97d9f8fcecd 100644 --- a/app/controllers/groups/labels_controller.rb +++ b/app/controllers/groups/labels_controller.rb @@ -2,6 +2,7 @@ class Groups::LabelsController < Groups::ApplicationController include ToggleSubscriptionAction + include ShowInheritedLabelsChecker before_action :label, only: [:edit, :update, :destroy] before_action :authorize_admin_labels!, only: [:new, :create, :edit, :update, :destroy] @@ -12,8 +13,9 @@ class Groups::LabelsController < Groups::ApplicationController def index respond_to do |format| format.html do - @labels = GroupLabelsFinder - .new(current_user, @group, params.merge(sort: sort)).execute + # at group level we do not want to list project labels, + # we only want `only_group_labels = false` when pulling labels for label filter dropdowns, fetched through json + @labels = available_labels(params.merge(only_group_labels: true)).page(params[:page]) end format.json do render json: LabelSerializer.new.represent_appearance(available_labels) @@ -60,13 +62,7 @@ class Groups::LabelsController < Groups::ApplicationController def destroy @label.destroy - - respond_to do |format| - format.html do - redirect_to group_labels_path(@group), status: :found, notice: "#{@label.name} deleted permanently" - end - format.js - end + redirect_to group_labels_path(@group), status: :found, notice: "#{@label.name} deleted permanently" end protected @@ -80,7 +76,7 @@ class Groups::LabelsController < Groups::ApplicationController end def label - @label ||= @group.labels.find(params[:id]) + @label ||= available_labels(params.merge(only_group_labels: true)).find(params[:id]) end alias_method :subscribable_resource, :label @@ -108,15 +104,17 @@ class Groups::LabelsController < Groups::ApplicationController session[:previous_labels_path] = URI(request.referer || '').path end - def available_labels + def available_labels(options = params) @available_labels ||= LabelsFinder.new( current_user, group_id: @group.id, - only_group_labels: params[:only_group_labels], - include_ancestor_groups: params[:include_ancestor_groups], - include_descendant_groups: params[:include_descendant_groups], - search: params[:search]).execute + only_group_labels: options[:only_group_labels], + include_ancestor_groups: show_inherited_labels?(params[:include_ancestor_groups]), + sort: sort, + subscribed: options[:subscribed], + include_descendant_groups: options[:include_descendant_groups], + search: options[:search]).execute end def sort diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb index df3fb6b67c2..3f2894d378b 100644 --- a/app/controllers/groups/milestones_controller.rb +++ b/app/controllers/groups/milestones_controller.rb @@ -3,7 +3,7 @@ class Groups::MilestonesController < Groups::ApplicationController include MilestoneActions - before_action :milestone, only: [:edit, :show, :update, :merge_requests, :participants, :labels, :destroy] + before_action :milestone, only: [:edit, :show, :update, :issues, :merge_requests, :participants, :labels, :destroy] before_action :authorize_admin_milestones!, only: [:edit, :new, :create, :update, :destroy] before_action do push_frontend_feature_flag(:burnup_charts, @group) diff --git a/app/controllers/groups/registry/repositories_controller.rb b/app/controllers/groups/registry/repositories_controller.rb index 14651e0794a..87a62a8f9b0 100644 --- a/app/controllers/groups/registry/repositories_controller.rb +++ b/app/controllers/groups/registry/repositories_controller.rb @@ -2,6 +2,8 @@ module Groups module Registry class RepositoriesController < Groups::ApplicationController + include PackagesHelper + before_action :verify_container_registry_enabled! before_action :authorize_read_container_image! @@ -13,7 +15,7 @@ module Groups .execute .with_api_entity_associations - track_event(:list_repositories) + track_package_event(:list_repositories, :container) serializer = ContainerRepositoriesSerializer .new(current_user: current_user) diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb index bf3a38ce57b..ceee049d824 100644 --- a/app/controllers/groups/settings/ci_cd_controller.rb +++ b/app/controllers/groups/settings/ci_cd_controller.rb @@ -3,6 +3,8 @@ module Groups module Settings class CiCdController < Groups::ApplicationController + include RunnerSetupScripts + skip_cross_project_access_check :show before_action :authorize_admin_group! before_action :authorize_update_max_artifacts_size!, only: [:update] @@ -49,6 +51,10 @@ module Groups redirect_to group_settings_ci_cd_path end + def runner_setup_scripts + private_runner_setup_scripts(group: group) + end + private def define_variables diff --git a/app/controllers/import/bulk_imports_controller.rb b/app/controllers/import/bulk_imports_controller.rb new file mode 100644 index 00000000000..58b9f8c0fbb --- /dev/null +++ b/app/controllers/import/bulk_imports_controller.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class Import::BulkImportsController < ApplicationController + before_action :ensure_group_import_enabled + before_action :verify_blocked_uri, only: :status + + def configure + session[access_token_key] = params[access_token_key]&.strip + session[url_key] = params[url_key] + + redirect_to status_import_bulk_import_url + end + + private + + def import_params + params.permit(access_token_key, url_key) + end + + def ensure_group_import_enabled + render_404 unless Feature.enabled?(:bulk_import) + end + + def access_token_key + :bulk_import_gitlab_access_token + end + + def url_key + :bulk_import_gitlab_url + end + + def verify_blocked_uri + Gitlab::UrlBlocker.validate!( + session[url_key], + **{ + allow_localhost: allow_local_requests?, + allow_local_network: allow_local_requests?, + schemes: %w(http https) + } + ) + rescue Gitlab::UrlBlocker::BlockedUrlError => e + session[access_token_key] = nil + session[url_key] = nil + + redirect_to new_group_path, alert: _('Specified URL cannot be used: "%{reason}"') % { reason: e.message } + end + + def allow_local_requests? + Gitlab::CurrentSettings.allow_local_requests_from_web_hooks_and_services? + end +end diff --git a/app/controllers/import/fogbugz_controller.rb b/app/controllers/import/fogbugz_controller.rb index a34bc9c953f..bcbf5938e11 100644 --- a/app/controllers/import/fogbugz_controller.rb +++ b/app/controllers/import/fogbugz_controller.rb @@ -136,7 +136,7 @@ class Import::FogbugzController < Import::BaseController def verify_blocked_uri Gitlab::UrlBlocker.validate!( params[:uri], - { + **{ allow_localhost: allow_local_requests?, allow_local_network: allow_local_requests?, schemes: %w(http https) diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb index 29fe34f0734..a1adc6e062a 100644 --- a/app/controllers/import/github_controller.rb +++ b/app/controllers/import/github_controller.rb @@ -108,7 +108,7 @@ class Import::GithubController < Import::BaseController @client ||= if Feature.enabled?(:remove_legacy_github_client) Gitlab::GithubImport::Client.new(session[access_token_key]) else - Gitlab::LegacyGithubImport::Client.new(session[access_token_key], client_options) + Gitlab::LegacyGithubImport::Client.new(session[access_token_key], **client_options) end end diff --git a/app/controllers/import/manifest_controller.rb b/app/controllers/import/manifest_controller.rb index 9c47e6d4b0b..fdca6da95c5 100644 --- a/app/controllers/import/manifest_controller.rb +++ b/app/controllers/import/manifest_controller.rb @@ -26,8 +26,7 @@ class Import::ManifestController < Import::BaseController manifest = Gitlab::ManifestImport::Manifest.new(params[:manifest].tempfile) if manifest.valid? - session[:manifest_import_repositories] = manifest.projects - session[:manifest_import_group_id] = group.id + manifest_import_metadata.save(manifest.projects, group.id) redirect_to status_import_manifest_path else @@ -96,12 +95,16 @@ class Import::ManifestController < Import::BaseController # rubocop: disable CodeReuse/ActiveRecord def group - @group ||= Group.find_by(id: session[:manifest_import_group_id]) + @group ||= Group.find_by(id: manifest_import_metadata.group_id) end # rubocop: enable CodeReuse/ActiveRecord + def manifest_import_metadata + @manifest_import_status ||= Gitlab::ManifestImport::Metadata.new(current_user, fallback: session) + end + def repositories - @repositories ||= session[:manifest_import_repositories] + @repositories ||= manifest_import_metadata.repositories end def find_jobs diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index aa9c7d01ba3..591ded7630c 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -4,6 +4,7 @@ class InvitesController < ApplicationController include Gitlab::Utils::StrongMemoize before_action :member + before_action :ensure_member_exists before_action :invite_details skip_before_action :authenticate_user!, only: :decline @@ -12,13 +13,14 @@ class InvitesController < ApplicationController respond_to :html def show - track_experiment('opened') + track_new_user_invite_experiment('opened') accept if skip_invitation_prompt? end def accept if member.accept_invite!(current_user) - track_experiment('accepted') + track_new_user_invite_experiment('accepted') + track_invitation_reminders_experiment('accepted') redirect_to invite_details[:path], notice: _("You have been granted %{member_human_access} access to %{title} %{name}.") % { member_human_access: member.human_access, title: invite_details[:title], name: invite_details[:name] } else @@ -28,6 +30,8 @@ class InvitesController < ApplicationController def decline if member.decline_invite! + return render layout: 'devise_experimental_onboarding_issues' if !current_user && member.invite_to_unknown_user? && member.created_by + path = if current_user dashboard_projects_path @@ -59,14 +63,16 @@ class InvitesController < ApplicationController end def member - return @member if defined?(@member) - - @token = params[:id] - @member = Member.find_by_invite_token(@token) + strong_memoize(:member) do + @token = params[:id] + Member.find_by_invite_token(@token) + end + end - return render_404 unless @member + def ensure_member_exists + return if member - @member + render_404 end def authenticate_user! @@ -76,10 +82,7 @@ class InvitesController < ApplicationController notice << "or create an account" if Gitlab::CurrentSettings.allow_signup? notice = notice.join(' ') + "." - # this is temporary finder instead of using member method due to render_404 possibility - # will be resolved via https://gitlab.com/gitlab-org/gitlab/-/issues/245325 - initial_member = Member.find_by_invite_token(params[:id]) - redirect_params = initial_member ? { invite_email: initial_member.invite_email } : {} + redirect_params = member ? { invite_email: member.invite_email } : {} store_location_for :user, request.fullpath @@ -87,31 +90,43 @@ class InvitesController < ApplicationController end def invite_details - @invite_details ||= case @member.source + @invite_details ||= case member.source when Project { - name: @member.source.full_name, - url: project_url(@member.source), + name: member.source.full_name, + url: project_url(member.source), title: _("project"), - path: project_path(@member.source) + path: project_path(member.source) } when Group { - name: @member.source.name, - url: group_url(@member.source), + name: member.source.name, + url: group_url(member.source), title: _("group"), - path: group_path(@member.source) + path: group_path(member.source) } end end - def track_experiment(action) + def track_new_user_invite_experiment(action) return unless params[:new_user_invite] property = params[:new_user_invite] == 'experiment' ? 'experiment_group' : 'control_group' + track_experiment(:invite_email, action, property) + end + + def track_invitation_reminders_experiment(action) + return unless Gitlab::Experimentation.enabled?(:invitation_reminders) + + property = Gitlab::Experimentation.enabled_for_attribute?(:invitation_reminders, member.invite_email) ? 'experimental_group' : 'control_group' + + track_experiment(:invitation_reminders, action, property) + end + + def track_experiment(experiment_key, action, property) Gitlab::Tracking.event( - Gitlab::Experimentation::EXPERIMENTS[:invite_email][:tracking_category], + Gitlab::Experimentation.experiment(experiment_key).tracking_category, action, property: property, label: Digest::MD5.hexdigest(member.to_global_id.to_s) diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index b798d6680bc..2708e6669e7 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true class OmniauthCallbacksController < Devise::OmniauthCallbacksController - include AuthenticatesWithTwoFactor - include Authenticates2FAForAdminMode + include AuthenticatesWithTwoFactorForAdminMode include Devise::Controllers::Rememberable include AuthHelper include InitializesCurrentUserMode diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 1568d9966dd..f0ac86d1581 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -33,8 +33,7 @@ class Projects::BlobController < Projects::ApplicationController before_action :set_last_commit_sha, only: [:edit, :update] before_action only: :show do - push_frontend_feature_flag(:code_navigation, @project, default_enabled: true) - push_frontend_feature_flag(:suggest_pipeline) if experiment_enabled?(:suggest_pipeline) + push_frontend_experiment(:suggest_pipeline) push_frontend_feature_flag(:gitlab_ci_yml_preview, @project, default_enabled: false) end diff --git a/app/controllers/projects/ci/daily_build_group_report_results_controller.rb b/app/controllers/projects/ci/daily_build_group_report_results_controller.rb index b36c5f1aea6..3d3b62fa797 100644 --- a/app/controllers/projects/ci/daily_build_group_report_results_controller.rb +++ b/app/controllers/projects/ci/daily_build_group_report_results_controller.rb @@ -6,7 +6,6 @@ class Projects::Ci::DailyBuildGroupReportResultsController < Projects::Applicati MAX_ITEMS = 1000 REPORT_WINDOW = 90.days - before_action :validate_feature_flag! before_action :authorize_read_build_report_results! before_action :validate_param_type! @@ -19,10 +18,6 @@ class Projects::Ci::DailyBuildGroupReportResultsController < Projects::Applicati private - def validate_feature_flag! - render_404 unless Feature.enabled?(:ci_download_daily_code_coverage, project, default_enabled: true) - end - def validate_param_type! respond_422 unless allowed_param_types.include?(param_type) end @@ -43,7 +38,7 @@ class Projects::Ci::DailyBuildGroupReportResultsController < Projects::Applicati end def report_results - Ci::DailyBuildGroupReportResultsFinder.new(finder_params).execute + Ci::DailyBuildGroupReportResultsFinder.new(**finder_params).execute end def finder_params diff --git a/app/controllers/projects/graphs_controller.rb b/app/controllers/projects/graphs_controller.rb index 9b889f9e837..91c07c37d2a 100644 --- a/app/controllers/projects/graphs_controller.rb +++ b/app/controllers/projects/graphs_controller.rb @@ -57,7 +57,6 @@ class Projects::GraphsController < Projects::ApplicationController end def get_daily_coverage_options - return unless Feature.enabled?(:ci_download_daily_code_coverage, @project, default_enabled: true) return unless can?(current_user, :read_build_report_results, project) date_today = Date.current diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb index a30c455a7e4..f8ea6f834a3 100644 --- a/app/controllers/projects/group_links_controller.rb +++ b/app/controllers/projects/group_links_controller.rb @@ -21,8 +21,17 @@ class Projects::GroupLinksController < Projects::ApplicationController end def update - @group_link = @project.project_group_links.find(params[:id]) - Projects::GroupLinks::UpdateService.new(@group_link).execute(group_link_params) + group_link = @project.project_group_links.find(params[:id]) + Projects::GroupLinks::UpdateService.new(group_link).execute(group_link_params) + + if group_link.expires? + render json: { + expires_in: helpers.distance_of_time_in_words_to_now(group_link.expires_at), + expires_soon: group_link.expires_soon? + } + else + render json: {} + end end def destroy diff --git a/app/controllers/projects/incidents_controller.rb b/app/controllers/projects/incidents_controller.rb index 12cc4dde1f4..1c915842e61 100644 --- a/app/controllers/projects/incidents_controller.rb +++ b/app/controllers/projects/incidents_controller.rb @@ -1,8 +1,52 @@ # frozen_string_literal: true class Projects::IncidentsController < Projects::ApplicationController - before_action :authorize_read_incidents! + include IssuableActions + include Gitlab::Utils::StrongMemoize + + before_action :authorize_read_issue! + before_action :check_feature_flag, only: [:show] + before_action :load_incident, only: [:show] + + before_action do + push_frontend_feature_flag(:issues_incident_details, @project) + end def index end + + private + + def incident + strong_memoize(:incident) do + incident_finder + .execute + .inc_relations_for_view + .iid_in(params[:id]) + .without_order + .first + end + end + + def load_incident + @issue = incident # needed by rendered view + return render_404 unless can?(current_user, :read_issue, incident) + + @noteable = incident + @note = incident.project.notes.new(noteable: issuable) + end + + alias_method :issuable, :incident + + def incident_finder + IssuesFinder.new(current_user, project_id: @project.id, issue_types: :incident) + end + + def serializer + IssueSerializer.new(current_user: current_user, project: incident.project) + end + + def check_feature_flag + render_404 unless Feature.enabled?(:issues_incident_details, @project) + end end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 7f0d23b79ce..319a5183429 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -56,7 +56,7 @@ class Projects::IssuesController < Projects::ApplicationController end before_action only: :index do - push_frontend_feature_flag(:scoped_labels, @project) + push_frontend_feature_flag(:scoped_labels, @project, type: :licensed) end around_action :allow_gitaly_ref_name_caching, only: [:discussions] @@ -239,7 +239,7 @@ class Projects::IssuesController < Projects::ApplicationController return @issue if defined?(@issue) # The Sortable default scope causes performance issues when used with find_by - @issuable = @noteable = @issue ||= @project.issues.includes(author: :status).where(iid: params[:id]).reorder(nil).take! + @issuable = @noteable = @issue ||= @project.issues.inc_relations_for_view.iid_in(params[:id]).without_order.take! @note = @project.notes.new(noteable: @issuable) return render_404 unless can?(current_user, :read_issue, @issue) diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index 3f7f8da3478..a8ac20cf96b 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -28,11 +28,6 @@ class Projects::JobsController < Projects::ApplicationController # rubocop: disable CodeReuse/ActiveRecord def show - @pipeline = @build.pipeline - @builds = @pipeline.builds - .order('id DESC') - .present(current_user: current_user) - respond_to do |format| format.html format.json do diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb index b7aeab8f5ff..ca2fad35451 100644 --- a/app/controllers/projects/labels_controller.rb +++ b/app/controllers/projects/labels_controller.rb @@ -2,6 +2,7 @@ class Projects::LabelsController < Projects::ApplicationController include ToggleSubscriptionAction + include ShowInheritedLabelsChecker before_action :check_issuables_available! before_action :label, only: [:edit, :update, :destroy, :promote] @@ -161,7 +162,7 @@ class Projects::LabelsController < Projects::ApplicationController @available_labels ||= LabelsFinder.new(current_user, project_id: @project.id, - include_ancestor_groups: params[:include_ancestor_groups], + include_ancestor_groups: show_inherited_labels?(params[:include_ancestor_groups]), search: params[:search], subscribed: params[:subscribed], sort: sort).execute diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb index 921da788ad2..7b4024ed79c 100644 --- a/app/controllers/projects/merge_requests/application_controller.rb +++ b/app/controllers/projects/merge_requests/application_controller.rb @@ -35,6 +35,7 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont :source_branch, :source_project_id, :state_event, + :wip_event, :squash, :target_branch, :target_project_id, diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index 8aacfdce094..28aef6f4328 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -64,7 +64,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic render: ->(partial, locals) { view_to_html_string(partial, locals) } } - options = additional_attributes.merge(diff_view: Feature.enabled?(:unified_diff_lines, @merge_request.project) ? "inline" : diff_view) + options = additional_attributes.merge(diff_view: Feature.enabled?(:unified_diff_lines, @merge_request.project, default_enabled: true) ? "inline" : diff_view) if @merge_request.project.context_commits_enabled? options[:context_commits] = @merge_request.recent_context_commits diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 92785540172..8ca70602c89 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -27,9 +27,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo before_action :authenticate_user!, only: [:assign_related_issues] before_action :check_user_can_push_to_source_branch!, only: [:rebase] before_action only: [:show] do - push_frontend_feature_flag(:deploy_from_footer, @project, default_enabled: true) - push_frontend_feature_flag(:suggest_pipeline) if experiment_enabled?(:suggest_pipeline) - push_frontend_feature_flag(:code_navigation, @project, default_enabled: true) + push_frontend_experiment(:suggest_pipeline) push_frontend_feature_flag(:widget_visibility_polling, @project, default_enabled: true) push_frontend_feature_flag(:merge_ref_head_comments, @project, default_enabled: true) push_frontend_feature_flag(:mr_commit_neighbor_nav, @project, default_enabled: true) @@ -39,7 +37,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:approvals_commented_by, @project, default_enabled: true) push_frontend_feature_flag(:hide_jump_to_next_unresolved_in_threads, default_enabled: true) push_frontend_feature_flag(:merge_request_widget_graphql, @project) - push_frontend_feature_flag(:unified_diff_lines, @project) + push_frontend_feature_flag(:unified_diff_lines, @project, default_enabled: true) push_frontend_feature_flag(:highlight_current_diff_row, @project) push_frontend_feature_flag(:default_merge_ref_for_diffs, @project) end @@ -52,12 +50,17 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo after_action :log_merge_request_show, only: [:show] - 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] + feature_category :code_review, [ + :assign_related_issues, :bulk_update, :cancel_auto_merge, + :ci_environments_status, :commit_change_content, :commits, + :context_commits, :destroy, :diff_for_path, :discussions, + :edit, :exposed_artifacts, :index, :merge, + :pipeline_status, :pipelines, :rebase, :remove_wip, :show, + :toggle_award_emoji, :toggle_subscription, :update + ] + + feature_category :code_testing, [:test_reports, :coverage_reports, :terraform_reports] + feature_category :accessibility_testing, [:accessibility_reports] def index @merge_requests = @issuables diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index 16d63cc184f..8049a17068b 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -5,7 +5,7 @@ class Projects::MilestonesController < Projects::ApplicationController include MilestoneActions before_action :check_issuables_available! - before_action :milestone, only: [:edit, :update, :destroy, :show, :merge_requests, :participants, :labels, :promote] + before_action :milestone, only: [:edit, :update, :destroy, :show, :issues, :merge_requests, :participants, :labels, :promote] before_action do push_frontend_feature_flag(:burnup_charts, @project) end @@ -14,7 +14,7 @@ class Projects::MilestonesController < Projects::ApplicationController before_action :authorize_read_milestone! # Allow admin milestone - before_action :authorize_admin_milestone!, except: [:index, :show, :merge_requests, :participants, :labels] + before_action :authorize_admin_milestone!, except: [:index, :show, :issues, :merge_requests, :participants, :labels] # Allow to promote milestone before_action :authorize_promote_milestone!, only: :promote diff --git a/app/controllers/projects/packages/packages_controller.rb b/app/controllers/projects/packages/packages_controller.rb index fc4ef7a01dc..302847eeaf5 100644 --- a/app/controllers/projects/packages/packages_controller.rb +++ b/app/controllers/projects/packages/packages_controller.rb @@ -5,20 +5,11 @@ module Projects class PackagesController < Projects::ApplicationController include PackagesAccess - before_action :authorize_destroy_package!, only: [:destroy] - def show @package = project.packages.find(params[:id]) @package_files = @package.package_files.recent @maven_metadatum = @package.maven_metadatum end - - def destroy - @package = project.packages.find(params[:id]) - @package.destroy - - redirect_to project_packages_path(@project), status: :found, notice: _('Package was removed') - end end end end diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index c1734d2cd8a..8676c90ca86 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -15,7 +15,8 @@ class Projects::PipelinesController < Projects::ApplicationController push_frontend_feature_flag(:filter_pipelines_search, project, default_enabled: true) push_frontend_feature_flag(:dag_pipeline_tab, project, default_enabled: true) push_frontend_feature_flag(:pipelines_security_report_summary, project) - push_frontend_feature_flag(:new_pipeline_form) + push_frontend_feature_flag(:new_pipeline_form, project) + push_frontend_feature_flag(:graphql_pipeline_header, project, type: :development, default_enabled: false) end before_action :ensure_pipeline, only: [:show] diff --git a/app/controllers/projects/protected_refs_controller.rb b/app/controllers/projects/protected_refs_controller.rb index 060403a9cd9..f1a7ac36138 100644 --- a/app/controllers/projects/protected_refs_controller.rb +++ b/app/controllers/projects/protected_refs_controller.rb @@ -62,7 +62,7 @@ class Projects::ProtectedRefsController < Projects::ApplicationController end def access_level_attributes - %i[access_level id _destroy] + %i[access_level id _destroy deploy_key_id] end end diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb index 19d0cb9acdc..28a86ecc9f0 100644 --- a/app/controllers/projects/registry/repositories_controller.rb +++ b/app/controllers/projects/registry/repositories_controller.rb @@ -3,6 +3,8 @@ module Projects module Registry class RepositoriesController < ::Projects::Registry::ApplicationController + include PackagesHelper + before_action :authorize_update_container_image!, only: [:destroy] before_action :ensure_root_container_repository!, only: [:index] @@ -13,7 +15,7 @@ module Projects @images = ContainerRepositoriesFinder.new(user: current_user, subject: project, params: params.slice(:name)) .execute - track_event(:list_repositories) + track_package_event(:list_repositories, :container) serializer = ContainerRepositoriesSerializer .new(project: project, current_user: current_user) @@ -31,7 +33,7 @@ module Projects def destroy image.delete_scheduled! DeleteContainerRepositoryWorker.perform_async(current_user.id, image.id) # rubocop:disable CodeReuse/Worker - track_event(:delete_repository) + track_package_event(:delete_repository, :container) respond_to do |format| format.json { head :no_content } diff --git a/app/controllers/projects/registry/tags_controller.rb b/app/controllers/projects/registry/tags_controller.rb index c42e3f6bdba..ebdb668207f 100644 --- a/app/controllers/projects/registry/tags_controller.rb +++ b/app/controllers/projects/registry/tags_controller.rb @@ -3,12 +3,15 @@ module Projects module Registry class TagsController < ::Projects::Registry::ApplicationController + include PackagesHelper + before_action :authorize_destroy_container_image!, only: [:destroy] LIMIT = 15 def index - track_event(:list_tags) + track_package_event(:list_tags, :tag) + respond_to do |format| format.json do render json: ContainerTagsSerializer @@ -23,7 +26,7 @@ module Projects result = Projects::ContainerRepository::DeleteTagsService .new(image.project, current_user, tags: [params[:id]]) .execute(image) - track_event(:delete_tag) + track_package_event(:delete_tag, :tag) respond_to do |format| format.json { head(result[:status] == :success ? :ok : bad_request) } @@ -40,7 +43,7 @@ module Projects result = Projects::ContainerRepository::DeleteTagsService .new(image.project, current_user, tags: tag_names) .execute(image) - track_event(:delete_tag_bulk) + track_package_event(:delete_tag_bulk, :tag) respond_to do |format| format.json { head(result[:status] == :success ? :no_content : :bad_request) } diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb index bd24aae980c..808e1c755ae 100644 --- a/app/controllers/projects/releases_controller.rb +++ b/app/controllers/projects/releases_controller.rb @@ -13,7 +13,7 @@ class Projects::ReleasesController < Projects::ApplicationController push_frontend_feature_flag(:release_asset_link_type, project, default_enabled: true) push_frontend_feature_flag(:graphql_release_data, project, default_enabled: true) push_frontend_feature_flag(:graphql_milestone_stats, project, default_enabled: true) - push_frontend_feature_flag(:graphql_releases_page, project, default_enabled: false) + push_frontend_feature_flag(:graphql_releases_page, project, default_enabled: true) end before_action :authorize_update_release!, only: %i[edit update] before_action :authorize_create_release!, only: :new diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb index ca62f54813b..fb00156d320 100644 --- a/app/controllers/projects/runners_controller.rb +++ b/app/controllers/projects/runners_controller.rb @@ -50,6 +50,10 @@ class Projects::RunnersController < Projects::ApplicationController end def toggle_shared_runners + if Feature.enabled?(:disable_shared_runners_on_group, default_enabled: true) && !project.shared_runners_enabled && project.group && project.group.shared_runners_setting == 'disabled_and_unoverridable' + return redirect_to project_runners_path(@project), alert: _("Cannot enable shared runners because parent group does not allow it") + end + project.toggle!(:shared_runners_enabled) redirect_to project_settings_ci_cd_path(@project, anchor: 'js-runners-settings') diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb index 9a69ef991dd..41ce834c658 100644 --- a/app/controllers/projects/services_controller.rb +++ b/app/controllers/projects/services_controller.rb @@ -12,7 +12,7 @@ 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(:jira_issues_integration, @project, { default_enabled: true }) + push_frontend_feature_flag(:jira_issues_integration, @project, type: :licensed, default_enabled: true) end respond_to :html diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index d0d100fd88c..b30f54acf90 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -3,15 +3,21 @@ module Projects module Settings class CiCdController < Projects::ApplicationController + include RunnerSetupScripts + before_action :authorize_admin_pipeline! before_action :define_variables before_action do push_frontend_feature_flag(:new_variables_ui, @project, default_enabled: true) push_frontend_feature_flag(:ajax_new_deploy_token, @project) - push_frontend_feature_flag(:ci_key_autocomplete, default_enabled: true) end def show + if Feature.enabled?(:ci_pipeline_triggers_settings_vue_ui, @project) + @triggers_json = ::Ci::TriggerSerializer.new.represent( + @project.triggers, current_user: current_user, project: @project + ).to_json + end end def update @@ -48,6 +54,10 @@ module Projects redirect_to namespace_project_settings_ci_cd_path end + def runner_setup_scripts + private_runner_setup_scripts(project: @project) + end + private def update_params @@ -116,6 +126,7 @@ module Projects def define_triggers_variables @triggers = @project.triggers .present(current_user: current_user) + @trigger = ::Ci::Trigger.new .present(current_user: current_user) end diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb index 35ca9336613..182968b9a41 100644 --- a/app/controllers/projects/settings/repository_controller.rb +++ b/app/controllers/projects/settings/repository_controller.rb @@ -7,6 +7,7 @@ module Projects before_action :define_variables, only: [:create_deploy_token] before_action do push_frontend_feature_flag(:ajax_new_deploy_token, @project) + push_frontend_feature_flag(:deploy_keys_on_protected_branches, @project) end def show @@ -125,6 +126,7 @@ module Projects gon.push(protectable_tags_for_dropdown) gon.push(protectable_branches_for_dropdown) gon.push(access_levels_options) + gon.push(current_project_id: project.id) if project end end end diff --git a/app/controllers/projects/static_site_editor_controller.rb b/app/controllers/projects/static_site_editor_controller.rb index e97a8db0b79..f99ffe170b0 100644 --- a/app/controllers/projects/static_site_editor_controller.rb +++ b/app/controllers/projects/static_site_editor_controller.rb @@ -25,14 +25,30 @@ class Projects::StaticSiteEditorController < Projects::ApplicationController ).execute if service_response.success? - @data = service_response.payload + @data = serialize_necessary_payload_values_to_json(service_response.payload) else - respond_422 + # TODO: For now, if the service returns any error, the user is redirected + # to the root project page with the error message displayed as an alert. + # See https://gitlab.com/gitlab-org/gitlab/-/issues/213285#note_414808004 + # for discussion of plans to handle this via a page owned by the Static Site Editor. + flash[:alert] = service_response.message + redirect_to project_path(project) end end private + def serialize_necessary_payload_values_to_json(payload) + # This will convert booleans, Array-like and Hash-like objects to JSON + payload.transform_values do |value| + if value.is_a?(String) || value.is_a?(Integer) + value + else + value.to_json + end + end + end + def assign_ref_and_path @ref, @path = extract_ref(params.fetch(:id)) diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb index 475ca8894e4..71effebf1c0 100644 --- a/app/controllers/projects/tags_controller.rb +++ b/app/controllers/projects/tags_controller.rb @@ -76,25 +76,10 @@ class Projects::TagsController < Projects::ApplicationController def destroy result = ::Tags::DestroyService.new(project, current_user).execute(params[:id]) - respond_to do |format| - if result[:status] == :success - format.html do - redirect_to project_tags_path(@project), status: :see_other - end - - format.js - else - @error = result[:message] - - format.html do - redirect_to project_tags_path(@project), - alert: @error, status: :see_other - end - - format.js do - render status: :ok - end - end + if result[:status] == :success + render json: result + else + render json: { message: result[:message] }, status: result[:return_code] end end diff --git a/app/controllers/runner_setup_controller.rb b/app/controllers/runner_setup_controller.rb new file mode 100644 index 00000000000..2cb204b729c --- /dev/null +++ b/app/controllers/runner_setup_controller.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class RunnerSetupController < ApplicationController + def platforms + render json: Gitlab::Ci::RunnerInstructions::OS.merge(Gitlab::Ci::RunnerInstructions::OTHER_ENVIRONMENTS) + end +end diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index dedaf0c903a..b5e221a8894 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -8,7 +8,8 @@ class SearchController < ApplicationController SCOPE_PRELOAD_METHOD = { projects: :with_web_entity_associations, - issues: :with_web_entity_associations + issues: :with_web_entity_associations, + epics: :with_web_entity_associations }.freeze track_redis_hll_event :show, name: 'i_search_total', feature: :search_track_unique_users, feature_default_enabled: true @@ -96,8 +97,6 @@ class SearchController < ApplicationController end def eager_load_user_status - return if Feature.disabled?(:users_search, default_enabled: true) - @search_objects = @search_objects.eager_load(:status) # rubocop:disable CodeReuse/ActiveRecord end diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index 0b092d2622b..9510734bc9b 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -19,6 +19,7 @@ class UploadsController < ApplicationController rescue_from UnknownUploadModelError, with: :render_404 skip_before_action :authenticate_user! + skip_before_action :check_two_factor_requirement, only: [:show] before_action :upload_mount_satisfied? before_action :authorize_access!, only: [:show] before_action :authorize_create_access!, only: [:create, :authorize] diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 75a861423ed..e7af60beade 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -106,7 +106,7 @@ class UsersController < ApplicationController def calendar_activities @calendar_date = Date.parse(params[:date]) rescue Date.today - @events = contributions_calendar.events_by_date(@calendar_date) + @events = contributions_calendar.events_by_date(@calendar_date).map(&:present) render 'calendar_activities', layout: false end diff --git a/app/finders/group_labels_finder.rb b/app/finders/group_labels_finder.rb deleted file mode 100644 index a668a0f0fae..00000000000 --- a/app/finders/group_labels_finder.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -class GroupLabelsFinder - attr_reader :current_user, :group, :params - - def initialize(current_user, group, params = {}) - @current_user = current_user - @group = group - @params = params - end - - def execute - group.labels - .optionally_subscribed_by(subscriber_id) - .optionally_search(params[:search]) - .order_by(params[:sort]) - .page(params[:page]) - end - - private - - def subscriber_id - current_user&.id if subscribed? - end - - def subscribed? - params[:subscribed] == 'true' - end -end diff --git a/app/finders/group_members_finder.rb b/app/finders/group_members_finder.rb index ce0d52ad97a..09283f061c0 100644 --- a/app/finders/group_members_finder.rb +++ b/app/finders/group_members_finder.rb @@ -17,9 +17,8 @@ class GroupMembersFinder < UnionFinder @params = params end - # rubocop: disable CodeReuse/ActiveRecord def execute(include_relations: [:inherited, :direct]) - group_members = group.members + group_members = group_members_list relations = [] return group_members if include_relations == [:direct] @@ -27,17 +26,13 @@ class GroupMembersFinder < UnionFinder relations << group_members if include_relations.include?(:direct) if include_relations.include?(:inherited) && group.parent - parents_members = GroupMember.non_request.non_minimal_access - .where(source_id: group.ancestors.select(:id)) - .where.not(user_id: group.users.select(:id)) + parents_members = relation_group_members(group.ancestors) relations << parents_members end if include_relations.include?(:descendants) - descendant_members = GroupMember.non_request.non_minimal_access - .where(source_id: group.descendants.select(:id)) - .where.not(user_id: group.users.select(:id)) + descendant_members = relation_group_members(group.descendants) relations << descendant_members end @@ -47,7 +42,6 @@ class GroupMembersFinder < UnionFinder members = find_union(relations, GroupMember) filter_members(members) end - # rubocop: enable CodeReuse/ActiveRecord private @@ -67,6 +61,22 @@ class GroupMembersFinder < UnionFinder def can_manage_members Ability.allowed?(user, :admin_group_member, group) end + + def group_members_list + group.members + end + + def relation_group_members(relation) + all_group_members(relation).non_minimal_access + end + + # rubocop: disable CodeReuse/ActiveRecord + def all_group_members(relation) + GroupMember.non_request + .where(source_id: relation.select(:id)) + .where.not(user_id: group.users.select(:id)) + end + # rubocop: enable CodeReuse/ActiveRecord end GroupMembersFinder.prepend_if_ee('EE::GroupMembersFinder') diff --git a/app/finders/groups_finder.rb b/app/finders/groups_finder.rb index 54715557399..4b6b2716c64 100644 --- a/app/finders/groups_finder.rb +++ b/app/finders/groups_finder.rb @@ -12,6 +12,8 @@ # all_available: boolean (defaults to true) # min_access_level: integer # exclude_group_ids: array of integers +# include_parent_descendants: boolean (defaults to false) - includes descendant groups when +# filtering by parent. The parent param must be present. # # Users with full private access can see all groups. The `owned` and `parent` # params can be used to restrict the groups that are returned. @@ -84,7 +86,11 @@ class GroupsFinder < UnionFinder def by_parent(groups) return groups unless params[:parent] - groups.where(parent: params[:parent]) + if include_parent_descendants? + groups.id_in(params[:parent].descendants) + else + groups.where(parent: params[:parent]) + end end # rubocop: enable CodeReuse/ActiveRecord @@ -100,6 +106,10 @@ class GroupsFinder < UnionFinder params.fetch(:all_available, true) end + def include_parent_descendants? + params.fetch(:include_parent_descendants, false) + end + def min_access_level? current_user && params[:min_access_level].present? end diff --git a/app/finders/merge_requests/by_approvals_finder.rb b/app/finders/merge_requests/by_approvals_finder.rb new file mode 100644 index 00000000000..e6ab1467f06 --- /dev/null +++ b/app/finders/merge_requests/by_approvals_finder.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module MergeRequests + # Used to filter MergeRequest collections by approvers + class ByApprovalsFinder + attr_reader :usernames, :ids + + # We apply a limitation to the amount of elements that can be part of the filter condition + MAX_FILTER_ELEMENTS = 5 + + # Initialize the finder + # + # @param [Array<String>] usernames + # @param [Array<Integers>] ids + def initialize(usernames, ids) + # rubocop:disable CodeReuse/ActiveRecord + @usernames = Array(usernames).map(&:to_s).uniq.take(MAX_FILTER_ELEMENTS) + @ids = Array(ids).uniq.take(MAX_FILTER_ELEMENTS) + # rubocop:enable CodeReuse/ActiveRecord + end + + # Filter MergeRequest collections by approvers + # + # @param [ActiveRecord::Relation] items the activerecord relation + def execute(items) + if by_no_approvals? + without_approvals(items) + elsif by_any_approvals? + with_any_approvals(items) + elsif ids.present? + find_approved_by_ids(items) + elsif usernames.present? + find_approved_by_names(items) + else + items + end + end + + private + + # Is param using special condition: "None" ? + # + # @return [Boolean] whether special condition "None" is being used + def by_no_approvals? + includes_special_label?(IssuableFinder::Params::FILTER_NONE) + end + + # Is param using special condition: "Any" ? + # + # @return [Boolean] whether special condition "Any" is being used + def by_any_approvals? + includes_special_label?(IssuableFinder::Params::FILTER_ANY) + end + + # Check if we have the special label in ids or usernames field + # + # @param [String] label the special label + # @return [Boolean] whether ids or usernames includes the special label + def includes_special_label?(label) + ids.first.to_s.downcase == label || usernames.map(&:downcase).include?(label) + end + + # Merge Requests without any approval + # + # @param [ActiveRecord::Relation] items + def without_approvals(items) + items.without_approvals + end + + # Merge Requests with any number of approvals + # + # @param [ActiveRecord::Relation] items the activerecord relation + def with_any_approvals(items) + items.select_from_union([ + items.with_approvals + ]) + end + + # Merge Requests approved by given usernames + # + # @param [ActiveRecord::Relation] items the activerecord relation + def find_approved_by_names(items) + items.approved_by_users_with_usernames(*usernames) + end + + # Merge Requests approved by given user IDs + # + # @param [ActiveRecord::Relation] items the activerecord relation + def find_approved_by_ids(items) + items.approved_by_users_with_ids(*ids) + end + end +end diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index 37da29b32ff..7bdc98d2e3d 100644 --- a/app/finders/merge_requests_finder.rb +++ b/app/finders/merge_requests_finder.rb @@ -33,7 +33,11 @@ class MergeRequestsFinder < IssuableFinder include MergedAtFilter def self.scalar_params - @scalar_params ||= super + [:wip, :draft, :target_branch, :merged_after, :merged_before] + @scalar_params ||= super + [:wip, :draft, :target_branch, :merged_after, :merged_before, :approved_by_ids] + end + + def self.array_params + @array_params ||= super.merge(approved_by_usernames: []) end def klass @@ -47,6 +51,7 @@ class MergeRequestsFinder < IssuableFinder items = by_draft(items) items = by_target_branch(items) items = by_merged_at(items) + items = by_approvals(items) by_source_project_id(items) end @@ -131,6 +136,15 @@ class MergeRequestsFinder < IssuableFinder def deployment_id @deployment_id ||= params[:deployment_id].presence end + + # Filter by merge requests that had been approved by specific users + # rubocop: disable CodeReuse/Finder + def by_approvals(items) + MergeRequests::ByApprovalsFinder + .new(params[:approved_by_usernames], params[:approved_by_ids]) + .execute(items) + end + # rubocop: enable CodeReuse/Finder end MergeRequestsFinder.prepend_if_ee('EE::MergeRequestsFinder') diff --git a/app/finders/packages/generic/package_finder.rb b/app/finders/packages/generic/package_finder.rb new file mode 100644 index 00000000000..3a260e11fa3 --- /dev/null +++ b/app/finders/packages/generic/package_finder.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Packages + module Generic + class PackageFinder + def initialize(project) + @project = project + end + + def execute!(package_name, package_version) + project + .packages + .generic + .by_name_and_version!(package_name, package_version) + end + + private + + attr_reader :project + end + end +end diff --git a/app/graphql/mutations/base_mutation.rb b/app/graphql/mutations/base_mutation.rb index 577f10545b3..ac5ddc5bd4c 100644 --- a/app/graphql/mutations/base_mutation.rb +++ b/app/graphql/mutations/base_mutation.rb @@ -4,6 +4,7 @@ module Mutations class BaseMutation < GraphQL::Schema::RelayClassicMutation prepend Gitlab::Graphql::Authorize::AuthorizeResource prepend Gitlab::Graphql::CopyFieldDescription + prepend ::Gitlab::Graphql::GlobalIDCompatibility ERROR_MESSAGE = 'You cannot perform write operations on a read-only instance' diff --git a/app/graphql/mutations/boards/lists/destroy.rb b/app/graphql/mutations/boards/lists/destroy.rb new file mode 100644 index 00000000000..61ffae7c047 --- /dev/null +++ b/app/graphql/mutations/boards/lists/destroy.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Mutations + module Boards + module Lists + class Destroy < ::Mutations::BaseMutation + graphql_name 'DestroyBoardList' + + field :list, + Types::BoardListType, + null: true, + description: 'The list after mutation.' + + argument :list_id, ::Types::GlobalIDType[::List], + required: true, + loads: Types::BoardListType, + description: 'Global ID of the list to destroy. Only label lists are accepted.' + + def resolve(list:) + raise_resource_not_available_error! unless can_admin_list?(list) + + response = ::Boards::Lists::DestroyService.new(list.board.resource_parent, current_user) + .execute(list) + + { + list: response.success? ? nil : list, + errors: response.errors + } + end + + private + + def can_admin_list?(list) + return false unless list.present? + + Ability.allowed?(current_user, :admin_list, list.board) + end + end + end + end +end diff --git a/app/graphql/mutations/ci/base.rb b/app/graphql/mutations/ci/base.rb index 09df4487a50..aaece2a3021 100644 --- a/app/graphql/mutations/ci/base.rb +++ b/app/graphql/mutations/ci/base.rb @@ -3,13 +3,18 @@ module Mutations module Ci class Base < BaseMutation - argument :id, ::Types::GlobalIDType[::Ci::Pipeline], + PipelineID = ::Types::GlobalIDType[::Ci::Pipeline] + + argument :id, PipelineID, required: true, description: 'The id of the pipeline to mutate' private def find_object(id:) + # TODO: remove this line when the compatibility layer is removed + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883 + id = PipelineID.coerce_isolated_input(id) GlobalID::Locator.locate(id) end end diff --git a/app/graphql/mutations/design_management/move.rb b/app/graphql/mutations/design_management/move.rb index 6126af8b68b..43e2e542408 100644 --- a/app/graphql/mutations/design_management/move.rb +++ b/app/graphql/mutations/design_management/move.rb @@ -21,7 +21,7 @@ module Mutations description: "The current state of the collection" def resolve(**args) - service = ::DesignManagement::MoveDesignsService.new(current_user, parameters(args)) + service = ::DesignManagement::MoveDesignsService.new(current_user, parameters(**args)) { design_collection: service.collection, errors: service.execute.errors } end @@ -29,11 +29,18 @@ module Mutations private def parameters(**args) - args.transform_values { |id| GitlabSchema.find_by_gid(id) }.transform_values(&:sync).tap do |hash| + args.transform_values { |id| find_design(id) }.transform_values(&:sync).tap do |hash| hash.each { |k, design| not_found(args[k]) unless current_user.can?(:read_design, design) } end end + def find_design(id) + # TODO: remove this line when the compatibility layer is removed + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883 + id = DesignID.coerce_isolated_input(id) + GitlabSchema.object_from_id(id) + end + def not_found(gid) raise Gitlab::Graphql::Errors::ResourceNotAvailable, "Resource not available: #{gid}" end diff --git a/app/graphql/mutations/metrics/dashboard/annotations/create.rb b/app/graphql/mutations/metrics/dashboard/annotations/create.rb index f99688aeac6..6f316e76e2a 100644 --- a/app/graphql/mutations/metrics/dashboard/annotations/create.rb +++ b/app/graphql/mutations/metrics/dashboard/annotations/create.rb @@ -80,7 +80,7 @@ module Mutations raise Gitlab::Graphql::Errors::ArgumentError, ANNOTATION_SOURCE_ARGUMENT_ERROR end - super(args) + super(**args) end def find_object(id:) diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb index 791c6eab42f..5d29d0bd437 100644 --- a/app/graphql/resolvers/base_resolver.rb +++ b/app/graphql/resolvers/base_resolver.rb @@ -4,6 +4,7 @@ module Resolvers class BaseResolver < GraphQL::Schema::Resolver extend ::Gitlab::Utils::Override include ::Gitlab::Utils::StrongMemoize + include ::Gitlab::Graphql::GlobalIDCompatibility def self.single @single ||= Class.new(self) do diff --git a/app/graphql/resolvers/board_list_issues_resolver.rb b/app/graphql/resolvers/board_list_issues_resolver.rb index dba9f99edeb..3421e1024c0 100644 --- a/app/graphql/resolvers/board_list_issues_resolver.rb +++ b/app/graphql/resolvers/board_list_issues_resolver.rb @@ -14,7 +14,7 @@ module Resolvers def resolve(**args) filter_params = issue_filters(args[:filters]).merge(board_id: list.board.id, id: list.id) - service = Boards::Issues::ListService.new(list.board.resource_parent, context[:current_user], filter_params) + service = ::Boards::Issues::ListService.new(list.board.resource_parent, context[:current_user], filter_params) Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection.new(service.execute) end diff --git a/app/graphql/resolvers/board_lists_resolver.rb b/app/graphql/resolvers/board_lists_resolver.rb index b1d43934f24..ccb09e29f07 100644 --- a/app/graphql/resolvers/board_lists_resolver.rb +++ b/app/graphql/resolvers/board_lists_resolver.rb @@ -27,7 +27,7 @@ module Resolvers private def board_lists(id) - service = Boards::Lists::ListService.new( + service = ::Boards::Lists::ListService.new( board.resource_parent, context[:current_user], list_id: extract_list_id(id) diff --git a/app/graphql/resolvers/board_resolver.rb b/app/graphql/resolvers/board_resolver.rb new file mode 100644 index 00000000000..e42cb427c0c --- /dev/null +++ b/app/graphql/resolvers/board_resolver.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Resolvers + class BoardResolver < BaseResolver.single + alias_method :parent, :synchronized_object + + type Types::BoardType, null: true + + argument :id, GraphQL::ID_TYPE, + required: true, + description: 'The board\'s ID' + + def resolve(id: nil) + return unless parent + + ::Boards::ListService.new(parent, context[:current_user], board_id: extract_board_id(id)).execute(create_default_board: false).first + rescue ActiveRecord::RecordNotFound + nil + end + + private + + def extract_board_id(gid) + GitlabSchema.parse_gid(gid, expected_type: ::Board).model_id + end + end +end diff --git a/app/graphql/resolvers/boards_resolver.rb b/app/graphql/resolvers/boards_resolver.rb index eceb5b38031..82efd92d33f 100644 --- a/app/graphql/resolvers/boards_resolver.rb +++ b/app/graphql/resolvers/boards_resolver.rb @@ -16,7 +16,7 @@ module Resolvers return Board.none unless parent - Boards::ListService.new(parent, context[:current_user], board_id: extract_board_id(id)).execute(create_default_board: false) + ::Boards::ListService.new(parent, context[:current_user], board_id: extract_board_id(id)).execute(create_default_board: false) rescue ActiveRecord::RecordNotFound Board.none end diff --git a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb index 2b14d8275d1..fe6fa0bb262 100644 --- a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb +++ b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb @@ -18,9 +18,15 @@ module IssueResolverArguments argument :milestone_title, GraphQL::STRING_TYPE.to_list_type, required: false, description: 'Milestone applied to this issue' + argument :author_username, GraphQL::STRING_TYPE, + required: false, + description: 'Username of the author of the issue' argument :assignee_username, GraphQL::STRING_TYPE, required: false, description: 'Username of a user assigned to the issue' + argument :assignee_usernames, [GraphQL::STRING_TYPE], + required: false, + description: 'Usernames of users assigned to the issue' argument :assignee_id, GraphQL::STRING_TYPE, required: false, description: 'ID of a user assigned to the issues, "none" and "any" values supported' diff --git a/app/graphql/resolvers/concerns/looks_ahead.rb b/app/graphql/resolvers/concerns/looks_ahead.rb index e7230287e13..61f23920ebb 100644 --- a/app/graphql/resolvers/concerns/looks_ahead.rb +++ b/app/graphql/resolvers/concerns/looks_ahead.rb @@ -3,8 +3,6 @@ module LooksAhead extend ActiveSupport::Concern - FEATURE_FLAG = :graphql_lookahead_support - included do attr_accessor :lookahead end @@ -16,8 +14,6 @@ module LooksAhead end def apply_lookahead(query) - return query unless Feature.enabled?(FEATURE_FLAG) - selection = node_selection includes = preloads.each.flat_map do |name, requirements| diff --git a/app/graphql/resolvers/concerns/resolves_merge_requests.rb b/app/graphql/resolvers/concerns/resolves_merge_requests.rb index 0c01efd4f9a..3d845c8e9df 100644 --- a/app/graphql/resolvers/concerns/resolves_merge_requests.rb +++ b/app/graphql/resolvers/concerns/resolves_merge_requests.rb @@ -40,7 +40,7 @@ module ResolvesMergeRequests author: [:author], merged_at: [:metrics], commit_count: [:metrics], - approved_by: [:approver_users], + approved_by: [:approved_by_users], milestone: [:milestone], head_pipeline: [:merge_request_diff, { head_pipeline: [:merge_request] }] } diff --git a/app/graphql/resolvers/projects_resolver.rb b/app/graphql/resolvers/projects_resolver.rb index 3bbadf87a71..f6afe945fe8 100644 --- a/app/graphql/resolvers/projects_resolver.rb +++ b/app/graphql/resolvers/projects_resolver.rb @@ -16,6 +16,10 @@ module Resolvers required: false, description: 'Filter projects by IDs' + argument :search_namespaces, GraphQL::BOOLEAN_TYPE, + required: false, + description: 'Include namespace in project search' + def resolve(**args) ProjectsFinder .new(current_user: current_user, params: project_finder_params(args), project_ids_relation: parse_gids(args[:ids])) @@ -28,7 +32,8 @@ module Resolvers { without_deleted: true, non_public: params[:membership], - search: params[:search] + search: params[:search], + search_namespaces: params[:search_namespaces] }.compact end diff --git a/app/graphql/resolvers/snippets/blobs_resolver.rb b/app/graphql/resolvers/snippets/blobs_resolver.rb new file mode 100644 index 00000000000..dc28358cab6 --- /dev/null +++ b/app/graphql/resolvers/snippets/blobs_resolver.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Resolvers + module Snippets + class BlobsResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + alias_method :snippet, :object + + argument :paths, [GraphQL::STRING_TYPE], + required: false, + description: 'Paths of the blobs' + + def resolve(**args) + authorize!(snippet) + + return [snippet.blob] if snippet.empty_repo? + + paths = Array(args.fetch(:paths, [])) + + if paths.empty? + snippet.blobs + else + snippet.repository.blobs_at(transformed_blob_paths(paths)) + end + end + + def authorized_resource?(snippet) + Ability.allowed?(context[:current_user], :read_snippet, snippet) + end + + private + + def transformed_blob_paths(paths) + ref = snippet.default_branch + paths.map { |path| [ref, path] } + end + end + end +end diff --git a/app/graphql/resolvers/terraform/states_resolver.rb b/app/graphql/resolvers/terraform/states_resolver.rb new file mode 100644 index 00000000000..38b26a948b1 --- /dev/null +++ b/app/graphql/resolvers/terraform/states_resolver.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Resolvers + module Terraform + class StatesResolver < BaseResolver + type Types::Terraform::StateType, null: true + + alias_method :project, :object + + def resolve(**args) + return ::Terraform::State.none unless can_read_terraform_states? + + project.terraform_states.ordered_by_name + end + + private + + def can_read_terraform_states? + current_user.can?(:read_terraform_state, project) + end + end + end +end diff --git a/app/graphql/types/admin/analytics/instance_statistics/measurement_identifier_enum.rb b/app/graphql/types/admin/analytics/instance_statistics/measurement_identifier_enum.rb index 13c67442c2e..b9f7f616e13 100644 --- a/app/graphql/types/admin/analytics/instance_statistics/measurement_identifier_enum.rb +++ b/app/graphql/types/admin/analytics/instance_statistics/measurement_identifier_enum.rb @@ -14,6 +14,10 @@ module Types value 'MERGE_REQUESTS', 'Merge request count', value: :merge_requests value 'GROUPS', 'Group count', value: :groups value 'PIPELINES', 'Pipeline count', value: :pipelines + value 'PIPELINES_SUCCEEDED', 'Pipeline count with success status', value: :pipelines_succeeded + value 'PIPELINES_FAILED', 'Pipeline count with failed status', value: :pipelines_failed + value 'PIPELINES_CANCELED', 'Pipeline count with canceled status', value: :pipelines_canceled + value 'PIPELINES_SKIPPED', 'Pipeline count with skipped status', value: :pipelines_skipped end end end diff --git a/app/graphql/types/design_management/design_collection_copy_state_enum.rb b/app/graphql/types/design_management/design_collection_copy_state_enum.rb new file mode 100644 index 00000000000..7e7303c50ef --- /dev/null +++ b/app/graphql/types/design_management/design_collection_copy_state_enum.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Types + module DesignManagement + class DesignCollectionCopyStateEnum < BaseEnum + graphql_name 'DesignCollectionCopyState' + description 'Copy state of a DesignCollection' + + DESCRIPTION_VARIANTS = { + in_progress: 'is being copied', + error: 'encountered an error during a copy', + ready: 'has no copy in progress' + }.freeze + + def self.description_variant(copy_state) + DESCRIPTION_VARIANTS[copy_state.to_sym] || + (raise ArgumentError, "Unknown copy state: #{copy_state}") + end + + ::DesignManagement::DesignCollection.state_machines[:copy_state].states.keys.each do |copy_state| + value copy_state.upcase, + value: copy_state.to_s, + description: "The DesignCollection #{description_variant(copy_state)}" + end + end + end +end diff --git a/app/graphql/types/design_management/design_collection_type.rb b/app/graphql/types/design_management/design_collection_type.rb index 904fb270e11..9af1f4db425 100644 --- a/app/graphql/types/design_management/design_collection_type.rb +++ b/app/graphql/types/design_management/design_collection_type.rb @@ -39,6 +39,10 @@ module Types null: true, resolver: ::Resolvers::DesignManagement::DesignResolver, description: 'Find a specific design' + + field :copy_state, ::Types::DesignManagement::DesignCollectionCopyStateEnum, + null: true, + description: 'Copy state of the design collection' end end end diff --git a/app/graphql/types/global_id_type.rb b/app/graphql/types/global_id_type.rb index a3964ba83e1..9ae9ba32c13 100644 --- a/app/graphql/types/global_id_type.rb +++ b/app/graphql/types/global_id_type.rb @@ -1,5 +1,21 @@ # frozen_string_literal: true +module GraphQLExtensions + module ScalarExtensions + # Allow ID to unify with GlobalID Types + def ==(other) + if name == 'ID' && other.is_a?(self.class) && + other.type_class.ancestors.include?(::Types::GlobalIDType) + return true + end + + super + end + end +end + +::GraphQL::ScalarType.prepend(GraphQLExtensions::ScalarExtensions) + module Types class GlobalIDType < BaseScalar graphql_name 'GlobalID' diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb index 60b2e3c7b6e..9b5fb778f6c 100644 --- a/app/graphql/types/group_type.rb +++ b/app/graphql/types/group_type.rb @@ -64,7 +64,7 @@ module Types Types::BoardType, null: true, description: 'A single board of the group', - resolver: Resolvers::BoardsResolver.single + resolver: Resolvers::BoardResolver field :label, Types::LabelType, diff --git a/app/graphql/types/issue_sort_enum.rb b/app/graphql/types/issue_sort_enum.rb index e458d6e02c5..08762264b1b 100644 --- a/app/graphql/types/issue_sort_enum.rb +++ b/app/graphql/types/issue_sort_enum.rb @@ -8,6 +8,8 @@ module Types value 'DUE_DATE_ASC', 'Due date by ascending order', value: :due_date_asc value 'DUE_DATE_DESC', 'Due date by descending order', value: :due_date_desc value 'RELATIVE_POSITION_ASC', 'Relative position by ascending order', value: :relative_position_asc + 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 end end diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb index d6253f74ce5..487508f448f 100644 --- a/app/graphql/types/issue_type.rb +++ b/app/graphql/types/issue_type.rb @@ -36,8 +36,7 @@ module Types end field :author, Types::UserType, null: false, - description: 'User that created the issue', - resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.author_id).find } + description: 'User that created the issue' field :assignees, Types::UserType.connection_type, null: true, description: 'Assignees of the issue' @@ -45,16 +44,14 @@ module Types field :labels, Types::LabelType.connection_type, null: true, description: 'Labels of the issue' field :milestone, Types::MilestoneType, null: true, - description: 'Milestone of the issue', - resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, obj.milestone_id).find } + description: 'Milestone of the issue' field :due_date, Types::TimeType, null: true, description: 'Due date of the issue' field :confidential, GraphQL::BOOLEAN_TYPE, null: false, description: 'Indicates the issue is confidential' field :discussion_locked, GraphQL::BOOLEAN_TYPE, null: false, - description: 'Indicates discussion is locked on the issue', - resolve: -> (obj, _args, _ctx) { !!obj.discussion_locked } + description: 'Indicates discussion is locked on the issue' field :upvotes, GraphQL::INT_TYPE, null: false, description: 'Number of upvotes the issue has received' @@ -108,6 +105,18 @@ module Types field :severity, Types::IssuableSeverityEnum, null: true, description: 'Severity level of the incident' + + def author + Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.author_id).find + end + + def milestone + Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, object.milestone_id).find + end + + def discussion_locked + !!object.discussion_locked + end end end diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index 56c88491684..573818b1b7a 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -174,10 +174,6 @@ module Types def commit_count object&.metrics&.commits_count end - - def approvers - object.approver_users - end end end Types::MergeRequestType.prepend_if_ee('::EE::Types::MergeRequestType') diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index b2732d83aac..3a9d5529266 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -18,6 +18,7 @@ module Types mount_mutation Mutations::Boards::Issues::IssueMoveList mount_mutation Mutations::Boards::Lists::Create mount_mutation Mutations::Boards::Lists::Update + mount_mutation Mutations::Boards::Lists::Destroy mount_mutation Mutations::Branches::Create, calls_gitaly: true mount_mutation Mutations::Commits::Create, calls_gitaly: true mount_mutation Mutations::Discussions::ToggleResolve @@ -71,4 +72,5 @@ module Types end ::Types::MutationType.prepend(::Types::DeprecatedMutations) +::Types::MutationType.prepend_if_ee('EE::Types::DeprecatedMutations') ::Types::MutationType.prepend_if_ee('::EE::Types::MutationType') diff --git a/app/graphql/types/notes/noteable_type.rb b/app/graphql/types/notes/noteable_type.rb index 3a16d54f9cd..602634d9292 100644 --- a/app/graphql/types/notes/noteable_type.rb +++ b/app/graphql/types/notes/noteable_type.rb @@ -8,24 +8,24 @@ module Types field :notes, Types::Notes::NoteType.connection_type, null: false, description: "All notes on this noteable" field :discussions, Types::Notes::DiscussionType.connection_type, null: false, description: "All discussions on this noteable" - definition_methods do - def resolve_type(object, context) - case object - when Issue - Types::IssueType - when MergeRequest - Types::MergeRequestType - when Snippet - Types::SnippetType - when ::DesignManagement::Design - Types::DesignManagement::DesignType - when ::AlertManagement::Alert - Types::AlertManagement::AlertType - else - raise "Unknown GraphQL type for #{object}" - end + def self.resolve_type(object, context) + case object + when Issue + Types::IssueType + when MergeRequest + Types::MergeRequestType + when Snippet + Types::SnippetType + when ::DesignManagement::Design + Types::DesignManagement::DesignType + when ::AlertManagement::Alert + Types::AlertManagement::AlertType + else + raise "Unknown GraphQL type for #{object}" end end end end end + +Types::Notes::NoteableType.prepend_if_ee('::EE::Types::Notes::NoteableType') diff --git a/app/graphql/types/project_member_type.rb b/app/graphql/types/project_member_type.rb index f08781238d0..01731531ae2 100644 --- a/app/graphql/types/project_member_type.rb +++ b/app/graphql/types/project_member_type.rb @@ -12,7 +12,10 @@ module Types authorize :read_project field :project, Types::ProjectType, null: true, - description: 'Project that User is a member of', - resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, obj.source_id).find } + description: 'Project that User is a member of' + + def project + Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.source_id).find + end end end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 0fd54af1538..c7fc193abe8 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -234,7 +234,7 @@ module Types Types::BoardType, null: true, description: 'A single board of the project', - resolver: Resolvers::BoardsResolver.single + resolver: Resolvers::BoardResolver field :jira_imports, Types::JiraImportType.connection_type, @@ -294,6 +294,12 @@ module Types description: 'Title of the label' end + field :terraform_states, + Types::Terraform::StateType.connection_type, + null: true, + description: 'Terraform states associated with the project', + resolver: Resolvers::Terraform::StatesResolver + def label(title:) BatchLoader::GraphQL.for(title).batch(key: project) do |titles, loader, args| LabelsFinder diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 447ac63a294..73dd7c57223 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -49,8 +49,7 @@ module Types field :milestone, ::Types::MilestoneType, null: true, - description: 'Find a milestone', - resolve: -> (_obj, args, _ctx) { GitlabSchema.find_by_gid(args[:id]) } do + description: 'Find a milestone' do argument :id, ::Types::GlobalIDType[Milestone], required: true, description: 'Find a milestone by its ID' @@ -86,7 +85,17 @@ module Types end def issue(id:) - GitlabSchema.object_from_id(id, expected_type: ::Issue) + # TODO: remove this line when the compatibility layer is removed + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883 + id = ::Types::GlobalIDType[::Issue].coerce_isolated_input(id) + GitlabSchema.find_by_gid(id) + end + + def milestone(id:) + # TODO: remove this line when the compatibility layer is removed + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883 + id = ::Types::GlobalIDType[Milestone].coerce_isolated_input(id) + GitlabSchema.find_by_gid(id) end end end diff --git a/app/graphql/types/snippet_type.rb b/app/graphql/types/snippet_type.rb index db98e62c10a..495c25c1776 100644 --- a/app/graphql/types/snippet_type.rb +++ b/app/graphql/types/snippet_type.rb @@ -24,16 +24,14 @@ module Types field :project, Types::ProjectType, description: 'The project the snippet is associated with', null: true, - authorize: :read_project, - resolve: -> (snippet, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, snippet.project_id).find } + authorize: :read_project # Author can be nil in some scenarios. For example, # when the admin setting restricted visibility # level is set to public field :author, Types::UserType, description: 'The owner of the snippet', - null: true, - resolve: -> (snippet, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, snippet.author_id).find } + null: true field :file_name, GraphQL::STRING_TYPE, description: 'File Name of the snippet', @@ -69,10 +67,11 @@ module Types null: false, deprecated: { reason: 'Use `blobs`', milestone: '13.3' } - field :blobs, type: [Types::Snippets::BlobType], + field :blobs, type: Types::Snippets::BlobType.connection_type, description: 'Snippet blobs', calls_gitaly: true, - null: false + null: true, + resolver: Resolvers::Snippets::BlobsResolver field :ssh_url_to_repo, type: GraphQL::STRING_TYPE, description: 'SSH URL to the snippet repository', @@ -85,5 +84,13 @@ module Types null: true markdown_field :description_html, null: true, method: :description + + def author + Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.author_id).find + end + + def project + Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.project_id).find + end end end diff --git a/app/graphql/types/sort_enum.rb b/app/graphql/types/sort_enum.rb index 3245cb33e0d..d0a6eecb672 100644 --- a/app/graphql/types/sort_enum.rb +++ b/app/graphql/types/sort_enum.rb @@ -5,9 +5,16 @@ module Types graphql_name 'Sort' description 'Common sort values' - value 'updated_desc', 'Updated at descending order' - value 'updated_asc', 'Updated at ascending order' - value 'created_desc', 'Created at descending order' - value 'created_asc', 'Created at ascending order' + # Deprecated, as we prefer uppercase enums + # https://gitlab.com/groups/gitlab-org/-/epics/1838 + value 'updated_desc', 'Updated at descending order', deprecated: { reason: 'Use UPDATED_DESC', milestone: '13.5' } + value 'updated_asc', 'Updated at ascending order', deprecated: { reason: 'Use UPDATED_ASC', milestone: '13.5' } + value 'created_desc', 'Created at descending order', deprecated: { reason: 'Use CREATED_DESC', milestone: '13.5' } + value 'created_asc', 'Created at ascending order', deprecated: { reason: 'Use CREATED_ASC', milestone: '13.5' } + + value 'UPDATED_DESC', 'Updated at descending order', value: :updated_desc + value 'UPDATED_ASC', 'Updated at ascending order', value: :updated_asc + value 'CREATED_DESC', 'Created at descending order', value: :created_desc + value 'CREATED_ASC', 'Created at ascending order', value: :created_asc end end diff --git a/app/graphql/types/terraform/state_type.rb b/app/graphql/types/terraform/state_type.rb new file mode 100644 index 00000000000..f25f3a7789b --- /dev/null +++ b/app/graphql/types/terraform/state_type.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Types + module Terraform + class StateType < BaseObject + graphql_name 'TerraformState' + + authorize :read_terraform_state + + field :id, GraphQL::ID_TYPE, + null: false, + description: 'ID of the Terraform state' + + field :name, GraphQL::STRING_TYPE, + null: false, + description: 'Name of the Terraform state' + + field :locked_by_user, Types::UserType, + null: true, + authorize: :read_user, + description: 'The user currently holding a lock on the Terraform state', + resolve: -> (state, _, _) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, state.locked_by_user_id).find } + + field :locked_at, Types::TimeType, + null: true, + description: 'Timestamp the Terraform state was locked' + + field :created_at, Types::TimeType, + null: false, + description: 'Timestamp the Terraform state was created' + + field :updated_at, Types::TimeType, + null: false, + description: 'Timestamp the Terraform state was updated' + end + end +end diff --git a/app/helpers/analytics/navbar_helper.rb b/app/helpers/analytics/navbar_helper.rb index ddf2655c887..bc0b5e7c74f 100644 --- a/app/helpers/analytics/navbar_helper.rb +++ b/app/helpers/analytics/navbar_helper.rb @@ -28,7 +28,7 @@ module Analytics private def navbar_sub_item(args) - NavbarSubItem.new(args) + NavbarSubItem.new(**args) end def cycle_analytics_navbar_link(project, current_user) diff --git a/app/helpers/analytics/unique_visits_helper.rb b/app/helpers/analytics/unique_visits_helper.rb index ded7f54e44e..4c709b2ed23 100644 --- a/app/helpers/analytics/unique_visits_helper.rb +++ b/app/helpers/analytics/unique_visits_helper.rb @@ -14,8 +14,7 @@ module Analytics end def track_visit(target_id) - return unless Feature.enabled?(:track_unique_visits) - return unless Gitlab::CurrentSettings.usage_ping_enabled? + return unless Feature.enabled?(:track_unique_visits, default_enabled: true) return unless visitor_id Gitlab::Analytics::UniqueVisits.new.track_visit(visitor_id, target_id) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index a81225c8954..665184f268c 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -8,8 +8,16 @@ module ApplicationHelper # See https://docs.gitlab.com/ee/development/ee_features.html#code-in-app-views # rubocop: disable CodeReuse/ActiveRecord - def render_if_exists(partial, locals = {}) - render(partial, locals) if partial_exists?(partial) + # We allow partial to be nil so that collection views can be passed in + # `render partial: 'some/view', collection: @some_collection` + def render_if_exists(partial = nil, **options) + return unless partial_exists?(partial || options[:partial]) + + if partial.nil? + render(**options) + else + render(partial, options) + end end def partial_exists?(partial) @@ -349,6 +357,12 @@ module ApplicationHelper } end + def add_page_specific_style(path) + content_for :page_specific_styles do + stylesheet_link_tag_defer path + end + end + def page_startup_api_calls @api_startup_calls end diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 9245cc1cb1c..3da4113497f 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -168,7 +168,7 @@ module ApplicationSettingsHelper def visible_attributes [ - :admin_notification_email, + :abuse_notification_email, :after_sign_out_path, :after_sign_up_text, :akismet_api_key, @@ -265,6 +265,7 @@ module ApplicationSettingsHelper :receive_max_input_size, :repository_checks_enabled, :repository_storages_weighted, + :require_admin_approval_after_user_signup, :require_two_factor_authentication, :restricted_visibility_levels, :rsa_key_restriction, @@ -345,6 +346,12 @@ module ApplicationSettingsHelper ] end + def deprecated_attributes + [ + :admin_notification_email # ok to remove in REST API v5 + ] + end + def expanded_by_default? Rails.env.test? end diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb index 68dbc5b65d1..5457f96d506 100644 --- a/app/helpers/avatars_helper.rb +++ b/app/helpers/avatars_helper.rb @@ -60,7 +60,7 @@ module AvatarsHelper avatar_size = options[:size] || 16 user_name = options[:user].try(:name) || options[:user_name] - avatar_url = user_avatar_url_for(options.merge(size: avatar_size)) + avatar_url = user_avatar_url_for(**options.merge(size: avatar_size)) has_tooltip = options[:has_tooltip].nil? ? true : options[:has_tooltip] data_attributes = options[:data] || {} diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb index 6a4a7a8dfb2..4750580e20d 100644 --- a/app/helpers/boards_helper.rb +++ b/app/helpers/boards_helper.rb @@ -14,6 +14,7 @@ module BoardsHelper root_path: root_path, full_path: full_path, bulk_update_path: @bulk_issues_path, + can_update: (!!can?(current_user, :admin_issue, board)).to_s, time_tracking_limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s, recent_boards_endpoint: recent_boards_path, parent: current_board_parent.model_name.param_key, diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb index caad215e996..cc633df77f9 100644 --- a/app/helpers/clusters_helper.rb +++ b/app/helpers/clusters_helper.rb @@ -12,6 +12,18 @@ module ClustersHelper end end + def display_cluster_agents?(_clusterable) + false + end + + def js_cluster_agents_list_data(clusterable_project) + { + default_branch_name: clusterable_project.default_branch, + empty_state_image: image_path('illustrations/clusters_empty.svg'), + project_path: clusterable_project.full_path + } + end + def js_clusters_list_data(path = nil) { ancestor_help_path: help_page_path('user/group/clusters/index', anchor: 'cluster-precedence'), @@ -42,14 +54,6 @@ module ClustersHelper } end - # This method is depreciated and will be removed when associated HAML files are moved to JavaScript - def provider_icon(provider = nil) - img_data = js_clusters_list_data.dig(:img_tags, provider&.to_sym) || - js_clusters_list_data.dig(:img_tags, :default) - - image_tag img_data[:path], alt: img_data[:text], class: 'gl-h-full' - end - def render_gcp_signup_offer return if Gitlab::CurrentSettings.current_application_settings.hide_third_party_offers? return unless show_gcp_signup_offer? diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb index d5c22927991..0a0dc77e5e2 100644 --- a/app/helpers/emails_helper.rb +++ b/app/helpers/emails_helper.rb @@ -218,8 +218,28 @@ module EmailsHelper _('Please contact your administrator with any questions.') end + def change_reviewer_notification_text(new_reviewers, previous_reviewers, html_tag = nil) + new = new_reviewers.any? ? users_to_sentence(new_reviewers) : s_('ChangeReviewer|Unassigned') + old = previous_reviewers.any? ? users_to_sentence(previous_reviewers) : nil + + if html_tag.present? + new = content_tag(html_tag, new) + old = content_tag(html_tag, old) if old.present? + end + + if old.present? + s_('ChangeReviewer|Reviewer changed from %{old} to %{new}').html_safe % { old: old, new: new } + else + s_('ChangeReviewer|Reviewer changed to %{new}').html_safe % { new: new } + end + end + private + def users_to_sentence(users) + sanitize_name(users.map(&:name).to_sentence) + end + def generate_link(text, url) link_to(text, url, target: :_blank, rel: 'noopener noreferrer') end diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 0167f2ef698..f40755b9439 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -28,19 +28,7 @@ module EventsHelper end def event_action_name(event) - target = if event.target_type - 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 - end - else - 'project' - end + target = event.note_target_type_name || event.target_type_name [event.action_name, target].join(" ") end @@ -229,7 +217,7 @@ 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 gl-mr-2") + concat content_tag(:span, event.note_target_type_name, 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 diff --git a/app/helpers/feature_flags_helper.rb b/app/helpers/feature_flags_helper.rb new file mode 100644 index 00000000000..e50191a471f --- /dev/null +++ b/app/helpers/feature_flags_helper.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module FeatureFlagsHelper + include ::API::Helpers::RelatedResourcesHelpers + + def unleash_api_url(project) + expose_url(api_v4_feature_flags_unleash_path(project_id: project.id)) + end + + def unleash_api_instance_id(project) + project.feature_flags_client_token + end + + def feature_flag_issues_links_endpoint(_project, _feature_flag, _user) + '' + end +end + +FeatureFlagsHelper.prepend_if_ee('::EE::FeatureFlagsHelper') diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index d71e6b4c004..7df6bef7914 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -343,6 +343,18 @@ module GitlabRoutingHelper Gitlab::UrlBuilder.wiki_page_url(wiki, page, only_path: true, **options) end + def gitlab_ide_merge_request_path(merge_request) + target_project = merge_request.target_project + source_project = merge_request.source_project + params = {} + + if target_project != source_project + params = { target_project: target_project.full_path } + end + + ide_merge_request_path(source_project.namespace, source_project, merge_request, params) + end + private def snippet_query_params(snippet, *args) diff --git a/app/helpers/gitpod_helper.rb b/app/helpers/gitpod_helper.rb new file mode 100644 index 00000000000..7edf7dc218d --- /dev/null +++ b/app/helpers/gitpod_helper.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module GitpodHelper + def gitpod_enable_description + link_start = '<a href="https://gitpod.io/" target="_blank" rel="noopener noreferrer">'.html_safe + link_end = "#{sprite_icon('external-link', size: 12, css_class: 'ml-1 vertical-align-center')}</a>".html_safe + + s_('Enable %{link_start}Gitpod%{link_end} integration to launch a development environment in your browser directly from GitLab.').html_safe % { link_start: link_start, link_end: link_end } + end +end diff --git a/app/helpers/groups/group_members_helper.rb b/app/helpers/groups/group_members_helper.rb index dcff2be34da..0782961f541 100644 --- a/app/helpers/groups/group_members_helper.rb +++ b/app/helpers/groups/group_members_helper.rb @@ -10,11 +10,11 @@ module Groups::GroupMembersHelper end def render_invite_member_for_group(group, default_access_level) - render 'shared/members/invite_member', submit_url: group_group_members_path(group), access_levels: GroupMember.access_level_roles, default_access_level: default_access_level + render 'shared/members/invite_member', submit_url: group_group_members_path(group), access_levels: group.access_level_roles, default_access_level: default_access_level end def linked_groups_data_json(group_links) - GroupGroupLinkSerializer.new.represent(group_links).to_json + GroupGroupLinkSerializer.new.represent(group_links, { current_user: current_user }).to_json end def members_data_json(group, members) @@ -47,10 +47,10 @@ module Groups::GroupMembersHelper } }.merge(member_created_by_data(member.created_by)) - if user.present? - data[:user] = member_user_data(user) - else + if member.invite? data[:invite] = member_invite_data(member) + elsif user.present? + data[:user] = member_user_data(user) end data @@ -77,6 +77,17 @@ module Groups::GroupMembersHelper avatar_url: avatar_icon_for_user(user, AVATAR_SIZE), blocked: user.blocked?, two_factor_enabled: user.two_factor_enabled? + }.merge(member_user_status_data(user.status)) + end + + def member_user_status_data(status) + return {} unless status.present? + + { + status: { + emoji: status.emoji, + message_html: status.message_html + } } end diff --git a/app/helpers/invite_members_helper.rb b/app/helpers/invite_members_helper.rb new file mode 100644 index 00000000000..cbd08cb82ed --- /dev/null +++ b/app/helpers/invite_members_helper.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module InviteMembersHelper + def invite_members_allowed?(group) + Feature.enabled?(:invite_members_group_modal, group) && can?(current_user, :admin_group_member, group) + end +end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index b255597b18d..5b5902b1fa2 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -4,7 +4,10 @@ module IssuablesHelper include GitlabRoutingHelper def sidebar_gutter_toggle_icon - sidebar_gutter_collapsed? ? icon('angle-double-left', { 'aria-hidden': 'true' }) : icon('angle-double-right', { 'aria-hidden': 'true' }) + content_tag(:span, class: 'js-sidebar-toggle-container', data: { is_expanded: !sidebar_gutter_collapsed? }) do + sprite_icon('chevron-double-lg-left', css_class: "js-sidebar-expand #{'hidden' unless sidebar_gutter_collapsed?}") + + sprite_icon('chevron-double-lg-right', css_class: "js-sidebar-collapse #{'hidden' if sidebar_gutter_collapsed?}") + end end def sidebar_gutter_collapsed_class @@ -206,7 +209,7 @@ module IssuablesHelper end if access = project.team.human_max_access(issuable.author_id) - output << content_tag(:span, access, class: "user-access-role has-tooltip d-none d-xl-inline-block gl-ml-3 ", title: _("This user is a %{access} of the %{name} project.") % { access: access.downcase, name: project.name }) + output << content_tag(:span, access, class: "user-access-role has-tooltip d-none d-xl-inline-block gl-ml-3 ", title: _("This user has the %{access} role in the %{name} project.") % { access: access.downcase, name: project.name }) elsif project.team.contributor?(issuable.author_id) output << content_tag(:span, _("Contributor"), class: "user-access-role has-tooltip d-none d-xl-inline-block gl-ml-3", title: _("This user has previously committed to the %{name} project.") % { name: project.name }) end @@ -342,6 +345,12 @@ module IssuablesHelper issuable.closed? ^ should_inverse ? reopen_issuable_path(issuable) : close_issuable_path(issuable) end + def toggle_draft_issuable_path(issuable) + wip_event = issuable.work_in_progress? ? 'unwip' : 'wip' + + issuable_path(issuable, { merge_request: { wip_event: wip_event } }) + end + def issuable_path(issuable, *options) polymorphic_path(issuable, *options) end @@ -386,6 +395,12 @@ module IssuablesHelper end end + def reviewer_sidebar_data(reviewer, merge_request: nil) + { avatar_url: reviewer.avatar_url, name: reviewer.name, username: reviewer.username }.tap do |data| + data[:can_merge] = merge_request.can_be_merged_by?(reviewer) if merge_request + end + end + def issuable_squash_option?(issuable, project) if issuable.persisted? issuable.squash diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index e8ea39d7ffc..dbf284e70e4 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -137,6 +137,21 @@ module IssuesHelper issue.moved_from.project.service_desk_enabled? && !issue.project.service_desk_enabled? end + + def use_startup_call? + request.query_parameters.empty? && @sort == 'created_date' + end + + def startup_call_params + { + state: 'opened', + with_labels_details: 'true', + page: 1, + per_page: 20, + order_by: 'created_at', + sort: 'desc' + } + end end IssuesHelper.prepend_if_ee('EE::IssuesHelper') diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb index 3142d7d7782..bfe1728adad 100644 --- a/app/helpers/labels_helper.rb +++ b/app/helpers/labels_helper.rb @@ -36,11 +36,11 @@ module LabelsHelper # link_to_label(label) { "My Custom Label Text" } # # Returns a String - def link_to_label(label, type: :issue, tooltip: true, small: false, &block) + def link_to_label(label, type: :issue, tooltip: true, small: false, css_class: nil, &block) link = label.filter_path(type: type) if block_given? - link_to link, &block + link_to link, class: css_class, &block else render_label(label, link: link, tooltip: tooltip, small: small) end diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 1125ecb9b41..9cb7edbaeb6 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -109,10 +109,6 @@ module MergeRequestsHelper @merge_request_diffs.size - @merge_request_diffs.index(merge_request_diff) end - def different_base?(version1, version2) - version1 && version2 && version1.base_commit_sha != version2.base_commit_sha - end - def merge_params(merge_request) { auto_merge_strategy: AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS, diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb index 81451e398f2..8cf5cd49322 100644 --- a/app/helpers/namespaces_helper.rb +++ b/app/helpers/namespaces_helper.rb @@ -53,7 +53,7 @@ module NamespacesHelper selected = options.delete(:selected) || :current_user options[:groups] = current_user.manageable_groups_with_routes(include_groups_with_developer_maintainer_access: true) - namespaces_options(selected, options) + namespaces_options(selected, **options) end private diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index 578c7ae7923..3c757a4ef26 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -55,7 +55,8 @@ module NavHelper current_path?('projects/merge_requests/conflicts#show') || current_path?('issues#show') || current_path?('milestones#show') || - current_path?('issues#designs') + current_path?('issues#designs') || + current_path?('incidents#show') end def admin_monitoring_nav_links diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb index e6ecc403a88..0a296b4e6ba 100644 --- a/app/helpers/packages_helper.rb +++ b/app/helpers/packages_helper.rb @@ -51,9 +51,15 @@ module PackagesHelper { resource_id: resource.id, page_type: type, - empty_list_help_url: help_page_path('administration/packages/index'), + empty_list_help_url: help_page_path('user/packages/package_registry/index'), empty_list_illustration: image_path('illustrations/no-packages.svg'), - coming_soon_json: packages_coming_soon_data(resource).to_json + coming_soon_json: packages_coming_soon_data(resource).to_json, + package_help_url: help_page_path('user/packages/index') } end + + def track_package_event(event_name, scope, **args) + ::Packages::CreateEventService.new(nil, current_user, event_name: event_name, scope: scope).execute + track_event(event_name, **args) + end end diff --git a/app/helpers/page_layout_helper.rb b/app/helpers/page_layout_helper.rb index a44760e85ca..6808ffc3e27 100644 --- a/app/helpers/page_layout_helper.rb +++ b/app/helpers/page_layout_helper.rb @@ -40,6 +40,14 @@ module PageLayoutHelper end end + def page_canonical_link(link = nil) + if link + @page_canonical_link = link + else + @page_canonical_link + end + end + def favicon Gitlab::Favicon.main end diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index 2c406641882..9bf819febb0 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -61,8 +61,8 @@ module PreferencesHelper @user_application_theme ||= Gitlab::Themes.for_user(current_user).css_class end - def user_application_theme_name - @user_application_theme_name ||= Gitlab::Themes.for_user(current_user).name.downcase.tr(' ', '_') + def user_application_theme_css_filename + @user_application_theme_css_filename ||= Gitlab::Themes.for_user(current_user).css_filename end def user_color_scheme diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb index 5a42e581867..44d869fbd8f 100644 --- a/app/helpers/profiles_helper.rb +++ b/app/helpers/profiles_helper.rb @@ -29,4 +29,19 @@ module ProfilesHelper def user_profile? params[:controller] == 'users' end + + def ssh_key_delete_modal_data(key, is_admin) + { + path: path_to_key(key, is_admin), + method: 'delete', + qa_selector: 'delete_ssh_key_button', + modal_attributes: { + 'data-qa-selector': 'ssh_key_delete_modal', + title: _('Are you sure you want to delete this SSH key?'), + message: _('This action cannot be undone, and will permanently delete the %{key} SSH key') % { key: key.title }, + okVariant: 'danger', + okTitle: _('Delete') + } + } + end end diff --git a/app/helpers/projects/incidents_helper.rb b/app/helpers/projects/incidents_helper.rb index e96f0f5a384..0cac142f2dc 100644 --- a/app/helpers/projects/incidents_helper.rb +++ b/app/helpers/projects/incidents_helper.rb @@ -1,14 +1,17 @@ # frozen_string_literal: true module Projects::IncidentsHelper - def incidents_data(project) + def incidents_data(project, params) { 'project-path' => project.full_path, 'new-issue-path' => new_project_issue_path(project), 'incident-template-name' => 'incident', 'incident-type' => 'incident', 'issue-path' => project_issues_path(project), - 'empty-list-svg-path' => image_path('illustrations/incident-empty-state.svg') + 'empty-list-svg-path' => image_path('illustrations/incident-empty-state.svg'), + 'text-query': params[:search], + 'author-usernames-query': params[:author_username], + 'assignee-usernames-query': params[:assignee_username] } end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 72cc07b13a5..6e317a63e47 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -468,7 +468,7 @@ module ProjectsHelper serverless: :read_cluster, error_tracking: :read_sentry_issue, alert_management: :read_alert_management_alert, - incidents: :read_incidents, + incidents: :read_issue, labels: :read_label, issues: :read_issue, project_members: :read_project_member, @@ -477,7 +477,14 @@ module ProjectsHelper end def can_view_operations_tab?(current_user, project) - [:read_environment, :read_cluster, :metrics_dashboard].any? do |ability| + [ + :metrics_dashboard, + :read_alert_management_alert, + :read_environment, + :read_issue, + :read_sentry_issue, + :read_cluster + ].any? do |ability| can?(current_user, ability, project) end end @@ -758,10 +765,6 @@ module ProjectsHelper !project.repository.gitlab_ci_yml end - def native_code_navigation_enabled?(project) - Feature.enabled?(:code_navigation, project, default_enabled: true) - end - def show_visibility_confirm_modal?(project) project.unlink_forks_upon_visibility_decrease_enabled? && project.visibility_level > Gitlab::VisibilityLevel::PRIVATE && project.forks_count > 0 end @@ -774,7 +777,7 @@ module ProjectsHelper def project_access_token_available?(project) return false if ::Gitlab.com? - ::Feature.enabled?(:resource_access_token, project, default_enabled: true) + can?(current_user, :admin_resource_access_tokens, project) end end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index d55ad878b92..36b58be60fc 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module SearchHelper - SEARCH_PERMITTED_PARAMS = [:search, :scope, :project_id, :group_id, :repository_ref, :snippets, :state].freeze + SEARCH_PERMITTED_PARAMS = [:search, :scope, :project_id, :group_id, :repository_ref, :snippets, :sort, :state, :confidential].freeze def search_autocomplete_opts(term) return unless current_user @@ -86,6 +86,11 @@ module SearchHelper }).html_safe end + def repository_ref(project) + # Always #to_s the repository_ref param in case the value is also a number + params[:repository_ref].to_s.presence || project.default_branch + end + # Overridden in EE def search_blob_title(project, path) path @@ -294,9 +299,12 @@ module SearchHelper sanitize(html, tags: %w(a p ol ul li pre code)) end - def show_user_search_tab? - return false if Feature.disabled?(:users_search, default_enabled: true) + # _search_highlight is used in EE override + def highlight_and_truncate_issue(issue, search_term, _search_highlight) + simple_search_highlight_and_truncate(issue.description, search_term, highlighter: '<span class="gl-text-black-normal gl-font-weight-bold">\1</span>') + end + def show_user_search_tab? if @project project_search_tabs?(:members) else diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb index 6b5de73a831..ae59f84e7da 100644 --- a/app/helpers/services_helper.rb +++ b/app/helpers/services_helper.rb @@ -124,6 +124,10 @@ module ServicesHelper @group.present? && Feature.enabled?(:group_level_integrations, @group) end + def instance_level_integrations? + !Gitlab.com? + end + extend self private diff --git a/app/helpers/suggest_pipeline_helper.rb b/app/helpers/suggest_pipeline_helper.rb index aa67f0ea770..d64e8d6f2cd 100644 --- a/app/helpers/suggest_pipeline_helper.rb +++ b/app/helpers/suggest_pipeline_helper.rb @@ -2,7 +2,7 @@ module SuggestPipelineHelper def should_suggest_gitlab_ci_yml? - Feature.enabled?(:suggest_pipeline) && + experiment_enabled?(:suggest_pipeline) && current_user && params[:suggest_gitlab_ci_yml] == 'true' end diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb index 0227ad1092d..79f4810e13a 100644 --- a/app/helpers/system_note_helper.rb +++ b/app/helpers/system_note_helper.rb @@ -2,6 +2,8 @@ module SystemNoteHelper ICON_NAMES_BY_ACTION = { + 'approved' => 'approval', + 'unapproved' => 'unapproval', 'cherry_pick' => 'cherry-pick-commit', 'commit' => 'commit', 'description' => 'pencil-square', @@ -11,6 +13,7 @@ module SystemNoteHelper 'closed' => 'issue-close', 'time_tracking' => 'timer', 'assignee' => 'user', + 'reviewer' => 'user', 'title' => 'pencil-square', 'task' => 'task-done', 'label' => 'label', @@ -34,7 +37,8 @@ module SystemNoteHelper 'designs_discussion_added' => 'doc-image', 'status' => 'status', 'alert_issue_added' => 'issues', - 'new_alert_added' => 'warning' + 'new_alert_added' => 'warning', + 'severity' => 'information-o' }.freeze def system_note_icon_name(note) diff --git a/app/helpers/tags_helper.rb b/app/helpers/tags_helper.rb index 4984b51555d..bfc8803f514 100644 --- a/app/helpers/tags_helper.rb +++ b/app/helpers/tags_helper.rb @@ -38,4 +38,13 @@ module TagsHelper text.html_safe end + + def delete_tag_modal_attributes(tag_name) + { + title: s_('TagsPage|Delete tag'), + message: s_('TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?') % { tag_name: tag_name }, + okVariant: 'danger', + okTitle: s_('TagsPage|Delete tag') + }.to_json + end end diff --git a/app/helpers/timeboxes_helper.rb b/app/helpers/timeboxes_helper.rb index 34919f994ee..bbf8cf7dac3 100644 --- a/app/helpers/timeboxes_helper.rb +++ b/app/helpers/timeboxes_helper.rb @@ -228,8 +228,8 @@ module TimeboxesHelper end alias_method :milestone_date_range, :timebox_date_range - def milestone_tab_path(milestone, tab) - url_for(action: tab, format: :json) + def milestone_tab_path(milestone, tab, params = {}) + url_for(params.merge(action: tab, format: :json)) end def update_milestone_path(milestone, params = {}) diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index 9865f7dfbef..7b0e0df8998 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -16,6 +16,7 @@ module TodosHelper def todo_action_name(todo) case todo.action when Todo::ASSIGNED then todo.self_added? ? 'assigned' : 'assigned you' + when Todo::REVIEW_REQUESTED then 'requested a review of' when Todo::MENTIONED then "mentioned #{todo_action_subject(todo)} on" when Todo::BUILD_FAILED then 'The build failed for' when Todo::MARKED then 'added a todo for' @@ -26,6 +27,13 @@ module TodosHelper end end + def todo_self_addressing(todo) + case todo.action + when Todo::ASSIGNED then 'to yourself' + when Todo::REVIEW_REQUESTED then 'from yourself' + end + end + def todo_target_link(todo) text = raw(todo_target_type_name(todo) + ' ') + if todo.for_commit? @@ -141,6 +149,7 @@ module TodosHelper [ { id: '', text: 'Any Action' }, { id: Todo::ASSIGNED, text: 'Assigned' }, + { id: Todo::REVIEW_REQUESTED, text: 'Review requested' }, { id: Todo::MENTIONED, text: 'Mentioned' }, { id: Todo::MARKED, text: 'Added' }, { id: Todo::BUILD_FAILED, text: 'Pipelines' }, diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index 7644ed783eb..1d8d9ddc1ec 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -199,14 +199,14 @@ module TreeHelper } end - def ide_base_path(project) + def web_ide_url_data(project) can_push_code = current_user&.can?(:push_code, project) fork_path = current_user&.fork_of(project)&.full_path - if can_push_code - project.full_path + if fork_path && !can_push_code + { path: fork_path, is_fork: true } else - fork_path || project.full_path + { path: project.full_path, is_fork: false } end end @@ -216,7 +216,7 @@ module TreeHelper show_web_ide_button = (can_collaborate || current_user&.already_forked?(project) || can_create_mr_from_fork) { - ide_base_path: ide_base_path(project), + web_ide_url_data: web_ide_url_data(project), needs_to_fork: !can_collaborate && !current_user&.already_forked?(project), show_web_ide_button: show_web_ide_button, show_gitpod_button: show_web_ide_button && Gitlab::Gitpod.feature_and_settings_enabled?(project), diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb index 967271a8431..b0cfda67ad4 100644 --- a/app/helpers/user_callouts_helper.rb +++ b/app/helpers/user_callouts_helper.rb @@ -9,7 +9,6 @@ module UserCalloutsHelper TABS_POSITION_HIGHLIGHT = 'tabs_position_highlight' WEBHOOKS_MOVED = 'webhooks_moved' CUSTOMIZE_HOMEPAGE = 'customize_homepage' - WEB_IDE_ALERT_DISMISSED = 'web_ide_alert_dismissed' def show_admin_integrations_moved? !user_dismissed?(ADMIN_INTEGRATIONS_MOVED) @@ -51,10 +50,6 @@ module UserCalloutsHelper customize_homepage && !user_dismissed?(CUSTOMIZE_HOMEPAGE) end - def show_web_ide_alert? - !user_dismissed?(WEB_IDE_ALERT_DISMISSED) - end - private def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil) diff --git a/app/helpers/whats_new_helper.rb b/app/helpers/whats_new_helper.rb index f0044daa645..edb3544d2d4 100644 --- a/app/helpers/whats_new_helper.rb +++ b/app/helpers/whats_new_helper.rb @@ -3,6 +3,24 @@ module WhatsNewHelper EMPTY_JSON = ''.to_json + def whats_new_most_recent_release_items_count + items = parsed_most_recent_release_items + + return unless items.is_a?(Array) + + items.count + end + + def whats_new_storage_key + items = parsed_most_recent_release_items + + return unless items.is_a?(Array) + + release = items.first.try(:[], 'release') + + ['display-whats-new-notification', release].compact.join('-') + end + def whats_new_most_recent_release_items YAML.load_file(most_recent_release_file_path).to_json @@ -14,6 +32,10 @@ module WhatsNewHelper private + def parsed_most_recent_release_items + Gitlab::Json.parse(whats_new_most_recent_release_items) + end + def most_recent_release_file_path Dir.glob(files_path).max end diff --git a/app/mailers/abuse_report_mailer.rb b/app/mailers/abuse_report_mailer.rb index 0f2f63b43f5..20aabb6fe58 100644 --- a/app/mailers/abuse_report_mailer.rb +++ b/app/mailers/abuse_report_mailer.rb @@ -11,7 +11,7 @@ class AbuseReportMailer < ApplicationMailer @abuse_report = AbuseReport.find(abuse_report_id) mail( - to: Gitlab::CurrentSettings.admin_notification_email, + to: Gitlab::CurrentSettings.abuse_notification_email, subject: "#{@abuse_report.user.name} (#{@abuse_report.user.username}) was reported for abuse" ) end @@ -19,6 +19,6 @@ class AbuseReportMailer < ApplicationMailer private def deliverable? - Gitlab::CurrentSettings.admin_notification_email.present? + Gitlab::CurrentSettings.abuse_notification_email.present? end end diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb index 3a13c5949bd..376b1a723c3 100644 --- a/app/mailers/emails/members.rb +++ b/app/mailers/emails/members.rb @@ -77,6 +77,15 @@ module Emails Gitlab::Tracking.event(Gitlab::Experimentation::EXPERIMENTS[:invite_email][:tracking_category], 'sent', property: 'control_group') end end + + if member.invite_to_unknown_user? && Gitlab::Experimentation.enabled?(:invitation_reminders) + Gitlab::Tracking.event( + Gitlab::Experimentation.experiment(:invitation_reminders).tracking_category, + 'sent', + property: Gitlab::Experimentation.enabled_for_attribute?(:invitation_reminders, member.invite_email) ? 'experimental_group' : 'control_group', + label: Digest::MD5.hexdigest(member.to_global_id.to_s) + ) + end end def member_invite_accepted_email(member_source_type, member_id) diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb index c709c2950d6..2d1d271882d 100644 --- a/app/mailers/emails/merge_requests.rb +++ b/app/mailers/emails/merge_requests.rb @@ -34,6 +34,17 @@ module Emails end # rubocop: enable CodeReuse/ActiveRecord + # rubocop: disable CodeReuse/ActiveRecord + def changed_reviewer_of_merge_request_email(recipient_id, merge_request_id, previous_reviewer_ids, updated_by_user_id, reason = nil) + setup_merge_request_mail(merge_request_id, recipient_id) + + @previous_reviewers = [] + @previous_reviewers = User.where(id: previous_reviewer_ids) if previous_reviewer_ids.any? + + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason)) + end + # rubocop: enable CodeReuse/ActiveRecord + def relabeled_merge_request_email(recipient_id, merge_request_id, label_names, updated_by_user_id, reason = nil) setup_merge_request_mail(merge_request_id, recipient_id) diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb index fdf40a77ca4..17ef8b41e79 100644 --- a/app/mailers/emails/projects.rb +++ b/app/mailers/emails/projects.rb @@ -56,16 +56,14 @@ module Emails subject: @message.subject) end - def prometheus_alert_fired_email(project_id, user_id, alert_payload) + def prometheus_alert_fired_email(project_id, user_id, alert_attributes) @project = ::Project.find(project_id) user = ::User.find(user_id) - @alert = ::Gitlab::Alerting::Alert - .new(project: @project, payload: alert_payload) - .present - return unless @alert.valid? + @alert = AlertManagement::Alert.new(alert_attributes.with_indifferent_access).present + return unless @alert.parsed_payload.has_required_attributes? - subject_text = "Alert: #{@alert.full_title}" + subject_text = "Alert: #{@alert.email_title}" mail(to: user.notification_email_for(@project.group), subject: subject(subject_text)) end end diff --git a/app/models/alert_management/alert.rb b/app/models/alert_management/alert.rb index e9b89af45c6..f5e56cb50c9 100644 --- a/app/models/alert_management/alert.rb +++ b/app/models/alert_management/alert.rb @@ -191,7 +191,7 @@ module AlertManagement end def prometheus? - monitoring_tool == Gitlab::AlertManagement::AlertParams::MONITORING_TOOLS[:prometheus] + monitoring_tool == Gitlab::AlertManagement::Payload::MONITORING_TOOLS[:prometheus] end def register_new_event! diff --git a/app/models/alert_management/http_integration.rb b/app/models/alert_management/http_integration.rb new file mode 100644 index 00000000000..7f954e1d384 --- /dev/null +++ b/app/models/alert_management/http_integration.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module AlertManagement + class HttpIntegration < ApplicationRecord + belongs_to :project, inverse_of: :alert_management_http_integrations + + attr_encrypted :token, + mode: :per_attribute_iv, + key: Settings.attr_encrypted_db_key_base_truncated, + algorithm: 'aes-256-gcm' + + validates :project, presence: true + validates :active, inclusion: { in: [true, false] } + + validates :token, presence: true + validates :name, presence: true, length: { maximum: 255 } + validates :endpoint_identifier, presence: true, length: { maximum: 255 } + validates :endpoint_identifier, uniqueness: { scope: [:project_id, :active] }, if: :active? + + before_validation :prevent_token_assignment + before_validation :ensure_token + + private + + def prevent_token_assignment + if token.present? && token_changed? + self.token = nil + self.encrypted_token = encrypted_token_was + self.encrypted_token_iv = encrypted_token_iv_was + end + end + + def ensure_token + self.token = generate_token if token.blank? + end + + def generate_token + SecureRandom.hex + end + end +end diff --git a/app/models/analytics/instance_statistics/measurement.rb b/app/models/analytics/instance_statistics/measurement.rb index eaaf9e999b3..76cc1111e90 100644 --- a/app/models/analytics/instance_statistics/measurement.rb +++ b/app/models/analytics/instance_statistics/measurement.rb @@ -3,13 +3,19 @@ module Analytics module InstanceStatistics class Measurement < ApplicationRecord + EXPERIMENTAL_IDENTIFIERS = %i[pipelines_succeeded pipelines_failed pipelines_canceled pipelines_skipped].freeze + enum identifier: { projects: 1, users: 2, issues: 3, merge_requests: 4, groups: 5, - pipelines: 6 + pipelines: 6, + pipelines_succeeded: 7, + pipelines_failed: 8, + pipelines_canceled: 9, + pipelines_skipped: 10 } IDENTIFIER_QUERY_MAPPING = { @@ -18,7 +24,11 @@ module Analytics identifiers[:issues] => -> { Issue }, identifiers[:merge_requests] => -> { MergeRequest }, identifiers[:groups] => -> { Group }, - identifiers[:pipelines] => -> { Ci::Pipeline } + identifiers[:pipelines] => -> { Ci::Pipeline }, + identifiers[:pipelines_succeeded] => -> { Ci::Pipeline.success }, + identifiers[:pipelines_failed] => -> { Ci::Pipeline.failed }, + identifiers[:pipelines_canceled] => -> { Ci::Pipeline.canceled }, + identifiers[:pipelines_skipped] => -> { Ci::Pipeline.skipped } }.freeze validates :recorded_at, :identifier, :count, presence: true @@ -26,6 +36,14 @@ module Analytics scope :order_by_latest, -> { order(recorded_at: :desc) } scope :with_identifier, -> (identifier) { where(identifier: identifier) } + + def self.measurement_identifier_values + if Feature.enabled?(:store_ci_pipeline_counts_by_status, default_enabled: true) + identifiers.values + else + identifiers.values - EXPERIMENTAL_IDENTIFIERS.map { |identifier| identifiers[identifier] } + end + end end end end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index e9a3dcf39df..2d26d5655ca 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -95,7 +95,7 @@ class ApplicationSetting < ApplicationRecord allow_blank: true, addressable_url: true - validates :admin_notification_email, + validates :abuse_notification_email, devise_email: true, allow_blank: true diff --git a/app/models/application_setting/term.rb b/app/models/application_setting/term.rb index 723540c9b91..bab036f5697 100644 --- a/app/models/application_setting/term.rb +++ b/app/models/application_setting/term.rb @@ -14,6 +14,8 @@ class ApplicationSetting end def accepted_by_user?(user) + return true if user.project_bot? + user.accepted_term_id == id || term_agreements.accepted.where(user: user).exists? end diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb index f46803be057..929e7ecbf48 100644 --- a/app/models/audit_event.rb +++ b/app/models/audit_event.rb @@ -5,7 +5,13 @@ class AuditEvent < ApplicationRecord include IgnorableColumns include BulkInsertSafe - PARALLEL_PERSISTENCE_COLUMNS = [:author_name, :entity_path, :target_details, :target_type].freeze + PARALLEL_PERSISTENCE_COLUMNS = [ + :author_name, + :entity_path, + :target_details, + :target_type, + :target_id + ].freeze ignore_column :type, remove_with: '13.6', remove_after: '2020-11-22' @@ -16,6 +22,7 @@ class AuditEvent < ApplicationRecord validates :author_id, presence: true validates :entity_id, presence: true validates :entity_type, presence: true + validates :ip_address, ip_address: true scope :by_entity_type, -> (entity_type) { where(entity_type: entity_type) } scope :by_entity_id, -> (entity_id) { where(entity_id: entity_id) } @@ -59,8 +66,8 @@ class AuditEvent < ApplicationRecord end def lazy_author - BatchLoader.for(author_id).batch(default_value: default_author_value) do |author_ids, loader| - User.where(id: author_ids).find_each do |user| + BatchLoader.for(author_id).batch(default_value: default_author_value, replace_methods: false) do |author_ids, loader| + User.select(:id, :name, :username).where(id: author_ids).find_each do |user| loader.call(user.id, user) end end diff --git a/app/models/authentication_event.rb b/app/models/authentication_event.rb index 1ac3c5fbd9c..ac6e08caf50 100644 --- a/app/models/authentication_event.rb +++ b/app/models/authentication_event.rb @@ -1,12 +1,22 @@ # frozen_string_literal: true class AuthenticationEvent < ApplicationRecord + include UsageStatistics + belongs_to :user, optional: true validates :provider, :user_name, :result, presence: true + validates :ip_address, ip_address: true enum result: { failed: 0, success: 1 } + + scope :for_provider, ->(provider) { where(provider: provider) } + scope :ldap, -> { where('provider LIKE ?', 'ldap%')} + + def self.providers + distinct.pluck(:provider) + end end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 99580a52e96..6b4a71d4e28 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -327,6 +327,8 @@ module Ci after_transition any => [:success, :failed, :canceled] do |build| build.run_after_commit do + build.run_status_commit_hooks! + BuildFinishedWorker.perform_async(id) end end @@ -963,8 +965,24 @@ module Ci pending_state.try(:delete) end + def run_on_status_commit(&block) + status_commit_hooks.push(block) + end + + protected + + def run_status_commit_hooks! + status_commit_hooks.reverse_each do |hook| + instance_eval(&hook) + end + end + private + def status_commit_hooks + @status_commit_hooks ||= [] + end + def auto_retry strong_memoize(:auto_retry) do Gitlab::Ci::Build::AutoRetry.new(self) diff --git a/app/models/ci/build_pending_state.rb b/app/models/ci/build_pending_state.rb index 45f323adec2..299c67f441d 100644 --- a/app/models/ci/build_pending_state.rb +++ b/app/models/ci/build_pending_state.rb @@ -9,4 +9,10 @@ class Ci::BuildPendingState < ApplicationRecord enum failure_reason: CommitStatus.failure_reasons validates :build, presence: true + + def crc32 + trace_checksum.try do |checksum| + checksum.to_s.split('crc32:').last.to_i(16) + end + end end diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb index 444742062d9..55bcb8adaca 100644 --- a/app/models/ci/build_trace_chunk.rb +++ b/app/models/ci/build_trace_chunk.rb @@ -3,6 +3,7 @@ module Ci class BuildTraceChunk < ApplicationRecord extend ::Gitlab::Ci::Model + include ::Comparable include ::FastDestroyAll include ::Checksummable include ::Gitlab::ExclusiveLeaseHelpers @@ -29,6 +30,7 @@ module Ci } scope :live, -> { redis } + scope :persisted, -> { not_redis.order(:chunk_index) } class << self def all_stores @@ -63,12 +65,24 @@ module Ci get_store_class(store).delete_keys(value) end end + + ## + # Sometimes we do not want to read raw data. This method makes it easier + # to find attributes that are just metadata excluding raw data. + # + def metadata_attributes + attribute_names - %w[raw_data] + end end def data @data ||= get_data.to_s end + def crc32 + checksum.to_i + end + def truncate(offset = 0) raise ArgumentError, 'Offset is out of range' if offset > size || offset < 0 return if offset == size # Skip the following process as it doesn't affect anything @@ -126,8 +140,13 @@ module Ci # no chunk with higher index in the database. # def final? - build.pending_state.present? && - build.trace_chunks.maximum(:chunk_index).to_i == chunk_index + build.pending_state.present? && chunks_max_index == chunk_index + end + + def <=>(other) + return unless self.build_id == other.build_id + + self.chunk_index <=> other.chunk_index end private @@ -145,12 +164,19 @@ module Ci current_size = current_data&.bytesize.to_i unless current_size == CHUNK_SIZE || final? - raise FailedToPersistDataError, 'Data is not fulfilled in a bucket' + raise FailedToPersistDataError, <<~MSG + data is not fulfilled in a bucket + + size: #{current_size} + state: #{build.pending_state.present?} + max: #{chunks_max_index} + index: #{chunk_index} + MSG end self.raw_data = nil self.data_store = new_store - self.checksum = crc32(current_data) + self.checksum = self.class.crc32(current_data) ## # We need to so persist data then save a new store identifier before we @@ -203,6 +229,10 @@ module Ci self.class.get_store_class(data_store) end + def chunks_max_index + build.trace_chunks.maximum(:chunk_index).to_i + end + def lock_params ["trace_write:#{build_id}:chunks:#{chunk_index}", { ttl: WRITE_LOCK_TTL, diff --git a/app/models/ci/build_trace_chunks/database.rb b/app/models/ci/build_trace_chunks/database.rb index ea8072099c6..7448afba4c2 100644 --- a/app/models/ci/build_trace_chunks/database.rb +++ b/app/models/ci/build_trace_chunks/database.rb @@ -17,6 +17,8 @@ module Ci def data(model) model.raw_data + rescue ActiveModel::MissingAttributeError + model.reset.raw_data end def set_data(model, new_data) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 47eba685afe..f9a55fa9157 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -42,6 +42,7 @@ module Ci has_many :stages, -> { order(position: :asc) }, inverse_of: :pipeline has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline has_many :latest_statuses_ordered_by_stage, -> { latest.order(:stage_idx, :stage) }, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline + has_many :latest_statuses, -> { latest }, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline has_many :processables, class_name: 'Ci::Processable', foreign_key: :commit_id, inverse_of: :pipeline has_many :bridges, class_name: 'Ci::Bridge', foreign_key: :commit_id, inverse_of: :pipeline has_many :builds, foreign_key: :commit_id, inverse_of: :pipeline @@ -577,11 +578,11 @@ module Ci end def retried - @retried ||= (statuses.order(id: :desc) - statuses.latest) + @retried ||= (statuses.order(id: :desc) - latest_statuses) end def coverage - coverage_array = statuses.latest.map(&:coverage).compact + coverage_array = latest_statuses.map(&:coverage).compact if coverage_array.size >= 1 '%.2f' % (coverage_array.reduce(:+) / coverage_array.size) end @@ -875,7 +876,7 @@ module Ci end def builds_with_coverage - builds.with_coverage + builds.latest.with_coverage end def has_reports?(reports_scope) diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index f0b3c11ba1d..d07ea7b71dc 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.20.2' + VERSION = '0.21.1' self.table_name = 'clusters_applications_runners' diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb index 7af78960e35..b85a902d58b 100644 --- a/app/models/clusters/platforms/kubernetes.rb +++ b/app/models/clusters/platforms/kubernetes.rb @@ -11,6 +11,7 @@ module Clusters RESERVED_NAMESPACES = %w(gitlab-managed-apps).freeze self.table_name = 'cluster_platforms_kubernetes' + self.reactive_cache_work_type = :external_dependency belongs_to :cluster, inverse_of: :platform_kubernetes, class_name: 'Clusters::Cluster' @@ -101,7 +102,7 @@ module Clusters def terminals(environment, data) pods = filter_by_project_environment(data[:pods], environment.project.full_path_slug, environment.slug) terminals = pods.flat_map { |pod| terminals_for_pod(api_url, environment.deployment_namespace, pod) }.compact - terminals.each { |terminal| add_terminal_auth(terminal, terminal_auth) } + terminals.each { |terminal| add_terminal_auth(terminal, **terminal_auth) } end def kubeclient diff --git a/app/models/commit.rb b/app/models/commit.rb index 5e0fceb23a4..5fe1b451ccd 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -29,12 +29,6 @@ class Commit delegate :repository, to: :container delegate :project, to: :repository, allow_nil: true - DIFF_SAFE_LINES = Gitlab::Git::DiffCollection::DEFAULT_LIMITS[:max_lines] - - # Commits above this size will not be rendered in HTML - DIFF_HARD_LIMIT_FILES = 1000 - DIFF_HARD_LIMIT_LINES = 50000 - MIN_SHA_LENGTH = Gitlab::Git::Commit::MIN_SHA_LENGTH COMMIT_SHA_PATTERN = /\h{#{MIN_SHA_LENGTH},40}/.freeze EXACT_COMMIT_SHA_PATTERN = /\A#{COMMIT_SHA_PATTERN}\z/.freeze @@ -80,10 +74,30 @@ class Commit sha[0..MIN_SHA_LENGTH] end - def max_diff_options + def diff_safe_lines + Gitlab::Git::DiffCollection.default_limits[:max_lines] + end + + def diff_hard_limit_files(project: nil) + if Feature.enabled?(:increased_diff_limits, project) + 2000 + else + 1000 + end + end + + def diff_hard_limit_lines(project: nil) + if Feature.enabled?(:increased_diff_limits, project) + 75000 + else + 50000 + end + end + + def max_diff_options(project: nil) { - max_files: DIFF_HARD_LIMIT_FILES, - max_lines: DIFF_HARD_LIMIT_LINES + max_files: diff_hard_limit_files(project: project), + max_lines: diff_hard_limit_lines(project: project) } end diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 2f0596c93cc..b0169d6290a 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -204,8 +204,13 @@ class CommitStatus < ApplicationRecord # 'rspec:linux: 1/10' => 'rspec:linux' common_name = name.to_s.gsub(%r{\d+[\s:\/\\]+\d+\s*}, '') - # 'rspec:linux: [aws, max memory]' => 'rspec:linux' - common_name.gsub!(%r{: \[.*, .*\]\s*\z}, '') + if ::Gitlab::Ci::Features.one_dimensional_matrix_enabled? + # 'rspec:linux: [aws, max memory]' => 'rspec:linux', 'rspec:linux: [aws]' => 'rspec:linux' + common_name.gsub!(%r{: \[.*\]\s*\z}, '') + else + # 'rspec:linux: [aws, max memory]' => 'rspec:linux', 'rspec:linux: [aws]' => 'rspec:linux: [aws]' + common_name.gsub!(%r{: \[.*, .*\]\s*\z}, '') + end common_name.strip! common_name diff --git a/app/models/concerns/approvable_base.rb b/app/models/concerns/approvable_base.rb index d07c4ec43ac..c2d94b50f8d 100644 --- a/app/models/concerns/approvable_base.rb +++ b/app/models/concerns/approvable_base.rb @@ -2,10 +2,34 @@ module ApprovableBase extend ActiveSupport::Concern + include FromUnion included do has_many :approvals, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_many :approved_by_users, through: :approvals, source: :user + + scope :without_approvals, -> { left_outer_joins(:approvals).where(approvals: { id: nil }) } + scope :with_approvals, -> { joins(:approvals) } + scope :approved_by_users_with_ids, -> (*user_ids) do + with_approvals + .merge(Approval.with_user) + .where(users: { id: user_ids }) + .group(:id) + .having("COUNT(users.id) = ?", user_ids.size) + end + scope :approved_by_users_with_usernames, -> (*usernames) do + with_approvals + .merge(Approval.with_user) + .where(users: { username: usernames }) + .group(:id) + .having("COUNT(users.id) = ?", usernames.size) + end + end + + class_methods do + def select_from_union(relations) + where(id: from_union(relations)) + end end def approved_by?(user) diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb index 0dd55ab67b5..92926620f8c 100644 --- a/app/models/concerns/avatarable.rb +++ b/app/models/concerns/avatarable.rb @@ -3,16 +3,11 @@ module Avatarable extend ActiveSupport::Concern - ALLOWED_IMAGE_SCALER_WIDTHS = [ - 400, - 200, - 64, - 48, - 40, - 26, - 20, - 16 - ].freeze + USER_AVATAR_SIZES = [16, 20, 23, 24, 26, 32, 36, 38, 40, 48, 60, 64, 96, 120, 160].freeze + PROJECT_AVATAR_SIZES = [15, 40, 48, 64, 88].freeze + GROUP_AVATAR_SIZES = [15, 37, 38, 39, 40, 64, 96].freeze + + ALLOWED_IMAGE_SCALER_WIDTHS = (USER_AVATAR_SIZES | PROJECT_AVATAR_SIZES | GROUP_AVATAR_SIZES).freeze included do prepend ShadowMethods diff --git a/app/models/concerns/checksummable.rb b/app/models/concerns/checksummable.rb index d6d17bfc604..056abafd0ce 100644 --- a/app/models/concerns/checksummable.rb +++ b/app/models/concerns/checksummable.rb @@ -3,11 +3,11 @@ module Checksummable extend ActiveSupport::Concern - def crc32(data) - Zlib.crc32(data) - end - class_methods do + def crc32(data) + Zlib.crc32(data) + end + def hexdigest(path) ::Digest::SHA256.file(path).hexdigest end diff --git a/app/models/concerns/counter_attribute.rb b/app/models/concerns/counter_attribute.rb index a5c7393e8f7..b468415c4c7 100644 --- a/app/models/concerns/counter_attribute.rb +++ b/app/models/concerns/counter_attribute.rb @@ -20,6 +20,14 @@ # To increment the counter we can use the method: # delayed_increment_counter(:commit_count, 3) # +# It is possible to register callbacks to be executed after increments have +# been flushed to the database. Callbacks are not executed if there are no increments +# to flush. +# +# counter_attribute_after_flush do |statistic| +# Namespaces::ScheduleAggregationWorker.perform_async(statistic.namespace_id) +# end +# module CounterAttribute extend ActiveSupport::Concern extend AfterCommitQueue @@ -48,6 +56,15 @@ module CounterAttribute def counter_attributes @counter_attributes ||= Set.new end + + def after_flush_callbacks + @after_flush_callbacks ||= [] + end + + # perform registered callbacks after increments have been flushed to the database + def counter_attribute_after_flush(&callback) + after_flush_callbacks << callback + end end # This method must only be called by FlushCounterIncrementsWorker @@ -75,6 +92,8 @@ module CounterAttribute unsafe_update_counters(id, attribute => increment_value) redis_state { |redis| redis.del(flushed_key) } end + + execute_after_flush_callbacks end end @@ -108,13 +127,13 @@ module CounterAttribute counter_key(attribute) + ':lock' end - private - def counter_attribute_enabled?(attribute) Feature.enabled?(:efficient_counter_attribute, project) && self.class.counter_attributes.include?(attribute) end + private + def steal_increments(increment_key, flushed_key) redis_state do |redis| redis.eval(LUA_STEAL_INCREMENT_SCRIPT, keys: [increment_key, flushed_key]) @@ -129,6 +148,12 @@ module CounterAttribute self.class.update_counters(id, increments) end + def execute_after_flush_callbacks + self.class.after_flush_callbacks.each do |callback| + callback.call(self) + end + end + def redis_state(&block) Gitlab::Redis::SharedState.with(&block) end diff --git a/app/models/concerns/integration.rb b/app/models/concerns/integration.rb index 34ff5bb1195..9d446841a9f 100644 --- a/app/models/concerns/integration.rb +++ b/app/models/concerns/integration.rb @@ -16,7 +16,7 @@ module Integration Project.where(id: custom_integration_project_ids) end - def ids_without_integration(integration, limit) + def without_integration(integration) services = Service .select('1') .where('services.project_id = projects.id') @@ -26,8 +26,6 @@ module Integration .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 888e1b384a2..7624a1a4e80 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -182,7 +182,7 @@ module Issuable end def supports_time_tracking? - is_a?(TimeTrackable) && !incident? + is_a?(TimeTrackable) end def supports_severity? @@ -203,15 +203,6 @@ module Issuable issuable_severity&.severity || IssuableSeverity::DEFAULT end - def update_severity(severity) - return unless incident? - - severity = severity.to_s.downcase - severity = IssuableSeverity::DEFAULT unless IssuableSeverity.severities.key?(severity) - - (issuable_severity || build_issuable_severity(issue_id: id)).update(severity: severity) - end - private def description_max_length_for_new_records_is_valid diff --git a/app/models/concerns/issue_available_features.rb b/app/models/concerns/issue_available_features.rb new file mode 100644 index 00000000000..6efb8103b7b --- /dev/null +++ b/app/models/concerns/issue_available_features.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +# Verifies features availability based on issue type. +# This can be used, for example, for hiding UI elements or blocking specific +# quick actions for particular issue types; +module IssueAvailableFeatures + extend ActiveSupport::Concern + + # EE only features are listed on EE::IssueAvailableFeatures + def available_features_for_issue_types + {}.with_indifferent_access + end + + def issue_type_supports?(feature) + unless available_features_for_issue_types.has_key?(feature) + raise ArgumentError, 'invalid feature' + end + + available_features_for_issue_types[feature].include?(issue_type) + end +end + +IssueAvailableFeatures.prepend_if_ee('EE::IssueAvailableFeatures') diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb index 7b4485376d4..b10e8547e86 100644 --- a/app/models/concerns/mentionable.rb +++ b/app/models/concerns/mentionable.rb @@ -81,13 +81,6 @@ module Mentionable end def store_mentions! - # if store_mentioned_users_to_db feature flag is not enabled then consider storing operation as succeeded - # because we wrap this method in transaction with with_transaction_returning_status, and we need the status to be - # successful if mentionable.save is successful. - # - # This line will get removed when we remove the feature flag. - return true unless store_mentioned_users_to_db_enabled? - refs = all_references(self.author) references = {} @@ -253,15 +246,6 @@ module Mentionable def model_user_mention user_mentions.where(note_id: nil).first_or_initialize end - - # We need this method to be checking that store_mentioned_users_to_db feature flag is enabled at the group level - # and not the project level as epics are defined at group level and we want to have epics store user mentions as well - # for the test period. - # During the test period the flag should be enabled at the group level. - def store_mentioned_users_to_db_enabled? - return Feature.enabled?(:store_mentioned_users_to_db, self.project&.group, default_enabled: true) if self.respond_to?(:project) - return Feature.enabled?(:store_mentioned_users_to_db, self.group, default_enabled: true) if self.respond_to?(:group) - end end Mentionable.prepend_if_ee('EE::Mentionable') diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb index 5f30fc0c36c..26f1544103c 100644 --- a/app/models/concerns/reactive_caching.rb +++ b/app/models/concerns/reactive_caching.rb @@ -9,7 +9,7 @@ module ReactiveCaching ExceededReactiveCacheLimit = Class.new(StandardError) WORK_TYPE = { - default: ReactiveCachingWorker, + no_dependency: ReactiveCachingWorker, external_dependency: ExternalServiceReactiveCachingWorker }.freeze @@ -30,7 +30,6 @@ module ReactiveCaching self.reactive_cache_refresh_interval = 1.minute self.reactive_cache_lifetime = 10.minutes 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) end diff --git a/app/models/concerns/reactive_service.rb b/app/models/concerns/reactive_service.rb index af69da24994..c444f238944 100644 --- a/app/models/concerns/reactive_service.rb +++ b/app/models/concerns/reactive_service.rb @@ -8,5 +8,6 @@ module ReactiveService # Default cache key: class name + project_id self.reactive_cache_key = ->(service) { [service.class.model_name.singular, service.project_id] } + self.reactive_cache_work_type = :external_dependency end end diff --git a/app/models/concerns/referable.rb b/app/models/concerns/referable.rb index 40edd3b3ead..9a17131c91c 100644 --- a/app/models/concerns/referable.rb +++ b/app/models/concerns/referable.rb @@ -85,7 +85,7 @@ module Referable \/#{route.is_a?(Regexp) ? route : Regexp.escape(route)} \/#{pattern} (?<path> - (\/[a-z0-9_=-]+)* + (\/[a-z0-9_=-]+)*\/* )? (?<query> \?[a-z0-9_=-]+ diff --git a/app/models/concerns/relative_positioning.rb b/app/models/concerns/relative_positioning.rb index 3cbc174536c..b62eb50840d 100644 --- a/app/models/concerns/relative_positioning.rb +++ b/app/models/concerns/relative_positioning.rb @@ -200,4 +200,10 @@ module RelativePositioning # Override if you want to be notified of failures to move def could_not_move(exception) end + + # Override if the implementing class is not a simple application record, for + # example if the record is loaded from a union. + def reset_relative_position + reset.relative_position + end end diff --git a/app/models/concerns/update_project_statistics.rb b/app/models/concerns/update_project_statistics.rb index a7028e18451..586f1dbb65c 100644 --- a/app/models/concerns/update_project_statistics.rb +++ b/app/models/concerns/update_project_statistics.rb @@ -80,10 +80,7 @@ module UpdateProjectStatistics run_after_commit do ProjectStatistics.increment_statistic( - project_id, self.class.project_statistics_name, delta) - - Namespaces::ScheduleAggregationWorker.perform_async( - project.namespace_id) + project, self.class.project_statistics_name, delta) end end end diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb index b0f7edac2f3..d97b8776085 100644 --- a/app/models/container_repository.rb +++ b/app/models/container_repository.rb @@ -107,6 +107,14 @@ class ContainerRepository < ApplicationRecord client.delete_repository_tag_by_name(self.path, name) end + def reset_expiration_policy_started_at! + update!(expiration_policy_started_at: nil) + end + + def start_expiration_policy! + update!(expiration_policy_started_at: Time.zone.now) + end + def self.build_from_path(path) self.new(project: path.repository_project, name: path.repository_name) diff --git a/app/models/data_list.rb b/app/models/data_list.rb index 2cee3447886..adad8e3013e 100644 --- a/app/models/data_list.rb +++ b/app/models/data_list.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true class DataList - def initialize(batch_ids, data_fields_hash, klass) - @batch_ids = batch_ids + def initialize(batch, data_fields_hash, klass) + @batch = batch @data_fields_hash = data_fields_hash @klass = klass end @@ -13,15 +13,15 @@ class DataList private - attr_reader :batch_ids, :data_fields_hash, :klass + attr_reader :batch, :data_fields_hash, :klass def columns data_fields_hash.keys << 'service_id' end def values - batch_ids.map do |row| - data_fields_hash.values << row['id'] + batch.map do |record| + data_fields_hash.values << record['id'] end end end diff --git a/app/models/design_management/design.rb b/app/models/design_management/design.rb index 57bb250829d..62e4bd6cebc 100644 --- a/app/models/design_management/design.rb +++ b/app/models/design_management/design.rb @@ -167,6 +167,10 @@ module DesignManagement end end + def self.build_full_path(issue, design) + File.join(DesignManagement.designs_directory, "issue-#{issue.iid}", design.filename) + end + def to_ability_name 'design' end @@ -180,7 +184,7 @@ module DesignManagement end def full_path - @full_path ||= File.join(DesignManagement.designs_directory, "issue-#{issue.iid}", filename) + @full_path ||= self.class.build_full_path(issue, self) end def diff_refs @@ -224,6 +228,10 @@ module DesignManagement !interloper.exists? end + def notes_with_associations + notes.includes(:author) + end + private def head_version diff --git a/app/models/design_management/design_collection.rb b/app/models/design_management/design_collection.rb index c48b36588c9..6deba14a6ba 100644 --- a/app/models/design_management/design_collection.rb +++ b/app/models/design_management/design_collection.rb @@ -5,6 +5,7 @@ module DesignManagement attr_reader :issue delegate :designs, :project, to: :issue + delegate :empty?, to: :designs state_machine :copy_state, initial: :ready, namespace: :copy do after_transition any => any, do: :update_stored_copy_state! diff --git a/app/models/environment.rb b/app/models/environment.rb index cfdcb0499e6..f64776a6991 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -86,6 +86,7 @@ class Environment < ApplicationRecord scope :with_rank, -> do select('environments.*, rank() OVER (PARTITION BY project_id ORDER BY id DESC)') end + scope :for_id, -> (id) { where(id: id) } state_machine :state, initial: :available do event :start do diff --git a/app/models/event.rb b/app/models/event.rb index 92609144576..671def16151 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -242,6 +242,8 @@ class Event < ApplicationRecord target if note? end + # rubocop: disable Metrics/CyclomaticComplexity + # rubocop: disable Metrics/PerceivedComplexity def action_name if push_action? push_action_name @@ -267,10 +269,14 @@ class Event < ApplicationRecord 'updated' elsif created_project_action? created_project_action_name + elsif approved_action? + 'approved' else "opened" end end + # rubocop: enable Metrics/CyclomaticComplexity + # rubocop: enable Metrics/PerceivedComplexity def target_iid target.respond_to?(:iid) ? target.iid : target_id @@ -323,14 +329,6 @@ class Event < ApplicationRecord end end - def note_target_type - if target.noteable_type.present? - target.noteable_type.titleize - else - "Wall" - end.downcase - end - def body? if push_action? push_with_commits? diff --git a/app/models/group.rb b/app/models/group.rb index c0f145997cc..1dec831606b 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -15,11 +15,10 @@ class Group < Namespace include WithUploads include Gitlab::Utils::StrongMemoize include GroupAPICompatibility + include EachBatch ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT = 10 - UpdateSharedRunnersError = Class.new(StandardError) - has_many :all_group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source, class_name: 'GroupMember' # rubocop:disable Cop/ActiveRecordDependent has_many :group_members, -> { where(requested_at: nil).where.not(members: { access_level: Gitlab::Access::MINIMAL_ACCESS }) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent alias_method :members, :group_members @@ -140,6 +139,15 @@ class Group < Namespace end end + def without_integration(integration) + services = Service + .select('1') + .where('services.group_id = namespaces.id') + .where(type: integration.type) + + where('NOT EXISTS (?)', services) + end + private def public_to_user_arel(user) @@ -348,6 +356,7 @@ class Group < Namespace end group_hierarchy_members = GroupMember.active_without_invites_and_requests + .non_minimal_access .where(source_id: source_ids) GroupMember.from_union([group_hierarchy_members, @@ -528,57 +537,26 @@ 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? + def update_shared_runners_setting!(state) + raise ArgumentError unless SHARED_RUNNERS_SETTINGS.include?(state) - 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) + case state + when 'disabled_and_unoverridable' then disable_shared_runners! # also disallows override + when 'disabled_with_override' then disable_shared_runners_and_allow_override! + when 'enabled' then enable_shared_runners! # set both to true + end 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) + def default_owner + owners.first || parent&.default_owner || owner 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) + def access_level_roles + GroupMember.access_level_roles end - def default_owner - owners.first || parent&.default_owner || owner + def access_level_values + access_level_roles.values end private @@ -658,6 +636,45 @@ class Group < Namespace .new(Group.where(id: group_ids)) .base_and_descendants end + + def disable_shared_runners! + update!( + shared_runners_enabled: false, + allow_descendants_override_disabled_shared_runners: false) + + group_ids = descendants + unless group_ids.empty? + Group.by_id(group_ids).update_all( + shared_runners_enabled: false, + allow_descendants_override_disabled_shared_runners: false) + end + + all_projects.update_all(shared_runners_enabled: false) + end + + def disable_shared_runners_and_allow_override! + # enabled -> disabled_with_override + if shared_runners_enabled? + update!( + shared_runners_enabled: false, + allow_descendants_override_disabled_shared_runners: true) + + group_ids = descendants + unless group_ids.empty? + Group.by_id(group_ids).update_all(shared_runners_enabled: false) + end + + all_projects.update_all(shared_runners_enabled: false) + + # disabled_and_unoverridable -> disabled_with_override + else + update!(allow_descendants_override_disabled_shared_runners: true) + end + end + + def enable_shared_runners! + update!(shared_runners_enabled: true) + end end Group.prepend_if_ee('EE::Group') diff --git a/app/models/group_import_state.rb b/app/models/group_import_state.rb index d22c1ac5550..89602e40357 100644 --- a/app/models/group_import_state.rb +++ b/app/models/group_import_state.rb @@ -4,8 +4,9 @@ class GroupImportState < ApplicationRecord self.primary_key = :group_id belongs_to :group, inverse_of: :import_state + belongs_to :user, optional: false - validates :group, :status, presence: true + validates :group, :status, :user, presence: true validates :jid, presence: true, if: -> { started? || finished? } state_machine :status, initial: :created do diff --git a/app/models/issuable_severity.rb b/app/models/issuable_severity.rb index d68b3dc48ee..35d03a544bd 100644 --- a/app/models/issuable_severity.rb +++ b/app/models/issuable_severity.rb @@ -2,6 +2,13 @@ class IssuableSeverity < ApplicationRecord DEFAULT = 'unknown' + SEVERITY_LABELS = { + unknown: 'Unknown', + low: 'Low - S4', + medium: 'Medium - S3', + high: 'High - S2', + critical: 'Critical - S1' + }.freeze belongs_to :issue diff --git a/app/models/issue.rb b/app/models/issue.rb index 5a5de371301..621b1a83b82 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -19,6 +19,8 @@ class Issue < ApplicationRecord include WhereComposite include StateEventable include IdInOrdered + include Presentable + include IssueAvailableFeatures DueDateStruct = Struct.new(:title, :name).freeze NoDueDate = DueDateStruct.new('No Due Date', '0').freeze @@ -54,6 +56,7 @@ class Issue < ApplicationRecord dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_many :issue_assignees + has_many :issue_email_participants has_many :assignees, class_name: "User", through: :issue_assignees has_many :zoom_meetings has_many :user_mentions, class_name: "IssueUserMention", dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent @@ -101,6 +104,8 @@ class Issue < ApplicationRecord scope :order_relative_position_asc, -> { reorder(::Gitlab::Database.nulls_last_order('relative_position', 'ASC')) } scope :order_closed_date_desc, -> { reorder(closed_at: :desc) } scope :order_created_at_desc, -> { reorder(created_at: :desc) } + scope :order_severity_asc, -> { includes(:issuable_severity).order('issuable_severities.severity ASC NULLS FIRST') } + scope :order_severity_desc, -> { includes(:issuable_severity).order('issuable_severities.severity DESC NULLS LAST') } scope :preload_associated_models, -> { preload(:assignees, :labels, project: :namespace) } scope :with_web_entity_associations, -> { preload(:author, :project) } @@ -122,6 +127,7 @@ class Issue < ApplicationRecord scope :counts_by_state, -> { reorder(nil).group(:state_id).count } scope :service_desk, -> { where(author: ::User.support_bot) } + scope :inc_relations_for_view, -> { includes(author: :status) } # 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 @@ -145,6 +151,7 @@ class Issue < ApplicationRecord after_commit :expire_etag_cache, unless: :importing? after_save :ensure_metrics, unless: :importing? + after_create_commit :record_create_action, unless: :importing? attr_spammable :title, spam_title: true attr_spammable :description, spam_description: true @@ -232,6 +239,8 @@ class Issue < ApplicationRecord when 'due_date', 'due_date_asc' then order_due_date_asc.with_order_id_desc when 'due_date_desc' then order_due_date_desc.with_order_id_desc when 'relative_position', 'relative_position_asc' then order_relative_position_asc.with_order_id_desc + when 'severity_asc' then order_severity_asc.with_order_id_desc + when 'severity_desc' then order_severity_desc.with_order_id_desc else super end @@ -413,6 +422,10 @@ class Issue < ApplicationRecord IssueLink.inverse_link_type(type) end + def relocation_target + moved_to || duplicated_to + end + private def ensure_metrics @@ -420,6 +433,10 @@ class Issue < ApplicationRecord metrics.record! end + def record_create_action + Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_created_action(author: author) + end + # Returns `true` if the given User can read the current Issue. # # This method duplicates the same check of issue_policy.rb diff --git a/app/models/issue_email_participant.rb b/app/models/issue_email_participant.rb new file mode 100644 index 00000000000..8eb9b6a8152 --- /dev/null +++ b/app/models/issue_email_participant.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class IssueEmailParticipant < ApplicationRecord + belongs_to :issue + + validates :email, presence: true, uniqueness: { scope: [:issue_id] } + validates :issue, presence: true + validate :validate_email_format + + def validate_email_format + self.errors.add(:email, I18n.t(:invalid, scope: 'valid_email.validations.email')) unless ValidateEmail.valid?(self.email) + end +end diff --git a/app/models/iteration.rb b/app/models/iteration.rb index d223c80fca0..bd245de411c 100644 --- a/app/models/iteration.rb +++ b/app/models/iteration.rb @@ -94,13 +94,25 @@ class Iteration < ApplicationRecord private + def parent_group + group || project.group + end + def start_or_due_dates_changed? start_date_changed? || due_date_changed? end - # ensure dates do not overlap with other Iterations in the same group/project + # ensure dates do not overlap with other Iterations in the same group/project tree def dates_do_not_overlap - return unless resource_parent.iterations.where.not(id: self.id).within_timeframe(start_date, due_date).exists? + iterations = if parent_group.present? && resource_parent.is_a?(Project) + Iteration.where(group: parent_group.self_and_ancestors).or(project.iterations) + elsif parent_group.present? + Iteration.where(group: parent_group.self_and_ancestors) + else + project.iterations + end + + return unless 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/member.rb b/app/models/member.rb index 7ea9caa45d3..498e03b2c1a 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -80,7 +80,10 @@ class Member < ApplicationRecord scope :request, -> { where.not(requested_at: nil) } scope :non_request, -> { where(requested_at: nil) } - scope :not_accepted_invitations_by_user, -> (user) { invite.where(invite_accepted_at: nil, created_by: user) } + scope :not_accepted_invitations, -> { invite.where(invite_accepted_at: nil) } + scope :not_accepted_invitations_by_user, -> (user) { not_accepted_invitations.where(created_by: user) } + scope :not_expired, -> (today = Date.current) { where(arel_table[:expires_at].gt(today).or(arel_table[:expires_at].eq(nil))) } + scope :last_ten_days_excluding_today, -> (today = Date.current) { where(created_at: (today - 10).beginning_of_day..(today - 1).end_of_day) } scope :has_access, -> { active.where('access_level > 0') } @@ -372,6 +375,14 @@ class Member < ApplicationRecord send_invite end + def send_invitation_reminder(reminder_index) + return unless invite? + + generate_invite_token! unless @raw_invite_token + + run_after_commit_or_now { notification_service.invite_member_reminder(self, @raw_invite_token, reminder_index) } + end + def create_notification_setting user.notification_settings.find_or_create_for(source) end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 3fdc501644d..22c6777fca3 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -31,6 +31,7 @@ class MergeRequest < ApplicationRecord 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_work_type = :no_dependency SORTING_PREFERENCE_FIELD = :merge_requests_sort @@ -121,6 +122,8 @@ class MergeRequest < ApplicationRecord # when creating new merge request attr_accessor :can_be_created, :compare_commits, :diff_options, :compare + participant :reviewers + # Keep states definition to be evaluated before the state_machine block to avoid spec failures. # If this gets evaluated after, the `merged` and `locked` states which are overrided can be nil. def self.available_state_names @@ -255,11 +258,7 @@ class MergeRequest < ApplicationRecord scope :join_project, -> { joins(:target_project) } scope :join_metrics, -> do query = joins(:metrics) - - if Feature.enabled?(:improved_mr_merged_at_queries, default_enabled: true) - query = query.where(MergeRequest.arel_table[:target_project_id].eq(MergeRequest::Metrics.arel_table[:target_project_id])) - end - + query = query.where(MergeRequest.arel_table[:target_project_id].eq(MergeRequest::Metrics.arel_table[:target_project_id])) query end scope :references_project, -> { references(:target_project) } @@ -271,6 +270,8 @@ class MergeRequest < ApplicationRecord metrics: [:latest_closed_by, :merged_by]) } + scope :with_csv_entity_associations, -> { preload(:assignees, :approved_by_users, :author, :milestone, metrics: [:merged_by]) } + scope :by_target_branch_wildcard, ->(wildcard_branch_name) do where("target_branch LIKE ?", ApplicationRecord.sanitize_sql_like(wildcard_branch_name).tr('*', '%')) end @@ -629,7 +630,7 @@ class MergeRequest < ApplicationRecord def diff_size # Calling `merge_request_diff.diffs.real_size` will also perform # highlighting, which we don't need here. - merge_request_diff&.real_size || diff_stats&.real_size || diffs.real_size + merge_request_diff&.real_size || diff_stats&.real_size(project: project) || diffs.real_size end def modified_paths(past_merge_request_diff: nil, fallback_on_overflow: false) @@ -1301,6 +1302,14 @@ class MergeRequest < ApplicationRecord unlock_mr end + def update_and_mark_in_progress_merge_commit_sha(commit_id) + self.update(in_progress_merge_commit_sha: commit_id) + # Since another process checks for matching merge request, we need + # to make it possible to detect whether the query should go to the + # primary. + target_project.mark_primary_write_location + end + def diverged_commits_count cache = Rails.cache.read(:"merge_request_#{id}_diverged_commits") @@ -1375,8 +1384,6 @@ class MergeRequest < ApplicationRecord end def has_coverage_reports? - return false unless Feature.enabled?(:coverage_report_view, project, default_enabled: true) - actual_head_pipeline&.has_coverage_reports? end @@ -1511,6 +1518,7 @@ class MergeRequest < ApplicationRecord metrics&.merged_at || merge_event&.created_at || + resource_state_events.find_by(state: :merged)&.created_at || notes.system.reorder(nil).find_by(note: 'merged')&.created_at end end @@ -1680,6 +1688,10 @@ class MergeRequest < ApplicationRecord Feature.enabled?(:merge_request_reviewers, project) end + def allows_multiple_reviewers? + false + end + private def with_rebase_lock diff --git a/app/models/merge_request_context_commit.rb b/app/models/merge_request_context_commit.rb index a2982a5dd73..59cc82cfaf5 100644 --- a/app/models/merge_request_context_commit.rb +++ b/app/models/merge_request_context_commit.rb @@ -22,8 +22,8 @@ class MergeRequestContextCommit < ApplicationRecord end # create MergeRequestContextCommit by given commit sha and it's diff file record - def self.bulk_insert(*args) - Gitlab::Database.bulk_insert('merge_request_context_commits', *args) # rubocop:disable Gitlab/BulkInsert + def self.bulk_insert(rows, **args) + Gitlab::Database.bulk_insert('merge_request_context_commits', rows, **args) # rubocop:disable Gitlab/BulkInsert end def to_commit diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 880e3cc1ba5..3cf0db9403d 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -509,6 +509,8 @@ class MergeRequestDiff < ApplicationRecord end def encode_in_base64?(diff_text) + return false if diff_text.nil? + (diff_text.encoding == Encoding::BINARY && !diff_text.ascii_only?) || diff_text.include?("\0") end @@ -536,7 +538,7 @@ class MergeRequestDiff < ApplicationRecord rows.each do |row| data = row.delete(:diff) row[:external_diff_offset] = file.pos - row[:external_diff_size] = data.bytesize + row[:external_diff_size] = data&.bytesize || 0 file.write(data) end @@ -651,7 +653,7 @@ class MergeRequestDiff < ApplicationRecord if compare.commits.empty? new_attributes[:state] = :empty else - diff_collection = compare.diffs(Commit.max_diff_options) + diff_collection = compare.diffs(Commit.max_diff_options(project: merge_request.project)) new_attributes[:real_size] = diff_collection.real_size if diff_collection.any? diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 527fa9d52d0..f0550713d01 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -18,6 +18,8 @@ class Namespace < ApplicationRecord # Android repo (15) + some extra backup. NUMBER_OF_ANCESTORS_ALLOWED = 20 + SHARED_RUNNERS_SETTINGS = %w[disabled_and_unoverridable disabled_with_override enabled].freeze + cache_markdown_field :description, pipeline: :description has_many :projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent @@ -59,6 +61,8 @@ class Namespace < ApplicationRecord validates :max_artifacts_size, numericality: { only_integer: true, greater_than: 0, allow_nil: true } validate :nesting_level_allowed + validate :changing_shared_runners_enabled_is_allowed + validate :changing_allow_descendants_override_disabled_shared_runners_is_allowed validates_associated :runners @@ -378,6 +382,52 @@ class Namespace < ApplicationRecord actual_plan.name end + def changing_shared_runners_enabled_is_allowed + return unless Feature.enabled?(:disable_shared_runners_on_group, default_enabled: true) + return unless new_record? || changes.has_key?(:shared_runners_enabled) + + if shared_runners_enabled && has_parent? && parent.shared_runners_setting == 'disabled_and_unoverridable' + errors.add(:shared_runners_enabled, _('cannot be enabled because parent group has shared Runners disabled')) + end + end + + def changing_allow_descendants_override_disabled_shared_runners_is_allowed + return unless Feature.enabled?(:disable_shared_runners_on_group, default_enabled: true) + return unless new_record? || changes.has_key?(:allow_descendants_override_disabled_shared_runners) + + if shared_runners_enabled && !new_record? + errors.add(:allow_descendants_override_disabled_shared_runners, _('cannot be changed if shared runners are enabled')) + end + + if allow_descendants_override_disabled_shared_runners && has_parent? && parent.shared_runners_setting == 'disabled_and_unoverridable' + errors.add(:allow_descendants_override_disabled_shared_runners, _('cannot be enabled because parent group does not allow it')) + end + end + + def shared_runners_setting + if shared_runners_enabled + 'enabled' + else + if allow_descendants_override_disabled_shared_runners + 'disabled_with_override' + else + 'disabled_and_unoverridable' + end + end + end + + def shared_runners_setting_higher_than?(other_setting) + if other_setting == 'enabled' + false + elsif other_setting == 'disabled_with_override' + shared_runners_setting == 'enabled' + elsif other_setting == 'disabled_and_unoverridable' + shared_runners_setting == 'enabled' || shared_runners_setting == 'disabled_with_override' + else + raise ArgumentError + end + end + private def all_projects_with_pages diff --git a/app/models/note.rb b/app/models/note.rb index 812d77d5f86..954843505d4 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -322,8 +322,6 @@ class Note < ApplicationRecord end def contributor? - return false unless ::Feature.enabled?(:show_contributor_on_note, project) - project&.team&.contributor?(self.author_id) end diff --git a/app/models/notification_reason.rb b/app/models/notification_reason.rb index a7967239417..c227626af9e 100644 --- a/app/models/notification_reason.rb +++ b/app/models/notification_reason.rb @@ -5,6 +5,7 @@ class NotificationReason OWN_ACTIVITY = 'own_activity' ASSIGNED = 'assigned' + REVIEW_REQUESTED = 'review_requested' MENTIONED = 'mentioned' SUBSCRIBED = 'subscribed' @@ -12,6 +13,7 @@ class NotificationReason REASON_PRIORITY = [ OWN_ACTIVITY, ASSIGNED, + REVIEW_REQUESTED, MENTIONED, SUBSCRIBED ].freeze diff --git a/app/models/notification_recipient.rb b/app/models/notification_recipient.rb index 6a6b2bb1b58..79a84231083 100644 --- a/app/models/notification_recipient.rb +++ b/app/models/notification_recipient.rb @@ -5,7 +5,7 @@ class NotificationRecipient attr_reader :user, :type, :reason - def initialize(user, type, **opts) + def initialize(user, type, opts = {}) unless NotificationSetting.levels.key?(type) || type == :subscription raise ArgumentError, "invalid type: #{type.inspect}" end diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index c003a20f0fc..6066046a722 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -43,6 +43,7 @@ class NotificationSetting < ApplicationRecord :reopen_merge_request, :close_merge_request, :reassign_merge_request, + :change_reviewer_merge_request, :merge_merge_request, :failed_pipeline, :fixed_pipeline, diff --git a/app/models/operations/feature_flags/strategy.rb b/app/models/operations/feature_flags/strategy.rb index ff68af9741e..c70e10c72d5 100644 --- a/app/models/operations/feature_flags/strategy.rb +++ b/app/models/operations/feature_flags/strategy.rb @@ -6,14 +6,17 @@ module Operations STRATEGY_DEFAULT = 'default' STRATEGY_GITLABUSERLIST = 'gitlabUserList' STRATEGY_GRADUALROLLOUTUSERID = 'gradualRolloutUserId' + STRATEGY_FLEXIBLEROLLOUT = 'flexibleRollout' STRATEGY_USERWITHID = 'userWithId' STRATEGIES = { STRATEGY_DEFAULT => [].freeze, STRATEGY_GITLABUSERLIST => [].freeze, STRATEGY_GRADUALROLLOUTUSERID => %w[groupId percentage].freeze, + STRATEGY_FLEXIBLEROLLOUT => %w[groupId rollout stickiness].freeze, STRATEGY_USERWITHID => ['userIds'].freeze }.freeze USERID_MAX_LENGTH = 256 + STICKINESS_SETTINGS = %w[DEFAULT USERID SESSIONID RANDOM].freeze self.table_name = 'operations_strategies' @@ -67,16 +70,25 @@ module Operations case name when STRATEGY_GRADUALROLLOUTUSERID gradual_rollout_user_id_parameters_validation + when STRATEGY_FLEXIBLEROLLOUT + flexible_rollout_parameters_validation when STRATEGY_USERWITHID FeatureFlagUserXidsValidator.validate_user_xids(self, :parameters, parameters['userIds'], 'userIds') end end + def within_range?(value, min, max) + return false unless value.is_a?(String) + return false unless value.match?(/\A\d+\z/) + + value.to_i.between?(min, max) + end + def gradual_rollout_user_id_parameters_validation percentage = parameters['percentage'] group_id = parameters['groupId'] - unless percentage.is_a?(String) && percentage.match(/\A[1-9]?[0-9]\z|\A100\z/) + unless within_range?(percentage, 0, 100) parameters_error('percentage must be a string between 0 and 100 inclusive') end @@ -85,6 +97,25 @@ module Operations end end + def flexible_rollout_parameters_validation + stickiness = parameters['stickiness'] + group_id = parameters['groupId'] + rollout = parameters['rollout'] + + unless STICKINESS_SETTINGS.include?(stickiness) + options = STICKINESS_SETTINGS.to_sentence(last_word_connector: ', or ') + parameters_error("stickiness parameter must be #{options}") + end + + unless group_id.is_a?(String) && group_id.match(/\A[a-z]{1,32}\z/) + parameters_error('groupId parameter is invalid') + end + + unless within_range?(rollout, 0, 100) + parameters_error('rollout must be a string between 0 and 100 inclusive') + end + end + def parameters_error(message) errors.add(:parameters, message) false diff --git a/app/models/packages/event.rb b/app/models/packages/event.rb new file mode 100644 index 00000000000..730ce267273 --- /dev/null +++ b/app/models/packages/event.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class Packages::Event < ApplicationRecord + belongs_to :package, optional: true + + # FIXME: Remove debian: 9 from here when it's added to the types in package.rb model + EVENT_SCOPES = ::Packages::Package.package_types.merge(debian: 9, container: 1000, tag: 1001).freeze + + enum event_scope: EVENT_SCOPES + + enum event_type: { + push_package: 0, + delete_package: 1, + pull_package: 2, + search_package: 3, + list_package: 4, + list_repositories: 5, + delete_repository: 6, + delete_tag: 7, + delete_tag_bulk: 8, + list_tags: 9, + cli_metadata: 10 + } + + enum originator_type: { user: 0, deploy_token: 1, guest: 2 } +end diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb index bda11160957..caf2522e3dd 100644 --- a/app/models/packages/package.rb +++ b/app/models/packages/package.rb @@ -26,7 +26,7 @@ class Packages::Package < ApplicationRecord validates :project, presence: true validates :name, presence: true - validates :name, format: { with: Gitlab::Regex.package_name_regex }, unless: :conan? + validates :name, format: { with: Gitlab::Regex.package_name_regex }, unless: -> { conan? || generic? } validates :name, uniqueness: { scope: %i[project_id version package_type] }, unless: :conan? @@ -35,17 +35,19 @@ class Packages::Package < ApplicationRecord 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 :name, format: { with: Gitlab::Regex.generic_package_name_regex }, if: :generic? + validates :version, format: { with: Gitlab::Regex.semver_regex }, if: -> { npm? || nuget? } validates :version, format: { with: Gitlab::Regex.conan_recipe_component_regex }, if: :conan? validates :version, format: { with: Gitlab::Regex.maven_version_regex }, if: -> { version? && maven? } validates :version, format: { with: Gitlab::Regex.pypi_version_regex }, if: :pypi? + validates :version, format: { with: Gitlab::Regex.prefixed_semver_regex }, if: :golang? validates :version, presence: true, format: { with: Gitlab::Regex.generic_package_version_regex }, if: :generic? - enum package_type: { maven: 1, npm: 2, conan: 3, nuget: 4, pypi: 5, composer: 6, generic: 7 } + enum package_type: { maven: 1, npm: 2, conan: 3, nuget: 4, pypi: 5, composer: 6, generic: 7, golang: 8 } scope :with_name, ->(name) { where(name: name) } scope :with_name_like, ->(name) { where(arel_table[:name].matches(name)) } @@ -119,6 +121,10 @@ class Packages::Package < ApplicationRecord .where(packages_package_files: { file_name: file_name, file_sha256: sha256 }).last! end + def self.by_name_and_version!(name, version) + find_by!(name: name, version: version) + end + def self.pluck_names pluck(:name) end diff --git a/app/models/pages_deployment.rb b/app/models/pages_deployment.rb index 78e0f185a11..d8f122cfb23 100644 --- a/app/models/pages_deployment.rb +++ b/app/models/pages_deployment.rb @@ -2,10 +2,14 @@ # PagesDeployment stores a zip archive containing GitLab Pages web-site class PagesDeployment < ApplicationRecord + include FileStoreMounter + belongs_to :project, optional: false belongs_to :ci_build, class_name: 'Ci::Build', optional: true validates :file, presence: true validates :file_store, presence: true, inclusion: { in: ObjectStorage::SUPPORTED_STORES } validates :size, presence: true, numericality: { greater_than: 0, only_integer: true } + + mount_file_store_uploader ::Pages::DeploymentUploader end diff --git a/app/models/postgresql/replication_slot.rb b/app/models/postgresql/replication_slot.rb index a4370eda5ba..c96786423e5 100644 --- a/app/models/postgresql/replication_slot.rb +++ b/app/models/postgresql/replication_slot.rb @@ -22,8 +22,8 @@ module Postgresql def self.lag_too_great?(max = 100.megabytes) return false unless in_use? - lag_function = "#{Gitlab::Database.pg_wal_lsn_diff}" \ - "(#{Gitlab::Database.pg_current_wal_insert_lsn}(), restart_lsn)::bigint" + lag_function = "pg_wal_lsn_diff" \ + "(pg_current_wal_insert_lsn(), restart_lsn)::bigint" # We force the use of a transaction here so the query always goes to the # primary, even when using the EE DB load balancer. diff --git a/app/models/project.rb b/app/models/project.rb index 4db0eaa0442..52cad50809f 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -33,6 +33,7 @@ class Project < ApplicationRecord include FromUnion include IgnorableColumns include Integration + include EachBatch extend Gitlab::Cache::RequestCache extend Gitlab::ConfigHelper @@ -198,6 +199,7 @@ class Project < ApplicationRecord has_one :import_export_upload, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :export_jobs, class_name: 'ProjectExportJob' has_one :project_repository, inverse_of: :project + has_one :tracing_setting, class_name: 'ProjectTracingSetting' has_one :incident_management_setting, inverse_of: :project, class_name: 'IncidentManagement::ProjectIncidentManagementSetting' has_one :error_tracking_setting, inverse_of: :project, class_name: 'ErrorTracking::ProjectErrorTrackingSetting' has_one :metrics_setting, inverse_of: :project, class_name: 'ProjectMetricsSetting' @@ -268,6 +270,7 @@ class Project < ApplicationRecord has_many :metrics_users_starred_dashboards, class_name: 'Metrics::UsersStarredDashboard', inverse_of: :project has_many :alert_management_alerts, class_name: 'AlertManagement::Alert', inverse_of: :project + has_many :alert_management_http_integrations, class_name: 'AlertManagement::HttpIntegration', inverse_of: :project # Container repositories need to remove data from the container registry, # which is not managed by the DB. Hence we're still using dependent: :destroy @@ -336,6 +339,8 @@ class Project < ApplicationRecord has_many :webide_pipelines, -> { webide_source }, class_name: 'Ci::Pipeline', inverse_of: :project has_many :reviews, inverse_of: :project + has_many :terraform_states, class_name: 'Terraform::State', inverse_of: :project + # GitLab Pages has_many :pages_domains has_one :pages_metadatum, class_name: 'ProjectPagesMetadatum', inverse_of: :project @@ -432,6 +437,7 @@ class Project < ApplicationRecord validate :visibility_level_allowed_by_group, if: :should_validate_visibility_level? validate :visibility_level_allowed_as_fork, if: :should_validate_visibility_level? validate :validate_pages_https_only, if: -> { changes.has_key?(:pages_https_only) } + validate :changing_shared_runners_enabled_is_allowed validates :repository_storage, presence: true, inclusion: { in: ->(_object) { Gitlab.config.repositories.storages.keys } } @@ -560,6 +566,7 @@ class Project < ApplicationRecord } scope :imported_from, -> (type) { where(import_type: type) } + scope :with_tracing_enabled, -> { joins(:tracing_setting) } enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 } @@ -1186,6 +1193,15 @@ class Project < ApplicationRecord end end + def changing_shared_runners_enabled_is_allowed + return unless Feature.enabled?(:disable_shared_runners_on_group, default_enabled: true) + return unless new_record? || changes.has_key?(:shared_runners_enabled) + + if shared_runners_enabled && group && group.shared_runners_setting == 'disabled_and_unoverridable' + errors.add(:shared_runners_enabled, _('cannot be enabled because parent group does not allow it')) + end + end + def to_param if persisted? && errors.include?(:path) path_was @@ -2292,6 +2308,10 @@ class Project < ApplicationRecord [] end + def mark_primary_write_location + # Overriden in EE + end + def toggle_ci_cd_settings!(settings_attribute) ci_cd_settings.toggle!(settings_attribute) end @@ -2501,6 +2521,15 @@ class Project < ApplicationRecord GroupDeployKey.for_groups(group.self_and_ancestors_ids) end + def feature_flags_client_token + instance = operations_feature_flags_client || create_operations_feature_flags_client! + instance.token + end + + def tracing_external_url + tracing_setting&.external_url + end + private def find_service(services, name) @@ -2509,10 +2538,10 @@ class Project < ApplicationRecord def build_from_instance_or_template(name) instance = find_service(services_instances, name) - return Service.build_from_integration(id, instance) if instance + return Service.build_from_integration(instance, project_id: id) if instance template = find_service(services_templates, name) - return Service.build_from_integration(id, template) if template + return Service.build_from_integration(template, project_id: id) if template end def services_templates diff --git a/app/models/project_services/drone_ci_service.rb b/app/models/project_services/drone_ci_service.rb index 4e4955b45d8..5a49f780d46 100644 --- a/app/models/project_services/drone_ci_service.rb +++ b/app/models/project_services/drone_ci_service.rb @@ -42,7 +42,7 @@ class DroneCiService < CiService def commit_status_path(sha, ref) Gitlab::Utils.append_path( drone_url, - "gitlab/#{project.full_path}/commits/#{sha}?branch=#{URI.encode(ref.to_s)}&access_token=#{token}") + "gitlab/#{project.full_path}/commits/#{sha}?branch=#{Addressable::URI.encode_component(ref.to_s)}&access_token=#{token}") end def commit_status(sha, ref) @@ -75,7 +75,7 @@ class DroneCiService < CiService def build_page(sha, ref) Gitlab::Utils.append_path( drone_url, - "gitlab/#{project.full_path}/redirect/commits/#{sha}?branch=#{URI.encode(ref.to_s)}") + "gitlab/#{project.full_path}/redirect/commits/#{sha}?branch=#{Addressable::URI.encode_component(ref.to_s)}") end def title diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb index 67ab2c0ce8a..0d2f89fb18d 100644 --- a/app/models/project_statistics.rb +++ b/app/models/project_statistics.rb @@ -2,6 +2,7 @@ class ProjectStatistics < ApplicationRecord include AfterCommitQueue + include CounterAttribute belongs_to :project belongs_to :namespace @@ -9,6 +10,13 @@ class ProjectStatistics < ApplicationRecord default_value_for :wiki_size, 0 default_value_for :snippets_size, 0 + counter_attribute :build_artifacts_size + counter_attribute :storage_size + + counter_attribute_after_flush do |project_statistic| + Namespaces::ScheduleAggregationWorker.perform_async(project_statistic.namespace_id) + end + before_save :update_storage_size COLUMNS_TO_REFRESH = [:repository_size, :wiki_size, :lfs_objects_size, :commit_count, :snippets_size].freeze @@ -29,6 +37,8 @@ class ProjectStatistics < ApplicationRecord end def refresh!(only: []) + return if Gitlab::Database.read_only? + COLUMNS_TO_REFRESH.each do |column, generator| if only.empty? || only.include?(column) public_send("update_#{column}") # rubocop:disable GitlabSecurity/PublicSend @@ -96,12 +106,27 @@ class ProjectStatistics < ApplicationRecord # Additional columns are updated depending on key => [columns], which allows # to update statistics which are and also those which aren't included in storage_size # or any other additional summary column in the future. - def self.increment_statistic(project_id, key, amount) + def self.increment_statistic(project, key, amount) raise ArgumentError, "Cannot increment attribute: #{key}" unless INCREMENTABLE_COLUMNS.key?(key) return if amount == 0 - where(project_id: project_id) - .columns_to_increment(key, amount) + project.statistics.try do |project_statistics| + if project_statistics.counter_attribute_enabled?(key) + statistics_to_increment = [key] + INCREMENTABLE_COLUMNS[key].to_a + statistics_to_increment.each do |statistic| + project_statistics.delayed_increment_counter(statistic, amount) + end + else + legacy_increment_statistic(project, key, amount) + end + end + end + + def self.legacy_increment_statistic(project, key, amount) + where(project_id: project.id).columns_to_increment(key, amount) + + Namespaces::ScheduleAggregationWorker.perform_async( # rubocop: disable CodeReuse/Worker + project.namespace_id) end def self.columns_to_increment(key, amount) diff --git a/app/models/project_tracing_setting.rb b/app/models/project_tracing_setting.rb new file mode 100644 index 00000000000..93fa80aed67 --- /dev/null +++ b/app/models/project_tracing_setting.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class ProjectTracingSetting < ApplicationRecord + belongs_to :project + + validates :external_url, length: { maximum: 255 }, public_url: true + + before_validation :sanitize_external_url + + private + + def sanitize_external_url + self.external_url = Rails::Html::FullSanitizer.new.sanitize(self.external_url) + end +end diff --git a/app/models/prometheus_alert.rb b/app/models/prometheus_alert.rb index f0441d4a3cb..684f50d5f58 100644 --- a/app/models/prometheus_alert.rb +++ b/app/models/prometheus_alert.rb @@ -4,6 +4,7 @@ class PrometheusAlert < ApplicationRecord include Sortable include UsageStatistics include Presentable + include EachBatch OPERATORS_MAP = { lt: "<", @@ -35,6 +36,7 @@ class PrometheusAlert < ApplicationRecord scope :for_metric, -> (metric) { where(prometheus_metric: metric) } scope :for_project, -> (project) { where(project_id: project) } scope :for_environment, -> (environment) { where(environment_id: environment) } + scope :get_environment_id, -> { select(:environment_id).pluck(:environment_id) } def self.distinct_projects sub_query = self.group(:project_id).select(1) diff --git a/app/models/prometheus_metric.rb b/app/models/prometheus_metric.rb index 9ddf66cd388..590eda62c11 100644 --- a/app/models/prometheus_metric.rb +++ b/app/models/prometheus_metric.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class PrometheusMetric < ApplicationRecord + include EachBatch + belongs_to :project, validate: true, inverse_of: :prometheus_metrics has_many :prometheus_alerts, inverse_of: :prometheus_metric diff --git a/app/models/repository.rb b/app/models/repository.rb index ef17e010ba8..6bc2ec24811 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -853,7 +853,7 @@ class Repository def merge(user, source_sha, merge_request, message) with_cache_hooks do raw_repository.merge(user, source_sha, merge_request.target_branch, message) do |commit_id| - merge_request.update(in_progress_merge_commit_sha: commit_id) + merge_request.update_and_mark_in_progress_merge_commit_sha(commit_id) nil # Return value does not matter. end end @@ -873,7 +873,7 @@ class Repository their_commit_id = commit(source)&.id raise 'Invalid merge source' if their_commit_id.nil? - merge_request&.update(in_progress_merge_commit_sha: their_commit_id) + merge_request&.update_and_mark_in_progress_merge_commit_sha(their_commit_id) with_cache_hooks { raw.ff_merge(user, their_commit_id, target_branch) } end diff --git a/app/models/resource_label_event.rb b/app/models/resource_label_event.rb index cc96698be09..18e2944a9ca 100644 --- a/app/models/resource_label_event.rb +++ b/app/models/resource_label_event.rb @@ -15,6 +15,7 @@ class ResourceLabelEvent < ResourceEvent validate :exactly_one_issuable after_save :expire_etag_cache + after_save :usage_metrics after_destroy :expire_etag_cache enum action: { @@ -113,6 +114,16 @@ class ResourceLabelEvent < ResourceEvent def discussion_id_key [self.class.name, created_at, user_id] end + + def for_issue? + issue_id.present? + end + + def usage_metrics + return unless for_issue? + + Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_label_changed_action(author: user) + end end ResourceLabelEvent.prepend_if_ee('EE::ResourceLabelEvent') diff --git a/app/models/resource_state_event.rb b/app/models/resource_state_event.rb index 1ce4e14d289..6475633868a 100644 --- a/app/models/resource_state_event.rb +++ b/app/models/resource_state_event.rb @@ -11,6 +11,8 @@ class ResourceStateEvent < ResourceEvent # state is used for issue and merge request states. enum state: Issue.available_states.merge(MergeRequest.available_states).merge(reopened: 5) + after_save :usage_metrics + def self.issuable_attrs %i(issue merge_request).freeze end @@ -18,6 +20,29 @@ class ResourceStateEvent < ResourceEvent def issuable issue || merge_request end + + def for_issue? + issue_id.present? + end + + private + + def usage_metrics + return unless for_issue? + + case state + when 'closed' + issue_usage_counter.track_issue_closed_action(author: user) + when 'reopened' + issue_usage_counter.track_issue_reopened_action(author: user) + else + # no-op, nothing to do, not a state we're tracking + end + end + + def issue_usage_counter + Gitlab::UsageDataCounters::IssueActivityUniqueCounter + end end ResourceStateEvent.prepend_if_ee('EE::ResourceStateEvent') diff --git a/app/models/resource_timebox_event.rb b/app/models/resource_timebox_event.rb index 44f48915425..dbb2b428c7b 100644 --- a/app/models/resource_timebox_event.rb +++ b/app/models/resource_timebox_event.rb @@ -13,6 +13,8 @@ class ResourceTimeboxEvent < ResourceEvent remove: 2 } + after_save :usage_metrics + def self.issuable_attrs %i(issue merge_request).freeze end @@ -20,4 +22,17 @@ class ResourceTimeboxEvent < ResourceEvent def issuable issue || merge_request end + + private + + def usage_metrics + case self + when ResourceMilestoneEvent + Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_milestone_changed_action(author: user) + when ResourceIterationEvent + Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_iteration_changed_action(author: user) + else + # no-op + end + end end diff --git a/app/models/resource_weight_event.rb b/app/models/resource_weight_event.rb index bbabd54325e..a261f3a1bd7 100644 --- a/app/models/resource_weight_event.rb +++ b/app/models/resource_weight_event.rb @@ -1,7 +1,15 @@ # frozen_string_literal: true class ResourceWeightEvent < ResourceEvent + include IssueResourceEvent + validates :issue, presence: true - include IssueResourceEvent + after_save :usage_metrics + + private + + def usage_metrics + Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_weight_changed_action(author: user) + end end diff --git a/app/models/service.rb b/app/models/service.rb index e63e06bf46f..48fa62d3d46 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -7,9 +7,7 @@ class Service < ApplicationRecord include Importable include ProjectServicesLoggable include DataFields - include IgnorableColumns - - ignore_columns %i[default], remove_with: '13.5', remove_after: '2020-10-22' + include FromUnion SERVICE_NAMES = %w[ alerts asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker discord @@ -65,6 +63,7 @@ class Service < ApplicationRecord scope :active, -> { where(active: true) } scope :by_type, -> (type) { where(type: type) } scope :by_active_flag, -> (flag) { where(active: flag) } + scope :inherit_from_id, -> (id) { where(inherit_from_id: id) } scope :for_group, -> (group) { where(group_id: group, type: available_services_types) } scope :for_template, -> { where(template: true, type: available_services_types) } scope :for_instance, -> { where(instance: true, type: available_services_types) } @@ -217,7 +216,7 @@ class Service < ApplicationRecord services_names.map { |service_name| "#{service_name}_service".camelize } end - def self.build_from_integration(project_id, integration) + def self.build_from_integration(integration, project_id: nil, group_id: nil) service = integration.dup if integration.supports_data_fields? @@ -227,8 +226,9 @@ class Service < ApplicationRecord service.template = false service.instance = false - service.inherit_from_id = integration.id if integration.instance? service.project_id = project_id + service.group_id = group_id + service.inherit_from_id = integration.id if integration.instance? || integration.group service.active = false if service.invalid? service end @@ -256,6 +256,19 @@ class Service < ApplicationRecord end private_class_method :instance_level_integration + def self.create_from_active_default_integrations(scope, association, with_templates: false) + group_ids = scope.ancestors.select(:id) + array = group_ids.to_sql.present? ? "array(#{group_ids.to_sql})" : 'ARRAY[]' + + from_union([ + with_templates ? active.where(template: true) : none, + active.where(instance: true), + active.where(group_id: group_ids) + ]).order(Arel.sql("type ASC, array_position(#{array}::bigint[], services.group_id), instance DESC")).group_by(&:type).each do |type, records| + build_from_integration(records.first, association => scope.id).save! + end + end + def activated? active end diff --git a/app/models/service_list.rb b/app/models/service_list.rb index 9cbc5e68059..5eca5f2bda1 100644 --- a/app/models/service_list.rb +++ b/app/models/service_list.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true class ServiceList - def initialize(batch_ids, service_hash, association) - @batch_ids = batch_ids + def initialize(batch, service_hash, association) + @batch = batch @service_hash = service_hash @association = association end @@ -13,15 +13,15 @@ class ServiceList private - attr_reader :batch_ids, :service_hash, :association + attr_reader :batch, :service_hash, :association def columns - (service_hash.keys << "#{association}_id") + service_hash.keys << "#{association}_id" end def values - batch_ids.map do |id| - (service_hash.values << id) + batch.select(:id).map do |record| + service_hash.values << record.id end end end diff --git a/app/models/snippet_input_action_collection.rb b/app/models/snippet_input_action_collection.rb index 38313e3a980..1e886e98083 100644 --- a/app/models/snippet_input_action_collection.rb +++ b/app/models/snippet_input_action_collection.rb @@ -8,7 +8,11 @@ class SnippetInputActionCollection delegate :empty?, :any?, :[], to: :actions def initialize(actions = [], allowed_actions: nil) - @actions = actions.map { |action| SnippetInputAction.new(action.merge(allowed_actions: allowed_actions)) } + @actions = actions.map do |action| + params = action.merge(allowed_actions: allowed_actions) + + SnippetInputAction.new(**params) + end end def to_commit_actions diff --git a/app/models/snippet_repository.rb b/app/models/snippet_repository.rb index 2cfb201191d..fa25a6f8441 100644 --- a/app/models/snippet_repository.rb +++ b/app/models/snippet_repository.rb @@ -12,7 +12,7 @@ class SnippetRepository < ApplicationRecord belongs_to :snippet, inverse_of: :snippet_repository - delegate :repository, to: :snippet + delegate :repository, :repository_storage, to: :snippet class << self def find_snippet(disk_path) diff --git a/app/models/snippet_statistics.rb b/app/models/snippet_statistics.rb index 8545296d076..6fb6f0ef713 100644 --- a/app/models/snippet_statistics.rb +++ b/app/models/snippet_statistics.rb @@ -34,6 +34,8 @@ class SnippetStatistics < ApplicationRecord end def refresh! + return if Gitlab::Database.read_only? + update_commit_count update_repository_size update_file_count diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb index 961212d0295..0ddf2c5fbcd 100644 --- a/app/models/system_note_metadata.rb +++ b/app/models/system_note_metadata.rb @@ -18,9 +18,9 @@ class SystemNoteMetadata < ApplicationRecord commit description merge confidential visible label assignee cross_reference designs_added designs_modified designs_removed designs_discussion_added title time_tracking branch milestone discussion task moved - opened closed merged duplicate locked unlocked outdated + opened closed merged duplicate locked unlocked outdated reviewer tag due_date pinned_embed cherry_pick health_status approved unapproved - status alert_issue_added relate unrelate new_alert_added + status alert_issue_added relate unrelate new_alert_added severity ].freeze validates :note, presence: true diff --git a/app/models/terraform/state.rb b/app/models/terraform/state.rb index 419fffcb666..2ff2e3d66c0 100644 --- a/app/models/terraform/state.rb +++ b/app/models/terraform/state.rb @@ -4,6 +4,12 @@ module Terraform class State < ApplicationRecord include UsageStatistics include FileStoreMounter + include IgnorableColumns + # These columns are being removed since geo replication falls to the versioned state + # Tracking in https://gitlab.com/gitlab-org/gitlab/-/issues/258262 + ignore_columns %i[verification_failure verification_retry_at verified_at verification_retry_count verification_checksum], + remove_with: '13.7', + remove_after: '2020-12-22' HEX_REGEXP = %r{\A\h+\z}.freeze UUID_LENGTH = 32 @@ -15,6 +21,7 @@ module Terraform has_one :latest_version, -> { ordered_by_version_desc }, class_name: 'Terraform::StateVersion', foreign_key: :terraform_state_id scope :versioning_not_enabled, -> { where(versioning_enabled: false) } + scope :ordered_by_name, -> { order(:name) } validates :project_id, presence: true validates :uuid, presence: true, uniqueness: true, length: { is: UUID_LENGTH }, @@ -33,10 +40,6 @@ module Terraform versioning_enabled ? latest_version&.file : file end - def local? - file_store == ObjectStorage::Store::LOCAL - end - def locked? self.lock_xid.present? end @@ -53,5 +56,3 @@ module Terraform end end end - -Terraform::State.prepend_if_ee('EE::Terraform::State') diff --git a/app/models/terraform/state_version.rb b/app/models/terraform/state_version.rb index d5e315d18a1..eff44485401 100644 --- a/app/models/terraform/state_version.rb +++ b/app/models/terraform/state_version.rb @@ -14,5 +14,11 @@ module Terraform mount_file_store_uploader VersionedStateUploader delegate :project_id, :uuid, to: :terraform_state, allow_nil: true + + def local? + file_store == ObjectStorage::Store::LOCAL + end end end + +Terraform::StateVersion.prepend_if_ee('EE::Terraform::StateVersion') diff --git a/app/models/todo.rb b/app/models/todo.rb index 6c8e085762d..0d893b25253 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -227,7 +227,7 @@ class Todo < ApplicationRecord end def self_assigned? - assigned? && self_added? + self_added? && (assigned? || review_requested?) end private diff --git a/app/models/user.rb b/app/models/user.rb index 0a784b30d8f..5bbdb2b2e9a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -64,14 +64,7 @@ class User < ApplicationRecord # and should be added after Devise modules are initialized. include AsyncDeviseEmail - BLOCKED_MESSAGE = "Your account has been blocked. Please contact your GitLab " \ - "administrator if you think this is an error." - LOGIN_FORBIDDEN = "Your account does not have the required permission to login. Please contact your GitLab " \ - "administrator if you think this is an error." - - MINIMUM_INACTIVE_DAYS = 180 - - ignore_column :bio, remove_with: '13.4', remove_after: '2020-09-22' + MINIMUM_INACTIVE_DAYS = 90 # Override Devise::Models::Trackable#update_tracked_fields! # to limit database writes to at most once every hour @@ -134,6 +127,8 @@ class User < ApplicationRecord -> { where(members: { access_level: [Gitlab::Access::REPORTER, Gitlab::Access::DEVELOPER, Gitlab::Access::MAINTAINER, Gitlab::Access::OWNER] }) }, through: :group_members, source: :group + has_many :minimal_access_group_members, -> { where(access_level: [Gitlab::Access::MINIMAL_ACCESS]) }, source: 'GroupMember', class_name: 'GroupMember' + has_many :minimal_access_groups, through: :minimal_access_group_members, source: :group # Projects has_many :groups_projects, through: :groups, source: :projects @@ -381,11 +376,12 @@ class User < ApplicationRecord super && can?(:log_in) end + # The messages for these keys are defined in `devise.en.yml` def inactive_message if blocked? - BLOCKED_MESSAGE + :blocked elsif internal? - LOGIN_FORBIDDEN + :forbidden else super end @@ -1676,6 +1672,8 @@ class User < ApplicationRecord end def terms_accepted? + return true if project_bot? + accepted_term_id.present? end diff --git a/app/models/user_callout.rb b/app/models/user_callout.rb index 0ba319aa444..e39ff8712fc 100644 --- a/app/models/user_callout.rb +++ b/app/models/user_callout.rb @@ -19,7 +19,7 @@ class UserCallout < ApplicationRecord webhooks_moved: 13, service_templates_deprecated: 14, admin_integrations_moved: 15, - web_ide_alert_dismissed: 16, + web_ide_alert_dismissed: 16, # no longer in use active_user_count_threshold: 18, # EE-only buy_pipeline_minutes_notification_dot: 19, # EE-only personal_access_token_expiry: 21, # EE-only diff --git a/app/models/user_interacted_project.rb b/app/models/user_interacted_project.rb index 1c615777018..7e7a387d3d4 100644 --- a/app/models/user_interacted_project.rb +++ b/app/models/user_interacted_project.rb @@ -21,7 +21,7 @@ class UserInteractedProject < ApplicationRecord user_id: event.author_id } - cached_exists?(attributes) do + cached_exists?(**attributes) do transaction(requires_new: true) do where(attributes).select(1).first || create!(attributes) true # not caching the whole record here for now diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb index d3b3a46bf74..c05bc80415a 100644 --- a/app/models/user_preference.rb +++ b/app/models/user_preference.rb @@ -8,6 +8,9 @@ class UserPreference < ApplicationRecord belongs_to :user + scope :with_user, -> { joins(:user) } + scope :gitpod_enabled, -> { where(gitpod_enabled: true) } + validates :issue_notes_filter, :merge_request_notes_filter, inclusion: { in: NOTES_FILTERS.values }, presence: true validates :tab_width, numericality: { only_integer: true, diff --git a/app/models/wiki.rb b/app/models/wiki.rb index 9462f7401c4..9114de0e965 100644 --- a/app/models/wiki.rb +++ b/app/models/wiki.rb @@ -103,10 +103,10 @@ class Wiki limited = pages.size > limit pages = pages.first(limit) if limited - [WikiPage.group_by_directory(pages), limited] + [WikiDirectory.group_pages(pages), limited] end - # Finds a page within the repository based on a tile + # Finds a page within the repository based on a title # or slug. # # title - The human readable or parameterized title of diff --git a/app/models/wiki_directory.rb b/app/models/wiki_directory.rb index df2fe25b08b..3a2613e15d9 100644 --- a/app/models/wiki_directory.rb +++ b/app/models/wiki_directory.rb @@ -3,13 +3,46 @@ class WikiDirectory include ActiveModel::Validations - attr_accessor :slug, :pages + attr_accessor :slug, :entries validates :slug, presence: true - def initialize(slug, pages = []) + # Groups a list of wiki pages into a nested collection of WikiPage and WikiDirectory objects, + # preserving the order of the passed pages. + # + # Returns an array with all entries for the toplevel directory. + # + # @param [Array<WikiPage>] pages + # @return [Array<WikiPage, WikiDirectory>] + # + def self.group_pages(pages) + # Build a hash to map paths to created WikiDirectory objects, + # and recursively create them for each level of the path. + # For the toplevel directory we use '' as path, as that's what WikiPage#directory returns. + directories = Hash.new do |_, path| + directories[path] = new(path).tap do |directory| + if path.present? + parent = File.dirname(path) + parent = '' if parent == '.' + directories[parent].entries << directory + end + end + end + + pages.each do |page| + directories[page.directory].entries << page + end + + directories[''].entries + end + + def initialize(slug, entries = []) @slug = slug - @pages = pages + @entries = entries + end + + def title + WikiPage.unhyphenize(File.basename(slug)) end # Relative path to the partial to be used when rendering collections diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index faf3d19d936..989128987d5 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -31,29 +31,6 @@ class WikiPage alias_method :==, :eql? - # Sorts and groups pages by directory. - # - # pages - an array of WikiPage objects. - # - # Returns an array of WikiPage and WikiDirectory objects. The entries are - # sorted by alphabetical order (directories and pages inside each directory). - # Pages at the root level come before everything. - def self.group_by_directory(pages) - return [] if pages.blank? - - pages.each_with_object([]) do |page, grouped_pages| - next grouped_pages << page unless page.directory.present? - - directory = grouped_pages.find do |obj| - obj.is_a?(WikiDirectory) && obj.slug == page.directory - end - - next directory.pages << page if directory - - grouped_pages << WikiDirectory.new(page.directory, [page]) - end - end - def self.unhyphenize(name) name.gsub(/-+/, ' ') end diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb index 13d732e4edd..1c93073025d 100644 --- a/app/policies/base_policy.rb +++ b/app/policies/base_policy.rb @@ -27,10 +27,7 @@ class BasePolicy < DeclarativePolicy::Base desc "User email is unconfirmed or user account is locked" with_options scope: :user, score: 0 - condition(:inactive) do - Feature.enabled?(:inactive_policy_condition, default_enabled: true) && - @user&.confirmation_required_on_sign_in? || @user&.access_locked? - end + condition(:inactive) { @user&.confirmation_required_on_sign_in? || @user&.access_locked? } with_options scope: :user, score: 0 condition(:external_user) { @user.nil? || @user.external? } diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index c98e82efef7..f9ec026a6d2 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -46,6 +46,19 @@ class GroupPolicy < BasePolicy group_projects_for(user: @user, group: @subject, only_owned: false).any? { |p| p.design_management_enabled? } end + desc "Deploy token with read_package_registry scope" + condition(:read_package_registry_deploy_token) do + @user.is_a?(DeployToken) && @user.groups.include?(@subject) && @user.read_package_registry + end + + desc "Deploy token with write_package_registry scope" + condition(:write_package_registry_deploy_token) do + @user.is_a?(DeployToken) && @user.groups.include?(@subject) && @user.write_package_registry + end + + with_scope :subject + condition(:resource_access_token_available) { resource_access_token_available? } + rule { design_management_enabled }.policy do enable :read_design_activity end @@ -91,7 +104,6 @@ class GroupPolicy < BasePolicy rule { developer }.policy do enable :admin_milestone - enable :read_package enable :create_metrics_dashboard_annotation enable :delete_metrics_dashboard_annotation enable :update_metrics_dashboard_annotation @@ -105,6 +117,7 @@ class GroupPolicy < BasePolicy enable :admin_issue enable :read_metrics_dashboard_annotation enable :read_prometheus + enable :read_package end rule { maintainer }.policy do @@ -167,6 +180,20 @@ class GroupPolicy < BasePolicy rule { maintainer & can?(:create_projects) }.enable :transfer_projects + rule { read_package_registry_deploy_token }.policy do + enable :read_package + enable :read_group + end + + rule { write_package_registry_deploy_token }.policy do + enable :create_package + enable :read_group + end + + rule { resource_access_token_available & can?(:admin_group) }.policy do + enable :admin_resource_access_tokens + end + def access_level return GroupMember::NO_ACCESS if @user.nil? return GroupMember::NO_ACCESS unless user_is_user? @@ -183,6 +210,14 @@ class GroupPolicy < BasePolicy def user_is_user? user.is_a?(User) end + + def group + @subject + end + + def resource_access_token_available? + true + end end GroupPolicy.prepend_if_ee('EE::GroupPolicy') diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb index b02bb8621ed..44c448eb601 100644 --- a/app/policies/issue_policy.rb +++ b/app/policies/issue_policy.rb @@ -15,9 +15,6 @@ class IssuePolicy < IssuablePolicy desc "Issue is confidential" condition(:confidential, scope: :subject) { @subject.confidential? } - desc "Issue has moved" - condition(:moved) { @subject.moved? } - rule { confidential & ~can_read_confidential }.policy do prevent(*create_read_update_admin_destroy(:issue)) prevent :read_issue_iid @@ -38,12 +35,6 @@ class IssuePolicy < IssuablePolicy rule { ~can?(:read_design) }.policy do prevent :move_design end - - rule { locked | moved }.policy do - prevent :create_design - prevent :move_design - prevent :destroy_design - end end IssuePolicy.prepend_if_ee('EE::IssuePolicy') diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 87ee7d201e4..59e2d617bf7 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -104,6 +104,9 @@ class ProjectPolicy < BasePolicy with_scope :subject condition(:service_desk_enabled) { @subject.service_desk_enabled? } + with_scope :subject + condition(:resource_access_token_available) { resource_access_token_available? } + # 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 @@ -237,7 +240,6 @@ class ProjectPolicy < BasePolicy enable :read_merge_request enable :read_sentry_issue enable :update_sentry_issue - enable :read_incidents enable :read_prometheus enable :read_metrics_dashboard_annotation enable :metrics_dashboard @@ -589,6 +591,10 @@ class ProjectPolicy < BasePolicy prevent :read_project end + rule { resource_access_token_available & can?(:admin_project) }.policy do + enable :admin_resource_access_tokens + end + private def user_is_user? @@ -663,6 +669,10 @@ class ProjectPolicy < BasePolicy end end + def resource_access_token_available? + true + end + def project @subject end diff --git a/app/policies/releases/evidence_policy.rb b/app/policies/releases/evidence_policy.rb index 701913e6fe4..3e35f2f5e87 100644 --- a/app/policies/releases/evidence_policy.rb +++ b/app/policies/releases/evidence_policy.rb @@ -15,6 +15,7 @@ module Releases # - Project # - Milestones # - Issues + # TODO: remove issues from this check: https://gitlab.com/gitlab-org/gitlab/-/issues/259674 condition(:allowed_to_read_evidence) do can?(:read_release) && can?(:download_code) && diff --git a/app/policies/terraform/state_policy.rb b/app/policies/terraform/state_policy.rb new file mode 100644 index 00000000000..ba6109e5975 --- /dev/null +++ b/app/policies/terraform/state_policy.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Terraform + class StatePolicy < BasePolicy + alias_method :terraform_state, :subject + + delegate { terraform_state.project } + end +end diff --git a/app/presenters/alert_management/alert_presenter.rb b/app/presenters/alert_management/alert_presenter.rb index 5debe6d5dbd..4bfa3dc9a13 100644 --- a/app/presenters/alert_management/alert_presenter.rb +++ b/app/presenters/alert_management/alert_presenter.rb @@ -8,10 +8,11 @@ module AlertManagement MARKDOWN_LINE_BREAK = " \n" HORIZONTAL_LINE = "\n\n---\n\n" + INCIDENT_LABEL_NAME = ::IncidentManagement::CreateIncidentLabelService::LABEL_PROPERTIES[:title] delegate :metrics_dashboard_url, :runbook, to: :parsed_payload - def initialize(alert, _attributes = {}) + def initialize(alert, **attributes) super @alert = alert @@ -38,6 +39,30 @@ module AlertManagement Gitlab::Utils::InlineHash.merge_keys(payload) end + def show_incident_issues_link? + project.incident_management_setting&.create_issue? + end + + def show_performance_dashboard_link? + prometheus_alert.present? + end + + def incident_issues_link + project_issues_url(project, label_name: INCIDENT_LABEL_NAME) + end + + def performance_dashboard_link + if environment + metrics_project_environment_url(project, environment) + else + metrics_project_environments_url(project) + end + end + + def email_title + [environment&.name, query_title].compact.join(': ') + end + private attr_reader :alert, :project @@ -80,5 +105,11 @@ module AlertManagement def host_links hosts.join(' ') end + + def query_title + return title unless prometheus_alert + + "#{prometheus_alert.title} #{prometheus_alert.computed_operator} #{prometheus_alert.threshold} for 5 minutes" + end end end diff --git a/app/presenters/event_presenter.rb b/app/presenters/event_presenter.rb index 8f2388c2c31..c37721f7213 100644 --- a/app/presenters/event_presenter.rb +++ b/app/presenters/event_presenter.rb @@ -29,4 +29,26 @@ class EventPresenter < Gitlab::View::Presenter::Delegated '' end end + + def target_type_name + if design? + 'Design' + elsif wiki_page? + 'Wiki Page' + elsif target_type.present? + target_type.titleize + else + "Project" + end.downcase + end + + def note_target_type_name + return unless note? + + if design_note? + 'Design' + else + target.noteable_type.titleize + end.downcase + end end diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb index 1ff02412994..a22138011ae 100644 --- a/app/presenters/merge_request_presenter.rb +++ b/app/presenters/merge_request_presenter.rb @@ -37,7 +37,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated end def remove_wip_path - if work_in_progress? && can?(current_user, :update_merge_request, merge_request.project) + if can?(current_user, :update_merge_request, merge_request.project) remove_wip_project_merge_request_path(project, merge_request) end end diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index ef75c160b2d..0be8a4d5472 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -118,10 +118,6 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated add_special_file_path(file_name: ci_config_path_or_default) end - def add_ci_yml_ide_path - ide_edit_path(project, default_branch_or_master, ci_config_path_or_default) - end - def add_readme_path add_special_file_path(file_name: 'README.md') end @@ -330,7 +326,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated if cicd_missing? AnchorData.new(false, statistic_icon + _('Set up CI/CD'), - add_ci_yml_ide_path) + add_ci_yml_path) elsif repository.gitlab_ci_yml.present? AnchorData.new(false, statistic_icon('doc-text') + _('CI/CD configuration'), diff --git a/app/presenters/projects/prometheus/alert_presenter.rb b/app/presenters/projects/prometheus/alert_presenter.rb deleted file mode 100644 index 783b2b2b1e0..00000000000 --- a/app/presenters/projects/prometheus/alert_presenter.rb +++ /dev/null @@ -1,179 +0,0 @@ -# frozen_string_literal: true - -module Projects - module Prometheus - class AlertPresenter < Gitlab::View::Presenter::Delegated - GENERIC_ALERT_SUMMARY_ANNOTATIONS = %w(monitoring_tool service hosts).freeze - MARKDOWN_LINE_BREAK = " \n".freeze - INCIDENT_LABEL_NAME = ::IncidentManagement::CreateIncidentLabelService::LABEL_PROPERTIES[:title].freeze - METRIC_TIME_WINDOW = 30.minutes - - def full_title - [environment_name, alert_title].compact.join(': ') - end - - def project_full_path - project.full_path - end - - def metric_query - gitlab_alert&.full_query - end - - def environment_name - environment&.name - end - - def performance_dashboard_link - if environment - metrics_project_environment_url(project, environment) - else - metrics_project_environments_url(project) - end - end - - def show_performance_dashboard_link? - gitlab_alert.present? - end - - def show_incident_issues_link? - project.incident_management_setting&.create_issue? - end - - def incident_issues_link - project_issues_url(project, label_name: INCIDENT_LABEL_NAME) - end - - def start_time - starts_at&.strftime('%d %B %Y, %-l:%M%p (%Z)') - end - - def issue_summary_markdown - <<~MARKDOWN.chomp - #{metadata_list} - #{metric_embed_for_alert} - MARKDOWN - 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 - - def details_url - return unless am_alert - - ::Gitlab::Routing.url_helpers.details_project_alert_management_url( - project, - am_alert.iid - ) - end - - private - - def alert_title - query_title || title - end - - def query_title - return unless gitlab_alert - - "#{gitlab_alert.title} #{gitlab_alert.computed_operator} #{gitlab_alert.threshold} for 5 minutes" - end - - def metadata_list - metadata = [] - - metadata << list_item('Start time', start_time) if start_time - metadata << list_item('full_query', backtick(full_query)) if full_query - metadata << list_item(service.label.humanize, service.value) if service - metadata << list_item(monitoring_tool.label.humanize, monitoring_tool.value) if monitoring_tool - metadata << list_item(hosts.label.humanize, host_links) if hosts - metadata << list_item('GitLab alert', details_url) if details_url - - metadata.join(MARKDOWN_LINE_BREAK) - end - - def details - Gitlab::Utils::InlineHash.merge_keys(payload) - end - - def list_item(key, value) - "**#{key}:** #{value}".strip - end - - def backtick(value) - "`#{value}`" - end - - GENERIC_ALERT_SUMMARY_ANNOTATIONS.each do |annotation_name| - define_method(annotation_name) do - annotations.find { |a| a.label == annotation_name } - end - end - - def host_links - Array(hosts.value).join(' ') - end - - def embed_url_for_gitlab_alert - return unless gitlab_alert - - metrics_dashboard_project_prometheus_alert_url( - project, - gitlab_alert.prometheus_metric_id, - environment_id: environment.id, - embedded: true, - **alert_embed_window_params(embed_time) - ) - end - - def embed_url_for_self_managed_alert - return unless environment && full_query && title - - metrics_dashboard_project_environment_url( - project, - environment, - embed_json: dashboard_for_self_managed_alert.to_json, - embedded: true, - **alert_embed_window_params(embed_time) - ) - end - - def embed_time - starts_at || Time.current - end - - def alert_embed_window_params(time) - { - start: format_embed_timestamp(time - METRIC_TIME_WINDOW), - end: format_embed_timestamp(time + METRIC_TIME_WINDOW) - } - end - - def format_embed_timestamp(time) - time.utc.strftime('%FT%TZ') - end - - def dashboard_for_self_managed_alert - { - panel_groups: [{ - panels: [{ - type: 'area-chart', - title: title, - y_label: y_label, - metrics: [{ - query_range: full_query - }] - }] - }] - } - end - end - end -end diff --git a/app/presenters/sentry_error_presenter.rb b/app/presenters/sentry_error_presenter.rb index ba724b0f8be..669bcb68b7c 100644 --- a/app/presenters/sentry_error_presenter.rb +++ b/app/presenters/sentry_error_presenter.rb @@ -14,7 +14,7 @@ class SentryErrorPresenter < Gitlab::View::Presenter::Delegated end def project_id - Gitlab::GlobalId.build(model_name: 'Project', id: error.project_id).to_s + Gitlab::GlobalId.build(model_name: 'SentryProject', id: error.project_id).to_s end def frequency diff --git a/app/presenters/snippet_presenter.rb b/app/presenters/snippet_presenter.rb index d814c4404b6..695aa266e2c 100644 --- a/app/presenters/snippet_presenter.rb +++ b/app/presenters/snippet_presenter.rb @@ -32,15 +32,9 @@ class SnippetPresenter < Gitlab::View::Presenter::Delegated end def blob - blobs.first - end + return snippet.blob if snippet.empty_repo? - def blobs - if snippet.empty_repo? - [snippet.blob] - else - snippet.blobs - end + blobs.first end private diff --git a/app/serializers/ci/trigger_entity.rb b/app/serializers/ci/trigger_entity.rb new file mode 100644 index 00000000000..005a9b752ed --- /dev/null +++ b/app/serializers/ci/trigger_entity.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Ci + class TriggerEntity < Grape::Entity + include Gitlab::Routing + include Gitlab::Allowable + + expose :description + expose :owner, using: UserEntity + expose :last_used + + expose :token do |trigger| + can_admin_trigger?(trigger) ? trigger.token : trigger.short_token + end + + expose :has_token_exposed do |trigger| + can_admin_trigger?(trigger) + end + + expose :can_access_project do |trigger| + trigger.can_access_project? + end + + expose :project_trigger_path, if: -> (trigger) { can_manage_trigger?(trigger) } do |trigger| + project_trigger_path(options[:project], trigger) + end + + expose :edit_project_trigger_path, if: -> (trigger) { can_admin_trigger?(trigger) } do |trigger| + edit_project_trigger_path(options[:project], trigger) + end + + private + + def can_manage_trigger?(trigger) + can?(options[:current_user], :manage_trigger, trigger) + end + + def can_admin_trigger?(trigger) + can?(options[:current_user], :admin_trigger, trigger) + end + end +end diff --git a/app/serializers/ci/trigger_serializer.rb b/app/serializers/ci/trigger_serializer.rb new file mode 100644 index 00000000000..8e42ec12c3f --- /dev/null +++ b/app/serializers/ci/trigger_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Ci + class TriggerSerializer < BaseSerializer + entity ::Ci::TriggerEntity + end +end diff --git a/app/serializers/cluster_entity.rb b/app/serializers/cluster_entity.rb index eea0acdc11b..b904666971e 100644 --- a/app/serializers/cluster_entity.rb +++ b/app/serializers/cluster_entity.rb @@ -6,6 +6,8 @@ class ClusterEntity < Grape::Entity expose :cluster_type expose :enabled expose :environment_scope + expose :id + expose :namespace_per_environment expose :name expose :nodes expose :provider_type diff --git a/app/serializers/cluster_serializer.rb b/app/serializers/cluster_serializer.rb index 700a46040e3..f71591612a6 100644 --- a/app/serializers/cluster_serializer.rb +++ b/app/serializers/cluster_serializer.rb @@ -12,6 +12,7 @@ class ClusterSerializer < BaseSerializer :environment_scope, :gitlab_managed_apps_logs_path, :enable_advanced_logs_querying, + :id, :kubernetes_errors, :name, :nodes, diff --git a/app/serializers/container_repository_entity.rb b/app/serializers/container_repository_entity.rb index 4c87d1438b0..9a002971bef 100644 --- a/app/serializers/container_repository_entity.rb +++ b/app/serializers/container_repository_entity.rb @@ -4,6 +4,7 @@ class ContainerRepositoryEntity < Grape::Entity include RequestAwareEntity expose :id, :name, :path, :location, :created_at, :status, :tags_count + expose :expiration_policy_started_at, as: :cleanup_policy_started_at expose :tags_path do |repository| project_registry_repository_tags_path(project, repository, format: :json) diff --git a/app/serializers/diff_file_base_entity.rb b/app/serializers/diff_file_base_entity.rb index 9f27191c3c8..596f5d686da 100644 --- a/app/serializers/diff_file_base_entity.rb +++ b/app/serializers/diff_file_base_entity.rb @@ -34,7 +34,7 @@ class DiffFileBaseEntity < Grape::Entity expose :edit_path, if: -> (_, options) { options[:merge_request] } do |diff_file| merge_request = options[:merge_request] - next unless merge_request.merged? || merge_request.source_branch_exists? + next unless has_edit_path?(merge_request) target_project, target_branch = edit_project_branch_options(merge_request) @@ -43,6 +43,14 @@ class DiffFileBaseEntity < Grape::Entity project_edit_blob_path(target_project, tree_join(target_branch, diff_file.new_path), options) end + expose :ide_edit_path, if: -> (_, options) { options[:merge_request] } do |diff_file| + merge_request = options[:merge_request] + + next unless has_edit_path?(merge_request) + + gitlab_ide_merge_request_path(merge_request) + end + expose :old_path_html do |diff_file| old_path, _ = mark_inline_diffs(diff_file.old_path, diff_file.new_path) old_path @@ -125,4 +133,8 @@ class DiffFileBaseEntity < Grape::Entity [merge_request.target_project, merge_request.target_branch] end end + + def has_edit_path?(merge_request) + merge_request.merged? || merge_request.source_branch_exists? + end end diff --git a/app/serializers/diffs_entity.rb b/app/serializers/diffs_entity.rb index 6ef524b5bec..0b4f21c55f4 100644 --- a/app/serializers/diffs_entity.rb +++ b/app/serializers/diffs_entity.rb @@ -78,7 +78,7 @@ class DiffsEntity < Grape::Entity options[:merge_request_diffs] end - expose :definition_path_prefix, if: -> (diff_file) { Feature.enabled?(:code_navigation, merge_request.project, default_enabled: true) } do |diffs| + expose :definition_path_prefix do |diffs| project_blob_path(merge_request.project, diffs.diff_refs&.head_sha) end @@ -89,8 +89,6 @@ class DiffsEntity < Grape::Entity private def code_navigation_path(diffs) - return unless Feature.enabled?(:code_navigation, merge_request.project, default_enabled: true) - Gitlab::CodeNavigationPath.new(merge_request.project, diffs.diff_refs&.head_sha) end diff --git a/app/serializers/feature_flag_entity.rb b/app/serializers/feature_flag_entity.rb new file mode 100644 index 00000000000..80cf869a389 --- /dev/null +++ b/app/serializers/feature_flag_entity.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +class FeatureFlagEntity < Grape::Entity + include RequestAwareEntity + + expose :id + expose :iid + expose :active + expose :created_at + expose :updated_at + expose :name + expose :description + expose :version + + expose :edit_path, if: -> (feature_flag, _) { can_update?(feature_flag) } do |feature_flag| + edit_project_feature_flag_path(feature_flag.project, feature_flag) + end + + expose :update_path, if: -> (feature_flag, _) { can_update?(feature_flag) } do |feature_flag| + project_feature_flag_path(feature_flag.project, feature_flag) + end + + expose :destroy_path, if: -> (feature_flag, _) { can_destroy?(feature_flag) } do |feature_flag| + project_feature_flag_path(feature_flag.project, feature_flag) + end + + expose :scopes, with: FeatureFlagScopeEntity do |feature_flag| + feature_flag.scopes.sort_by(&:id) + end + + expose :strategies, with: FeatureFlags::StrategyEntity do |feature_flag| + feature_flag.strategies.sort_by(&:id) + end + + private + + def can_update?(feature_flag) + can?(current_user, :update_feature_flag, feature_flag) + end + + def can_destroy?(feature_flag) + can?(current_user, :destroy_feature_flag, feature_flag) + end + + def current_user + request.current_user + end +end diff --git a/app/serializers/feature_flag_scope_entity.rb b/app/serializers/feature_flag_scope_entity.rb new file mode 100644 index 00000000000..0450797a545 --- /dev/null +++ b/app/serializers/feature_flag_scope_entity.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class FeatureFlagScopeEntity < Grape::Entity + include RequestAwareEntity + + expose :id + expose :active + expose :environment_scope + expose :created_at + expose :updated_at + expose :strategies +end diff --git a/app/serializers/feature_flag_serializer.rb b/app/serializers/feature_flag_serializer.rb new file mode 100644 index 00000000000..e0ff33cc61a --- /dev/null +++ b/app/serializers/feature_flag_serializer.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class FeatureFlagSerializer < BaseSerializer + include WithPagination + entity FeatureFlagEntity + + def represent(resource, opts = {}) + super(resource, opts) + end +end diff --git a/app/serializers/feature_flag_summary_entity.rb b/app/serializers/feature_flag_summary_entity.rb new file mode 100644 index 00000000000..be4f02dabca --- /dev/null +++ b/app/serializers/feature_flag_summary_entity.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class FeatureFlagSummaryEntity < Grape::Entity + include RequestAwareEntity + + expose :count do + expose :all do |project| + project.operations_feature_flags.count + end + + expose :enabled do |project| + project.operations_feature_flags.enabled.count + end + + expose :disabled do |project| + project.operations_feature_flags.disabled.count + end + end +end diff --git a/app/serializers/feature_flag_summary_serializer.rb b/app/serializers/feature_flag_summary_serializer.rb new file mode 100644 index 00000000000..46f70666e40 --- /dev/null +++ b/app/serializers/feature_flag_summary_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class FeatureFlagSummarySerializer < BaseSerializer + entity FeatureFlagSummaryEntity +end diff --git a/app/serializers/feature_flags/scope_entity.rb b/app/serializers/feature_flags/scope_entity.rb new file mode 100644 index 00000000000..1c9dd491652 --- /dev/null +++ b/app/serializers/feature_flags/scope_entity.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module FeatureFlags + class ScopeEntity < Grape::Entity + expose :id + expose :environment_scope + end +end diff --git a/app/serializers/feature_flags/strategy_entity.rb b/app/serializers/feature_flags/strategy_entity.rb new file mode 100644 index 00000000000..73450476869 --- /dev/null +++ b/app/serializers/feature_flags/strategy_entity.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module FeatureFlags + class StrategyEntity < Grape::Entity + expose :id + expose :name + expose :parameters + expose :scopes, with: FeatureFlags::ScopeEntity + expose :user_list, with: FeatureFlags::UserListEntity, expose_nil: false + end +end diff --git a/app/serializers/feature_flags/user_list_entity.rb b/app/serializers/feature_flags/user_list_entity.rb new file mode 100644 index 00000000000..d3fddb4fa7a --- /dev/null +++ b/app/serializers/feature_flags/user_list_entity.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module FeatureFlags + class UserListEntity < Grape::Entity + expose :id + expose :iid + expose :name + expose :user_xids + end +end diff --git a/app/serializers/feature_flags_client_entity.rb b/app/serializers/feature_flags_client_entity.rb new file mode 100644 index 00000000000..4a195c7d759 --- /dev/null +++ b/app/serializers/feature_flags_client_entity.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class FeatureFlagsClientEntity < Grape::Entity + include RequestAwareEntity + + expose :token +end diff --git a/app/serializers/feature_flags_client_serializer.rb b/app/serializers/feature_flags_client_serializer.rb new file mode 100644 index 00000000000..104729b6668 --- /dev/null +++ b/app/serializers/feature_flags_client_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class FeatureFlagsClientSerializer < BaseSerializer + entity FeatureFlagsClientEntity + + def represent_token(resource, opts = {}) + represent(resource, only: [:token]) + end +end diff --git a/app/serializers/group_group_link_entity.rb b/app/serializers/group_group_link_entity.rb index 7a51e1a9316..491c672233d 100644 --- a/app/serializers/group_group_link_entity.rb +++ b/app/serializers/group_group_link_entity.rb @@ -1,12 +1,22 @@ # frozen_string_literal: true class GroupGroupLinkEntity < Grape::Entity + include RequestAwareEntity + expose :id expose :created_at expose :expires_at do |group_link| group_link.expires_at&.to_time end + expose :can_update do |group_link| + can_manage?(group_link) + end + + expose :can_remove do |group_link| + can_manage?(group_link) + end + expose :access_level do expose :human_access, as: :string_value expose :group_access, as: :integer_value @@ -23,4 +33,14 @@ class GroupGroupLinkEntity < Grape::Entity expose :shared_with_group, merge: true, using: GroupBasicEntity end + + private + + def current_user + options[:current_user] + end + + def can_manage?(group_link) + can?(current_user, :admin_group_member, group_link.shared_group) + end end diff --git a/app/serializers/merge_request_basic_entity.rb b/app/serializers/merge_request_basic_entity.rb index ef1177e9967..9e2bce53c8a 100644 --- a/app/serializers/merge_request_basic_entity.rb +++ b/app/serializers/merge_request_basic_entity.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class MergeRequestBasicEntity < Grape::Entity + expose :title expose :public_merge_status, as: :merge_status expose :merge_error expose :state diff --git a/app/serializers/merge_request_poll_cached_widget_entity.rb b/app/serializers/merge_request_poll_cached_widget_entity.rb index 002be8be729..080b6554de1 100644 --- a/app/serializers/merge_request_poll_cached_widget_entity.rb +++ b/app/serializers/merge_request_poll_cached_widget_entity.rb @@ -15,7 +15,7 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity expose :target_project_id expose :squash expose :rebase_in_progress?, as: :rebase_in_progress - expose :default_squash_commit_message, if: -> (merge_request, _) { merge_request.mergeable? } + expose :default_squash_commit_message expose :commits_count expose :merge_ongoing?, as: :merge_ongoing expose :work_in_progress?, as: :work_in_progress @@ -25,10 +25,10 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity expose :source_branch_exists?, as: :source_branch_exists expose :branch_missing?, as: :branch_missing - expose :commits_without_merge_commits, using: MergeRequestWidgetCommitEntity, - if: -> (merge_request, _) { merge_request.mergeable? } do |merge_request| + expose :commits_without_merge_commits, using: MergeRequestWidgetCommitEntity do |merge_request| merge_request.recent_commits.without_merge_commits end + expose :diff_head_sha do |merge_request| merge_request.diff_head_sha.presence end @@ -46,6 +46,12 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity end end + expose :actual_head_pipeline, as: :pipeline, if: -> (mr, _) { + Feature.enabled?(:merge_request_cached_pipeline_serializer, mr.project) && presenter(mr).can_read_pipeline? + } do |merge_request, options| + MergeRequests::PipelineEntity.represent(merge_request.actual_head_pipeline, options) + end + # Paths # expose :target_branch_commits_path do |merge_request| diff --git a/app/serializers/merge_request_poll_widget_entity.rb b/app/serializers/merge_request_poll_widget_entity.rb index 41ab5005091..9609a894e6d 100644 --- a/app/serializers/merge_request_poll_widget_entity.rb +++ b/app/serializers/merge_request_poll_widget_entity.rb @@ -19,20 +19,14 @@ class MergeRequestPollWidgetEntity < Grape::Entity # User entities expose :merge_user, using: UserEntity - expose :actual_head_pipeline, as: :pipeline, if: -> (mr, _) { presenter(mr).can_read_pipeline? } do |merge_request, options| - if Feature.enabled?(:merge_request_short_pipeline_serializer, merge_request.project, default_enabled: true) - MergeRequests::PipelineEntity.represent(merge_request.actual_head_pipeline, options) - else - PipelineDetailsEntity.represent(merge_request.actual_head_pipeline, options) - end + expose :actual_head_pipeline, as: :pipeline, if: -> (mr, _) { + Feature.disabled?(:merge_request_cached_pipeline_serializer, mr.project) && presenter(mr).can_read_pipeline? + } do |merge_request, options| + MergeRequests::PipelineEntity.represent(merge_request.actual_head_pipeline, options) end expose :merge_pipeline, if: ->(mr, _) { mr.merged? && can?(request.current_user, :read_pipeline, mr.target_project)} do |merge_request, options| - if Feature.enabled?(:merge_request_short_pipeline_serializer, merge_request.project, default_enabled: true) - MergeRequests::PipelineEntity.represent(merge_request.merge_pipeline, options) - else - PipelineDetailsEntity.represent(merge_request.merge_pipeline, options) - end + MergeRequests::PipelineEntity.represent(merge_request.merge_pipeline, options) end expose :default_merge_commit_message diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index 494192c8dbb..3fd2edfd425 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -67,15 +67,15 @@ class MergeRequestWidgetEntity < Grape::Entity ) end - expose :user_callouts_path, if: -> (*) { Feature.enabled?(:suggest_pipeline) } do |merge_request| + expose :user_callouts_path, if: -> (*) { Gitlab::Experimentation.enabled?(:suggest_pipeline) } do |_merge_request| user_callouts_path end - expose :suggest_pipeline_feature_id, if: -> (*) { Feature.enabled?(:suggest_pipeline) } do |merge_request| + expose :suggest_pipeline_feature_id, if: -> (*) { Gitlab::Experimentation.enabled?(:suggest_pipeline) } do |_merge_request| SUGGEST_PIPELINE end - expose :is_dismissed_suggest_pipeline, if: -> (*) { Feature.enabled?(:suggest_pipeline) } do |merge_request| + expose :is_dismissed_suggest_pipeline, if: -> (*) { Gitlab::Experimentation.enabled?(:suggest_pipeline) } do |_merge_request| current_user && current_user.dismissed_callout?(feature_name: SUGGEST_PIPELINE) end diff --git a/app/serializers/paginated_diff_entity.rb b/app/serializers/paginated_diff_entity.rb index 37c48338e55..f24571f7d7d 100644 --- a/app/serializers/paginated_diff_entity.rb +++ b/app/serializers/paginated_diff_entity.rb @@ -37,8 +37,6 @@ class PaginatedDiffEntity < Grape::Entity private def code_navigation_path(diffs) - return unless Feature.enabled?(:code_navigation, merge_request.project, default_enabled: true) - Gitlab::CodeNavigationPath.new(merge_request.project, diffs.diff_refs&.head_sha) end diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb index 45c5a1d3e1c..a45214670fa 100644 --- a/app/serializers/pipeline_serializer.rb +++ b/app/serializers/pipeline_serializer.rb @@ -47,6 +47,7 @@ class PipelineSerializer < BaseSerializer :retryable_builds, :scheduled_actions, :stages, + :latest_statuses, :trigger_requests, :user, { @@ -62,7 +63,14 @@ class PipelineSerializer < BaseSerializer pending_builds: :project, project: [:route, { namespace: :route }], triggered_by_pipeline: [{ project: [:route, { namespace: :route }] }, :user], - triggered_pipelines: [{ project: [:route, { namespace: :route }] }, :user, :source_job] + triggered_pipelines: [ + { + project: [:route, { namespace: :route }] + }, + :source_job, + :latest_statuses, + :user + ] } ] end diff --git a/app/serializers/test_case_entity.rb b/app/serializers/test_case_entity.rb index d2e08590ef0..b44aa62ad73 100644 --- a/app/serializers/test_case_entity.rb +++ b/app/serializers/test_case_entity.rb @@ -6,6 +6,7 @@ class TestCaseEntity < Grape::Entity expose :status expose :name expose :classname + expose :file expose :execution_time expose :system_output expose :stack_trace diff --git a/app/services/admin/propagate_integration_service.rb b/app/services/admin/propagate_integration_service.rb index 34d6008cb6a..80e27c21d5b 100644 --- a/app/services/admin/propagate_integration_service.rb +++ b/app/services/admin/propagate_integration_service.rb @@ -14,59 +14,19 @@ module Admin private # rubocop: disable Cop/InBatches - # rubocop: disable CodeReuse/ActiveRecord def update_inherited_integrations - Service.where(type: integration.type, inherit_from_id: integration.id).in_batches(of: BATCH_SIZE) do |batch| - bulk_update_from_integration(batch) + Service.by_type(integration.type).inherit_from_id(integration.id).in_batches(of: BATCH_SIZE) do |services| + min_id, max_id = services.pick("MIN(services.id), MAX(services.id)") + PropagateIntegrationInheritWorker.perform_async(integration.id, min_id, max_id) end end # rubocop: enable Cop/InBatches - # rubocop: enable CodeReuse/ActiveRecord - - # rubocop: disable CodeReuse/ActiveRecord - def bulk_update_from_integration(batch) - # Retrieving the IDs instantiates the ActiveRecord relation (batch) - # into concrete models, otherwise update_all will clear the relation. - # https://stackoverflow.com/q/34811646/462015 - batch_ids = batch.pluck(:id) - - Service.transaction do - batch.update_all(service_hash) - - if data_fields_present? - integration.data_fields.class.where(service_id: batch_ids).update_all(data_fields_hash) - end - end - end - # rubocop: enable CodeReuse/ActiveRecord def create_integration_for_groups_without_integration - loop do - batch = Group.uncached { group_ids_without_integration(integration, BATCH_SIZE) } - - bulk_create_from_integration(batch, 'group') unless batch.empty? - - break if batch.size < BATCH_SIZE + Group.without_integration(integration).each_batch(of: BATCH_SIZE) do |groups| + min_id, max_id = groups.pick("MIN(namespaces.id), MAX(namespaces.id)") + PropagateIntegrationGroupWorker.perform_async(integration.id, min_id, max_id) end end - - def service_hash - @service_hash ||= integration.to_service_hash - .tap { |json| json['inherit_from_id'] = integration.id } - end - - # rubocop:disable CodeReuse/ActiveRecord - def group_ids_without_integration(integration, limit) - services = Service - .select('1') - .where('services.group_id = namespaces.id') - .where(type: integration.type) - - Group - .where('NOT EXISTS (?)', services) - .limit(limit) - .pluck(:id) - end - # rubocop:enable CodeReuse/ActiveRecord end end diff --git a/app/services/admin/propagate_service_template.rb b/app/services/admin/propagate_service_template.rb index cd0d2d5d03f..07be3c1027d 100644 --- a/app/services/admin/propagate_service_template.rb +++ b/app/services/admin/propagate_service_template.rb @@ -9,11 +9,5 @@ module Admin create_integration_for_projects_without_integration end - - private - - def service_hash - @service_hash ||= integration.to_service_hash - 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 95ae84a85a4..5c7698f724a 100644 --- a/app/services/alert_management/process_prometheus_alert_service.rb +++ b/app/services/alert_management/process_prometheus_alert_service.rb @@ -47,7 +47,7 @@ module AlertManagement def create_alert_management_alert if alert.save alert.execute_services - SystemNoteService.create_new_alert(alert, Gitlab::AlertManagement::AlertParams::MONITORING_TOOLS[:prometheus]) + SystemNoteService.create_new_alert(alert, Gitlab::AlertManagement::Payload::MONITORING_TOOLS[:prometheus]) return end diff --git a/app/services/audit_event_service.rb b/app/services/audit_event_service.rb index d7630dbdac9..3c21844ec62 100644 --- a/app/services/audit_event_service.rb +++ b/app/services/audit_event_service.rb @@ -53,7 +53,6 @@ class AuditEventService private - attr_accessor :authentication_event attr_reader :ip_address def build_author(author) @@ -99,23 +98,35 @@ class AuditEventService end def mark_as_authentication_event! - self.authentication_event = true + @authentication_event = true end def authentication_event? - authentication_event + @authentication_event end def log_security_event_to_database return if Gitlab::Database.read_only? - AuditEvent.create(base_payload.merge(details: @details)) + event = AuditEvent.new(base_payload.merge(details: @details)) + save_or_track event + + event end def log_authentication_event_to_database return unless Gitlab::Database.read_write? && authentication_event? - AuthenticationEvent.create(authentication_event_payload) + event = AuthenticationEvent.new(authentication_event_payload) + save_or_track event + + event + end + + def save_or_track(event) + event.save! + rescue => e + Gitlab::ErrorTracking.track_exception(e, audit_event_type: event.class.to_s) end end diff --git a/app/services/boards/create_service.rb b/app/services/boards/create_service.rb index 1a5dc790c41..2ccaea64d14 100644 --- a/app/services/boards/create_service.rb +++ b/app/services/boards/create_service.rb @@ -3,7 +3,11 @@ module Boards class CreateService < Boards::BaseService def execute - create_board! if can_create_board? + unless can_create_board? + return ServiceResponse.error(message: "You don't have the permission to create a board for this resource.") + end + + create_board! end private @@ -15,12 +19,16 @@ module Boards def create_board! board = parent.boards.create(params) - if board.persisted? - board.lists.create(list_type: :backlog) - board.lists.create(list_type: :closed) + unless board.persisted? + return ServiceResponse.error(message: "There was an error when creating a board.", payload: board) + end + + board.tap do |created_board| + created_board.lists.create(list_type: :backlog) + created_board.lists.create(list_type: :closed) end - board + ServiceResponse.success(payload: board) end end end diff --git a/app/services/boards/lists/destroy_service.rb b/app/services/boards/lists/destroy_service.rb index e20805d0405..ebac0f07fe1 100644 --- a/app/services/boards/lists/destroy_service.rb +++ b/app/services/boards/lists/destroy_service.rb @@ -4,7 +4,9 @@ module Boards module Lists class DestroyService < Boards::BaseService def execute(list) - return false unless list.destroyable? + unless list.destroyable? + return ServiceResponse.error(message: "The list cannot be destroyed. Only label lists can be destroyed.") + end @board = list.board @@ -12,6 +14,8 @@ module Boards decrement_higher_lists(list) remove_list(list) end + + ServiceResponse.success end private @@ -26,7 +30,7 @@ module Boards # rubocop: enable CodeReuse/ActiveRecord def remove_list(list) - list.destroy + list.destroy! end end end diff --git a/app/services/bulk_create_integration_service.rb b/app/services/bulk_create_integration_service.rb new file mode 100644 index 00000000000..23b89b0d8a9 --- /dev/null +++ b/app/services/bulk_create_integration_service.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +class BulkCreateIntegrationService + def initialize(integration, batch, association) + @integration = integration + @batch = batch + @association = association + end + + def execute + service_list = ServiceList.new(batch, service_hash, association).to_array + + Service.transaction do + results = bulk_insert(*service_list) + + if integration.data_fields_present? + data_list = DataList.new(results, data_fields_hash, integration.data_fields.class).to_array + + bulk_insert(*data_list) + end + + run_callbacks(batch) if association == 'project' + end + end + + private + + attr_reader :integration, :batch, :association + + def bulk_insert(klass, columns, values_array) + items_to_insert = values_array.map { |array| Hash[columns.zip(array)] } + + klass.insert_all(items_to_insert, returning: [:id]) + end + + # rubocop: disable CodeReuse/ActiveRecord + def run_callbacks(batch) + if integration.issue_tracker? + Project.where(id: batch.select(:id)).update_all(has_external_issue_tracker: true) + end + + if integration.type == 'ExternalWikiService' + Project.where(id: batch.select(:id)).update_all(has_external_wiki: true) + end + end + # rubocop: enable CodeReuse/ActiveRecord + + def service_hash + if integration.template? + integration.to_service_hash + else + integration.to_service_hash.tap { |json| json['inherit_from_id'] = integration.id } + end + end + + def data_fields_hash + integration.to_data_fields_hash + end +end diff --git a/app/services/bulk_update_integration_service.rb b/app/services/bulk_update_integration_service.rb new file mode 100644 index 00000000000..74d77618f2c --- /dev/null +++ b/app/services/bulk_update_integration_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class BulkUpdateIntegrationService + def initialize(integration, batch) + @integration = integration + @batch = batch + end + + # rubocop: disable CodeReuse/ActiveRecord + def execute + Service.transaction do + batch.update_all(service_hash) + + if integration.data_fields_present? + integration.data_fields.class.where(service_id: batch.select(:id)).update_all(data_fields_hash) + end + end + end + # rubocop: enable CodeReuse/ActiveRecord + + private + + attr_reader :integration, :batch + + def service_hash + integration.to_service_hash.tap { |json| json['inherit_from_id'] = integration.id } + end + + def data_fields_hash + integration.to_data_fields_hash + end +end diff --git a/app/services/ci/create_job_artifacts_service.rb b/app/services/ci/create_job_artifacts_service.rb index 1fe65898d55..5efb3805bf7 100644 --- a/app/services/ci/create_job_artifacts_service.rb +++ b/app/services/ci/create_job_artifacts_service.rb @@ -52,24 +52,15 @@ module Ci 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 diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 70ad18e80eb..3f1a2d1350d 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -82,8 +82,7 @@ module Ci schedule_head_pipeline_update if pipeline.persisted? # If pipeline is not persisted, try to recover IID - pipeline.reset_project_iid unless pipeline.persisted? || - Feature.disabled?(:ci_pipeline_rewind_iid, project, default_enabled: true) + pipeline.reset_project_iid unless pipeline.persisted? pipeline end diff --git a/app/services/ci/daily_build_group_report_result_service.rb b/app/services/ci/daily_build_group_report_result_service.rb index 6cdf3c88f8c..c32fc27c274 100644 --- a/app/services/ci/daily_build_group_report_result_service.rb +++ b/app/services/ci/daily_build_group_report_result_service.rb @@ -3,8 +3,6 @@ module Ci class DailyBuildGroupReportResultService def execute(pipeline) - return unless Feature.enabled?(:ci_daily_code_coverage, pipeline.project, default_enabled: true) - DailyBuildGroupReportResult.upsert_reports(coverage_reports(pipeline)) end diff --git a/app/services/ci/expire_pipeline_cache_service.rb b/app/services/ci/expire_pipeline_cache_service.rb index 32abd1a7626..8343e0f8cd0 100644 --- a/app/services/ci/expire_pipeline_cache_service.rb +++ b/app/services/ci/expire_pipeline_cache_service.rb @@ -32,11 +32,18 @@ module Ci Gitlab::Routing.url_helpers.project_new_merge_request_path(project, format: :json) end + def pipelines_project_merge_request_path(merge_request) + Gitlab::Routing.url_helpers.pipelines_project_merge_request_path(merge_request.target_project, merge_request, format: :json) + end + + def merge_request_widget_path(merge_request) + Gitlab::Routing.url_helpers.cached_widget_project_json_merge_request_path(merge_request.project, merge_request, format: :json) + end + def each_pipelines_merge_request_path(pipeline) pipeline.all_merge_requests.each do |merge_request| - path = Gitlab::Routing.url_helpers.pipelines_project_merge_request_path(merge_request.target_project, merge_request, format: :json) - - yield(path) + yield(pipelines_project_merge_request_path(merge_request)) + yield(merge_request_widget_path(merge_request)) end end diff --git a/app/services/ci/pipelines/create_artifact_service.rb b/app/services/ci/pipelines/create_artifact_service.rb index b7d334e436d..bfaf317241a 100644 --- a/app/services/ci/pipelines/create_artifact_service.rb +++ b/app/services/ci/pipelines/create_artifact_service.rb @@ -3,7 +3,6 @@ module Ci module Pipelines class CreateArtifactService def execute(pipeline) - return unless ::Gitlab::Ci::Features.coverage_report_view?(pipeline.project) return unless pipeline.can_generate_coverage_reports? return if pipeline.has_coverage_reports? diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb index 18bae26613f..e511e26adfe 100644 --- a/app/services/ci/process_pipeline_service.rb +++ b/app/services/ci/process_pipeline_service.rb @@ -31,14 +31,14 @@ module Ci # rubocop: disable CodeReuse/ActiveRecord def update_retried # find the latest builds for each name - latest_statuses = pipeline.statuses.latest + latest_statuses = pipeline.latest_statuses .group(:name) .having('count(*) > 1') .pluck(Arel.sql('MAX(id)'), 'name') # mark builds that are retried if latest_statuses.any? - pipeline.statuses.latest + pipeline.latest_statuses .where(name: latest_statuses.map(&:second)) .where.not(id: latest_statuses.map(&:first)) .update_all(retried: true) diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb index 6b2e6c245f3..f397ada0696 100644 --- a/app/services/ci/retry_build_service.rb +++ b/app/services/ci/retry_build_service.rb @@ -58,7 +58,7 @@ module Ci build = project.builds.new(attributes) build.assign_attributes(::Gitlab::Ci::Pipeline::Seed::Build.environment_attributes_for(build)) build.retried = false - BulkInsertableAssociations.with_bulk_insert(enabled: ::Gitlab::Ci::Features.bulk_insert_on_create?(project)) do + BulkInsertableAssociations.with_bulk_insert do build.save! end build diff --git a/app/services/ci/update_build_queue_service.rb b/app/services/ci/update_build_queue_service.rb index 31c7178c9e7..241eba733ea 100644 --- a/app/services/ci/update_build_queue_service.rb +++ b/app/services/ci/update_build_queue_service.rb @@ -9,9 +9,7 @@ module Ci private def tick_for(build, runners) - if Feature.enabled?(:ci_update_queues_for_online_runners, build.project, default_enabled: true) - runners = runners.with_recent_runner_queue - end + runners = runners.with_recent_runner_queue runners.each do |runner| runner.pick_build!(build) diff --git a/app/services/ci/update_build_state_service.rb b/app/services/ci/update_build_state_service.rb index 61e4c77c1e5..cc8e2060888 100644 --- a/app/services/ci/update_build_state_service.rb +++ b/app/services/ci/update_build_state_service.rb @@ -2,7 +2,10 @@ module Ci class UpdateBuildStateService - Result = Struct.new(:status, keyword_init: true) + include ::Gitlab::Utils::StrongMemoize + include ::Gitlab::ExclusiveLeaseHelpers + + Result = Struct.new(:status, :backoff, keyword_init: true) ACCEPT_TIMEOUT = 5.minutes.freeze @@ -17,44 +20,65 @@ module Ci def execute overwrite_trace! if has_trace? - if accept_request? - accept_build_state! - else - check_migration_state - update_build_state! + unless accept_available? + return update_build_state! + end + + ensure_pending_state! + + in_build_trace_lock do + process_build_state! end end private - def accept_build_state! - if Time.current - ensure_pending_state.created_at > ACCEPT_TIMEOUT - metrics.increment_trace_operation(operation: :discarded) + def overwrite_trace! + metrics.increment_trace_operation(operation: :overwrite) - return update_build_state! + build.trace.set(params[:trace]) if Gitlab::Ci::Features.trace_overwrite? + end + + def ensure_pending_state! + pending_state.created_at + end + + def process_build_state! + if live_chunks_pending? + if pending_state_outdated? + discard_build_trace! + update_build_state! + else + accept_build_state! + end + else + validate_build_trace! + update_build_state! end + end + def accept_build_state! build.trace_chunks.live.find_each do |chunk| chunk.schedule_to_persist! end metrics.increment_trace_operation(operation: :accepted) - Result.new(status: 202) - end - - def overwrite_trace! - metrics.increment_trace_operation(operation: :overwrite) - - build.trace.set(params[:trace]) if Gitlab::Ci::Features.trace_overwrite? + ::Gitlab::Ci::Runner::Backoff.new(pending_state.created_at).then do |backoff| + Result.new(status: 202, backoff: backoff.to_seconds) + end end - def check_migration_state - return unless accept_available? + def validate_build_trace! + return unless has_chunks? - if has_chunks? && !live_chunks_pending? + unless live_chunks_pending? metrics.increment_trace_operation(operation: :finalized) end + + unless ::Gitlab::Ci::Trace::Checksum.new(build).valid? + metrics.increment_trace_operation(operation: :invalid) + end end def update_build_state! @@ -76,12 +100,24 @@ module Ci end end + def discard_build_trace! + metrics.increment_trace_operation(operation: :discarded) + end + def accept_available? !build_running? && has_checksum? && chunks_migration_enabled? end - def accept_request? - accept_available? && live_chunks_pending? + def live_chunks_pending? + build.trace_chunks.live.any? + end + + def has_chunks? + build.trace_chunks.any? + end + + def pending_state_outdated? + Time.current - pending_state.created_at > ACCEPT_TIMEOUT end def build_state @@ -96,18 +132,14 @@ module Ci params.dig(:checksum).present? end - def has_chunks? - build.trace_chunks.any? - end - - def live_chunks_pending? - build.trace_chunks.live.any? - end - def build_running? build_state == 'running' end + def pending_state + strong_memoize(:pending_state) { ensure_pending_state } + end + def ensure_pending_state Ci::BuildPendingState.create_or_find_by!( build_id: build.id, @@ -121,6 +153,32 @@ module Ci build.pending_state end + ## + # This method is releasing an exclusive lock on a build trace the moment we + # conclude that build status has been written and the build state update + # has been committed to the database. + # + # Because a build state machine schedules a bunch of workers to run after + # build status transition to complete, we do not want to keep the lease + # until all the workers are scheduled because it opens a possibility of + # race conditions happening. + # + # Instead of keeping the lease until the transition is fully done and + # workers are scheduled, we immediately release the lock after the database + # commit happens. + # + def in_build_trace_lock(&block) + build.trace.lock do |_, lease| # rubocop:disable CodeReuse/ActiveRecord + build.run_on_status_commit { lease.cancel } + + yield + end + rescue ::Gitlab::Ci::Trace::LockedError + metrics.increment_trace_operation(operation: :locked) + + accept_build_state! + end + def chunks_migration_enabled? ::Gitlab::Ci::Features.accept_trace?(build.project) end diff --git a/app/services/concerns/admin/propagate_service.rb b/app/services/concerns/admin/propagate_service.rb index 974408f678c..065ab6f7ff9 100644 --- a/app/services/concerns/admin/propagate_service.rb +++ b/app/services/concerns/admin/propagate_service.rb @@ -4,9 +4,7 @@ module Admin module PropagateService extend ActiveSupport::Concern - BATCH_SIZE = 100 - - delegate :data_fields_present?, to: :integration + BATCH_SIZE = 10_000 class_methods do def propagate(integration) @@ -23,51 +21,10 @@ module Admin attr_reader :integration def create_integration_for_projects_without_integration - loop do - batch_ids = Project.uncached { Project.ids_without_integration(integration, BATCH_SIZE) } - - bulk_create_from_integration(batch_ids, 'project') unless batch_ids.empty? - - break if batch_ids.size < BATCH_SIZE - end - end - - def bulk_create_from_integration(batch_ids, association) - service_list = ServiceList.new(batch_ids, service_hash, association).to_array - - Service.transaction do - results = bulk_insert(*service_list) - - if data_fields_present? - data_list = DataList.new(results, data_fields_hash, integration.data_fields.class).to_array - - bulk_insert(*data_list) - end - - run_callbacks(batch_ids) if association == 'project' + Project.without_integration(integration).each_batch(of: BATCH_SIZE) do |projects| + min_id, max_id = projects.pick("MIN(projects.id), MAX(projects.id)") + PropagateIntegrationProjectWorker.perform_async(integration.id, min_id, max_id) end end - - def bulk_insert(klass, columns, values_array) - items_to_insert = values_array.map { |array| Hash[columns.zip(array)] } - - klass.insert_all(items_to_insert, returning: [:id]) - end - - # rubocop: disable CodeReuse/ActiveRecord - def run_callbacks(batch_ids) - if integration.issue_tracker? - Project.where(id: batch_ids).update_all(has_external_issue_tracker: true) - end - - if integration.type == 'ExternalWikiService' - Project.where(id: batch_ids).update_all(has_external_wiki: true) - end - end - # rubocop: enable CodeReuse/ActiveRecord - - def data_fields_hash - @data_fields_hash ||= integration.to_data_fields_hash - end end end diff --git a/app/services/design_management/copy_design_collection.rb b/app/services/design_management/copy_design_collection.rb new file mode 100644 index 00000000000..66cf6112062 --- /dev/null +++ b/app/services/design_management/copy_design_collection.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module DesignManagement + module CopyDesignCollection + end +end diff --git a/app/services/design_management/copy_design_collection/copy_service.rb b/app/services/design_management/copy_design_collection/copy_service.rb new file mode 100644 index 00000000000..68f69a9c8db --- /dev/null +++ b/app/services/design_management/copy_design_collection/copy_service.rb @@ -0,0 +1,306 @@ +# frozen_string_literal: true + +# Service to copy a DesignCollection from one Issue to another. +# Copies the DesignCollection's Designs, Versions, and Notes on Designs. +module DesignManagement + module CopyDesignCollection + class CopyService < DesignService + # rubocop: disable CodeReuse/ActiveRecord + def initialize(project, user, params = {}) + super + + @target_issue = params.fetch(:target_issue) + @target_project = @target_issue.project + @target_repository = @target_project.design_repository + @target_design_collection = @target_issue.design_collection + @temporary_branch = "CopyDesignCollectionService_#{SecureRandom.hex}" + + @designs = DesignManagement::Design.unscoped.where(issue: issue).order(:id).load + @versions = DesignManagement::Version.unscoped.where(issue: issue).order(:id).includes(:designs).load + + @sha_attribute = Gitlab::Database::ShaAttribute.new + @shas = [] + @event_enum_map = DesignManagement::DesignAction::EVENT_FOR_GITALY_ACTION.invert + end + # rubocop: enable CodeReuse/ActiveRecord + + def execute + return error('User cannot copy design collection to issue') unless user_can_copy? + return error('Target design collection must first be queued') unless target_design_collection.copy_in_progress? + return error('Design collection has no designs') if designs.empty? + return error('Target design collection already has designs') unless target_design_collection.empty? + + with_temporary_branch do + copy_commits! + + ActiveRecord::Base.transaction do + design_ids = copy_designs! + version_ids = copy_versions! + copy_actions!(design_ids, version_ids) + link_lfs_files! + copy_notes!(design_ids) + finalize! + end + end + + ServiceResponse.success + rescue => error + log_exception(error) + + target_design_collection.error_copy! + + error('Designs were unable to be copied successfully') + end + + private + + attr_reader :designs, :event_enum_map, :sha_attribute, :shas, :temporary_branch, + :target_design_collection, :target_issue, :target_repository, + :target_project, :versions + + alias_method :merge_branch, :target_branch + + def log_exception(exception) + payload = { + issue_id: issue.id, + project_id: project.id, + target_issue_id: target_issue.id, + target_project: target_project.id + } + + Gitlab::ErrorTracking.track_exception(exception, payload) + end + + def error(message) + ServiceResponse.error(message: message) + end + + def user_can_copy? + current_user.can?(:read_design, design_collection) && + current_user.can?(:admin_issue, target_issue) + end + + def with_temporary_branch(&block) + target_repository.create_if_not_exists + + create_master_branch! if target_repository.empty? + create_temporary_branch! + + yield + ensure + remove_temporary_branch! + end + + # A project that does not have any designs will have a blank design + # repository. To create a temporary branch from `master` we need + # create `master` first by adding a file to it. + def create_master_branch! + target_repository.create_file( + current_user, + ".CopyDesignCollectionService_#{Time.now.to_i}", + '.gitlab', + message: "Commit to create #{merge_branch} branch in CopyDesignCollectionService", + branch_name: merge_branch + ) + end + + def create_temporary_branch! + target_repository.add_branch( + current_user, + temporary_branch, + target_repository.root_ref + ) + end + + def remove_temporary_branch! + return unless target_repository.branch_exists?(temporary_branch) + + target_repository.rm_branch(current_user, temporary_branch) + end + + # Merge the temporary branch containing the commits to `master` + # and update the state of the target_design_collection. + def finalize! + source_sha = shas.last + + target_repository.raw.merge( + current_user, + source_sha, + merge_branch, + 'CopyDesignCollectionService finalize merge' + ) { nil } + + target_design_collection.end_copy! + end + + # rubocop: disable CodeReuse/ActiveRecord + def copy_commits! + # Execute another query to include actions and their designs + DesignManagement::Version.unscoped.where(id: versions).order(:id).includes(actions: :design).find_each(batch_size: 100) do |version| + gitaly_actions = version.actions.map do |action| + design = action.design + # Map the raw Action#event enum value to a Gitaly "action" for the + # `Repository#multi_action` call. + gitaly_action_name = @event_enum_map[action.event_before_type_cast] + # `content` will be the LfsPointer file and not the design file, + # and can be nil for deletions. + content = blobs.dig(version.sha, design.filename)&.data + file_path = DesignManagement::Design.build_full_path(target_issue, design) + + { + action: gitaly_action_name, + file_path: file_path, + content: content + }.compact + end + + sha = target_repository.multi_action( + current_user, + branch_name: temporary_branch, + message: commit_message(version), + actions: gitaly_actions + ) + + shas << sha + end + end + # rubocop: enable CodeReuse/ActiveRecord + + def copy_designs! + design_attributes = attributes_config[:design_attributes] + + new_rows = designs.map do |design| + design.attributes.slice(*design_attributes).merge( + issue_id: target_issue.id, + project_id: target_project.id + ) + end + + # TODO Replace `Gitlab::Database.bulk_insert` with `BulkInsertSafe` + # once https://gitlab.com/gitlab-org/gitlab/-/issues/247718 is fixed. + ::Gitlab::Database.bulk_insert( # rubocop:disable Gitlab/BulkInsert + DesignManagement::Design.table_name, + new_rows, + return_ids: true + ) + end + + def copy_versions! + version_attributes = attributes_config[:version_attributes] + # `shas` are the list of Git commits made during the Git copy phase, + # and will be ordered 1:1 with old versions + shas_enum = shas.to_enum + + new_rows = versions.map do |version| + version.attributes.slice(*version_attributes).merge( + issue_id: target_issue.id, + sha: sha_attribute.serialize(shas_enum.next) + ) + end + + # TODO Replace `Gitlab::Database.bulk_insert` with `BulkInsertSafe` + # once https://gitlab.com/gitlab-org/gitlab/-/issues/247718 is fixed. + ::Gitlab::Database.bulk_insert( # rubocop:disable Gitlab/BulkInsert + DesignManagement::Version.table_name, + new_rows, + return_ids: true + ) + end + + # rubocop: disable CodeReuse/ActiveRecord + def copy_actions!(new_design_ids, new_version_ids) + # Create a map of <Old design id> => <New design id> + design_id_map = new_design_ids.each_with_index.to_h do |design_id, i| + [designs[i].id, design_id] + end + + # Create a map of <Old version id> => <New version id> + version_id_map = new_version_ids.each_with_index.to_h do |version_id, i| + [versions[i].id, version_id] + end + + actions = DesignManagement::Action.unscoped.select(:design_id, :version_id, :event).where(design: designs, version: versions) + + new_rows = actions.map do |action| + { + design_id: design_id_map[action.design_id], + version_id: version_id_map[action.version_id], + event: action.event_before_type_cast + } + end + + # We cannot use `BulkInsertSafe` because of the uploader mounted in `Action`. + ::Gitlab::Database.bulk_insert( # rubocop:disable Gitlab/BulkInsert + DesignManagement::Action.table_name, + new_rows + ) + end + # rubocop: enable CodeReuse/ActiveRecord + + def commit_message(version) + "Copy commit #{version.sha} from issue #{issue.to_reference(full: true)}" + end + + # rubocop: disable CodeReuse/ActiveRecord + def copy_notes!(design_ids) + new_designs = DesignManagement::Design.unscoped.find(design_ids) + + # Execute another query to filter only designs with notes + DesignManagement::Design.unscoped.where(id: designs).joins(:notes).distinct.find_each(batch_size: 100) do |old_design| + new_design = new_designs.find { |d| d.filename == old_design.filename } + + Notes::CopyService.new(current_user, old_design, new_design).execute + end + end + # rubocop: enable CodeReuse/ActiveRecord + + # rubocop: disable CodeReuse/ActiveRecord + def link_lfs_files! + oids = blobs.values.flat_map(&:values).map(&:lfs_oid) + repository_type = LfsObjectsProject.repository_types[:design] + + new_rows = LfsObject.where(oid: oids).find_each(batch_size: 1000).map do |lfs_object| + { + project_id: target_project.id, + lfs_object_id: lfs_object.id, + repository_type: repository_type + } + end + + # We cannot use `BulkInsertSafe` due to the LfsObjectsProject#update_project_statistics + # callback that fires after_commit. + ::Gitlab::Database.bulk_insert( # rubocop:disable Gitlab/BulkInsert + LfsObjectsProject.table_name, + new_rows, + on_conflict: :do_nothing # Upsert + ) + end + # rubocop: enable CodeReuse/ActiveRecord + + # Blob data is used to find the oids for LfsObjects and to copy to Git. + # Blobs are reasonably small in memory, as their data are LFS Pointer files. + # + # Returns all blobs for the designs as a Hash of `{ Blob#commit_id => { Design#filename => Blob } }` + def blobs + @blobs ||= begin + items = versions.flat_map { |v| v.designs.map { |d| [v.sha, DesignManagement::Design.build_full_path(issue, d)] } } + + repository.blobs_at(items).each_with_object({}) do |blob, h| + design = designs.find { |d| DesignManagement::Design.build_full_path(issue, d) == blob.path } + + h[blob.commit_id] ||= {} + h[blob.commit_id][design.filename] = blob + end + end + end + + def attributes_config + @attributes_config ||= YAML.load_file(attributes_config_file).symbolize_keys + end + + def attributes_config_file + Rails.root.join('lib/gitlab/design_management/copy_design_collection_model_attributes.yml') + end + end + end +end diff --git a/app/services/design_management/copy_design_collection/queue_service.rb b/app/services/design_management/copy_design_collection/queue_service.rb new file mode 100644 index 00000000000..f76917dbe47 --- /dev/null +++ b/app/services/design_management/copy_design_collection/queue_service.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +# Service for setting the initial copy_state on the target DesignCollection +# and queuing a CopyDesignCollectionWorker. +module DesignManagement + module CopyDesignCollection + class QueueService + def initialize(current_user, issue, target_issue) + @current_user = current_user + @issue = issue + @target_issue = target_issue + @target_design_collection = target_issue.design_collection + end + + def execute + return error('User cannot copy designs to issue') unless user_can_copy? + return error('Target design collection copy state must be `ready`') unless target_design_collection.can_start_copy? + + target_design_collection.start_copy! + + DesignManagement::CopyDesignCollectionWorker.perform_async(current_user.id, issue.id, target_issue.id) + + ServiceResponse.success + end + + private + + delegate :design_collection, to: :issue + + attr_reader :current_user, :issue, :target_design_collection, :target_issue + + def error(message) + ServiceResponse.error(message: message) + end + + def user_can_copy? + current_user.can?(:read_design, issue) && + current_user.can?(:admin_issue, target_issue) + end + end + end +end diff --git a/app/services/design_management/design_service.rb b/app/services/design_management/design_service.rb index 54e53609646..5aa2a2f73bc 100644 --- a/app/services/design_management/design_service.rb +++ b/app/services/design_management/design_service.rb @@ -19,6 +19,7 @@ module DesignManagement def collection issue.design_collection end + alias_method :design_collection, :collection def repository collection.repository diff --git a/app/services/design_management/generate_image_versions_service.rb b/app/services/design_management/generate_image_versions_service.rb index 213aac164ff..e56d163c461 100644 --- a/app/services/design_management/generate_image_versions_service.rb +++ b/app/services/design_management/generate_image_versions_service.rb @@ -48,6 +48,9 @@ module DesignManagement # Store and process the file action.image_v432x230.store!(raw_file) action.save! + rescue CarrierWave::IntegrityError => e + Gitlab::ErrorTracking.log_exception(e, project_id: project.id, design_id: action.design_id, version_id: action.version_id) + log_error(e.message) rescue CarrierWave::UploadError => e Gitlab::ErrorTracking.track_exception(e, project_id: project.id, design_id: action.design_id, version_id: action.version_id) log_error(e.message) diff --git a/app/services/design_management/runs_design_actions.rb b/app/services/design_management/runs_design_actions.rb index 4bd6bb45658..ee6aa9286d3 100644 --- a/app/services/design_management/runs_design_actions.rb +++ b/app/services/design_management/runs_design_actions.rb @@ -4,14 +4,15 @@ module DesignManagement module RunsDesignActions NoActions = Class.new(StandardError) - # this concern requires the following methods to be implemented: + # This concern requires the following methods to be implemented: # current_user, target_branch, repository, commit_message # # Before calling `run_actions`, you should ensure the repository exists, by # calling `repository.create_if_not_exists`. # # @raise [NoActions] if actions are empty - def run_actions(actions) + # @return [DesignManagement::Version] + def run_actions(actions, skip_system_notes: false) raise NoActions if actions.empty? sha = repository.multi_action(current_user, @@ -21,14 +22,14 @@ module DesignManagement ::DesignManagement::Version .create_for_designs(actions, sha, current_user) - .tap { |version| post_process(version) } + .tap { |version| post_process(version, skip_system_notes) } end private - def post_process(version) + def post_process(version, skip_system_notes) version.run_after_commit_or_now do - ::DesignManagement::NewVersionWorker.perform_async(id) + ::DesignManagement::NewVersionWorker.perform_async(id, skip_system_notes) end end end diff --git a/app/services/design_management/save_designs_service.rb b/app/services/design_management/save_designs_service.rb index 0446d2f1ee8..8dcc678e87e 100644 --- a/app/services/design_management/save_designs_service.rb +++ b/app/services/design_management/save_designs_service.rb @@ -16,11 +16,15 @@ module DesignManagement def execute return error("Not allowed!") unless can_create_designs? return error("Only #{MAX_FILES} files are allowed simultaneously") if files.size > MAX_FILES + return error("Duplicate filenames are not allowed!") if files.map(&:original_filename).uniq.length != files.length + return error("Design copy is in progress") if design_collection.copy_in_progress? uploaded_designs, version = upload_designs! skipped_designs = designs - uploaded_designs create_events + design_collection.reset_copy! + success({ designs: uploaded_designs, version: version, skipped_designs: skipped_designs }) rescue ::ActiveRecord::RecordInvalid => e error(e.message) @@ -34,7 +38,10 @@ module DesignManagement ::DesignManagement::Version.with_lock(project.id, repository) do actions = build_actions - [actions.map(&:design), actions.presence && run_actions(actions)] + [ + actions.map(&:design), + actions.presence && run_actions(actions) + ] end end diff --git a/app/services/feature_flags/base_service.rb b/app/services/feature_flags/base_service.rb new file mode 100644 index 00000000000..9b27df90992 --- /dev/null +++ b/app/services/feature_flags/base_service.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module FeatureFlags + class BaseService < ::BaseService + include Gitlab::Utils::StrongMemoize + + AUDITABLE_ATTRIBUTES = %w(name description active).freeze + + protected + + def audit_event(feature_flag) + message = audit_message(feature_flag) + + return if message.blank? + + details = + { + custom_message: message, + target_id: feature_flag.id, + target_type: feature_flag.class.name, + target_details: feature_flag.name + } + + ::AuditEventService.new( + current_user, + feature_flag.project, + details + ) + end + + def save_audit_event(audit_event) + return unless audit_event + + audit_event.security_event + end + + def created_scope_message(scope) + "Created rule <strong>#{scope.environment_scope}</strong> "\ + "and set it as <strong>#{scope.active ? "active" : "inactive"}</strong> "\ + "with strategies <strong>#{scope.strategies}</strong>." + end + + def feature_flag_by_name + strong_memoize(:feature_flag_by_name) do + project.operations_feature_flags.find_by_name(params[:name]) + end + end + + def feature_flag_scope_by_environment_scope + strong_memoize(:feature_flag_scope_by_environment_scope) do + feature_flag_by_name.scopes.find_by_environment_scope(params[:environment_scope]) + end + end + end +end diff --git a/app/services/feature_flags/create_service.rb b/app/services/feature_flags/create_service.rb new file mode 100644 index 00000000000..b4ca90f7aae --- /dev/null +++ b/app/services/feature_flags/create_service.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module FeatureFlags + class CreateService < FeatureFlags::BaseService + def execute + return error('Access Denied', 403) unless can_create? + return error('Version is invalid', :bad_request) unless valid_version? + return error('New version feature flags are not enabled for this project', :bad_request) unless flag_version_enabled? + + ActiveRecord::Base.transaction do + feature_flag = project.operations_feature_flags.new(params) + + if feature_flag.save + save_audit_event(audit_event(feature_flag)) + + success(feature_flag: feature_flag) + else + error(feature_flag.errors.full_messages, 400) + end + end + end + + private + + def audit_message(feature_flag) + message_parts = ["Created feature flag <strong>#{feature_flag.name}</strong>", + "with description <strong>\"#{feature_flag.description}\"</strong>."] + + message_parts += feature_flag.scopes.map do |scope| + created_scope_message(scope) + end + + message_parts.join(" ") + end + + def can_create? + Ability.allowed?(current_user, :create_feature_flag, project) + end + + def valid_version? + !params.key?(:version) || Operations::FeatureFlag.versions.key?(params[:version]) + end + + def flag_version_enabled? + params[:version] != 'new_version_flag' || new_version_feature_flags_enabled? + end + + def new_version_feature_flags_enabled? + ::Feature.enabled?(:feature_flags_new_version, project, default_enabled: true) + end + end +end diff --git a/app/services/feature_flags/destroy_service.rb b/app/services/feature_flags/destroy_service.rb new file mode 100644 index 00000000000..c77e3e03ec3 --- /dev/null +++ b/app/services/feature_flags/destroy_service.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module FeatureFlags + class DestroyService < FeatureFlags::BaseService + def execute(feature_flag) + destroy_feature_flag(feature_flag) + end + + private + + def destroy_feature_flag(feature_flag) + return error('Access Denied', 403) unless can_destroy?(feature_flag) + + ActiveRecord::Base.transaction do + if feature_flag.destroy + save_audit_event(audit_event(feature_flag)) + + success(feature_flag: feature_flag) + else + error(feature_flag.errors.full_messages) + end + end + end + + def audit_message(feature_flag) + "Deleted feature flag <strong>#{feature_flag.name}</strong>." + end + + def can_destroy?(feature_flag) + Ability.allowed?(current_user, :destroy_feature_flag, feature_flag) + end + end +end diff --git a/app/services/feature_flags/disable_service.rb b/app/services/feature_flags/disable_service.rb new file mode 100644 index 00000000000..8a443ac1795 --- /dev/null +++ b/app/services/feature_flags/disable_service.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module FeatureFlags + class DisableService < BaseService + def execute + return error('Feature Flag not found', 404) unless feature_flag_by_name + return error('Feature Flag Scope not found', 404) unless feature_flag_scope_by_environment_scope + return error('Strategy not found', 404) unless strategy_exist_in_persisted_data? + + ::FeatureFlags::UpdateService + .new(project, current_user, update_params) + .execute(feature_flag_by_name) + end + + private + + def update_params + if remaining_strategies.empty? + params_to_destroy_scope + else + params_to_update_scope + end + end + + def remaining_strategies + strong_memoize(:remaining_strategies) do + feature_flag_scope_by_environment_scope.strategies.reject do |strategy| + strategy['name'] == params[:strategy]['name'] && + strategy['parameters'] == params[:strategy]['parameters'] + end + end + end + + def strategy_exist_in_persisted_data? + feature_flag_scope_by_environment_scope.strategies != remaining_strategies + end + + def params_to_destroy_scope + { scopes_attributes: [{ id: feature_flag_scope_by_environment_scope.id, _destroy: true }] } + end + + def params_to_update_scope + { scopes_attributes: [{ id: feature_flag_scope_by_environment_scope.id, strategies: remaining_strategies }] } + end + end +end diff --git a/app/services/feature_flags/enable_service.rb b/app/services/feature_flags/enable_service.rb new file mode 100644 index 00000000000..b4cbb32e003 --- /dev/null +++ b/app/services/feature_flags/enable_service.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module FeatureFlags + class EnableService < BaseService + def execute + if feature_flag_by_name + update_feature_flag + else + create_feature_flag + end + end + + private + + def create_feature_flag + ::FeatureFlags::CreateService + .new(project, current_user, create_params) + .execute + end + + def update_feature_flag + ::FeatureFlags::UpdateService + .new(project, current_user, update_params) + .execute(feature_flag_by_name) + end + + def create_params + if params[:environment_scope] == '*' + params_to_create_flag_with_default_scope + else + params_to_create_flag_with_additional_scope + end + end + + def update_params + if feature_flag_scope_by_environment_scope + params_to_update_scope + else + params_to_create_scope + end + end + + def params_to_create_flag_with_default_scope + { + name: params[:name], + scopes_attributes: [ + { + active: true, + environment_scope: '*', + strategies: [params[:strategy]] + } + ] + } + end + + def params_to_create_flag_with_additional_scope + { + name: params[:name], + scopes_attributes: [ + { + active: false, + environment_scope: '*' + }, + { + active: true, + environment_scope: params[:environment_scope], + strategies: [params[:strategy]] + } + ] + } + end + + def params_to_create_scope + { + scopes_attributes: [{ + active: true, + environment_scope: params[:environment_scope], + strategies: [params[:strategy]] + }] + } + end + + def params_to_update_scope + { + scopes_attributes: [{ + id: feature_flag_scope_by_environment_scope.id, + active: true, + strategies: feature_flag_scope_by_environment_scope.strategies | [params[:strategy]] + }] + } + end + end +end diff --git a/app/services/feature_flags/update_service.rb b/app/services/feature_flags/update_service.rb new file mode 100644 index 00000000000..c837e50b104 --- /dev/null +++ b/app/services/feature_flags/update_service.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module FeatureFlags + class UpdateService < FeatureFlags::BaseService + AUDITABLE_SCOPE_ATTRIBUTES_HUMAN_NAMES = { + 'active' => 'active state', + 'environment_scope' => 'environment scope', + 'strategies' => 'strategies' + }.freeze + + def execute(feature_flag) + return error('Access Denied', 403) unless can_update?(feature_flag) + + ActiveRecord::Base.transaction do + feature_flag.assign_attributes(params) + + feature_flag.strategies.each do |strategy| + if strategy.name_changed? && strategy.name_was == ::Operations::FeatureFlags::Strategy::STRATEGY_GITLABUSERLIST + strategy.user_list = nil + end + end + + audit_event = audit_event(feature_flag) + + if feature_flag.save + save_audit_event(audit_event) + + success(feature_flag: feature_flag) + else + error(feature_flag.errors.full_messages, :bad_request) + end + end + end + + private + + def audit_message(feature_flag) + changes = changed_attributes_messages(feature_flag) + changes += changed_scopes_messages(feature_flag) + + return if changes.empty? + + "Updated feature flag <strong>#{feature_flag.name}</strong>. " + changes.join(" ") + end + + def changed_attributes_messages(feature_flag) + feature_flag.changes.slice(*AUDITABLE_ATTRIBUTES).map do |attribute_name, changes| + "Updated #{attribute_name} "\ + "from <strong>\"#{changes.first}\"</strong> to "\ + "<strong>\"#{changes.second}\"</strong>." + end + end + + def changed_scopes_messages(feature_flag) + feature_flag.scopes.map do |scope| + if scope.new_record? + created_scope_message(scope) + elsif scope.marked_for_destruction? + deleted_scope_message(scope) + else + updated_scope_message(scope) + end + end.compact # updated_scope_message can return nil if nothing has been changed + end + + def deleted_scope_message(scope) + "Deleted rule <strong>#{scope.environment_scope}</strong>." + end + + def updated_scope_message(scope) + changes = scope.changes.slice(*AUDITABLE_SCOPE_ATTRIBUTES_HUMAN_NAMES.keys) + return if changes.empty? + + message = "Updated rule <strong>#{scope.environment_scope}</strong> " + message += changes.map do |attribute_name, change| + name = AUDITABLE_SCOPE_ATTRIBUTES_HUMAN_NAMES[attribute_name] + "#{name} from <strong>#{change.first}</strong> to <strong>#{change.second}</strong>" + end.join(' ') + + message + '.' + end + + def can_update?(feature_flag) + Ability.allowed?(current_user, :update_feature_flag, feature_flag) + end + end +end diff --git a/app/services/git/branch_hooks_service.rb b/app/services/git/branch_hooks_service.rb index dcb32b4c84b..93a0d139001 100644 --- a/app/services/git/branch_hooks_service.rb +++ b/app/services/git/branch_hooks_service.rb @@ -76,12 +76,20 @@ module Git def branch_change_hooks enqueue_process_commit_messages enqueue_jira_connect_sync_messages + enqueue_metrics_dashboard_sync end def branch_remove_hooks project.repository.after_remove_branch(expire_cache: false) end + def enqueue_metrics_dashboard_sync + return unless Feature.enabled?(:sync_metrics_dashboards, project) + return unless default_branch? + + ::Metrics::Dashboard::SyncDashboardsWorker.perform_async(project.id) + end + # Schedules processing of commit messages def enqueue_process_commit_messages referencing_commits = limited_commits.select(&:matches_cross_reference_regex?) diff --git a/app/services/git/wiki_push_service.rb b/app/services/git/wiki_push_service.rb index fa3019ee9d6..87e2be858c0 100644 --- a/app/services/git/wiki_push_service.rb +++ b/app/services/git/wiki_push_service.rb @@ -34,9 +34,7 @@ module Git def can_process_wiki_events? # TODO: Support activity events for group wikis # https://gitlab.com/gitlab-org/gitlab/-/issues/209306 - return false unless wiki.is_a?(ProjectWiki) - - Feature.enabled?(:wiki_events_on_git_push, wiki.container) + wiki.is_a?(ProjectWiki) end def push_changes diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb index ce583095168..4747e1d5ac5 100644 --- a/app/services/groups/create_service.rb +++ b/app/services/groups/create_service.rb @@ -15,6 +15,8 @@ module Groups after_build_hook(@group, params) + inherit_group_shared_runners_settings + unless can_use_visibility_level? && can_create_group? return @group end @@ -28,9 +30,12 @@ module Groups @group.build_chat_team(name: response['name'], team_id: response['id']) end - if @group.save - @group.add_owner(current_user) - add_settings_record + Group.transaction do + if @group.save + @group.add_owner(current_user) + @group.create_namespace_settings + Service.create_from_active_default_integrations(@group, :group_id) if Feature.enabled?(:group_level_integrations) + end end @group @@ -84,8 +89,11 @@ module Groups params[:visibility_level] = Gitlab::CurrentSettings.current_application_settings.default_group_visibility end - def add_settings_record - @group.create_namespace_settings + def inherit_group_shared_runners_settings + return unless @group.parent + + @group.shared_runners_enabled = @group.parent.shared_runners_enabled + @group.allow_descendants_override_disabled_shared_runners = @group.parent.allow_descendants_override_disabled_shared_runners end end end diff --git a/app/services/groups/import_export/import_service.rb b/app/services/groups/import_export/import_service.rb index a5c776f8fc2..a0ddc50e5e0 100644 --- a/app/services/groups/import_export/import_service.rb +++ b/app/services/groups/import_export/import_service.rb @@ -13,7 +13,7 @@ module Groups end def async_execute - group_import_state = GroupImportState.safe_find_or_create_by!(group: group) + group_import_state = GroupImportState.safe_find_or_create_by!(group: group, user: current_user) jid = GroupImportWorker.perform_async(current_user.id, group.id) if jid.present? diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb index 2bd571f60af..70f5c7e2ea7 100644 --- a/app/services/groups/transfer_service.rb +++ b/app/services/groups/transfer_service.rb @@ -103,6 +103,9 @@ module Groups @group.parent = @new_parent_group @group.clear_memoization(:self_and_ancestors_ids) + + inherit_group_shared_runners_settings + @group.save! end @@ -161,6 +164,17 @@ module Groups group_contains_npm_packages: s_('TransferGroup|Group contains projects with NPM packages.') }.freeze end + + def inherit_group_shared_runners_settings + parent_setting = @group.parent&.shared_runners_setting + return unless parent_setting + + if @group.shared_runners_setting_higher_than?(parent_setting) + result = Groups::UpdateSharedRunnersService.new(@group, current_user, shared_runners_setting: parent_setting).execute + + raise TransferError, result[:message] unless result[:status] == :success + end + end end end diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb index 81393681dc0..382a3dbf0f7 100644 --- a/app/services/groups/update_service.rb +++ b/app/services/groups/update_service.rb @@ -19,6 +19,8 @@ module Groups return false unless valid_path_change_with_npm_packages? + return false unless update_shared_runners + before_assignment_hook(group, params) group.assign_attributes(params) @@ -98,6 +100,17 @@ module Groups params[:share_with_group_lock] != group.share_with_group_lock end + + def update_shared_runners + return true if params[:shared_runners_setting].nil? + + result = Groups::UpdateSharedRunnersService.new(group, current_user, shared_runners_setting: params.delete(:shared_runners_setting)).execute + + return true if result[:status] == :success + + group.errors.add(:update_shared_runners, result[:message]) + false + end end end diff --git a/app/services/groups/update_shared_runners_service.rb b/app/services/groups/update_shared_runners_service.rb index 63f57104510..639c5bf6ae0 100644 --- a/app/services/groups/update_shared_runners_service.rb +++ b/app/services/groups/update_shared_runners_service.rb @@ -7,44 +7,24 @@ module Groups validate_params - enable_or_disable_shared_runners! - allow_or_disallow_descendants_override_disabled_shared_runners! + update_shared_runners success - rescue Group::UpdateSharedRunnersError => error + rescue ActiveRecord::RecordInvalid, ArgumentError => 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' + unless Namespace::SHARED_RUNNERS_SETTINGS.include?(params[:shared_runners_setting]) + raise ArgumentError, "state must be one of: #{Namespace::SHARED_RUNNERS_SETTINGS.join(', ')}" 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 + def update_shared_runners + group.update_shared_runners_setting!(params[:shared_runners_setting]) end end end diff --git a/app/services/incident_management/incidents/create_service.rb b/app/services/incident_management/incidents/create_service.rb index 5b925e0f440..cff288d602b 100644 --- a/app/services/incident_management/incidents/create_service.rb +++ b/app/services/incident_management/incidents/create_service.rb @@ -24,7 +24,7 @@ module IncidentManagement return error(issue.errors.full_messages.to_sentence, issue) unless issue.valid? - issue.update_severity(severity) + update_severity_for(issue) success(issue) end @@ -40,6 +40,10 @@ module IncidentManagement def error(message, issue = nil) ServiceResponse.error(payload: { issue: issue }, message: message) end + + def update_severity_for(issue) + ::IncidentManagement::Incidents::UpdateSeverityService.new(issue, current_user, severity).execute + end end end end diff --git a/app/services/incident_management/incidents/update_severity_service.rb b/app/services/incident_management/incidents/update_severity_service.rb new file mode 100644 index 00000000000..5b150f3f02e --- /dev/null +++ b/app/services/incident_management/incidents/update_severity_service.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module IncidentManagement + module Incidents + class UpdateSeverityService < BaseService + def initialize(issuable, current_user, severity) + super(issuable.project, current_user) + + @issuable = issuable + @severity = severity.to_s.downcase + @severity = IssuableSeverity::DEFAULT unless IssuableSeverity.severities.key?(@severity) + end + + def execute + return unless issuable.incident? + + update_severity! + add_system_note + end + + private + + attr_reader :issuable, :severity + + def issuable_severity + issuable.issuable_severity || issuable.build_issuable_severity(issue_id: issuable.id) + end + + def update_severity! + issuable_severity.update!(severity: severity) + end + + def add_system_note + ::IncidentManagement::AddSeveritySystemNoteWorker.perform_async(issuable.id, current_user.id) + 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 index fd8252f75fb..027425e4aaa 100644 --- a/app/services/incident_management/pager_duty/process_webhook_service.rb +++ b/app/services/incident_management/pager_duty/process_webhook_service.rb @@ -34,7 +34,7 @@ module IncidentManagement strong_memoize(:pager_duty_processable_events) do ::PagerDuty::WebhookPayloadParser .call(params.to_h) - .filter { |msg| msg['event'].in?(PAGER_DUTY_PROCESSABLE_EVENT_TYPES) } + .filter { |msg| msg['event'].to_s.in?(PAGER_DUTY_PROCESSABLE_EVENT_TYPES) } end end diff --git a/app/services/issuable/clone/attributes_rewriter.rb b/app/services/issuable/clone/attributes_rewriter.rb index b185ab592ff..c84074039ea 100644 --- a/app/services/issuable/clone/attributes_rewriter.rb +++ b/app/services/issuable/clone/attributes_rewriter.rb @@ -56,7 +56,7 @@ module Issuable end def copy_resource_weight_events - return unless original_entity.respond_to?(:resource_weight_events) + return unless both_respond_to?(:resource_weight_events) copy_events(ResourceWeightEvent.table_name, original_entity.resource_weight_events) do |event| event.attributes diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 1672ba2830a..60e5293e218 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -322,7 +322,7 @@ class IssuableBaseService < BaseService def change_severity(issuable) if severity = params.delete(:severity) - issuable.update_severity(severity) + ::IncidentManagement::Incidents::UpdateSeverityService.new(issuable, current_user, severity).execute end end @@ -366,6 +366,7 @@ class IssuableBaseService < BaseService } associations[:total_time_spent] = issuable.total_time_spent if issuable.respond_to?(:total_time_spent) associations[:description] = issuable.description + associations[:reviewers] = issuable.reviewers.to_a if issuable.allows_reviewers? associations end diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index 60e0d1eec3d..e8747b9d6d8 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -23,11 +23,15 @@ module Issues # to receive service desk emails on the new moved issue. update_service_desk_sent_notifications + queue_copy_designs + new_entity end private + attr_reader :target_project + def update_service_desk_sent_notifications return unless original_entity.from_service_desk? @@ -46,7 +50,7 @@ module Issues new_params = { id: nil, iid: nil, - project: @target_project, + project: target_project, author: original_entity.author, assignee_ids: original_entity.assignee_ids } @@ -58,6 +62,23 @@ module Issues CreateService.new(@target_project, @current_user, new_params).execute(skip_system_notes: true) end + def queue_copy_designs + return unless copy_designs_enabled? && original_entity.designs.present? + + response = DesignManagement::CopyDesignCollection::QueueService.new( + current_user, + original_entity, + new_entity + ).execute + + log_error(response.message) if response.error? + end + + def copy_designs_enabled? + Feature.enabled?(:design_management_copy_designs, old_project) && + Feature.enabled?(:design_management_copy_designs, target_project) + end + def mark_as_moved original_entity.update(moved_to: new_entity) end @@ -75,7 +96,7 @@ module Issues end def add_note_from - SystemNoteService.noteable_moved(new_entity, @target_project, + SystemNoteService.noteable_moved(new_entity, target_project, original_entity, current_user, direction: :from) end diff --git a/app/services/lfs/push_service.rb b/app/services/lfs/push_service.rb index 6e1a11ebff8..9b947fbed07 100644 --- a/app/services/lfs/push_service.rb +++ b/app/services/lfs/push_service.rb @@ -12,7 +12,7 @@ module Lfs def execute lfs_objects_relation.each_batch(of: BATCH_SIZE) do |objects| - push_objects(objects) + push_objects!(objects) end success @@ -30,8 +30,8 @@ module Lfs project.lfs_objects_for_repository_types(nil, :project) end - def push_objects(objects) - rsp = lfs_client.batch('upload', objects) + def push_objects!(objects) + rsp = lfs_client.batch!('upload', objects) objects = objects.index_by(&:oid) rsp.fetch('objects', []).each do |spec| @@ -53,14 +53,14 @@ module Lfs return end - lfs_client.upload(object, upload, authenticated: authenticated) + lfs_client.upload!(object, upload, authenticated: authenticated) end def verify_object!(object, spec) - # TODO: the remote has requested that we make another call to verify that - # the object has been sent correctly. - # https://gitlab.com/gitlab-org/gitlab/-/issues/250654 - log_error("LFS upload verification requested, but not supported for #{object.oid}") + authenticated = spec['authenticated'] + verify = spec.dig('actions', 'verify') + + lfs_client.verify!(object, verify, authenticated: authenticated) end def url diff --git a/app/services/members/invitation_reminder_email_service.rb b/app/services/members/invitation_reminder_email_service.rb new file mode 100644 index 00000000000..e589cdc2fa3 --- /dev/null +++ b/app/services/members/invitation_reminder_email_service.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Members + class InvitationReminderEmailService + include Gitlab::Utils::StrongMemoize + + attr_reader :invitation + + MAX_INVITATION_LIFESPAN = 14.0 + REMINDER_RATIO = [2, 5, 10].freeze + + def initialize(invitation) + @invitation = invitation + end + + def execute + return unless experiment_enabled? + + reminder_index = days_on_which_to_send_reminders.index(days_after_invitation_sent) + return unless reminder_index + + invitation.send_invitation_reminder(reminder_index) + end + + private + + def experiment_enabled? + Gitlab::Experimentation.enabled_for_attribute?(:invitation_reminders, invitation.invite_email) + end + + def days_after_invitation_sent + (Date.today - invitation.created_at.to_date).to_i + end + + def days_on_which_to_send_reminders + # Don't send any reminders if the invitation has expired or expires today + return [] if invitation.expires_at && invitation.expires_at <= Date.today + + # Calculate the number of days on which to send reminders based on the MAX_INVITATION_LIFESPAN and the REMINDER_RATIO + REMINDER_RATIO.map { |number_of_days| ((number_of_days * invitation_lifespan_in_days) / MAX_INVITATION_LIFESPAN).ceil }.uniq + end + + def invitation_lifespan_in_days + # When the invitation lifespan is more than 14 days or does not expire, send the reminders within 14 days + strong_memoize(:invitation_lifespan_in_days) do + if invitation.expires_at + [(invitation.expires_at - invitation.created_at.to_date).to_i, MAX_INVITATION_LIFESPAN].min + else + MAX_INVITATION_LIFESPAN + end + end + end + end +end diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index abc3f99797d..aa591312c6a 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -110,6 +110,10 @@ module MergeRequests return end + unless merge_request.allows_multiple_reviewers? + params[:reviewer_ids] = params[:reviewer_ids].first(1) + end + reviewer_ids = params[:reviewer_ids].select { |reviewer_id| user_can_read?(merge_request, reviewer_id) } if params[:reviewer_ids].map(&:to_s) == [IssuableFinder::Params::NONE] @@ -130,6 +134,11 @@ module MergeRequests merge_request, merge_request.project, current_user, old_assignees) end + def create_reviewer_note(merge_request, old_reviewers) + SystemNoteService.change_issuable_reviewers( + merge_request, merge_request.project, current_user, old_reviewers) + end + def create_pipeline_for(merge_request, user) MergeRequests::CreatePipelineService.new(project, user).execute(merge_request) end diff --git a/app/services/merge_requests/export_csv_service.rb b/app/services/merge_requests/export_csv_service.rb new file mode 100644 index 00000000000..2755fc6687c --- /dev/null +++ b/app/services/merge_requests/export_csv_service.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module MergeRequests + class ExportCsvService + include Gitlab::Routing.url_helpers + include GitlabRoutingHelper + + # Target attachment size before base64 encoding + TARGET_FILESIZE = 15.megabytes + + def initialize(merge_requests) + @merge_requests = merge_requests + end + + def csv_data + csv_builder.render(TARGET_FILESIZE) + end + + private + + def csv_builder + @csv_builder ||= CsvBuilder.new(@merge_requests.with_csv_entity_associations, header_to_value_hash) + end + + def header_to_value_hash + { + 'MR IID' => 'iid', + 'URL' => -> (merge_request) { merge_request_url(merge_request) }, + 'Title' => 'title', + 'State' => 'state', + 'Description' => 'description', + 'Source Branch' => 'source_branch', + 'Target Branch' => 'target_branch', + 'Source Project ID' => 'source_project_id', + 'Target Project ID' => 'target_project_id', + 'Author' => -> (merge_request) { merge_request.author.name }, + 'Author Username' => -> (merge_request) { merge_request.author.username }, + 'Assignees' => -> (merge_request) { merge_request.assignees.map(&:name).join(', ') }, + 'Assignee Usernames' => -> (merge_request) { merge_request.assignees.map(&:username).join(', ') }, + 'Approvers' => -> (merge_request) { merge_request.approved_by_users.map(&:name).join(', ') }, + 'Approver Usernames' => -> (merge_request) { merge_request.approved_by_users.map(&:username).join(', ') }, + 'Merged User' => -> (merge_request) { merge_request.metrics&.merged_by&.name.to_s }, + 'Merged Username' => -> (merge_request) { merge_request.metrics&.merged_by&.username.to_s }, + 'Milestone ID' => -> (merge_request) { merge_request&.milestone&.id || '' }, + 'Created At (UTC)' => -> (merge_request) { merge_request.created_at.utc }, + 'Updated At (UTC)' => -> (merge_request) { merge_request.updated_at.utc } + } + end + end +end diff --git a/app/services/merge_requests/ff_merge_service.rb b/app/services/merge_requests/ff_merge_service.rb index 79011094e88..c5640047899 100644 --- a/app/services/merge_requests/ff_merge_service.rb +++ b/app/services/merge_requests/ff_merge_service.rb @@ -27,7 +27,7 @@ module MergeRequests rescue StandardError => e raise MergeError, "Something went wrong during merge: #{e.message}" ensure - merge_request.update(in_progress_merge_commit_sha: nil) + merge_request.update_and_mark_in_progress_merge_commit_sha(nil) end end end diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb index 437e87dadf7..ba22b458777 100644 --- a/app/services/merge_requests/merge_service.rb +++ b/app/services/merge_requests/merge_service.rb @@ -84,7 +84,7 @@ module MergeRequests merge_request.update!(merge_commit_sha: commit_id) ensure - merge_request.update_column(:in_progress_merge_commit_sha, nil) + merge_request.update_and_mark_in_progress_merge_commit_sha(nil) end def try_merge diff --git a/app/services/merge_requests/mergeability_check_service.rb b/app/services/merge_requests/mergeability_check_service.rb index a3c39fa2e32..12c04772ef4 100644 --- a/app/services/merge_requests/mergeability_check_service.rb +++ b/app/services/merge_requests/mergeability_check_service.rb @@ -88,7 +88,7 @@ module MergeRequests sleep_sec: retry_lease ? 1.second : 0 } - in_lock(lease_key, lease_opts, &block) + in_lock(lease_key, **lease_opts, &block) end def payload diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb index 405b8fe9c9e..0873c20b99c 100644 --- a/app/services/merge_requests/refresh_service.rb +++ b/app/services/merge_requests/refresh_service.rb @@ -184,7 +184,7 @@ module MergeRequests def abort_auto_merge_with_todo(merge_request, reason) response = abort_auto_merge(merge_request, reason) - response = ServiceResponse.new(response) + response = ServiceResponse.new(**response) return unless response.success? todo_service.merge_request_became_unmergeable(merge_request) diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 1468bfd6bb6..8c069ea5bb0 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -112,6 +112,8 @@ module MergeRequests end def handle_reviewers_change(merge_request, old_reviewers) + create_reviewer_note(merge_request, old_reviewers) + notification_service.async.changed_reviewer_of_merge_request(merge_request, current_user, old_reviewers) todo_service.reassigned_reviewable(merge_request, current_user, old_reviewers) end diff --git a/app/services/metrics/dashboard/custom_dashboard_service.rb b/app/services/metrics/dashboard/custom_dashboard_service.rb index f0f19bf2ba3..bde8e86851a 100644 --- a/app/services/metrics/dashboard/custom_dashboard_service.rb +++ b/app/services/metrics/dashboard/custom_dashboard_service.rb @@ -42,6 +42,12 @@ module Metrics def cache_key "project_#{project.id}_metrics_dashboard_#{dashboard_path}" end + + def sequence + [ + ::Gitlab::Metrics::Dashboard::Stages::CustomDashboardMetricsInserter + ] + super + end end end end diff --git a/app/services/namespace_settings/update_service.rb b/app/services/namespace_settings/update_service.rb new file mode 100644 index 00000000000..3c9b7b637ac --- /dev/null +++ b/app/services/namespace_settings/update_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module NamespaceSettings + class UpdateService + include ::Gitlab::Allowable + + attr_reader :current_user, :group, :settings_params + + def initialize(current_user, group, settings) + @current_user = current_user + @group = group + @settings_params = settings + end + + def execute + if group.namespace_settings + group.namespace_settings.attributes = settings_params + else + group.build_namespace_settings(settings_params) + end + end + end +end + +NamespaceSettings::UpdateService.prepend_if_ee('EE::NamespaceSettings::UpdateService') diff --git a/app/services/notification_recipients/builder/default.rb b/app/services/notification_recipients/builder/default.rb index 790ce57452c..19527ba84e6 100644 --- a/app/services/notification_recipients/builder/default.rb +++ b/app/services/notification_recipients/builder/default.rb @@ -34,6 +34,9 @@ module NotificationRecipients when :reassign_merge_request, :reassign_issue add_recipients(previous_assignees, :mention, nil) add_recipients(target.assignees, :mention, NotificationReason::ASSIGNED) + when :change_reviewer_merge_request + add_recipients(previous_assignees, :mention, nil) + add_recipients(target.reviewers, :mention, NotificationReason::REVIEW_REQUESTED) end add_subscribed_users diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 731d72c41d4..f343433360e 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -238,6 +238,33 @@ class NotificationService end end + # When we change reviewer in a merge_request we should send an email to: + # + # * merge_request old reviewers if their notification level is not Disabled + # * merge_request new reviewers if their notification level is not Disabled + # * users with custom level checked with "change reviewer merge request" + # + def changed_reviewer_of_merge_request(merge_request, current_user, previous_reviewers = []) + recipients = NotificationRecipients::BuildService.build_recipients( + merge_request, + current_user, + action: "change_reviewer", + previous_assignees: previous_reviewers + ) + + previous_reviewer_ids = previous_reviewers.map(&:id) + + recipients.each do |recipient| + mailer.changed_reviewer_of_merge_request_email( + recipient.user.id, + merge_request.id, + previous_reviewer_ids, + current_user.id, + recipient.reason + ).deliver_later + end + end + # When we add labels to a merge request we should send an email to: # # * watchers of the mr's labels diff --git a/app/services/packages/create_event_service.rb b/app/services/packages/create_event_service.rb new file mode 100644 index 00000000000..d009cba2812 --- /dev/null +++ b/app/services/packages/create_event_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Packages + class CreateEventService < BaseService + def execute + event_scope = scope.is_a?(::Packages::Package) ? scope.package_type : scope + + ::Packages::Event.create!( + event_type: event_name, + originator: current_user&.id, + originator_type: originator_type, + event_scope: event_scope + ) + end + + private + + def scope + params[:scope] + end + + def event_name + params[:event_name] + end + + def originator_type + case current_user + when User + :user + when DeployToken + :deploy_token + else + :guest + end + end + end +end diff --git a/app/services/packages/create_package_service.rb b/app/services/packages/create_package_service.rb index 397a5f74e0a..e3b0ad218e2 100644 --- a/app/services/packages/create_package_service.rb +++ b/app/services/packages/create_package_service.rb @@ -10,6 +10,7 @@ module Packages .with_package_type(package_type) .safe_find_or_create_by!(name: name, version: version) do |pkg| pkg.creator = package_creator + yield pkg if block_given? end end diff --git a/app/services/packages/generic/create_package_file_service.rb b/app/services/packages/generic/create_package_file_service.rb new file mode 100644 index 00000000000..4d49c63799f --- /dev/null +++ b/app/services/packages/generic/create_package_file_service.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Packages + module Generic + class CreatePackageFileService < BaseService + def execute + ::Packages::Package.transaction do + create_package_file(find_or_create_package) + end + end + + private + + def find_or_create_package + package_params = { + name: params[:package_name], + version: params[:package_version], + build: params[:build] + } + + ::Packages::Generic::FindOrCreatePackageService + .new(project, current_user, package_params) + .execute + end + + def create_package_file(package) + file_params = { + file: params[:file], + size: params[:file].size, + file_sha256: params[:file].sha256, + file_name: params[:file_name] + } + + ::Packages::CreatePackageFileService.new(package, file_params).execute + end + end + end +end diff --git a/app/services/packages/generic/find_or_create_package_service.rb b/app/services/packages/generic/find_or_create_package_service.rb new file mode 100644 index 00000000000..8a8459d167e --- /dev/null +++ b/app/services/packages/generic/find_or_create_package_service.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Packages + module Generic + class FindOrCreatePackageService < ::Packages::CreatePackageService + def execute + find_or_create_package!(::Packages::Package.package_types['generic']) do |package| + if params[:build].present? + package.build_info = Packages::BuildInfo.new(pipeline: params[:build].pipeline) + end + end + end + end + end +end diff --git a/app/services/pod_logs/base_service.rb b/app/services/pod_logs/base_service.rb index 8936f9b67a5..e4b6ad31e33 100644 --- a/app/services/pod_logs/base_service.rb +++ b/app/services/pod_logs/base_service.rb @@ -10,6 +10,8 @@ module PodLogs CACHE_KEY_GET_POD_LOG = 'get_pod_log' K8S_NAME_MAX_LENGTH = 253 + self.reactive_cache_work_type = :external_dependency + def id cluster.id end diff --git a/app/services/pod_logs/elasticsearch_service.rb b/app/services/pod_logs/elasticsearch_service.rb index f79562c8ab3..58d1bfbf835 100644 --- a/app/services/pod_logs/elasticsearch_service.rb +++ b/app/services/pod_logs/elasticsearch_service.rb @@ -11,7 +11,6 @@ module PodLogs :pod_logs, :filter_return_keys - self.reactive_cache_work_type = :external_dependency self.reactive_cache_worker_finder = ->(id, _cache_key, namespace, params) { new(::Clusters::Cluster.find(id), namespace, params: params) } private diff --git a/app/services/pod_logs/kubernetes_service.rb b/app/services/pod_logs/kubernetes_service.rb index b573ceae1aa..03b84f98973 100644 --- a/app/services/pod_logs/kubernetes_service.rb +++ b/app/services/pod_logs/kubernetes_service.rb @@ -17,7 +17,6 @@ module PodLogs :split_logs, :filter_return_keys - self.reactive_cache_work_type = :external_dependency self.reactive_cache_worker_finder = ->(id, _cache_key, namespace, params) { new(::Clusters::Cluster.find(id), namespace, params: params) } private diff --git a/app/services/projects/alerting/notify_service.rb b/app/services/projects/alerting/notify_service.rb index bfce5f1ad63..affac45fc3d 100644 --- a/app/services/projects/alerting/notify_service.rb +++ b/app/services/projects/alerting/notify_service.rb @@ -7,43 +7,34 @@ module Projects include ::IncidentManagement::Settings def execute(token) + return bad_request unless valid_payload_size? return forbidden unless alerts_service_activated? return unauthorized unless valid_token?(token) - alert = process_alert + process_alert return bad_request unless alert.persisted? - process_incident_issues(alert) if process_issues? + process_incident_issues if process_issues? send_alert_email if send_email? ServiceResponse.success - rescue Gitlab::Alerting::NotificationPayloadParser::BadPayloadError - bad_request end private delegate :alerts_service, :alerts_service_activated?, to: :project - def am_alert_params - strong_memoize(:am_alert_params) do - Gitlab::AlertManagement::AlertParams.from_generic_alert(project: project, payload: params.to_h) - end - end - def process_alert - existing_alert = find_alert_by_fingerprint(am_alert_params[:fingerprint]) - - if existing_alert - process_existing_alert(existing_alert) + if alert.persisted? + process_existing_alert else create_alert end end - def process_existing_alert(alert) - if am_alert_params[:ended_at].present? - process_resolved_alert(alert) + def process_existing_alert + if incoming_payload.ends_at.present? + process_resolved_alert else alert.register_new_event! end @@ -51,10 +42,10 @@ module Projects alert end - def process_resolved_alert(alert) + def process_resolved_alert return unless auto_close_incident? - if alert.resolve(am_alert_params[:ended_at]) + if alert.resolve(incoming_payload.ends_at) close_issue(alert.issue) end @@ -72,20 +63,16 @@ module Projects end def create_alert - alert = AlertManagement::Alert.create(am_alert_params.except(:ended_at)) - alert.execute_services if alert.persisted? - SystemNoteService.create_new_alert(alert, 'Generic Alert Endpoint') - - alert - end - - def find_alert_by_fingerprint(fingerprint) - return unless fingerprint + return unless alert.save - AlertManagement::Alert.not_resolved.for_fingerprint(project, fingerprint).first + alert.execute_services + SystemNoteService.create_new_alert( + alert, + alert.monitoring_tool || 'Generic Alert Endpoint' + ) end - def process_incident_issues(alert) + def process_incident_issues return if alert.issue ::IncidentManagement::ProcessAlertWorker.perform_async(nil, nil, alert.id) @@ -94,11 +81,33 @@ module Projects def send_alert_email notification_service .async - .prometheus_alerts_fired(project, [parsed_payload]) + .prometheus_alerts_fired(project, [alert.attributes]) + end + + def alert + strong_memoize(:alert) do + existing_alert || new_alert + end + end + + def existing_alert + return unless incoming_payload.gitlab_fingerprint + + AlertManagement::Alert.not_resolved.for_fingerprint(project, incoming_payload.gitlab_fingerprint).first + end + + def new_alert + AlertManagement::Alert.new(**incoming_payload.alert_params, ended_at: nil) + end + + def incoming_payload + strong_memoize(:incoming_payload) do + Gitlab::AlertManagement::Payload.parse(project, params.to_h) + end end - def parsed_payload - Gitlab::Alerting::NotificationPayloadParser.call(params.to_h, project) + def valid_payload_size? + Gitlab::Utils::DeepSize.new(params).valid? end def valid_token?(token) diff --git a/app/services/projects/container_repository/cleanup_tags_service.rb b/app/services/projects/container_repository/cleanup_tags_service.rb index 204a54ff23a..31500043544 100644 --- a/app/services/projects/container_repository/cleanup_tags_service.rb +++ b/app/services/projects/container_repository/cleanup_tags_service.rb @@ -25,7 +25,10 @@ module Projects tag_names = tags.map(&:name) Projects::ContainerRepository::DeleteTagsService - .new(container_repository.project, current_user, tags: tag_names) + .new(container_repository.project, + current_user, + tags: tag_names, + container_expiration_policy: params['container_expiration_policy']) .execute(container_repository) end diff --git a/app/services/projects/container_repository/delete_tags_service.rb b/app/services/projects/container_repository/delete_tags_service.rb index a23a6a369b2..9fc3ec0aafb 100644 --- a/app/services/projects/container_repository/delete_tags_service.rb +++ b/app/services/projects/container_repository/delete_tags_service.rb @@ -7,7 +7,10 @@ module Projects def execute(container_repository) @container_repository = container_repository - return error('access denied') unless can?(current_user, :destroy_container_image, project) + + unless params[:container_expiration_policy] + return error('access denied') unless can?(current_user, :destroy_container_image, project) + end @tag_names = params[:tags] return error('not tags specified') if @tag_names.blank? @@ -23,9 +26,7 @@ module Projects end def delete_service - fast_delete_enabled = Feature.enabled?(:container_registry_fast_tag_delete, default_enabled: true) - - if fast_delete_enabled && @container_repository.client.supports_tag_delete? + if @container_repository.client.supports_tag_delete? ::Projects::ContainerRepository::Gitlab::DeleteTagsService.new(@container_repository, @tag_names) else ::Projects::ContainerRepository::ThirdParty::DeleteTagsService.new(@container_repository, @tag_names) diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index 68b40fdd8f1..6fc8e8f8935 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -19,6 +19,10 @@ module Projects @project = Project.new(params) + # If a project is newly created it should have shared runners settings + # based on its group having it enabled. This is like the "default value" + @project.shared_runners_enabled = false if !params.key?(:shared_runners_enabled) && @project.group && @project.group.shared_runners_setting != 'enabled' + # Make sure that the user is allowed to use the specified visibility level if project_visibility.restricted? deny_visibility_level(@project, project_visibility.visibility_level) @@ -162,7 +166,7 @@ module Projects if @project.save unless @project.gitlab_project_import? - create_services_from_active_instances_or_templates(@project) + Service.create_from_active_default_integrations(@project, :project_id, with_templates: true) @project.create_labels end @@ -228,15 +232,6 @@ module Projects private - # rubocop: disable CodeReuse/ActiveRecord - def create_services_from_active_instances_or_templates(project) - Service.active.where(instance: true).or(Service.active.where(template: true)).group_by(&:type).each do |type, records| - service = records.find(&:instance?) || records.find(&:template?) - Service.build_from_integration(project.id, service).save! - end - end - # rubocop: enable CodeReuse/ActiveRecord - def project_namespace @project_namespace ||= Namespace.find_by_id(@params[:namespace_id]) || current_user.namespace end diff --git a/app/services/projects/prometheus/alerts/notify_service.rb b/app/services/projects/prometheus/alerts/notify_service.rb index d32ead76d00..c002aca32db 100644 --- a/app/services/projects/prometheus/alerts/notify_service.rb +++ b/app/services/projects/prometheus/alerts/notify_service.rb @@ -125,7 +125,7 @@ module Projects notification_service .async - .prometheus_alerts_fired(project, firings) + .prometheus_alerts_fired(project, alerts_attributes) end def process_prometheus_alerts @@ -136,6 +136,18 @@ module Projects end end + def alerts_attributes + firings.map do |payload| + alert_params = Gitlab::AlertManagement::Payload.parse( + project, + payload, + monitoring_tool: Gitlab::AlertManagement::Payload::MONITORING_TOOLS[:prometheus] + ).alert_params + + AlertManagement::Alert.new(alert_params).attributes + end + end + def bad_request ServiceResponse.error(message: 'Bad Request', http_status: :bad_request) end diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb index dba5177718d..013861631a1 100644 --- a/app/services/projects/transfer_service.rb +++ b/app/services/projects/transfer_service.rb @@ -88,6 +88,10 @@ module Projects # Move uploads move_project_uploads(project) + # If a project is being transferred to another group it means it can already + # have shared runners enabled but we need to check whether the new group allows that. + project.shared_runners_enabled = false if project.group && project.group.shared_runners_setting == 'disabled_and_unoverridable' + project.old_path_with_namespace = @old_path update_repository_configuration(@new_path) diff --git a/app/services/projects/update_remote_mirror_service.rb b/app/services/projects/update_remote_mirror_service.rb index 5c41f00aac2..40cf916e2f5 100644 --- a/app/services/projects/update_remote_mirror_service.rb +++ b/app/services/projects/update_remote_mirror_service.rb @@ -2,12 +2,14 @@ module Projects class UpdateRemoteMirrorService < BaseService + include Gitlab::Utils::StrongMemoize + MAX_TRIES = 3 def execute(remote_mirror, tries) return success unless remote_mirror.enabled? - if Gitlab::UrlBlocker.blocked_url?(CGI.unescape(Gitlab::UrlSanitizer.sanitize(remote_mirror.url))) + if Gitlab::UrlBlocker.blocked_url?(normalized_url(remote_mirror.url)) return error("The remote mirror URL is invalid.") end @@ -27,6 +29,12 @@ module Projects private + def normalized_url(url) + strong_memoize(:normalized_url) do + CGI.unescape(Gitlab::UrlSanitizer.sanitize(url)) + end + end + def update_mirror(remote_mirror) remote_mirror.update_start! remote_mirror.ensure_remote! diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb index bb430811497..d44f5e637f1 100644 --- a/app/services/projects/update_service.rb +++ b/app/services/projects/update_service.rb @@ -135,8 +135,8 @@ module Projects end def ensure_wiki_exists - ProjectWiki.new(project, project.owner).wiki - rescue Wiki::CouldNotCreateWikiError + return if project.create_wiki + log_error("Could not create wiki for #{project.full_name}") Gitlab::Metrics.counter(:wiki_can_not_be_created_total, 'Counts the times we failed to create a wiki').increment end diff --git a/app/services/quick_actions/target_service.rb b/app/services/quick_actions/target_service.rb index 4273acfbf8b..a465632ccfb 100644 --- a/app/services/quick_actions/target_service.rb +++ b/app/services/quick_actions/target_service.rb @@ -17,12 +17,16 @@ module QuickActions # rubocop: disable CodeReuse/ActiveRecord def issue(type_id) + return project.issues.build if type_id.nil? + IssuesFinder.new(current_user, project_id: project.id).find_by(iid: type_id) || project.issues.build end # rubocop: enable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord def merge_request(type_id) + return project.merge_requests.build if type_id.nil? + MergeRequestsFinder.new(current_user, project_id: project.id).find_by(iid: type_id) || project.merge_requests.build end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/services/resource_access_tokens/create_service.rb b/app/services/resource_access_tokens/create_service.rb index c253154c1b7..4ff8973773d 100644 --- a/app/services/resource_access_tokens/create_service.rb +++ b/app/services/resource_access_tokens/create_service.rb @@ -32,20 +32,11 @@ module ResourceAccessTokens attr_reader :resource_type, :resource def feature_enabled? - return false if ::Gitlab.com? - - ::Feature.enabled?(:resource_access_token, resource, default_enabled: true) + return true unless ::Gitlab.com? end def has_permission_to_create? - case resource_type - when 'project' - can?(current_user, :admin_project, resource) - when 'group' - can?(current_user, :admin_group, resource) - else - false - end + %w(project group).include?(resource_type) && can?(current_user, :admin_resource_access_tokens, resource) end def create_user diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb index fab02697cf0..5f80b07aa59 100644 --- a/app/services/search/global_service.rb +++ b/app/services/search/global_service.rb @@ -4,6 +4,8 @@ module Search class GlobalService include Gitlab::Utils::StrongMemoize + ALLOWED_SCOPES = %w(issues merge_requests milestones users).freeze + attr_accessor :current_user, :params def initialize(user, params) @@ -14,7 +16,8 @@ module Search Gitlab::SearchResults.new(current_user, params[:search], projects, - filters: { state: params[:state] }) + sort: params[:sort], + filters: { state: params[:state], confidential: params[:confidential] }) end def projects @@ -22,10 +25,7 @@ module Search end def allowed_scopes - strong_memoize(:allowed_scopes) do - allowed_scopes = %w[issues merge_requests milestones] - allowed_scopes << 'users' if Feature.enabled?(:users_search, default_enabled: true) - end + ALLOWED_SCOPES end def scope diff --git a/app/services/search/group_service.rb b/app/services/search/group_service.rb index 68778aa2768..24409a04e74 100644 --- a/app/services/search/group_service.rb +++ b/app/services/search/group_service.rb @@ -16,7 +16,7 @@ module Search params[:search], projects, group: group, - filters: { state: params[:state] } + filters: { state: params[:state], confidential: params[:confidential] } ) end diff --git a/app/services/search/project_service.rb b/app/services/search/project_service.rb index 5eba909c23b..b1142b816d0 100644 --- a/app/services/search/project_service.rb +++ b/app/services/search/project_service.rb @@ -2,6 +2,10 @@ module Search class ProjectService + include Gitlab::Utils::StrongMemoize + + ALLOWED_SCOPES = %w(notes issues merge_requests milestones wiki_blobs commits users).freeze + attr_accessor :project, :current_user, :params def initialize(project, user, params) @@ -13,15 +17,17 @@ module Search params[:search], project: project, repository_ref: params[:repository_ref], - filters: { state: params[:state] }) + filters: { confidential: params[:confidential], state: params[:state] } + ) end - def scope - @scope ||= begin - allowed_scopes = %w[notes issues merge_requests milestones wiki_blobs commits] - allowed_scopes << 'users' if Feature.enabled?(:users_search, default_enabled: true) + def allowed_scopes + ALLOWED_SCOPES + end - allowed_scopes.delete(params[:scope]) { 'blobs' } + def scope + strong_memoize(:scope) do + allowed_scopes.include?(params[:scope]) ? params[:scope] : 'blobs' end end end diff --git a/app/services/snippets/base_service.rb b/app/services/snippets/base_service.rb index 53a04e5a398..278857b7933 100644 --- a/app/services/snippets/base_service.rb +++ b/app/services/snippets/base_service.rb @@ -4,6 +4,9 @@ module Snippets class BaseService < ::BaseService include SpamCheckMethods + UPDATE_COMMIT_MSG = 'Update snippet' + INITIAL_COMMIT_MSG = 'Initial commit' + CreateRepositoryError = Class.new(StandardError) attr_reader :uploaded_assets, :snippet_actions @@ -85,5 +88,20 @@ module Snippets def restricted_files_actions nil end + + def commit_attrs(snippet, msg) + { + branch_name: snippet.default_branch, + message: msg + } + end + + def delete_repository(snippet) + snippet.repository.remove + snippet.snippet_repository&.delete + + # Purge any existing value for repository_exists? + snippet.repository.expire_exists_cache + end end end diff --git a/app/services/snippets/create_service.rb b/app/services/snippets/create_service.rb index 5c9b2eb1aea..d7181883c39 100644 --- a/app/services/snippets/create_service.rb +++ b/app/services/snippets/create_service.rb @@ -59,7 +59,7 @@ module Snippets log_error(e.message) # If the commit action failed we need to remove the repository if exists - @snippet.repository.remove if @snippet.repository_exists? + delete_repository(@snippet) if @snippet.repository_exists? # If the snippet was created, we need to remove it as we # would do like if it had had any validation error @@ -81,12 +81,9 @@ module Snippets end def create_commit - commit_attrs = { - branch_name: @snippet.default_branch, - message: 'Initial commit' - } + attrs = commit_attrs(@snippet, INITIAL_COMMIT_MSG) - @snippet.snippet_repository.multi_files_action(current_user, files_to_commit(@snippet), commit_attrs) + @snippet.snippet_repository.multi_files_action(current_user, files_to_commit(@snippet), **attrs) end def move_temporary_files diff --git a/app/services/snippets/update_service.rb b/app/services/snippets/update_service.rb index a0e9ab6ffda..0115cd19287 100644 --- a/app/services/snippets/update_service.rb +++ b/app/services/snippets/update_service.rb @@ -37,7 +37,10 @@ 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_actions.any? + # + # If the repository does not exist we don't need to update `params` + # because we need to commit the information from the database + if snippet_actions.any? && snippet.repository_exists? params[:content] = snippet_actions[0].content if snippet_actions[0].content params[:file_name] = snippet_actions[0].file_path end @@ -52,7 +55,11 @@ module Snippets # the repository we can just return return true unless committable_attributes? - create_repository_for(snippet) + unless snippet.repository_exists? + create_repository_for(snippet) + create_first_commit_using_db_data(snippet) + end + create_commit(snippet) true @@ -72,13 +79,7 @@ module Snippets # If the commit action failed we remove it because # we don't want to leave empty repositories # around, to allow cloning them. - if repository_empty?(snippet) - snippet.repository.remove - snippet.snippet_repository&.delete - end - - # Purge any existing value for repository_exists? - snippet.repository.expire_exists_cache + delete_repository(snippet) if repository_empty?(snippet) false end @@ -89,15 +90,25 @@ module Snippets raise CreateRepositoryError, 'Repository could not be created' unless snippet.repository_exists? end + # If the user provides `snippet_actions` and the repository + # does not exist, we need to commit first the snippet info stored + # in the database. Mostly because the content inside `snippet_actions` + # would assume that the file is already in the repository. + def create_first_commit_using_db_data(snippet) + return if snippet_actions.empty? + + attrs = commit_attrs(snippet, INITIAL_COMMIT_MSG) + actions = [{ file_path: snippet.file_name, content: snippet.content }] + + snippet.snippet_repository.multi_files_action(current_user, actions, **attrs) + end + def create_commit(snippet) raise UpdateError unless snippet.snippet_repository - commit_attrs = { - branch_name: snippet.default_branch, - message: 'Update snippet' - } + attrs = commit_attrs(snippet, UPDATE_COMMIT_MSG) - snippet.snippet_repository.multi_files_action(current_user, files_to_commit(snippet), commit_attrs) + snippet.snippet_repository.multi_files_action(current_user, files_to_commit(snippet), **attrs) end # Because we are removing repositories we don't want to remove diff --git a/app/services/spam/spam_action_service.rb b/app/services/spam/spam_action_service.rb index b745b67f566..ab83fc401e9 100644 --- a/app/services/spam/spam_action_service.rb +++ b/app/services/spam/spam_action_service.rb @@ -45,7 +45,7 @@ module Spam attr_reader :user, :context def allowlisted?(user) - user.respond_to?(:gitlab_employee) && user.gitlab_employee? + user.try(:gitlab_employee?) || user.try(:gitlab_bot?) end def perform_spam_service_check(api) diff --git a/app/services/static_site_editor/config_service.rb b/app/services/static_site_editor/config_service.rb index 987ee071976..7b3115468a5 100644 --- a/app/services/static_site_editor/config_service.rb +++ b/app/services/static_site_editor/config_service.rb @@ -4,18 +4,38 @@ module StaticSiteEditor class ConfigService < ::BaseContainerService ValidationError = Class.new(StandardError) - def execute + def initialize(container:, current_user: nil, params: {}) + super + @project = container + @repository = project.repository + @ref = params.fetch(:ref) + end + + def execute check_access! + file_config = load_file_config! + file_data = file_config.to_hash_with_defaults + generated_data = load_generated_config.data + + check_for_duplicate_keys!(generated_data, file_data) + data = merged_data(generated_data, file_data) + ServiceResponse.success(payload: data) rescue ValidationError => e ServiceResponse.error(message: e.message) + rescue => e + Gitlab::ErrorTracking.track_and_raise_exception(e) end private - attr_reader :project + attr_reader :project, :repository, :ref + + def static_site_editor_config_file + '.gitlab/static-site-editor.yml' + end def check_access! unless can?(current_user, :download_code, project) @@ -23,27 +43,43 @@ module StaticSiteEditor end end - def data - check_for_duplicate_keys! - generated_data.merge(file_data) + def load_file_config! + yaml = yaml_from_repo.presence || '{}' + file_config = Gitlab::StaticSiteEditor::Config::FileConfig.new(yaml) + + unless file_config.valid? + raise ValidationError, file_config.errors.first + end + + file_config + rescue Gitlab::StaticSiteEditor::Config::FileConfig::ConfigError => e + raise ValidationError, e.message end - def generated_data - @generated_data ||= Gitlab::StaticSiteEditor::Config::GeneratedConfig.new( - project.repository, - params.fetch(:ref), + def load_generated_config + Gitlab::StaticSiteEditor::Config::GeneratedConfig.new( + repository, + ref, params.fetch(:path), params[:return_url] - ).data - end - - def file_data - @file_data ||= Gitlab::StaticSiteEditor::Config::FileConfig.new.data + ) end - def check_for_duplicate_keys! + def check_for_duplicate_keys!(generated_data, file_data) duplicate_keys = generated_data.keys & file_data.keys raise ValidationError.new("Duplicate key(s) '#{duplicate_keys}' found.") if duplicate_keys.present? end + + def merged_data(generated_data, file_data) + generated_data.merge(file_data) + end + + def yaml_from_repo + repository.blob_data_at(ref, static_site_editor_config_file) + rescue GRPC::NotFound + # Return nil in the case of a GRPC::NotFound exception, so the default config will be used. + # Allow any other unexpected exception will be tracked and re-raised. + nil + end end end diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index df042fdc393..1a4374f2e94 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -41,6 +41,10 @@ module SystemNoteService ::SystemNotes::IssuablesService.new(noteable: issuable, project: project, author: author).change_issuable_assignees(old_assignees) end + def change_issuable_reviewers(issuable, project, author, old_reviewers) + ::SystemNotes::IssuablesService.new(noteable: issuable, project: project, author: author).change_issuable_reviewers(old_reviewers) + end + def relate_issue(noteable, noteable_ref, user) ::SystemNotes::IssuablesService.new(noteable: noteable, project: noteable.project, author: user).relate_issue(noteable_ref) end @@ -308,6 +312,10 @@ module SystemNoteService ::SystemNotes::AlertManagementService.new(noteable: alert, project: alert.project).create_new_alert(monitoring_tool) end + def change_incident_severity(incident, author) + ::SystemNotes::IncidentService.new(noteable: incident, project: incident.project, author: author).change_incident_severity + end + private def merge_requests_service(noteable, project, author) diff --git a/app/services/system_notes/incident_service.rb b/app/services/system_notes/incident_service.rb new file mode 100644 index 00000000000..4628662f0e9 --- /dev/null +++ b/app/services/system_notes/incident_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module SystemNotes + class IncidentService < ::SystemNotes::BaseService + # Called when the severity of an Incident has changed + # + # Example Note text: + # + # "changed the severity to Medium - S3" + # + # Returns the created Note object + def change_incident_severity + severity = noteable.severity + + if severity_label = IssuableSeverity::SEVERITY_LABELS[severity.to_sym] + body = "changed the severity to **#{severity_label}**" + + create_note(NoteSummary.new(noteable, project, author, body, action: 'severity')) + else + Gitlab::AppLogger.error( + message: 'Cannot create a system note for severity change', + noteable_class: noteable.class.to_s, + noteable_id: noteable.id, + severity: severity + ) + end + end + end +end diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb index 2252503d97e..784bd6b9699 100644 --- a/app/services/system_notes/issuables_service.rb +++ b/app/services/system_notes/issuables_service.rb @@ -81,6 +81,32 @@ module SystemNotes create_note(NoteSummary.new(noteable, project, author, body, action: 'assignee')) end + # Called when the reviewers of an issuable is changed or removed + # + # reviewers - Users being requested to review, or nil + # + # Example Note text: + # + # "requested review from @user1 and @user2" + # + # "requested review from @user1, @user2 and @user3 and removed review request for @user4 and @user5" + # + # Returns the created Note object + def change_issuable_reviewers(old_reviewers) + unassigned_users = old_reviewers - noteable.reviewers + added_users = noteable.reviewers - old_reviewers + text_parts = [] + + Gitlab::I18n.with_default_locale do + text_parts << "requested review from #{added_users.map(&:to_reference).to_sentence}" if added_users.any? + text_parts << "removed review request for #{unassigned_users.map(&:to_reference).to_sentence}" if unassigned_users.any? + end + + body = text_parts.join(' and ') + + create_note(NoteSummary.new(noteable, project, author, body, action: 'reviewer')) + end + # Called when the title of a Noteable is changed # # old_title - Previous String title @@ -242,19 +268,7 @@ module SystemNotes # # Returns the created Note object def change_status(status, source = nil) - body = status.dup - body << " via #{source.gfm_reference(project)}" if source - - action = status == 'reopened' ? 'opened' : status - - # A state event which results in a synthetic note will be - # created by EventCreateService if change event tracking - # is 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 + create_resource_state_event(status: status, mentionable_source: source) end # Check if a cross reference to a noteable from a mentioner already exists @@ -312,23 +326,11 @@ module SystemNotes end def close_after_error_tracking_resolve - 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')) - end + create_resource_state_event(status: 'closed', close_after_error_tracking_resolve: true) end def auto_resolve_prometheus_alert - 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')) - end + create_resource_state_event(status: 'closed', close_auto_resolve_prometheus_alert: true) end private @@ -361,11 +363,6 @@ module SystemNotes .execute(params) end - def state_change_tracking_enabled? - noteable.respond_to?(:resource_state_events) && - ::Feature.enabled?(:track_resource_state_change_events, noteable.project, default_enabled: true) - end - def issue_activity_counter Gitlab::UsageDataCounters::IssueActivityUniqueCounter end diff --git a/app/services/users/build_service.rb b/app/services/users/build_service.rb index 2fc46f033dd..e3f02bf85f0 100644 --- a/app/services/users/build_service.rb +++ b/app/services/users/build_service.rb @@ -104,7 +104,6 @@ module Users def build_user_params(skip_authorization:) if current_user&.admin? user_params = params.slice(*admin_create_params) - user_params[:created_by_id] = current_user&.id if params[:reset_password] user_params.merge!(force_random_password: true, password_expires_at: nil) @@ -125,6 +124,8 @@ module Users end end + user_params[:created_by_id] = current_user&.id + if user_default_internal_regex_enabled? && !user_params.key?(:external) user_params[:external] = user_external? end diff --git a/app/uploaders/pages/deployment_uploader.rb b/app/uploaders/pages/deployment_uploader.rb new file mode 100644 index 00000000000..4fe1a548a05 --- /dev/null +++ b/app/uploaders/pages/deployment_uploader.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Pages + class DeploymentUploader < GitlabUploader + include ObjectStorage::Concern + + storage_options Gitlab.config.pages + + alias_method :upload, :model + + private + + def dynamic_segment + Gitlab::HashedPath.new('pages_deployments', model.id, root_hash: model.project_id) + end + + # @hashed is chosen to avoid conflict with namespace name because we use the same directory for storage + # @ is not valid character for namespace + def base_dir + "@hashed" + end + end +end diff --git a/app/validators/addressable_url_validator.rb b/app/validators/addressable_url_validator.rb index 9fa99903e36..c6d9bd73566 100644 --- a/app/validators/addressable_url_validator.rb +++ b/app/validators/addressable_url_validator.rb @@ -80,7 +80,7 @@ class AddressableUrlValidator < ActiveModel::EachValidator value = strip_value!(record, attribute, value) - Gitlab::UrlBlocker.validate!(value, blocker_args) + Gitlab::UrlBlocker.validate!(value, **blocker_args) rescue Gitlab::UrlBlocker::BlockedUrlError => e record.errors.add(attribute, options.fetch(:blocked_message) % { exception_message: e.message }) end diff --git a/app/validators/ip_address_validator.rb b/app/validators/ip_address_validator.rb new file mode 100644 index 00000000000..0acf2bdf4fc --- /dev/null +++ b/app/validators/ip_address_validator.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# IpAddressValidator +# +# Validates that an IP address is a valid IPv4 or IPv6 address. +# This should be coupled with a database column of type `inet` +# +# When using column type `inet` Rails will silently return the value +# as `nil` when the value is not valid according to its type cast +# using `IpAddr`. It's not very user friendly to return an error +# "IP Address can't be blank" when a value was clearly given but +# was not the right format. This validator will look at the value +# before Rails type casts it when the value itself is `nil`. +# This enables the validator to return a specific and useful error message. +# +# This validator allows `nil` values by default since the database +# allows null values by default. To disallow `nil` values, use in conjunction +# with `presence: true`. +# +# Do not use this validator with `allow_nil: true` or `allow_blank: true`. +# Because of Rails type casting, when an invalid value is set the attribute +# will return `nil` and Rails won't run this validator. +# +# Example: +# +# class Group < ActiveRecord::Base +# validates :ip_address, presence: true, ip_address: true +# end +# +class IpAddressValidator < ActiveModel::EachValidator + def validate_each(record, attribute, _) + value = record.public_send("#{attribute}_before_type_cast") # rubocop:disable GitlabSecurity/PublicSend + return if value.blank? + + IPAddress.parse(value.to_s) + rescue ArgumentError + record.errors.add(attribute, _('must be a valid IPv4 or IPv6 address')) + end +end diff --git a/app/views/admin/application_settings/_abuse.html.haml b/app/views/admin/application_settings/_abuse.html.haml index ddffec32c41..6ffeea81f28 100644 --- a/app/views/admin/application_settings/_abuse.html.haml +++ b/app/views/admin/application_settings/_abuse.html.haml @@ -3,8 +3,8 @@ %fieldset .form-group - = f.label :admin_notification_email, 'Abuse reports notification email', class: 'label-bold' - = f.text_field :admin_notification_email, class: 'form-control' + = f.label :abuse_notification_email, 'Abuse reports notification email', class: 'label-bold' + = f.text_field :abuse_notification_email, class: 'form-control' .form-text.text-muted Abuse reports will be sent to this address if it is set. Abuse reports are always available in the admin area. diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml index 184249bcaba..1701eb5b6e4 100644 --- a/app/views/admin/application_settings/_account_and_limit.html.haml +++ b/app/views/admin/application_settings/_account_and_limit.html.haml @@ -60,5 +60,4 @@ = render_if_exists 'admin/application_settings/updating_name_disabled_for_users', form: f = render_if_exists 'admin/application_settings/availability_on_namespace_setting', form: f - .gl-display-flex.gl-justify-content-end - = f.submit _('Save changes'), class: 'btn btn-success qa-save-changes-button' + = f.submit _('Save changes'), class: 'btn btn-success qa-save-changes-button' diff --git a/app/views/admin/application_settings/_diff_limits.html.haml b/app/views/admin/application_settings/_diff_limits.html.haml index 1bf25b6a558..734640c16a1 100644 --- a/app/views/admin/application_settings/_diff_limits.html.haml +++ b/app/views/admin/application_settings/_diff_limits.html.haml @@ -12,5 +12,4 @@ = link_to sprite_icon('question-o'), help_page_path('user/admin_area/diff_limits', anchor: 'maximum-diff-patch-size') - .gl-display-flex.gl-justify-content-end - = f.submit _('Save changes'), class: 'btn btn-success' + = f.submit _('Save changes'), class: 'btn btn-success' diff --git a/app/views/admin/application_settings/_external_authorization_service_form.html.haml b/app/views/admin/application_settings/_external_authorization_service_form.html.haml index 179eb2d5f2e..08620bdde35 100644 --- a/app/views/admin/application_settings/_external_authorization_service_form.html.haml +++ b/app/views/admin/application_settings/_external_authorization_service_form.html.haml @@ -47,5 +47,4 @@ .form-group = f.label :external_authorization_service_default_label, _('Default classification label'), class: 'label-bold' = f.text_field :external_authorization_service_default_label, class: 'form-control' - .gl-display-flex.gl-justify-content-end - = f.submit 'Save changes', class: "btn btn-success" + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_gitpod.html.haml b/app/views/admin/application_settings/_gitpod.html.haml index bbad5155ada..cf40eb7b108 100644 --- a/app/views/admin/application_settings/_gitpod.html.haml +++ b/app/views/admin/application_settings/_gitpod.html.haml @@ -1,6 +1,5 @@ - return unless Gitlab::Gitpod.feature_available? - expanded = integration_expanded?('gitpod_') -- gitpod_link = link_to("Gitpod#{sprite_icon('external-link', size: 12, css_class: 'ml-1 vertical-align-center')}".html_safe, 'https://gitpod.io/', target: '_blank', rel: 'noopener noreferrer') %section.settings.no-animate#js-gitpod-settings{ class: ('expanded' if expanded) } .settings-header @@ -9,7 +8,7 @@ %button.btn.btn-default.js-settings-toggle{ type: 'button' } = expanded ? _('Collapse') : _('Expand') %p - = s_('Enable %{gitpod_link} integration to launch a development environment in your browser directly from GitLab.').html_safe % { gitpod_link: gitpod_link } + = gitpod_enable_description = link_to sprite_icon('question-o'), help_page_path('integration/gitpod.md'), target: '_blank', class: 'has-tooltip', title: _('More information') diff --git a/app/views/admin/application_settings/_initial_branch_name.html.haml b/app/views/admin/application_settings/_initial_branch_name.html.haml index acbf971e4b9..bab841fcade 100644 --- a/app/views/admin/application_settings/_initial_branch_name.html.haml +++ b/app/views/admin/application_settings/_initial_branch_name.html.haml @@ -10,5 +10,4 @@ %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 - .gl-display-flex.gl-justify-content-end - = f.submit _('Save changes'), class: 'gl-button btn-success' + = f.submit _('Save changes'), class: 'gl-button btn-success' diff --git a/app/views/admin/application_settings/_repository_check.html.haml b/app/views/admin/application_settings/_repository_check.html.haml index 6f9d3a889cd..e6874cc3f78 100644 --- a/app/views/admin/application_settings/_repository_check.html.haml +++ b/app/views/admin/application_settings/_repository_check.html.haml @@ -18,8 +18,7 @@ If you got a lot of false alarms from repository checks you can choose to clear all repository check information from the database. - clear_repository_checks_link = _('Clear all repository checks') - clear_repository_checks_message = _('This will clear repository check states for ALL projects in the database. This cannot be undone. Are you sure?') - .gl-display-flex.gl-justify-content-end - = link_to clear_repository_checks_link, clear_repository_check_states_admin_application_settings_path, data: { confirm: clear_repository_checks_message }, method: :put, class: "btn btn-sm btn-remove" + = link_to clear_repository_checks_link, clear_repository_check_states_admin_application_settings_path, data: { confirm: clear_repository_checks_message }, method: :put, class: "btn btn-sm btn-remove" .sub-section %h4 Housekeeping @@ -56,5 +55,4 @@ .form-text.text-muted Number of Git pushes after which 'git gc' is run. - .gl-display-flex.gl-justify-content-end - = f.submit _('Save changes'), class: "btn btn-success" + = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/admin/application_settings/_repository_mirrors_form.html.haml b/app/views/admin/application_settings/_repository_mirrors_form.html.haml index d598f173ff3..86f8ea8821e 100644 --- a/app/views/admin/application_settings/_repository_mirrors_form.html.haml +++ b/app/views/admin/application_settings/_repository_mirrors_form.html.haml @@ -14,5 +14,4 @@ = render_if_exists 'admin/application_settings/mirror_settings', form: f - .gl-display-flex.gl-justify-content-end - = f.submit _('Save changes'), class: "btn btn-success" + = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/admin/application_settings/_repository_static_objects.html.haml b/app/views/admin/application_settings/_repository_static_objects.html.haml index 9bc751adc8b..03aa48b2282 100644 --- a/app/views/admin/application_settings/_repository_static_objects.html.haml +++ b/app/views/admin/application_settings/_repository_static_objects.html.haml @@ -15,5 +15,4 @@ %span.form-text.text-muted#static_objects_external_storage_auth_token_help_block = _('A secure token that identifies an external storage request.') - .gl-display-flex.gl-justify-content-end - = f.submit _('Save changes'), class: "btn btn-success" + = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/admin/application_settings/_repository_storage.html.haml b/app/views/admin/application_settings/_repository_storage.html.haml index 0dc8dc0740e..71c957b0bea 100644 --- a/app/views/admin/application_settings/_repository_storage.html.haml +++ b/app/views/admin/application_settings/_repository_storage.html.haml @@ -22,5 +22,4 @@ = f.text_field attribute[:name], class: 'form-text-input', value: attribute[:value] = f.label attribute[:label], attribute[:label], class: 'label-bold form-check-label' %br - .gl-display-flex.gl-justify-content-end - = f.submit _('Save changes'), class: "btn btn-success qa-save-changes-button" + = f.submit _('Save changes'), class: "btn btn-success qa-save-changes-button" diff --git a/app/views/admin/application_settings/_signin.html.haml b/app/views/admin/application_settings/_signin.html.haml index 2a26a0909fd..f2ff3891ace 100644 --- a/app/views/admin/application_settings/_signin.html.haml +++ b/app/views/admin/application_settings/_signin.html.haml @@ -57,5 +57,4 @@ = f.label :sign_in_text, class: 'label-bold' = f.text_area :sign_in_text, class: 'form-control', rows: 4 .form-text.text-muted Markdown enabled - .gl-display-flex.gl-justify-content-end - = f.submit 'Save changes', class: "btn btn-success" + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_signup.html.haml b/app/views/admin/application_settings/_signup.html.haml index 3b88696dc51..adc585125a6 100644 --- a/app/views/admin/application_settings/_signup.html.haml +++ b/app/views/admin/application_settings/_signup.html.haml @@ -9,6 +9,14 @@ Sign-up enabled .form-text.text-muted = _("When enabled, any user visiting %{host} will be able to create an account.") % { host: "#{new_user_session_url(host: Gitlab.config.gitlab.host)}" } + - if Feature.enabled?(:admin_approval_for_new_user_signups) + .form-group + .form-check + = f.check_box :require_admin_approval_after_user_signup, class: 'form-check-input' + = f.label :require_admin_approval_after_user_signup, class: 'form-check-label' do + = _('Require admin approval for new sign-ups') + .form-text.text-muted + = _("When enabled, any user visiting %{host} and creating an account will have to be explicitly approved by the admin before they can login. This setting is effective only if sign-ups are enabled.") % { host: "#{new_user_session_url(host: Gitlab.config.gitlab.host)}" } .form-group .form-check = f.check_box :send_user_confirmation_email, class: 'form-check-input' @@ -67,5 +75,4 @@ = f.label :after_sign_up_text, class: 'label-bold' = f.text_area :after_sign_up_text, class: 'form-control', rows: 4 .form-text.text-muted Markdown enabled - .gl-display-flex.gl-justify-content-end - = f.submit 'Save changes', class: "btn btn-success" + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_terminal.html.haml b/app/views/admin/application_settings/_terminal.html.haml index 25d23ea7a84..60d5ca1ee0f 100644 --- a/app/views/admin/application_settings/_terminal.html.haml +++ b/app/views/admin/application_settings/_terminal.html.haml @@ -8,5 +8,4 @@ .form-text.text-muted Maximum time for web terminal websocket connection (in seconds). 0 for unlimited. - .gl-display-flex.gl-justify-content-end - = f.submit 'Save changes', class: "btn btn-success" + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_terms.html.haml b/app/views/admin/application_settings/_terms.html.haml index a6d03ac1dde..07d372882b9 100644 --- a/app/views/admin/application_settings/_terms.html.haml +++ b/app/views/admin/application_settings/_terms.html.haml @@ -15,5 +15,4 @@ = f.text_area :terms, class: 'form-control', rows: 8 .form-text.text-muted = _("Markdown enabled") - .gl-display-flex.gl-justify-content-end - = f.submit _("Save changes"), class: "btn btn-success" + = f.submit _("Save changes"), class: "btn btn-success" diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml index 28208d923db..d44a1524f2e 100644 --- a/app/views/admin/application_settings/_visibility_and_access.html.haml +++ b/app/views/admin/application_settings/_visibility_and_access.html.haml @@ -66,5 +66,4 @@ .form-group = f.label field_name, "#{type.upcase} SSH keys", class: 'label-bold' = f.select field_name, key_restriction_options_for_select(type), {}, class: 'form-control' - .gl-display-flex.gl-justify-content-end - = f.submit _('Save changes'), class: "btn btn-success" + = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml index 823cee09d4b..493c995dd91 100644 --- a/app/views/admin/application_settings/general.html.haml +++ b/app/views/admin/application_settings/general.html.haml @@ -101,8 +101,7 @@ = s_('IDE|Live Preview') %span.form-text.text-muted = s_('IDE|Allow live previews of JavaScript projects in the Web IDE using CodeSandbox Live Preview.') - .gl-display-flex.gl-justify-content-end - = f.submit _('Save changes'), class: "btn btn-success" + = f.submit _('Save changes'), class: "btn btn-success" - if Feature.enabled?(:maintenance_mode) %section.settings.no-animate#js-maintenance-mode-toggle{ class: ('expanded' if expanded_by_default?) } diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml index 8a937bd66cf..9693a97367f 100644 --- a/app/views/admin/broadcast_messages/_form.html.haml +++ b/app/views/admin/broadcast_messages/_form.html.haml @@ -1,4 +1,4 @@ -.broadcast-message.broadcast-banner-message.alert-warning.js-broadcast-banner-message-preview.mt-2{ style: broadcast_message_style(@broadcast_message), class: ('hidden' unless @broadcast_message.banner? ) } +.broadcast-message.broadcast-banner-message.gl-alert-warning.js-broadcast-banner-message-preview.gl-mt-3{ style: broadcast_message_style(@broadcast_message), class: ('gl-display-none' unless @broadcast_message.banner? ) } = sprite_icon('bullhorn', css_class:'vertical-align-text-top') .js-broadcast-message-preview - if @broadcast_message.message.present? @@ -77,6 +77,6 @@ = f.datetime_select :ends_at, {}, class: 'form-control form-control-inline' .form-actions - if @broadcast_message.persisted? - = f.submit "Update broadcast message", class: "btn btn-success" + = f.submit "Update broadcast message", class: "btn gl-button btn-success" - else - = f.submit "Add broadcast message", class: "btn btn-success" + = f.submit "Add broadcast message", class: "btn gl-button btn-success" diff --git a/app/views/admin/dashboard/_billable_users_text.html.haml b/app/views/admin/dashboard/_billable_users_text.html.haml new file mode 100644 index 00000000000..e9485d23228 --- /dev/null +++ b/app/views/admin/dashboard/_billable_users_text.html.haml @@ -0,0 +1 @@ += s_('AdminArea|Active users') diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 4acfc96caf2..b0d4a3fd8f5 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -19,7 +19,7 @@ %h3.text-center = s_('AdminArea|Projects: %{number_of_projects}') % { number_of_projects: approximate_count_with_delimiters(@counts, Project) } %hr - = link_to(s_('AdminArea|New project'), new_project_path, class: "btn btn-success gl-w-full") + = link_to(s_('AdminArea|New project'), new_project_path, class: "btn gl-button btn-success gl-w-full") .col-sm-4 .info-well.dark-well .well-segment.well-centered @@ -28,8 +28,8 @@ = s_('AdminArea|Users: %{number_of_users}') % { number_of_users: approximate_count_with_delimiters(@counts, User) } %hr .btn-group.d-flex{ role: 'group' } - = link_to s_('AdminArea|New user'), new_admin_user_path, class: "btn btn-success gl-w-full" - = link_to s_('AdminArea|Users statistics'), admin_dashboard_stats_path, class: 'btn btn-primary gl-w-full' + = link_to s_('AdminArea|New user'), new_admin_user_path, class: "btn gl-button btn-success gl-w-full" + = link_to s_('AdminArea|Users statistics'), admin_dashboard_stats_path, class: 'btn gl-button btn-info gl-w-full' .col-sm-4 .info-well.dark-well .well-segment.well-centered @@ -37,7 +37,7 @@ %h3.text-center = s_('AdminArea|Groups: %{number_of_groups}') % { number_of_groups: approximate_count_with_delimiters(@counts, Group) } %hr - = link_to s_('AdminArea|New group'), new_admin_group_path, class: "btn btn-success gl-w-full" + = link_to s_('AdminArea|New group'), new_admin_group_path, class: "btn gl-button btn-success gl-w-full" .row .col-md-4 #js-admin-statistics-container @@ -51,7 +51,7 @@ = feature_entry(_('LDAP'), enabled: Gitlab.config.ldap.enabled, - doc_href: help_page_path('administration/auth/ldap')) + doc_href: help_page_path('administration/auth/ldap/index.md')) = feature_entry(_('Gravatar'), href: general_admin_application_settings_path(anchor: 'js-account-settings'), diff --git a/app/views/admin/dashboard/stats.html.haml b/app/views/admin/dashboard/stats.html.haml index 78707235cb5..9a89bf12365 100644 --- a/app/views/admin/dashboard/stats.html.haml +++ b/app/views/admin/dashboard/stats.html.haml @@ -50,11 +50,9 @@ = s_('AdminArea|Bots') %td.p-3.text-right = @users_statistics&.bots.to_i - %tr.bg-gray-light.gl-text-gray-900 %td.p-3 %strong - = s_('AdminArea|Active users') = render_if_exists 'admin/dashboard/billable_users_text' %td.p-3.text-right %strong diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml index 041b0661d37..1feb2ad16ad 100644 --- a/app/views/admin/groups/_form.html.haml +++ b/app/views/admin/groups/_form.html.haml @@ -36,5 +36,3 @@ .form-actions = f.submit _('Save changes'), class: "btn btn-success", data: { qa_selector: 'save_changes_button' } = link_to _('Cancel'), admin_group_path(@group), class: "btn btn-cancel" - -= render_if_exists 'ldap_group_links/ldap_syncrhonizations', group: @group diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml index 6c2c0b3a488..dc43b45195e 100644 --- a/app/views/admin/groups/show.html.haml +++ b/app/views/admin/groups/show.html.haml @@ -113,7 +113,7 @@ %div = users_select_tag(:user_ids, multiple: true, email_user: true, skip_ldap: @group.ldap_synced?, scope: :all) .gl-mt-3 - = select_tag :access_level, options_for_select(GroupMember.access_level_roles), class: "project-access-select select2" + = select_tag :access_level, options_for_select(@group.access_level_roles), class: "project-access-select select2" %hr = button_tag _('Add users to group'), class: "btn btn-success" = render 'shared/members/requests', membership_source: @group, requesters: @requesters, force_mobile_view: true diff --git a/app/views/admin/hook_logs/show.html.haml b/app/views/admin/hook_logs/show.html.haml index a8ef19dcf46..ca2737ca56f 100644 --- a/app/views/admin/hook_logs/show.html.haml +++ b/app/views/admin/hook_logs/show.html.haml @@ -4,6 +4,6 @@ %hr -= link_to _("Resend Request"), retry_admin_hook_hook_log_path(@hook, @hook_log), method: :post, class: "btn btn-default float-right gl-ml-3" += link_to _("Resend Request"), retry_admin_hook_hook_log_path(@hook, @hook_log), method: :post, class: "btn gl-button 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/edit.html.haml b/app/views/admin/hooks/edit.html.haml index f9faf5b11fa..924cc0fe54f 100644 --- a/app/views/admin/hooks/edit.html.haml +++ b/app/views/admin/hooks/edit.html.haml @@ -9,9 +9,9 @@ = form_for @hook, as: :hook, url: admin_hook_path do |f| = render partial: 'form', locals: { form: f, hook: @hook } .form-actions - %span>= f.submit _('Save changes'), class: 'btn btn-success gl-mr-3' + %span>= f.submit _('Save changes'), class: 'btn gl-button btn-success gl-mr-3' = render 'shared/web_hooks/test_button', hook: @hook - = link_to _('Delete'), admin_hook_path(@hook), method: :delete, class: 'btn btn-remove float-right', data: { confirm: _('Are you sure?') } + = link_to _('Delete'), admin_hook_path(@hook), method: :delete, class: 'btn gl-button btn-remove float-right', data: { confirm: _('Are you sure?') } %hr diff --git a/app/views/admin/hooks/index.html.haml b/app/views/admin/hooks/index.html.haml index d70baa592ea..c0bad6a0a63 100644 --- a/app/views/admin/hooks/index.html.haml +++ b/app/views/admin/hooks/index.html.haml @@ -7,7 +7,7 @@ .col-lg-8.gl-mb-3 = 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' + = f.submit _('Add system hook'), class: 'btn gl-button btn-success' = render 'shared/web_hooks/index', hooks: @hooks, hook_class: @hook.class diff --git a/app/views/admin/labels/_form.html.haml b/app/views/admin/labels/_form.html.haml index 299d0a12e6c..664081339f3 100644 --- a/app/views/admin/labels/_form.html.haml +++ b/app/views/admin/labels/_form.html.haml @@ -27,5 +27,5 @@ = render_suggested_colors .form-actions - = f.submit _('Save'), class: 'btn btn-success js-save-button' - = link_to _("Cancel"), admin_labels_path, class: 'btn btn-cancel' + = f.submit _('Save'), class: 'btn gl-button btn-success js-save-button' + = link_to _("Cancel"), admin_labels_path, class: 'btn gl-button btn-cancel' diff --git a/app/views/admin/labels/_label.html.haml b/app/views/admin/labels/_label.html.haml index 6d934654c5d..b31b9bdab0a 100644 --- a/app/views/admin/labels/_label.html.haml +++ b/app/views/admin/labels/_label.html.haml @@ -1,7 +1,7 @@ %li.label-list-item{ id: dom_id(label) } = render "shared/label_row", label: label.present(issuable_subject: nil) .label-actions-list - = link_to edit_admin_label_path(label), class: 'btn btn-transparent label-action has-tooltip', title: _('Edit'), data: { placement: 'bottom' }, aria_label: _('Edit') do + = link_to edit_admin_label_path(label), class: 'btn gl-button btn-transparent label-action has-tooltip', title: _('Edit'), data: { placement: 'bottom' }, aria_label: _('Edit') do = sprite_icon('pencil') - = link_to admin_label_path(label), class: 'btn btn-transparent remove-row label-action has-tooltip', title: _('Delete'), data: { placement: 'bottom', confirm: "Delete this label? Are you sure?" }, aria_label: _('Delete'), method: :delete, remote: true do + = link_to admin_label_path(label), class: 'btn gl-button btn-transparent remove-row label-action has-tooltip', title: _('Delete'), data: { placement: 'bottom', confirm: "Delete this label? Are you sure?" }, aria_label: _('Delete'), method: :delete, remote: true do = sprite_icon('remove') diff --git a/app/views/admin/labels/index.html.haml b/app/views/admin/labels/index.html.haml index 38137f360fd..76d37626fff 100644 --- a/app/views/admin/labels/index.html.haml +++ b/app/views/admin/labels/index.html.haml @@ -1,7 +1,7 @@ - page_title _("Labels") %div - = link_to new_admin_label_path, class: "float-right btn btn-nr btn-success" do + = link_to new_admin_label_path, class: "float-right btn gl-button btn-nr btn-success" do = _('New label') %h3.page-title = _('Labels') diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index d5af12fcd09..01a0b4d295d 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -13,8 +13,9 @@ - if @project.last_repository_check_failed? .row .col-md-12 - .card - .card-header.alert.alert-danger + .gl-alert.gl-alert-danger.gl-mb-5 + = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + .gl-alert-body - last_check_message = _("Last repository check (%{last_check_timestamp}) failed. See the 'repocheck.log' file for error messages.") - last_check_message = last_check_message % { last_check_timestamp: time_ago_with_tooltip(@project.last_repository_check_at) } = last_check_message.html_safe diff --git a/app/views/admin/users/_modals.html.haml b/app/views/admin/users/_modals.html.haml index 6cf6dc116e3..a8e5d962e5b 100644 --- a/app/views/admin/users/_modals.html.haml +++ b/app/views/admin/users/_modals.html.haml @@ -25,6 +25,6 @@ 'secondary-action': s_('AdminUsers|Block user') } } = s_('AdminUsers|You are about to permanently delete the user %{username}. This will delete all of the issues, merge requests, and groups linked to them. To avoid data loss, - consider using the %{strong_start}block user%{strong_end} feature instead. Once you %{strong_start}Delete user%{strong_end}, + consider using the %{strongStart}block user%{strongEnd} feature instead. Once you %{strongStart}Delete user%{strongEnd}, it cannot be undone or recovered.') diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml index 160303890f5..284307d1d54 100644 --- a/app/views/admin/users/_user.html.haml +++ b/app/views/admin/users/_user.html.haml @@ -4,7 +4,12 @@ = _('Name') .table-mobile-content = render 'user_detail', user: user - .table-section.section-25 + .table-section.section-10 + .table-mobile-header{ role: 'rowheader' } + = _('Projects') + .table-mobile-content.gl-str-truncated{ data: { testid: "user-project-count-#{user.id}" } } + = user.authorized_projects.length + .table-section.section-15 .table-mobile-header{ role: 'rowheader' } = _('Created on') .table-mobile-content diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml index 118bdf7bb17..78c3e41278d 100644 --- a/app/views/admin/users/index.html.haml +++ b/app/views/admin/users/index.html.haml @@ -72,7 +72,8 @@ .table-holder .thead-white.text-nowrap.gl-responsive-table-row.table-row-header{ role: 'row' } .table-section.section-40{ role: 'rowheader' }= _('Name') - .table-section.section-25{ role: 'rowheader' }= _('Created on') + .table-section.section-10{ role: 'rowheader' }= _('Projects') + .table-section.section-15{ role: 'rowheader' }= _('Created on') .table-section.section-15{ role: 'rowheader' }= _('Last activity') = render partial: 'admin/users/user', collection: @users diff --git a/app/views/clusters/clusters/_advanced_settings.html.haml b/app/views/clusters/clusters/_advanced_settings.html.haml index 117bdbc06a1..047f77ba94b 100644 --- a/app/views/clusters/clusters/_advanced_settings.html.haml +++ b/app/views/clusters/clusters/_advanced_settings.html.haml @@ -24,17 +24,18 @@ .text-muted = html_escape(s_('ClusterIntegration|A cluster management project can be used to run deployment jobs with Kubernetes %{code_open}cluster-admin%{code_close} privileges.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } = link_to _('More information'), help_page_path('user/clusters/management_project.md'), target: '_blank' - .gl-display-flex.gl-justify-content-end - = field.submit _('Save changes'), class: 'btn btn-success' + = field.submit _('Save changes'), class: 'btn btn-success' - - if @cluster.managed? - .sub-section.form-group - %h4 - = s_('ClusterIntegration|Clear cluster cache') - %p - = s_("ClusterIntegration|Clear the local cache of namespace and service accounts. This is necessary if your integration has become out of sync. The cache is repopulated during the next CI job that requires namespace and service accounts.") - .gl-display-flex.gl-justify-content-end - = link_to(s_('ClusterIntegration|Clear cluster cache'), clusterable.clear_cluster_cache_path(@cluster), method: :delete, class: 'btn btn-primary') + .sub-section.form-group + %h4 + = s_('ClusterIntegration|Clear cluster cache') + %p + = s_("ClusterIntegration|Clear the local cache of namespace and service accounts.") + - if @cluster.managed? + = s_("ClusterIntegration|This is necessary if your integration has become out of sync. The cache is repopulated during the next CI job that requires namespace and service accounts.") + - else + = s_("ClusterIntegration|This is necessary to clear existing environment-namespace associations from clusters previously managed by GitLab.") + = link_to(s_('ClusterIntegration|Clear cluster cache'), clusterable.clear_cluster_cache_path(@cluster), method: :delete, class: 'btn btn-primary') .sub-section.form-group %h4.text-danger diff --git a/app/views/clusters/clusters/_buttons.html.haml b/app/views/clusters/clusters/_buttons.html.haml deleted file mode 100644 index c81d1d5b05a..00000000000 --- a/app/views/clusters/clusters/_buttons.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -.nav-controls - - if clusterable.can_add_cluster? - = link_to s_('ClusterIntegration|Add Kubernetes cluster'), clusterable.new_path, class: 'btn btn-success js-add-cluster' - - else - %span.btn.btn-add-cluster.disabled.js-add-cluster - = s_("ClusterIntegration|Add Kubernetes cluster") diff --git a/app/views/clusters/clusters/_cluster.html.haml b/app/views/clusters/clusters/_cluster.html.haml deleted file mode 100644 index f11117ea5c4..00000000000 --- a/app/views/clusters/clusters/_cluster.html.haml +++ /dev/null @@ -1,19 +0,0 @@ -.card - .card-body.gl-responsive-table-row - .table-section.section-60 - .table-mobile-header{ role: "rowheader" }= s_("ClusterIntegration|Kubernetes cluster") - .table-mobile-content.gl-display-flex.gl-align-items-center.gl-justify-content-end.gl-justify-content-md-start - .gl-w-6.gl-h-6.gl-mr-3.gl-display-flex.gl-align-items-center= provider_icon(cluster.provider_type) - = cluster.item_link(clusterable, html_options: { data: { qa_selector: 'cluster', qa_cluster_name: cluster.name } }) - - if cluster.status_name == :creating - .spinner.ml-2.align-middle.has-tooltip{ title: s_("ClusterIntegration|Cluster being created") } - - unless cluster.enabled? - %span.badge.badge-danger Connection disabled - .table-section.section-25 - .table-mobile-header{ role: "rowheader" }= s_("ClusterIntegration|Environment scope") - .table-mobile-content= cluster.environment_scope - .table-section.section-15.text-right - .table-mobile-header{ role: "rowheader" } - .table-mobile-content - %span.badge.badge-light - = cluster.cluster_type_description diff --git a/app/views/clusters/clusters/_cluster_list.html.haml b/app/views/clusters/clusters/_cluster_list.html.haml new file mode 100644 index 00000000000..9627d940126 --- /dev/null +++ b/app/views/clusters/clusters/_cluster_list.html.haml @@ -0,0 +1,12 @@ +- if clusters.empty? + = render 'empty_state' +- else + .top-area.adjust + .gl-display-block.gl-text-right.gl-my-4.gl-w-full + - if clusterable.can_add_cluster? + = link_to s_('ClusterIntegration|Connect cluster with certificate'), clusterable.new_path, class: 'btn gl-button btn-success js-add-cluster gl-py-2', qa_selector: :integrate_kubernetes_cluster_button + - else + %span.btn.gl-button.btn-success.js-add-cluster.disabled.gl-py-2 + = s_("ClusterIntegration|Connect cluster with certificate") + + #js-clusters-list-app{ data: js_clusters_list_data(clusterable.index_path(format: :json)) } diff --git a/app/views/clusters/clusters/_empty_state.html.haml b/app/views/clusters/clusters/_empty_state.html.haml index cfdbfe2dea1..1798ba81075 100644 --- a/app/views/clusters/clusters/_empty_state.html.haml +++ b/app/views/clusters/clusters/_empty_state.html.haml @@ -3,12 +3,12 @@ .svg-content= image_tag 'illustrations/clusters_empty.svg' .col-12 .text-content - %h4.text-center= s_('ClusterIntegration|Integrate Kubernetes cluster automation') - %p + %h4.gl-text-center= s_('ClusterIntegration|Integrate Kubernetes with a cluster certificate') + %p.gl-text-center = s_('ClusterIntegration|Kubernetes clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way.') = clusterable.empty_state_help_text = clusterable.learn_more_link - if clusterable.can_add_cluster? - .text-center - = link_to s_('ClusterIntegration|Add Kubernetes cluster'), clusterable.new_path, class: 'btn btn-success' + .gl-text-center + = link_to s_('ClusterIntegration|Integrate with a cluster certificate'), clusterable.new_path, class: 'btn btn-success' 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 54f6fa91cf1..8c23fc7c590 100644 --- a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml +++ b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml @@ -1,11 +1,9 @@ - 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.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 - = sprite_icon("information") - .gcp-signup-offer--copy - %h4= s_('ClusterIntegration|Did you know?') - %p= s_('ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for both new and existing GCP accounts to get started with GitLab\'s Google Kubernetes Engine Integration.').html_safe % { sign_up_link: link } - %a.btn.btn-default{ href: 'https://cloud.google.com/partners/partnercredit/?pcn_code=0014M00001h35gDQAQ#contact-form', target: '_blank', rel: 'noopener noreferrer' } - = s_("ClusterIntegration|Apply for credit") +.gcp-signup-offer.gl-alert.gl-alert-info.gl-my-3{ role: 'alert', data: { feature_id: UserCalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: user_callouts_path } } + %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') } + = sprite_icon('close', size: 16, css_class: 'gl-icon') + = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + %h4.gl-alert-title= s_('ClusterIntegration|Did you know?') + %p.gl-alert-body= s_('ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for both new and existing GCP accounts to get started with GitLab\'s Google Kubernetes Engine Integration.').html_safe % { sign_up_link: link } + %a.gl-button.btn-info{ href: 'https://cloud.google.com/partners/partnercredit/?pcn_code=0014M00001h35gDQAQ#contact-form', target: '_blank', rel: 'noopener noreferrer' } + = s_("ClusterIntegration|Apply for credit") diff --git a/app/views/clusters/clusters/_provider_details_form.html.haml b/app/views/clusters/clusters/_provider_details_form.html.haml index e211851b939..16891c7fc21 100644 --- a/app/views/clusters/clusters/_provider_details_form.html.haml +++ b/app/views/clusters/clusters/_provider_details_form.html.haml @@ -42,11 +42,17 @@ class: 'js-gl-managed', label_class: 'label-bold' } .form-text.text-muted - = s_('ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster.') + = s_('ClusterIntegration|Allow GitLab to manage namespaces and service accounts for this cluster.') = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'), target: '_blank' + .form-group + = field.check_box :namespace_per_environment, { label: s_('ClusterIntegration|Namespace per environment'), label_class: 'label-bold' } + .form-text.text-muted + = s_('ClusterIntegration|Deploy each environment to its own namespace. Otherwise, environments within a project share a project-wide namespace. Note that anyone who can trigger a deployment of a namespace can read its secrets. If modified, existing environments will use their current namespaces until the cluster cache is cleared.') + = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'custom-namespace'), target: '_blank' + - if cluster.allow_user_defined_namespace? - = render('clusters/clusters/namespace', platform_field: platform_field) + = render('clusters/clusters/namespace', platform_field: platform_field, field: field) - .form-group.gl-display-flex.gl-justify-content-end + .form-group = field.submit s_('ClusterIntegration|Save changes'), class: 'btn btn-success' diff --git a/app/views/clusters/clusters/aws/_new.html.haml b/app/views/clusters/clusters/aws/_new.html.haml index 3eab9b46fb3..b1a277faae9 100644 --- a/app/views/clusters/clusters/aws/_new.html.haml +++ b/app/views/clusters/clusters/aws/_new.html.haml @@ -4,6 +4,7 @@ = 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'), + 'namespace-per-environment-help-path' => help_page_path('user/project/clusters/index.md', anchor: 'custom-namespace'), 'create-role-path' => clusterable.authorize_aws_role_path, 'create-cluster-path' => clusterable.create_aws_clusters_path, 'account-id' => Gitlab::CurrentSettings.eks_account_id, diff --git a/app/views/clusters/clusters/gcp/_form.html.haml b/app/views/clusters/clusters/gcp/_form.html.haml index 434c02a5c41..ceb6e1d46b0 100644 --- a/app/views/clusters/clusters/gcp/_form.html.haml +++ b/app/views/clusters/clusters/gcp/_form.html.haml @@ -75,9 +75,15 @@ = field.check_box :managed, { label: s_('ClusterIntegration|GitLab-managed cluster'), label_class: 'label-bold' } .form-text.text-muted - = s_('ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster.') + = s_('ClusterIntegration|Allow GitLab to manage namespaces and service accounts for this cluster.') = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'), target: '_blank' + .form-group + = field.check_box :namespace_per_environment, { label: s_('ClusterIntegration|Namespace per environment'), label_class: 'label-bold' } + .form-text.text-muted + = s_('ClusterIntegration|Deploy each environment to its own namespace. Otherwise, environments within a project share a project-wide namespace. Note that anyone who can trigger a deployment of a namespace can read its secrets. If modified, existing environments will use their current namespaces until the cluster cache is cleared.') + = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'custom-namespace'), target: '_blank' + .form-group.js-gke-cluster-creation-submit-container = field.submit s_('ClusterIntegration|Create Kubernetes cluster'), class: 'js-gke-cluster-creation-submit btn btn-success', disabled: true diff --git a/app/views/clusters/clusters/index.html.haml b/app/views/clusters/clusters/index.html.haml index 557ad1bf280..45287a01cc9 100644 --- a/app/views/clusters/clusters/index.html.haml +++ b/app/views/clusters/clusters/index.html.haml @@ -3,30 +3,24 @@ = render_gcp_signup_offer -.clusters-container - - if @clusters.empty? - = render "empty_state" - - else - .top-area.adjust - .nav-text - = s_('ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project') - = render 'clusters/clusters/buttons' +.clusters-container.gl-my-2 + - if display_cluster_agents?(clusterable) + .js-toggle-container + %ul.nav-links.nav-tabs.nav{ role: 'tablist' } + %li.nav-item{ role: 'presentation' } + %a.nav-link.active{ href: "#certificate-clusters-pane", id: "certificate-clusters-tab", data: { toggle: 'tab' }, role: 'tab' } + %span= s_('ClusterIntegration|Clusters connected with a certificate') + + %li.nav-item{ role: 'presentation' } + %a.nav-link{ href: "#agent-clusters-pane", id: "agent-clusters-tab", data: { toggle: 'tab' }, role: 'tab' } + %span= s_('ClusterIntegration|GitLab Agent managed clusters') + + .tab-content + .tab-pane.active{ id: 'certificate-clusters-pane', role: 'tabpanel' } + = render 'cluster_list', clusters: @clusters - - 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" } - = s_("ClusterIntegration|Kubernetes cluster") - .table-section.section-30{ role: "rowheader" } - = s_("ClusterIntegration|Environment scope") - .table-section.section-10{ role: "rowheader" } - - @clusters.each do |cluster| - = render "cluster", cluster: cluster.present(current_user: current_user) - = paginate @clusters, theme: "gitlab" + .tab-pane{ id: 'agent-clusters-pane', role: 'tabpanel' } + #js-cluster-agents-list{ data: js_cluster_agents_list_data(clusterable) } + + - else + = render 'cluster_list', clusters: @clusters diff --git a/app/views/clusters/clusters/user/_form.html.haml b/app/views/clusters/clusters/user/_form.html.haml index 11772107135..a6097038b2e 100644 --- a/app/views/clusters/clusters/user/_form.html.haml +++ b/app/views/clusters/clusters/user/_form.html.haml @@ -46,9 +46,15 @@ class: 'js-gl-managed', label_class: 'label-bold' } .form-text.text-muted - = s_('ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster.') + = s_('ClusterIntegration|Allow GitLab to manage namespaces and service accounts for this cluster.') = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'), target: '_blank' + .form-group + = field.check_box :namespace_per_environment, { label: s_('ClusterIntegration|Namespace per environment'), label_class: 'label-bold' } + .form-text.text-muted + = s_('ClusterIntegration|Deploy each environment to its own namespace. Otherwise, environments within a project share a project-wide namespace. Note that anyone who can trigger a deployment of a namespace can read its secrets. If modified, existing environments will use their current namespaces until the cluster cache is cleared.') + = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'custom-namespace'), target: '_blank' + = field.fields_for :platform_kubernetes, @user_cluster.platform_kubernetes do |platform_kubernetes_field| - if @user_cluster.allow_user_defined_namespace? = render('clusters/clusters/namespace', platform_field: platform_kubernetes_field) diff --git a/app/views/dashboard/milestones/index.html.haml b/app/views/dashboard/milestones/index.html.haml index a0c1c314a85..923e78ad360 100644 --- a/app/views/dashboard/milestones/index.html.haml +++ b/app/views/dashboard/milestones/index.html.haml @@ -1,6 +1,7 @@ - @hide_top_links = true - page_title _('Milestones') - header_title _('Milestones'), dashboard_milestones_path +- add_page_specific_style 'page_bundles/milestone' .page-title-holder.d-flex.align-items-center %h1.page-title= _('Milestones') diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml index 82abb9b3b8a..96714ebf922 100644 --- a/app/views/dashboard/todos/_todo.html.haml +++ b/app/views/dashboard/todos/_todo.html.haml @@ -31,7 +31,7 @@ - if todo.self_assigned? %span.title-item.action-name - to yourself + = todo_self_addressing(todo) %span.title-item · diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml index 9c6a6be1bc3..44d968ae26d 100644 --- a/app/views/dashboard/todos/index.html.haml +++ b/app/views/dashboard/todos/index.html.haml @@ -3,7 +3,7 @@ - header_title _("To-Do List"), dashboard_todos_path = render_dashboard_gold_trial(current_user) -= stylesheet_link_tag 'page_bundles/todos' +- add_page_specific_style 'page_bundles/todos' .page-title-holder.d-flex.align-items-center %h1.page-title= _('To-Do List') diff --git a/app/views/devise/mailer/confirmation_instructions.text.erb b/app/views/devise/mailer/confirmation_instructions.text.erb index 05fddddf415..925ad9bd22e 100644 --- a/app/views/devise/mailer/confirmation_instructions.text.erb +++ b/app/views/devise/mailer/confirmation_instructions.text.erb @@ -1 +1 @@ -<%= render partial: "confirmation_instructions_#{@resource.is_a?(User) ? 'account' : 'secondary'}" %>
\ No newline at end of file +<%= render partial: "confirmation_instructions_#{@resource.is_a?(User) ? 'account' : 'secondary'}" %> diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml index 8a3c841de0b..b34b6f09662 100644 --- a/app/views/discussions/_diff_with_notes.html.haml +++ b/app/views/discussions/_diff_with_notes.html.haml @@ -29,7 +29,7 @@ %td.line_content.js-success-lazy-load .js-code-placeholder %td.js-error-lazy-load-diff.hidden.diff-loading-error-block - - button = button_tag(_("Try again"), class: "btn-link btn-link-retry btn-no-padding js-toggle-lazy-diff-retry-button") + - button = button_tag(_("Try again"), class: "btn-link gl-button btn-link-retry btn-no-padding js-toggle-lazy-diff-retry-button") = _("Unable to load the diff. %{button_try_again}").html_safe % { button_try_again: button} = render "discussions/diff_discussion", discussions: [discussion], expanded: true - else diff --git a/app/views/discussions/_jump_to_next.html.haml b/app/views/discussions/_jump_to_next.html.haml deleted file mode 100644 index 3db509f24a5..00000000000 --- a/app/views/discussions/_jump_to_next.html.haml +++ /dev/null @@ -1,9 +0,0 @@ -- discussion = local_assigns.fetch(:discussion, nil) -- if current_user - %jump-to-discussion{ "inline-template" => true, ":discussion-id" => "'#{discussion.try(:id)}'" } - .btn-group{ role: "group", "v-show" => "!allResolved", "v-if" => "showButton" } - %button.btn.btn-default.discussion-next-btn.has-tooltip{ "@click" => "jumpToNextUnresolvedDiscussion", - ":title" => "buttonText", - ":aria-label" => "buttonText", - data: { container: "body" } } - = custom_icon("next_discussion") diff --git a/app/views/discussions/_new_issue_for_all_discussions.html.haml b/app/views/discussions/_new_issue_for_all_discussions.html.haml deleted file mode 100644 index 50dd5864195..00000000000 --- a/app/views/discussions/_new_issue_for_all_discussions.html.haml +++ /dev/null @@ -1,8 +0,0 @@ -- if merge_request.discussions_can_be_resolved_by?(current_user) && can?(current_user, :create_issue, @project) - .btn-group{ role: "group", "v-if" => "unresolvedDiscussionCount > 0" } - = link_to custom_icon('icon_mr_issue'), - new_project_issue_path(@project, merge_request_to_resolve_discussions_of: merge_request.iid), - title: 'Resolve all discussions in new issue', - aria: { label: 'Resolve all discussions in new issue' }, - data: { container: 'body' }, - class: 'new-issue-for-discussion btn btn-default discussion-create-issue-btn has-tooltip' diff --git a/app/views/discussions/_new_issue_for_discussion.html.haml b/app/views/discussions/_new_issue_for_discussion.html.haml deleted file mode 100644 index 49d5378d62e..00000000000 --- a/app/views/discussions/_new_issue_for_discussion.html.haml +++ /dev/null @@ -1,10 +0,0 @@ -- if discussion.can_resolve?(current_user) && can?(current_user, :create_issue, @project) - %new-issue-for-discussion-btn{ ":discussion-id" => "'#{discussion.id}'", - "inline-template" => true } - .btn-group{ role: "group", "v-if" => "showButton" } - = link_to custom_icon('icon_mr_issue'), - new_project_issue_path(@project, merge_request_to_resolve_discussions_of: merge_request.iid, discussion_to_resolve: discussion.id), - title: 'Resolve this thread in a new issue', - aria: { label: 'Resolve this thread in a new issue' }, - data: { container: 'body' }, - class: 'new-issue-for-discussion btn btn-default discussion-create-issue-btn has-tooltip' diff --git a/app/views/discussions/_notes.html.haml b/app/views/discussions/_notes.html.haml index 0a5541c3e82..7db318f83b1 100644 --- a/app/views/discussions/_notes.html.haml +++ b/app/views/discussions/_notes.html.haml @@ -9,9 +9,9 @@ -# to the first note position when we click on a badge diff discussion %ul.notes{ id: "discussion_#{discussion.id}", data: { discussion_id: discussion.id, position: discussion.notes[0].position.to_json } } - if discussion.try(:on_image?) && show_toggle - %button.diff-notes-collapse.js-diff-notes-toggle{ type: 'button' } + %button.gl-button.diff-notes-collapse.js-diff-notes-toggle{ type: 'button' } = sprite_icon('collapse', css_class: 'collapse-icon') - %button.btn-transparent.badge.badge-pill.js-diff-notes-toggle{ type: 'button' } + %button.gl-button.btn-transparent.badge.badge-pill.js-diff-notes-toggle{ type: 'button' } = badge_counter = render partial: "shared/notes/note", collection: discussion.notes, as: :note, locals: { badge_counter: badge_counter, show_image_comment_badge: show_image_comment_badge } @@ -21,22 +21,8 @@ - if can_create_note? %a.user-avatar-link.d-none.d-sm-block{ href: user_path(current_user) } = image_tag avatar_icon_for_user(current_user), alt: current_user.to_reference, class: 'avatar s40' - - if discussion.potentially_resolvable? - - line_type = local_assigns.fetch(:line_type, nil) - - .discussion-with-resolve-btn - .btn-group.discussion-with-resolve-btn{ role: "group" } - .btn-group{ role: "group" } - = link_to_reply_discussion(discussion, line_type) - - = render "discussions/resolve_all", discussion: discussion - - .btn-group.discussion-actions - = render "discussions/new_issue_for_discussion", discussion: discussion, merge_request: discussion.noteable - = render "discussions/jump_to_next", discussion: discussion - - else - .discussion-with-resolve-btn - = link_to_reply_discussion(discussion) + .discussion-with-resolve-btn + = link_to_reply_discussion(discussion) - elsif !current_user .disabled-comment.text-center Please diff --git a/app/views/events/event/_common.html.haml b/app/views/events/event/_common.html.haml index a38d6dd3836..93c6efc9083 100644 --- a/app/views/events/event/_common.html.haml +++ b/app/views/events/event/_common.html.haml @@ -7,7 +7,7 @@ - if event.target %span.event-type.d-inline-block.gl-mr-2{ class: event.action_name } = event.action_name - %span.event-target-type.gl-mr-2= event.target_type.titleize.downcase + %span.event-target-type.gl-mr-2= event.target_type_name = 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? diff --git a/app/views/groups/_invite_members_modal.html.haml b/app/views/groups/_invite_members_modal.html.haml new file mode 100644 index 00000000000..51f41d58029 --- /dev/null +++ b/app/views/groups/_invite_members_modal.html.haml @@ -0,0 +1,6 @@ +- if invite_members_allowed?(group) + .js-invite-members-modal{ data: { group_id: group.id, + group_name: group.name, + access_levels: GroupMember.access_level_roles.to_json, + default_access_level: Gitlab::Access::GUEST, + help_link: help_page_url('user/permissions') } } diff --git a/app/views/groups/_invite_members_side_nav_link.html.haml b/app/views/groups/_invite_members_side_nav_link.html.haml new file mode 100644 index 00000000000..1c90eaee992 --- /dev/null +++ b/app/views/groups/_invite_members_side_nav_link.html.haml @@ -0,0 +1,3 @@ +- if invite_members_allowed?(group) && body_data_page == 'groups:show' + %li + .js-invite-members-trigger{ data: { icon: 'plus', display_text: 'Invite team members' } } diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml index 1358e848154..ecf9141307a 100644 --- a/app/views/groups/issues.html.haml +++ b/app/views/groups/issues.html.haml @@ -23,9 +23,12 @@ = render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :issues - if Feature.enabled?(:vue_issuables_list, @group) + - if use_startup_call? + - add_page_startup_api_call(api_v4_groups_issues_path(id: @group.id, params: startup_call_params)) .js-issuables-list{ data: { endpoint: expose_url(api_v4_groups_issues_path(id: @group.id)), 'can-bulk-edit': @can_bulk_update.to_json, 'empty-state-meta': { svg_path: image_path('illustrations/issues.svg') }, - 'sort-key': @sort } } + 'sort-key': @sort, + type: 'issues' } } - else = render 'shared/issues' diff --git a/app/views/groups/labels/destroy.js.haml b/app/views/groups/labels/destroy.js.haml deleted file mode 100644 index 3dfbfc77c0d..00000000000 --- a/app/views/groups/labels/destroy.js.haml +++ /dev/null @@ -1,2 +0,0 @@ -- if @group.labels.empty? - $('.labels').load(document.URL + ' .nothing-here-block').hide().fadeIn(1000) diff --git a/app/views/groups/labels/index.html.haml b/app/views/groups/labels/index.html.haml index 3299d127222..debbe95d2aa 100644 --- a/app/views/groups/labels/index.html.haml +++ b/app/views/groups/labels/index.html.haml @@ -27,5 +27,5 @@ = render 'shared/empty_states/labels' %template#js-badge-item-template - %li.label-link-item.js-priority-badge.inline.gl-ml-3 - .label-badge.label-badge-blue= _('Prioritized label') + %li.js-priority-badge.inline.gl-ml-3 + .label-badge.gl-bg-blue-50= _('Prioritized label') diff --git a/app/views/groups/milestones/index.html.haml b/app/views/groups/milestones/index.html.haml index 1685707d457..d20fa938a68 100644 --- a/app/views/groups/milestones/index.html.haml +++ b/app/views/groups/milestones/index.html.haml @@ -1,4 +1,5 @@ - page_title _("Milestones") +- add_page_specific_style 'page_bundles/milestone' .top-area = render 'shared/milestones_filter', counts: @milestone_states diff --git a/app/views/groups/milestones/show.html.haml b/app/views/groups/milestones/show.html.haml index 33e68bc766e..5bbdd3a3b19 100644 --- a/app/views/groups/milestones/show.html.haml +++ b/app/views/groups/milestones/show.html.haml @@ -1,3 +1,4 @@ +- add_page_specific_style 'page_bundles/milestone' = render "header_title" = render 'shared/milestones/top', milestone: @milestone, group: @group = render 'shared/milestones/tabs', milestone: @milestone, show_project_name: true diff --git a/app/views/groups/registry/repositories/index.html.haml b/app/views/groups/registry/repositories/index.html.haml index 2cac8e653e5..21882c3e3ce 100644 --- a/app/views/groups/registry/repositories/index.html.haml +++ b/app/views/groups/registry/repositories/index.html.haml @@ -12,6 +12,8 @@ "containers_error_image" => image_path('illustrations/docker-error-state.svg'), "registry_host_url_with_port" => escape_once(registry_config.host_port), "garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'), + "run_cleanup_policies_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'run-the-cleanup-policy-now'), + "cleanup_policies_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'how-the-cleanup-policy-works'), "is_admin": current_user&.admin.to_s, is_group_page: "true", character_error: @character_error.to_s } } diff --git a/app/views/groups/settings/_advanced.html.haml b/app/views/groups/settings/_advanced.html.haml index 98f4acaa5e3..f415ca79bd4 100644 --- a/app/views/groups/settings/_advanced.html.haml +++ b/app/views/groups/settings/_advanced.html.haml @@ -22,8 +22,7 @@ pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, title: s_('GroupSettings|Please choose a group URL with no special characters.'), "data-bind-in" => "#{'create_chat_team' if Gitlab.config.mattermost.enabled}" - .gl-display-flex.gl-justify-content-end - = f.submit s_('GroupSettings|Change group URL'), class: 'btn btn-warning' + = f.submit s_('GroupSettings|Change group URL'), class: 'btn btn-warning' .sub-section %h4.warning-title= s_('GroupSettings|Transfer group') @@ -39,8 +38,7 @@ %li= s_('GroupSettings|You can only transfer the group to a group you manage.') %li= s_('GroupSettings|You will need to update your local repositories to point to the new location.') %li= s_("GroupSettings|If the parent group's visibility is lower than the group current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility.") - .gl-display-flex.gl-justify-content-end - = f.submit s_('GroupSettings|Transfer group'), class: 'btn btn-warning' + = f.submit s_('GroupSettings|Transfer group'), class: 'btn btn-warning' = render 'groups/settings/remove', group: @group = render_if_exists 'groups/settings/restore', group: @group diff --git a/app/views/groups/settings/_export.html.haml b/app/views/groups/settings/_export.html.haml index af06cfff397..94466b76ac8 100644 --- a/app/views/groups/settings/_export.html.haml +++ b/app/views/groups/settings/_export.html.haml @@ -24,6 +24,5 @@ = link_to _('Download export'), download_export_group_path(group), rel: 'nofollow', method: :get, class: 'btn btn-default', data: { qa_selector: 'download_export_link' } - else - .gl-display-flex.gl-justify-content-end - = link_to _('Export group'), export_group_path(group), - method: :post, class: 'btn btn-default', data: { qa_selector: 'export_group_link' } + = link_to _('Export group'), export_group_path(group), + method: :post, class: 'btn btn-default', data: { qa_selector: 'export_group_link' } diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml index e43d49b229e..35d82084263 100644 --- a/app/views/groups/settings/_general.html.haml +++ b/app/views/groups/settings/_general.html.haml @@ -29,5 +29,4 @@ = link_to _('Remove avatar'), group_avatar_path(@group.to_param), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'btn btn-link' = render 'shared/visibility_level', f: f, visibility_level: @group.visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group - .gl-display-flex.gl-justify-content-end - = f.submit _('Save changes'), class: 'btn btn-success mt-4 js-dirty-submit', data: { qa_selector: 'save_name_visibility_settings_button' } + = f.submit _('Save changes'), class: 'btn btn-success mt-4 js-dirty-submit', data: { qa_selector: 'save_name_visibility_settings_button' } diff --git a/app/views/groups/settings/_permanent_deletion.html.haml b/app/views/groups/settings/_permanent_deletion.html.haml index 063ff6dd132..5cdcdefae8c 100644 --- a/app/views/groups/settings/_permanent_deletion.html.haml +++ b/app/views/groups/settings/_permanent_deletion.html.haml @@ -5,5 +5,4 @@ = _('Removing this group also removes all child projects, including archived projects, and their resources.') %br %strong= _('Removed group can not be restored!') - .gl-display-flex.gl-justify-content-end - = button_to _('Remove group'), '#', class: 'btn btn-remove js-confirm-danger', data: { 'confirm-danger-message' => remove_group_message(group) } + = button_to _('Remove group'), '#', class: 'btn btn-remove js-confirm-danger', data: { 'confirm-danger-message' => remove_group_message(group) } diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml index 86f49672d66..5c50ae6bb13 100644 --- a/app/views/groups/settings/_permissions.html.haml +++ b/app/views/groups/settings/_permissions.html.haml @@ -41,5 +41,4 @@ = render 'groups/settings/two_factor_auth', f: f = render_if_exists 'groups/personal_access_token_expiration_policy', f: f, group: @group = render_if_exists 'groups/member_lock_setting', f: f, group: @group - .gl-display-flex.gl-justify-content-end - = f.submit _('Save changes'), class: 'btn btn-success gl-mt-3 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/show.html.haml b/app/views/groups/show.html.haml index ec4ab603d22..fa560942c5d 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -23,6 +23,8 @@ = render_if_exists 'groups/group_activity_analytics', group: @group += render_if_exists 'groups/invite_members_modal', group: @group + .groups-listing{ data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } } .top-area.group-nav-container.justify-content-between .scrolling-tabs-container.inner-page-scroll-tabs diff --git a/app/views/ide/_show.html.haml b/app/views/ide/_show.html.haml index d0384fd50bc..79cba2a54b0 100644 --- a/app/views/ide/_show.html.haml +++ b/app/views/ide/_show.html.haml @@ -1,8 +1,7 @@ - @body_class = 'ide-layout' - page_title _('IDE') -- content_for :page_specific_javascripts do - = stylesheet_link_tag 'page_bundles/ide' +- add_page_specific_style 'page_bundles/ide' #ide.ide-loading{ data: ide_data } .text-center diff --git a/app/views/import/bulk_imports/status.html.haml b/app/views/import/bulk_imports/status.html.haml new file mode 100644 index 00000000000..d909f6a13f0 --- /dev/null +++ b/app/views/import/bulk_imports/status.html.haml @@ -0,0 +1 @@ +- page_title 'Bulk Import' diff --git a/app/views/import/shared/_errors.html.haml b/app/views/import/shared/_errors.html.haml index de60c15351f..32b4a39924b 100644 --- a/app/views/import/shared/_errors.html.haml +++ b/app/views/import/shared/_errors.html.haml @@ -1,4 +1,6 @@ - if @errors.present? - .alert.alert-danger - - @errors.each do |error| - = error + .gl-alert.gl-alert-danger.gl-mb-5 + = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + .gl-alert-body + - @errors.each do |error| + = error diff --git a/app/views/invites/decline.html.haml b/app/views/invites/decline.html.haml new file mode 100644 index 00000000000..4a57d70cb6e --- /dev/null +++ b/app/views/invites/decline.html.haml @@ -0,0 +1,8 @@ +- page_title _('Invitation declined') +.decline-page.gl-display-flex.gl-flex-direction-column.gl-mx-auto{ class: 'gl-xs-w-full!' } + .gl-align-self-center.gl-mb-4.gl-mt-7.gl-sm-mt-0= sprite_icon('check-circle', size: 48, css_class: 'gl-text-green-400') + %h2.gl-font-size-h2= _('You successfully declined the invitation') + %p + = html_escape(_('We will notify %{inviter} that you declined their invitation to join GitLab. You will stop receiving reminders.')) % { inviter: sanitize_name(@member.created_by.name) } + %p + = _('You can now close this window.') diff --git a/app/views/jira_connect/subscriptions/index.html.haml b/app/views/jira_connect/subscriptions/index.html.haml index f7ecfd09209..32dd9a7c275 100644 --- a/app/views/jira_connect/subscriptions/index.html.haml +++ b/app/views/jira_connect/subscriptions/index.html.haml @@ -25,4 +25,4 @@ %td= link_to 'Remove', jira_connect_subscription_path(subscription), class: 'remove-subscription' = page_specific_javascript_tag('jira_connect.js') -= stylesheet_link_tag 'page_bundles/jira_connect' +- add_page_specific_style 'page_bundles/jira_connect' diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index 1c87452f0a3..6cc736e7056 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -42,35 +42,39 @@ %title= page_title(site_name) %meta{ name: "description", content: page_description } + - if page_canonical_link + %link{ rel: 'canonical', href: page_canonical_link } + = favicon_link_tag favicon, id: 'favicon', data: { original_href: favicon }, type: 'image/png' = render 'layouts/startup_css' - if user_application_theme == 'gl-dark' = stylesheet_link_tag_defer "application_dark" + = yield :page_specific_styles + = stylesheet_link_tag_defer "application_utilities_dark" - else = stylesheet_link_tag_defer "application" + = yield :page_specific_styles + = stylesheet_link_tag_defer "application_utilities" - unless use_startup_css? - = stylesheet_link_tag_defer "themes/theme_#{user_application_theme_name}" + = stylesheet_link_tag_defer "themes/#{user_application_theme_css_filename}" if user_application_theme_css_filename = stylesheet_link_tag "disable_animations", media: "all" if Rails.env.test? || Gitlab.config.gitlab['disable_animations'] - = stylesheet_link_tag_defer 'performance_bar' if performance_bar_enabled? = stylesheet_link_tag_defer "highlight/themes/#{user_color_scheme}" = render 'layouts/startup_css_activation' - = Gon::Base.render_data(nonce: content_security_policy_nonce) + = stylesheet_link_tag 'performance_bar' if performance_bar_enabled? - - if content_for?(:library_javascripts) - = yield :library_javascripts + = Gon::Base.render_data(nonce: content_security_policy_nonce) = javascript_include_tag locale_path unless I18n.locale == :en = webpack_bundle_tag "sentry" if Gitlab.config.sentry.enabled + = webpack_bundle_tag 'performance_bar' if performance_bar_enabled? - - if content_for?(:page_specific_javascripts) - = yield :page_specific_javascripts + = yield :page_specific_javascripts = webpack_controller_bundle_tags - = webpack_bundle_tag 'performance_bar' if performance_bar_enabled? = webpack_bundle_tag "chrome_84_icon_fix" if browser.chrome?([">=84", "<84.0.4147.125"]) || browser.edge?([">=84", "<84.0.522.59"]) = yield :project_javascripts @@ -79,8 +83,6 @@ = csp_meta_tag = action_cable_meta_tag - - unless browser.safari? - %meta{ name: 'referrer', content: 'origin-when-cross-origin' } %meta{ name: 'viewport', content: 'width=device-width, initial-scale=1, maximum-scale=1' } %meta{ name: 'theme-color', content: '#474D57' } diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index 5184bc93a81..9b925369660 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -19,7 +19,6 @@ = yield :customize_homepage_banner - unless @hide_breadcrumbs = render "layouts/nav/breadcrumbs" - .d-flex %div{ class: "#{(container_class unless @no_container)} #{@content_class}" } .content{ id: "content-body" } = render "layouts/flash", extra_flash_class: 'limit-container-width' diff --git a/app/views/layouts/_startup_css.haml b/app/views/layouts/_startup_css.haml index ea05157ed19..2f674f79b2f 100644 --- a/app/views/layouts/_startup_css.haml +++ b/app/views/layouts/_startup_css.haml @@ -3,5 +3,5 @@ - startup_filename = current_path?("sessions#new") ? 'signin' : user_application_theme == 'gl-dark' ? 'dark' : 'general' %style{ type: "text/css" } - = Rails.application.assets_manifest.find_sources("themes/theme_#{user_application_theme_name}.css").first.to_s.html_safe + = Rails.application.assets_manifest.find_sources("themes/#{user_application_theme_css_filename}.css").first.to_s.html_safe if user_application_theme_css_filename = Rails.application.assets_manifest.find_sources("startup/startup-#{startup_filename}.css").first.to_s.html_safe diff --git a/app/views/layouts/_startup_css_activation.haml b/app/views/layouts/_startup_css_activation.haml index 022b9a695bc..a426d686c34 100644 --- a/app/views/layouts/_startup_css_activation.haml +++ b/app/views/layouts/_startup_css_activation.haml @@ -7,4 +7,3 @@ const startupLinkLoadedEvent = new CustomEvent('CSSStartupLinkLoaded'); linkTag.addEventListener('load',function(){this.media='all';this.setAttribute('data-startupcss', 'loaded');document.dispatchEvent(startupLinkLoadedEvent);},{once: true}); }) -- return unless use_startup_css? diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml index 8f4c89a9e77..6d2c5870e43 100644 --- a/app/views/layouts/group.html.haml +++ b/app/views/layouts/group.html.haml @@ -1,6 +1,6 @@ - page_title @group.name -- page_description @group.description unless page_description -- header_title group_title(@group) unless header_title +- page_description @group.description_html unless page_description +- header_title group_title(@group) unless header_title - nav "group" - display_subscription_banner! - display_namespace_storage_limit_alert! diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 845231238f6..328c6031b24 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -73,7 +73,7 @@ %span.gl-sr-only = s_('Nav|Help') = sprite_icon('question') - = sprite_icon('angle-down', css_class: 'caret-down') + = sprite_icon('chevron-down', css_class: 'caret-down') .dropdown-menu.dropdown-menu-right = render 'layouts/header/help_dropdown' - if header_link?(:user_dropdown) @@ -81,7 +81,7 @@ = link_to current_user, class: user_dropdown_class, data: { toggle: "dropdown" } do = image_tag avatar_icon_for_user(current_user, 23), width: 23, height: 23, class: "header-user-avatar qa-user-avatar", alt: current_user.name = render_if_exists 'layouts/header/user_notification_dot', project: project, namespace: group - = sprite_icon('angle-down', css_class: 'caret-down') + = sprite_icon('chevron-down', css_class: 'caret-down') .dropdown-menu.dropdown-menu-right = render 'layouts/header/current_user_dropdown' - if has_impersonation_link @@ -100,7 +100,7 @@ = sprite_icon('close', size: 12, css_class: 'close-icon js-navbar-toggle-left') - if ::Feature.enabled?(:whats_new_drawer) - #whats-new-app{ data: { features: whats_new_most_recent_release_items } } + #whats-new-app{ data: { features: whats_new_most_recent_release_items, storage_key: whats_new_storage_key } } - if can?(current_user, :update_user_status, current_user) .js-set-status-modal-wrapper{ data: { current_emoji: current_user.status.present? ? current_user.status.emoji : '', current_message: current_user.status.present? ? current_user.status.message : '' } } diff --git a/app/views/layouts/header/_new_dropdown.haml b/app/views/layouts/header/_new_dropdown.haml index 0c989242194..2c5cd7e96c7 100644 --- a/app/views/layouts/header/_new_dropdown.haml +++ b/app/views/layouts/header/_new_dropdown.haml @@ -1,7 +1,7 @@ %li.header-new.dropdown{ data: { track_label: "new_dropdown", track_event: "click_dropdown", track_value: "" } } = link_to new_project_path, class: "header-new-dropdown-toggle has-tooltip qa-new-menu-toggle", id: "js-onboarding-new-project-link", title: _("New..."), ref: 'tooltip', aria: { label: _("New...") }, data: { toggle: 'dropdown', placement: 'bottom', container: 'body', display: 'static' } do = sprite_icon('plus-square') - = sprite_icon('angle-down', css_class: 'caret-down') + = sprite_icon('chevron-down', css_class: 'caret-down') .dropdown-menu.dropdown-menu-right %ul - if @group&.persisted? diff --git a/app/views/layouts/jira_connect.html.haml b/app/views/layouts/jira_connect.html.haml index fdeb3d3c9ac..17f6e9af61a 100644 --- a/app/views/layouts/jira_connect.html.haml +++ b/app/views/layouts/jira_connect.html.haml @@ -7,6 +7,7 @@ = stylesheet_link_tag 'https://unpkg.com/@atlaskit/reduced-ui-pack@10.5.5/dist/bundle.css' = javascript_include_tag 'https://connect-cdn.atl-paas.net/all.js' = javascript_include_tag 'https://unpkg.com/jquery@3.3.1/dist/jquery.min.js' + = yield :page_specific_styles = yield :head %body .ac-content diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml index 40ea42091bd..abaadc89a9e 100644 --- a/app/views/layouts/nav/_dashboard.html.haml +++ b/app/views/layouts/nav/_dashboard.html.haml @@ -3,17 +3,17 @@ %ul.list-unstyled.navbar-sub-nav - if dashboard_nav_link?(:projects) = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown header-projects qa-projects-dropdown", data: { track_label: "projects_dropdown", track_event: "click_dropdown", track_value: "" } }) do - %button.btn{ type: 'button', data: { toggle: "dropdown" } } + %button{ type: 'button', data: { toggle: "dropdown" } } = _('Projects') - = sprite_icon('angle-down', css_class: 'caret-down') + = sprite_icon('chevron-down', css_class: 'caret-down') .dropdown-menu.frequent-items-dropdown-menu = render "layouts/nav/projects_dropdown/show" - if dashboard_nav_link?(:groups) = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { id: 'nav-groups-dropdown', class: "d-none d-md-block home dropdown header-groups qa-groups-dropdown", data: { track_label: "groups_dropdown", track_event: "click_dropdown", track_value: "" } }) do - %button.btn{ type: 'button', data: { toggle: "dropdown" } } + %button{ type: 'button', data: { toggle: "dropdown" } } = _('Groups') - = sprite_icon('angle-down', css_class: 'caret-down') + = sprite_icon('chevron-down', css_class: 'caret-down') .dropdown-menu.frequent-items-dropdown-menu = render "layouts/nav/groups_dropdown/show" @@ -21,7 +21,7 @@ %li.header-more.dropdown{ **tracking_attrs('main_navigation', 'click_more_link', 'navigation') } %a{ href: "#", data: { toggle: "dropdown", qa_selector: 'more_dropdown' } } = _('More') - = sprite_icon('angle-down', css_class: 'caret-down') + = sprite_icon('chevron-down', css_class: 'caret-down') .dropdown-menu %ul - if dashboard_nav_link?(:groups) diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml index cb5277c02f0..0da4d4f7ddd 100644 --- a/app/views/layouts/nav/sidebar/_admin.html.haml +++ b/app/views/layouts/nav/sidebar/_admin.html.haml @@ -260,10 +260,11 @@ = link_to general_admin_application_settings_path, title: _('General'), class: 'qa-admin-settings-general-item' do %span = _('General') - = 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') + - if instance_level_integrations? + = nav_link(path: ['application_settings#integrations', 'integrations#edit']) do + = link_to integrations_admin_application_settings_path, title: _('Integrations'), data: { qa_selector: 'integration_settings_link' } do + %span + = _('Integrations') = nav_link(path: 'application_settings#repository') do = link_to repository_admin_application_settings_path, title: _('Repository'), class: 'qa-admin-settings-repository-item' do %span diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml index 9e9e6493e5b..5f4b1f8ad45 100644 --- a/app/views/layouts/nav/sidebar/_group.html.haml +++ b/app/views/layouts/nav/sidebar/_group.html.haml @@ -139,6 +139,8 @@ %strong.fly-out-top-item-name = _('Members') + = render_if_exists 'groups/invite_members_side_nav_link', group: @group + - if group_sidebar_link?(:settings) = nav_link(path: group_settings_nav_link_paths) do = link_to edit_group_path(@group) do diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index 0eef587d7c7..c11afc8e4ca 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -235,11 +235,11 @@ %span = _('Alerts') - - if project_nav_tab?(:incidents) - = nav_link(controller: :incidents) do - = link_to project_incidents_path(@project), title: _('Incidents'), data: { qa_selector: 'operations_incidents_link' } do - %span - = _('Incidents') + - if project_nav_tab?(:incidents) + = nav_link(controller: :incidents) do + = link_to project_incidents_path(@project), title: _('Incidents'), data: { qa_selector: 'operations_incidents_link' } do + %span + = _('Incidents') - if project_nav_tab? :environments = render_if_exists "layouts/nav/sidebar/tracing_link" diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml index 222ca02b1df..a0c82380023 100644 --- a/app/views/layouts/project.html.haml +++ b/app/views/layouts/project.html.haml @@ -1,6 +1,6 @@ - page_title @project.full_name -- page_description @project.description unless page_description -- header_title project_title(@project) unless header_title +- page_description @project.description_html unless page_description +- header_title project_title(@project) unless header_title - nav "project" - display_subscription_banner! - display_namespace_storage_limit_alert! diff --git a/app/views/notify/_failed_builds.html.haml b/app/views/notify/_failed_builds.html.haml index cde0ac21d6d..11cbd700258 100644 --- a/app/views/notify/_failed_builds.html.haml +++ b/app/views/notify/_failed_builds.html.haml @@ -6,7 +6,7 @@ #{'build'.pluralize(failed.size)}. %tr.table-warning %td{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; border: 1px solid #ededed; border-bottom: 0; border-radius: 4px 4px 0 0; overflow: hidden; background-color: #fdf4f6; color: #d22852; font-size: 14px; line-height: 1.4; text-align: center; padding: 8px 16px;" } - Logs may contain sensitive data. Please consider before forwarding this email. + Failed builds %tr.section %td{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; padding: 0 16px; border: 1px solid #ededed; border-radius: 4px; overflow: hidden; border-top: 0; border-radius: 0 0 4px 4px;" } %table.builds{ border: "0", cellpadding: "0", cellspacing: "0", style: "width: 100%; border-collapse: collapse;" } diff --git a/app/views/notify/autodevops_disabled_email.html.haml b/app/views/notify/autodevops_disabled_email.html.haml index 65a2f75a3e2..72bcfbdf3af 100644 --- a/app/views/notify/autodevops_disabled_email.html.haml +++ b/app/views/notify/autodevops_disabled_email.html.haml @@ -46,4 +46,4 @@ %td{ style: "font-family: 'Menlo','Liberation Mono','Consolas','DejaVu Sans Mono','Ubuntu Mono','Courier New','andale mono','lucida console',monospace; font-size: 14px; line-height: 1.4; vertical-align: baseline; padding:0 8px;" } API -= render 'notify/failed_builds', pipeline: @pipeline, failed: @pipeline.statuses.latest.failed += render 'notify/failed_builds', pipeline: @pipeline, failed: @pipeline.latest_statuses.failed diff --git a/app/views/notify/autodevops_disabled_email.text.erb b/app/views/notify/autodevops_disabled_email.text.erb index f849c017265..c75857e96d7 100644 --- a/app/views/notify/autodevops_disabled_email.text.erb +++ b/app/views/notify/autodevops_disabled_email.text.erb @@ -7,7 +7,7 @@ The Auto DevOps pipeline failed for pipeline <%= @pipeline.iid %> (<%= pipeline_ <% else -%> Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by API <% end -%> -<% failed = @pipeline.statuses.latest.failed -%> +<% failed = @pipeline.latest_statuses.failed -%> had <%= failed.size %> failed <%= 'build'.pluralize(failed.size) %>. <% failed.each do |build| -%> diff --git a/app/views/notify/changed_reviewer_of_merge_request_email.html.haml b/app/views/notify/changed_reviewer_of_merge_request_email.html.haml new file mode 100644 index 00000000000..ed7a3285f45 --- /dev/null +++ b/app/views/notify/changed_reviewer_of_merge_request_email.html.haml @@ -0,0 +1,2 @@ +%p + = change_reviewer_notification_text(@merge_request.reviewers, @previous_reviewers, :strong) diff --git a/app/views/notify/changed_reviewer_of_merge_request_email.text.erb b/app/views/notify/changed_reviewer_of_merge_request_email.text.erb new file mode 100644 index 00000000000..b6824966bb9 --- /dev/null +++ b/app/views/notify/changed_reviewer_of_merge_request_email.text.erb @@ -0,0 +1 @@ +<%= change_reviewer_notification_text(@merge_request.reviewers, @previous_reviewers) %> diff --git a/app/views/notify/issue_status_changed_email.text.erb b/app/views/notify/issue_status_changed_email.text.erb index f38b09e9820..f963e9b5c3d 100644 --- a/app/views/notify/issue_status_changed_email.text.erb +++ b/app/views/notify/issue_status_changed_email.text.erb @@ -1,4 +1,3 @@ Issue was <%= @issue_status %> by <%= sanitize_name(@updated_by.name) %> Issue <%= @issue.iid %>: <%= url_for(project_issue_url(@issue.project, @issue)) %> - diff --git a/app/views/notify/pipeline_failed_email.html.haml b/app/views/notify/pipeline_failed_email.html.haml index f01181857ce..575ec8c488e 100644 --- a/app/views/notify/pipeline_failed_email.html.haml +++ b/app/views/notify/pipeline_failed_email.html.haml @@ -108,4 +108,4 @@ %td{ style: "font-family:'Menlo','Liberation Mono','Consolas','DejaVu Sans Mono','Ubuntu Mono','Courier New','andale mono','lucida console',monospace;font-size:14px;line-height:1.4;vertical-align:baseline;padding:0 5px;" } API -= render 'notify/failed_builds', pipeline: @pipeline, failed: @pipeline.statuses.latest.failed += render 'notify/failed_builds', pipeline: @pipeline, failed: @pipeline.latest_statuses.failed diff --git a/app/views/notify/pipeline_failed_email.text.erb b/app/views/notify/pipeline_failed_email.text.erb index b388aad7048..a30e331d892 100644 --- a/app/views/notify/pipeline_failed_email.text.erb +++ b/app/views/notify/pipeline_failed_email.text.erb @@ -27,7 +27,7 @@ Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <% <% else -%> Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by API <% end -%> -<% failed = @pipeline.statuses.latest.failed -%> +<% failed = @pipeline.latest_statuses.failed -%> had <%= failed.size %> failed <%= 'build'.pluralize(failed.size) %>. <% failed.each do |build| -%> diff --git a/app/views/notify/prometheus_alert_fired_email.html.haml b/app/views/notify/prometheus_alert_fired_email.html.haml index 17f9481d353..75ba66b44f9 100644 --- a/app/views/notify/prometheus_alert_fired_email.html.haml +++ b/app/views/notify/prometheus_alert_fired_email.html.haml @@ -1,17 +1,17 @@ %p - = _('An alert has been triggered in %{project_path}.') % { project_path: @alert.project_full_path } + = _('An alert has been triggered in %{project_path}.') % { project_path: @alert.project.full_path } - if description = @alert.description %p = _('Description:') = description -- if env_name = @alert.environment_name +- if env_name = @alert.environment&.name %p = _('Environment:') = env_name -- if metric_query = @alert.metric_query +- if metric_query = @alert.prometheus_alert&.full_query %p = _('Metric:') @@ -25,4 +25,3 @@ - if @alert.show_performance_dashboard_link? %p = link_to(_('View performance dashboard.'), @alert.performance_dashboard_link) - diff --git a/app/views/notify/prometheus_alert_fired_email.text.erb b/app/views/notify/prometheus_alert_fired_email.text.erb index c3f005cfb7e..8853f2a317b 100644 --- a/app/views/notify/prometheus_alert_fired_email.text.erb +++ b/app/views/notify/prometheus_alert_fired_email.text.erb @@ -1,14 +1,14 @@ -<%= _('An alert has been triggered in %{project_path}.') % { project_path: @alert.project_full_path } %>. +<%= _('An alert has been triggered in %{project_path}.') % { project_path: @alert.project.full_path } %>. <% if description = @alert.description %> <%= _('Description:') %> <%= description %> <% end %> -<% if env_name = @alert.environment_name %> +<% if env_name = @alert.environment&.name %> <%= _('Environment:') %> <%= env_name %> <% end %> -<% if metric_query = @alert.metric_query %> +<% if metric_query = @alert.prometheus_alert&.full_query %> <%= _('Metric:') %> <%= metric_query %> <% end %> diff --git a/app/views/profiles/keys/_form.html.haml b/app/views/profiles/keys/_form.html.haml index 078b5907623..0a9ea5c4cb3 100644 --- a/app/views/profiles/keys/_form.html.haml +++ b/app/views/profiles/keys/_form.html.haml @@ -4,13 +4,13 @@ .form-group = f.label :key, s_('Profiles|Key'), class: 'label-bold' - %p= _("Paste your public SSH key, which is usually contained in the file '~/.ssh/id_ed25519.pub' or '~/.ssh/id_rsa.pub' and begins with 'ssh-ed25519' or 'ssh-rsa'. Don't use your private SSH key.") + %p= _("Paste your public SSH key, which is usually contained in the file '~/.ssh/id_ed25519.pub' or '~/.ssh/id_rsa.pub' and begins with 'ssh-ed25519' or 'ssh-rsa'. Do not paste your private SSH key, as that can compromise your identity.") = f.text_area :key, class: "form-control js-add-ssh-key-validation-input qa-key-public-key-field", rows: 8, required: true, placeholder: s_('Profiles|Typically starts with "ssh-ed25519 …" or "ssh-rsa …"') .form-row .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. This will be publically visible.') + %p.form-text.text-muted= s_('Profiles|Give your individual key a title.') .col.form-group = f.label :expires_at, s_('Profiles|Expires at'), class: 'label-bold' @@ -19,7 +19,7 @@ .js-add-ssh-key-validation-warning.hide .bs-callout.bs-callout-warning{ role: 'alert', aria_live: 'assertive' } %strong= _('Oops, are you sure?') - %p= s_("Profiles|This doesn't look like a public SSH key, are you sure you want to add it?") + %p= s_("Profiles|This doesn't look like a public SSH key, are you sure you want to add it? It will be publicly visible.") %button.btn.btn-success.js-add-ssh-key-validation-confirm-submit= _("Yes, add it") diff --git a/app/views/profiles/keys/_key.html.haml b/app/views/profiles/keys/_key.html.haml index 02b45853aa0..3f0c1596396 100644 --- a/app/views/profiles/keys/_key.html.haml +++ b/app/views/profiles/keys/_key.html.haml @@ -23,9 +23,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)} + %span.key-created-at.gl-display-flex.gl-align-items-center + = s_('Profiles|Created%{time_ago}'.html_safe) % { time_ago: time_ago_with_tooltip(key.created_at, html_class: 'gl-ml-2')} - if key.can_delete? - = 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') + .gl-ml-3 + = button_to '#', class: "btn btn-default gl-button btn-default-tertiary js-confirm-modal-button", data: ssh_key_delete_modal_data(key, is_admin) do + %span.sr-only= _('Delete') + = sprite_icon('remove') diff --git a/app/views/profiles/keys/_key_details.html.haml b/app/views/profiles/keys/_key_details.html.haml index 59d953678e7..2bc7e9eccb8 100644 --- a/app/views/profiles/keys/_key_details.html.haml +++ b/app/views/profiles/keys/_key_details.html.haml @@ -38,4 +38,4 @@ .col-md-12 .float-right - if @key.can_delete? - = link_to _('Remove'), path_to_key(@key, is_admin), data: {confirm: _('Are you sure?')}, method: :delete, class: "btn btn-remove delete-key qa-delete-key-button" + = button_to _('Delete'), '#', class: "btn btn-danger gl-button delete-key js-confirm-modal-button", data: ssh_key_delete_modal_data(@key, is_admin) diff --git a/app/views/profiles/preferences/_gitpod.html.haml b/app/views/profiles/preferences/_gitpod.html.haml index 69c9443ebbb..589c3a27c18 100644 --- a/app/views/profiles/preferences/_gitpod.html.haml +++ b/app/views/profiles/preferences/_gitpod.html.haml @@ -1,5 +1,3 @@ -- gitpod_link = link_to("Gitpod#{sprite_icon('external-link', size: 12, css_class: 'ml-1 vertical-align-center')}".html_safe, 'https://gitpod.io/', target: '_blank', rel: 'noopener noreferrer') - %label.label-bold#gitpod = s_('Gitpod') = link_to sprite_icon('question-o'), help_page_path('integration/gitpod.md'), target: '_blank', class: 'has-tooltip', title: _('More information') @@ -8,4 +6,4 @@ = f.label :gitpod_enabled, class: 'form-check-label' do = s_('Gitpod|Enable Gitpod integration').html_safe .form-text.text-muted - = s_('Enable %{gitpod_link} integration to launch a development environment in your browser directly from GitLab.').html_safe % { gitpod_link: gitpod_link } + = gitpod_enable_description diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index 2c705886f47..ea1126fb30f 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -2,7 +2,7 @@ - @content_class = "limit-container-width" unless fluid_layout - Gitlab::Themes.each do |theme| - = stylesheet_link_tag "themes/theme_#{theme.css_class.gsub('ui-', '')}" + = stylesheet_link_tag "themes/#{theme.css_filename}" if theme.css_filename = 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#navigation-theme diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index 82265938180..3e21928e306 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -64,12 +64,12 @@ - else = _('Register Universal Two-Factor (U2F) Device') %p - = _('Use a hardware device to add the second factor of authentication.') + = _('Set up a hardware device as a second factor to sign in.') %p - if webauthn_enabled - = _("As WebAuthn devices are only supported by a few browsers, we require that you set up a two-factor authentication app before a WebAuthn device. That way you'll always be able to log in - even when you're using an unsupported browser.") + = _("Not all browsers support WebAuthn. Therefore, we require that you set up a two-factor authentication app first. That way you'll always be able to sign in - even from an unsupported browser.") - else - = _("As U2F devices are only supported by a few browsers, we require that you set up a two-factor authentication app before a U2F device. That way you'll always be able to log in - even when you're using an unsupported browser.") + = _("Not all browsers support U2F devices. Therefore, we require that you set up a two-factor authentication app first. That way you'll always be able to sign in - even when you're using an unsupported browser.") .col-lg-8 - registration = webauthn_enabled ? @webauthn_registration : @u2f_registration - if registration.errors.present? @@ -102,7 +102,12 @@ %tbody - @registrations.each do |registration| %tr - %td= registration[:name].presence || html_escape_once(_("<no name set>")).html_safe + %td + - if registration[:name].present? + = registration[:name] + - else + %span.gl-text-gray-500 + = _("no name set") %td= registration[:created_at].to_date.to_s(:medium) %td= link_to _('Delete'), registration[:delete_path], method: :delete, class: "btn btn-danger float-right", data: { confirm: _('Are you sure you want to delete this device? This action cannot be undone.') } diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml index 41e13464b1e..5ec2dc57f96 100644 --- a/app/views/projects/_export.html.haml +++ b/app/views/projects/_export.html.haml @@ -26,6 +26,5 @@ = link_to _('Generate new export'), generate_new_export_project_path(project), method: :post, class: "btn btn-default" - else - .gl-display-flex.gl-justify-content-end - = link_to _('Export project'), export_project_path(project), - method: :post, class: "btn btn-default", data: { qa_selector: 'export_project_link' } + = link_to _('Export project'), export_project_path(project), + method: :post, class: "btn btn-default", data: { qa_selector: 'export_project_link' } diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index 1562cc065f1..81c42de13f0 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -14,7 +14,7 @@ - if is_project_overview .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) + = render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout), project_buttons: true #js-tree-list{ data: vue_file_list_data(project, ref) } - if can_edit_tree? diff --git a/app/views/projects/_stat_anchor_list.html.haml b/app/views/projects/_stat_anchor_list.html.haml index 8e3d759b683..516790fb6d9 100644 --- a/app/views/projects/_stat_anchor_list.html.haml +++ b/app/views/projects/_stat_anchor_list.html.haml @@ -1,8 +1,9 @@ - anchors = local_assigns.fetch(:anchors, []) +- project_buttons = local_assigns.fetch(:project_buttons, false) - return unless anchors.any? %ul.nav - anchors.each do |anchor| %li.nav-item = link_to_if anchor.link, anchor.label, anchor.link, class: anchor.is_link ? 'nav-link stat-link d-flex align-items-center' : "nav-link btn btn-#{anchor.class_modifier || 'missing'} d-flex align-items-center" do - .stat-text.d-flex.align-items-center= anchor.label + .stat-text.d-flex.align-items-center{ class: ('btn btn-default disabled' if project_buttons) }= anchor.label diff --git a/app/views/projects/_visibility_modal.html.haml b/app/views/projects/_visibility_modal.html.haml index 144f726572b..314211057f9 100644 --- a/app/views/projects/_visibility_modal.html.haml +++ b/app/views/projects/_visibility_modal.html.haml @@ -23,7 +23,7 @@ = ("To confirm, type %{phrase_code}").html_safe % { phrase_code: '<code class="js-confirm-danger-match">%{phrase_name}</code>'.html_safe % { phrase_name: @project.full_path } } .form-group = text_field_tag 'confirm_path_input', '', class: 'form-control js-confirm-danger-input qa-confirm-input' - .form-actions.gl-display-flex.gl-justify-content-end + .form-actions %button.btn.btn-default.gl-mr-4{ type: "button", "data-dismiss": "modal" } = _('Cancel') = submit_tag _('Reduce project visibility'), class: "btn btn-danger js-confirm-danger-submit qa-confirm-button", disabled: true diff --git a/app/views/projects/artifacts/_artifact.html.haml b/app/views/projects/artifacts/_artifact.html.haml index 36e149556e0..81c5a634616 100644 --- a/app/views/projects/artifacts/_artifact.html.haml +++ b/app/views/projects/artifacts/_artifact.html.haml @@ -10,7 +10,7 @@ %span.build-link ##{artifact.job_id} - if artifact.job.ref - .icon-container{ "aria-label" => artifact.job.tag? ? _('Tag') : _('Branch') } + .icon-container.gl-display-inline-block{ "aria-label" => artifact.job.tag? ? _('Tag') : _('Branch') } = artifact.job.tag? ? sprite_icon('tag', css_class: 'sprite') : sprite_icon('branch', css_class: 'sprite') = link_to artifact.job.ref, project_ref_path(@project, artifact.job.ref), class: 'ref-name' - else diff --git a/app/views/projects/blob/_content.html.haml b/app/views/projects/blob/_content.html.haml index 11946f22811..5b77e31eb00 100644 --- a/app/views/projects/blob/_content.html.haml +++ b/app/views/projects/blob/_content.html.haml @@ -2,9 +2,9 @@ - rich_viewer = blob.rich_viewer - rich_viewer_active = rich_viewer && params[:viewer] != 'simple' - blob_data = defined?(@blob) ? @blob.data : {} -- filename = defined?(@blob) ? @blob.name : '' +- is_ci_config_file = defined?(@blob) && defined?(@project) ? editing_ci_config?.to_s : 'false' -#js-blob-toggle-graph-preview{ data: { blob_data: blob_data, filename: filename } } +#js-blob-toggle-graph-preview{ data: { blob_data: blob_data, is_ci_config_file: is_ci_config_file } } = render 'projects/blob/viewer', viewer: simple_viewer, hidden: rich_viewer_active diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml index cea65bf9b4e..4ec5bb1be30 100644 --- a/app/views/projects/blob/_editor.html.haml +++ b/app/views/projects/blob/_editor.html.haml @@ -30,7 +30,7 @@ .file-buttons - if is_markdown = render 'shared/blob/markdown_buttons', show_fullscreen_button: false - = button_tag class: 'soft-wrap-toggle btn', type: 'button', tabindex: '-1' do + = button_tag class: 'soft-wrap-toggle btn gl-button', type: 'button', tabindex: '-1' do %span.no-wrap = custom_icon('icon_no_wrap') No wrap diff --git a/app/views/projects/blob/_new_dir.html.haml b/app/views/projects/blob/_new_dir.html.haml index e9010dc63fc..ca60827863a 100644 --- a/app/views/projects/blob/_new_dir.html.haml +++ b/app/views/projects/blob/_new_dir.html.haml @@ -15,7 +15,7 @@ = render 'shared/new_commit_form', placeholder: _("Add new directory") .form-actions - = submit_tag _("Create directory"), class: 'btn btn-success' - = link_to "Cancel", '#', class: "btn btn-cancel", "data-dismiss" => "modal" + = submit_tag _("Create directory"), class: 'btn gl-button btn-success' + = link_to "Cancel", '#', class: "btn gl-button btn-cancel", "data-dismiss" => "modal" = render 'shared/projects/edit_information' diff --git a/app/views/projects/blob/_remove.html.haml b/app/views/projects/blob/_remove.html.haml index f80bae5c88c..d3440ee41b5 100644 --- a/app/views/projects/blob/_remove.html.haml +++ b/app/views/projects/blob/_remove.html.haml @@ -12,5 +12,5 @@ .form-group.row .offset-sm-2.col-sm-10 - = button_tag 'Delete file', class: 'btn btn-remove btn-remove-file' - = link_to "Cancel", '#', class: "btn btn-cancel", "data-dismiss" => "modal" + = button_tag 'Delete file', class: 'btn gl-button btn-danger btn-remove-file' + = link_to "Cancel", '#', class: "btn gl-button btn-cancel", "data-dismiss" => "modal" diff --git a/app/views/projects/blob/_upload.html.haml b/app/views/projects/blob/_upload.html.haml index d2b3c8ef96b..4dbfa2b1e3c 100644 --- a/app/views/projects/blob/_upload.html.haml +++ b/app/views/projects/blob/_upload.html.haml @@ -15,14 +15,14 @@ #{ dropzone_text.html_safe } %br - .dropzone-alerts.alert.alert-danger.data{ style: "display:none" } + .dropzone-alerts.gl-alert.gl-alert-danger.gl-mb-5.data{ style: "display:none" } = render 'shared/new_commit_form', placeholder: placeholder .form-actions - = button_tag class: 'btn btn-success btn-upload-file', id: 'submit-all', type: 'button' do + = button_tag class: 'btn gl-button btn-success btn-upload-file', id: 'submit-all', type: 'button' do = icon('spin spinner', class: 'js-loading-icon hidden' ) = button_title - = link_to _("Cancel"), '#', class: "btn btn-cancel", "data-dismiss" => "modal" + = link_to _("Cancel"), '#', class: "btn gl-button btn-cancel", "data-dismiss" => "modal" = render 'shared/projects/edit_information' diff --git a/app/views/projects/blob/_viewer_switcher.html.haml b/app/views/projects/blob/_viewer_switcher.html.haml index df81e509c85..8e3cf607bbf 100644 --- a/app/views/projects/blob/_viewer_switcher.html.haml +++ b/app/views/projects/blob/_viewer_switcher.html.haml @@ -4,9 +4,9 @@ .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' } }> + %button.btn.gl-button.btn-default.btn-sm.js-blob-viewer-switch-btn.has-tooltip{ 'aria-label' => simple_label, title: simple_label, data: { viewer: 'simple', container: 'body' } }> = 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' } }> + %button.btn.gl-button.btn-default.btn-sm.js-blob-viewer-switch-btn.has-tooltip{ 'aria-label' => rich_label, title: rich_label, data: { viewer: 'rich', container: 'body' } }> = 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 9bb4342ffb4..54c47e7af38 100644 --- a/app/views/projects/blob/edit.html.haml +++ b/app/views/projects/blob/edit.html.haml @@ -9,9 +9,6 @@ = link_to "the file", project_blob_path(@project, tree_join(@branch_name, @file_path)), target: "_blank", rel: 'noopener noreferrer', class: 'gl-link' and make sure your changes will not unintentionally remove theirs. -- if editing_ci_config? && show_web_ide_alert? - #js-suggest-web-ide-ci{ data: { dismiss_endpoint: user_callouts_path, feature_id: UserCalloutsHelper::WEB_IDE_ALERT_DISMISSED, edit_path: ide_edit_path } } - .editor-title-row %h3.page-title.blob-edit-page-title Edit file diff --git a/app/views/projects/buttons/_fork.html.haml b/app/views/projects/buttons/_fork.html.haml index 2d9c7f9848f..dbe0bf35b98 100644 --- a/app/views/projects/buttons/_fork.html.haml +++ b/app/views/projects/buttons/_fork.html.haml @@ -3,14 +3,14 @@ .count-badge.d-inline-flex.align-item-stretch.gl-mr-3 - if current_user.already_forked?(@project) && current_user.manageable_namespaces.size < 2 = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: s_('ProjectOverview|Go to your fork'), class: 'btn btn-default has-tooltip count-badge-button d-flex align-items-center fork-btn' do - = sprite_icon('fork', { css_class: 'icon' }) + = sprite_icon('fork', css_class: 'icon') %span= s_('ProjectOverview|Fork') - else - can_create_fork = current_user.can?(:create_fork) = link_to new_project_fork_path(@project), class: "btn btn-default btn-xs has-tooltip count-badge-button d-flex align-items-center fork-btn #{'has-tooltip disabled' unless can_create_fork}", title: (s_('ProjectOverview|You have reached your project limit') unless can_create_fork) do - = sprite_icon('fork', { css_class: 'icon' }) + = sprite_icon('fork', css_class: 'icon') %span= s_('ProjectOverview|Fork') %span.fork-count.count-badge-count.d-flex.align-items-center = link_to project_forks_path(@project), title: n_(s_('ProjectOverview|Fork'), s_('ProjectOverview|Forks'), @project.forks_count), class: 'count' do diff --git a/app/views/projects/buttons/_remove_tag.html.haml b/app/views/projects/buttons/_remove_tag.html.haml new file mode 100644 index 00000000000..bc378b847be --- /dev/null +++ b/app/views/projects/buttons/_remove_tag.html.haml @@ -0,0 +1,6 @@ +- project = local_assigns.fetch(:project, nil) +- tag = local_assigns.fetch(:tag, nil) +- return unless project && tag + +%button{ type: "button", class: "js-remove-tag js-confirm-modal-button gl-button btn btn-remove remove-row has-tooltip gl-ml-3 #{protected_tag?(project, tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), data: { container: 'body', path: project_tag_path(@project, tag.name), modal_attributes: delete_tag_modal_attributes(tag.name) } } + = sprite_icon("remove") diff --git a/app/views/projects/buttons/_star.html.haml b/app/views/projects/buttons/_star.html.haml index 3dac38d1356..690f0fe10f7 100644 --- a/app/views/projects/buttons/_star.html.haml +++ b/app/views/projects/buttons/_star.html.haml @@ -2,10 +2,10 @@ .count-badge.d-inline-flex.align-item-stretch.gl-mr-3 %button.count-badge-button.btn.btn-default.btn-xs.d-flex.align-items-center.star-btn.toggle-star{ type: "button", data: { endpoint: toggle_star_project_path(@project, :json) } } - if current_user.starred?(@project) - = sprite_icon('star', { css_class: 'icon' }) + = sprite_icon('star', css_class: 'icon') %span.starred= s_('ProjectOverview|Unstar') - else - = sprite_icon('star-o', { css_class: 'icon' }) + = sprite_icon('star-o', css_class: 'icon') %span= s_('ProjectOverview|Star') %span.star-count.count-badge-count.d-flex.align-items-center = link_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'count' do @@ -14,7 +14,7 @@ - else .count-badge.d-inline-flex.align-item-stretch.gl-mr-3 = link_to new_user_session_path, class: 'btn btn-default btn-xs has-tooltip count-badge-button d-flex align-items-center star-btn', title: s_('ProjectOverview|You must sign in to star a project') do - = sprite_icon('star-o', { css_class: 'icon' }) + = sprite_icon('star-o', css_class: 'icon') %span= s_('ProjectOverview|Star') %span.star-count.count-badge-count.d-flex.align-items-center = link_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'count' do diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index c7ab01a4ef7..b01665daff4 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -21,7 +21,7 @@ - if ref - if job.ref - .icon-container + .icon-container.gl-display-inline-block = job.tag? ? icon('tag') : sprite_icon('fork', css_class: 'sprite') = link_to job.ref, project_ref_path(job.project, job.ref), class: "ref-name" - else @@ -101,11 +101,11 @@ = sprite_icon('download') - if can?(current_user, :update_build, job) - if job.active? - = link_to cancel_project_job_path(job.project, job, continue: { to: request.fullpath }), method: :post, title: _('Cancel'), class: 'btn btn-build' do + = link_to cancel_project_job_path(job.project, job, continue: { to: request.fullpath }), method: :post, title: _('Cancel'), class: 'btn gl-button btn-build' do = sprite_icon('close') - elsif job.scheduled? .btn-group - .btn.btn-default{ disabled: true } + .btn.gl-button.btn-default{ disabled: true } = sprite_icon('planning') %time.js-remaining-time{ datetime: job.scheduled_at.utc.iso8601 } = duration_in_numbers(job.execute_in) @@ -113,17 +113,17 @@ = link_to play_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: s_('DelayedJobs|Start now'), - class: 'btn btn-default btn-build has-tooltip', + class: 'btn gl-button btn-default btn-build has-tooltip', data: { confirm: confirmation_message } do = sprite_icon('play') = link_to unschedule_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: s_('DelayedJobs|Unschedule'), - class: 'btn btn-default btn-build has-tooltip' do + class: 'btn gl-button btn-default btn-build has-tooltip' do = sprite_icon('time-out') - elsif allow_retry - if job.playable? && !admin && can?(current_user, :update_build, job) - = link_to play_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Play'), class: 'btn btn-build' do + = link_to play_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Play'), class: 'btn gl-button btn-build' do = custom_icon('icon_play') - elsif job.retryable? = link_to retry_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Retry'), class: 'btn btn-build gl-button btn-icon btn-default' do diff --git a/app/views/projects/ci/lints/show.html.haml b/app/views/projects/ci/lints/show.html.haml index 2e79852f4c9..55aa1c3ae56 100644 --- a/app/views/projects/ci/lints/show.html.haml +++ b/app/views/projects/ci/lints/show.html.haml @@ -1,7 +1,7 @@ - page_title _("CI Lint") - page_description _("Validate your GitLab CI configuration file") -- unless Feature.enabled?(:monaco_ci) - - content_for :library_javascripts do +- unless Feature.enabled?(:monaco_ci, default_enabled: true) + - content_for :page_specific_javascripts do = page_specific_javascript_tag('lib/ace.js') %h2.pt-3.pb-3= _("Validate your GitLab CI configuration") @@ -17,7 +17,7 @@ .file-holder .js-file-title.file-title.clearfix = _("Contents of .gitlab-ci.yml") - - if Feature.enabled?(:monaco_ci) + - if Feature.enabled?(:monaco_ci, default_enabled: true) .file-editor.code .js-edit-mode-pane.qa-editor#editor{ data: { 'editor-loading': true } }< %pre.editor-loading-content= params[:content] diff --git a/app/views/projects/cleanup/_show.html.haml b/app/views/projects/cleanup/_show.html.haml index 019894ddbb4..02d35e690ca 100644 --- a/app/views/projects/cleanup/_show.html.haml +++ b/app/views/projects/cleanup/_show.html.haml @@ -26,5 +26,4 @@ .form-text.text-muted = _("The maximum file size allowed is %{size}.") % { size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes) } - .gl-display-flex.gl-justify-content-end - = f.submit _('Start cleanup'), class: 'btn btn-success' + = f.submit _('Start cleanup'), class: 'btn btn-success' diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index 29ee4a69e83..d9174843301 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -1,6 +1,6 @@ - can_collaborate = can_collaborate_with_project?(@project) -.page-content-header.js-commit-box{ 'data-commit-path' => branches_project_commit_path(@project, @commit.id) } +.page-content-header .header-main-content = render partial: 'signature', object: @commit.signature %strong @@ -58,7 +58,7 @@ %pre.commit-description< = preserve(markdown_field(@commit, :description)) -.info-well +.info-well.js-commit-box-info{ 'data-commit-path' => branches_project_commit_path(@project, @commit.id) } .well-segment.branch-info .icon-container.commit-icon = custom_icon("icon_commit") diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml index 293500a6c31..63cc96c2c05 100644 --- a/app/views/projects/commits/_commits.html.haml +++ b/app/views/projects/commits/_commits.html.haml @@ -28,7 +28,8 @@ = render partial: 'projects/commits/commit', collection: context_commits, locals: { project: project, ref: ref, merge_request: merge_request } - if hidden > 0 - %li.alert.alert-warning + %li.gl-alert.gl-alert-warning + = sprite_icon('warning', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') = n_('%s additional commit has been omitted to prevent performance issues.', '%s additional commits have been omitted to prevent performance issues.', hidden) % number_with_delimiter(hidden) - if project.context_commits_enabled? && can_update_merge_request && context_commits&.empty? diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index d7e10efc3b1..d99579c25c0 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -1,4 +1,5 @@ - page_title _("Value Stream Analytics") +- add_page_specific_style 'page_bundles/cycle_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 46ee60949db..2ba12601c79 100644 --- a/app/views/projects/default_branch/_show.html.haml +++ b/app/views/projects/default_branch/_show.html.haml @@ -28,5 +28,4 @@ = _("Issues referenced by merge requests and commits within the default branch will be closed automatically") = link_to sprite_icon('question-o'), help_page_path('user/project/issues/managing_issues.md', anchor: 'disabling-automatic-issue-closing'), target: '_blank' - .gl-display-flex.gl-justify-content-end - = f.submit _('Save changes'), class: "btn btn-success" + = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/projects/deployments/_commit.html.haml b/app/views/projects/deployments/_commit.html.haml index 743aa60b3ba..52e3e0fd997 100644 --- a/app/views/projects/deployments/_commit.html.haml +++ b/app/views/projects/deployments/_commit.html.haml @@ -1,7 +1,7 @@ .table-mobile-content .branch-commit.cgray - if deployment.ref - %span.icon-container + %span.icon-container.gl-display-inline-block = deployment.tag? ? icon('tag') : sprite_icon('fork', css_class: 'sprite') = link_to deployment.ref, project_ref_path(@project, deployment.ref), class: "ref-name" .icon-container.commit-icon diff --git a/app/views/projects/diffs/_file_header.html.haml b/app/views/projects/diffs/_file_header.html.haml index f954b09abee..e9dfda4e927 100644 --- a/app/views/projects/diffs/_file_header.html.haml +++ b/app/views/projects/diffs/_file_header.html.haml @@ -6,7 +6,7 @@ - if diff_file.submodule? - blob = diff_file.blob %span - = icon('archive fw') + = sprite_icon('archive') %strong.file-title-name = submodule_link(blob, diff_file.content_sha, diff_file.repository) diff --git a/app/views/projects/diffs/_line.html.haml b/app/views/projects/diffs/_line.html.haml index d35443cca1e..4d40071e07c 100644 --- a/app/views/projects/diffs/_line.html.haml +++ b/app/views/projects/diffs/_line.html.haml @@ -21,9 +21,6 @@ - else = add_diff_note_button(line_code, diff_file.position(line), type) %a{ href: "##{line_code}", data: { linenumber: link_text } } - - discussion = line_discussions.try(:first) - - if discussion && discussion.resolvable? && !plain - %diff-note-avatars{ "discussion-id" => discussion.id } %td.new_line.diff-line-num{ class: type, data: { linenumber: line.new_pos } } - link_text = type == "old" ? " " : line.new_pos - if plain diff --git a/app/views/projects/diffs/_parallel_view.html.haml b/app/views/projects/diffs/_parallel_view.html.haml index 9587ea4696b..ebe3aad064a 100644 --- a/app/views/projects/diffs/_parallel_view.html.haml +++ b/app/views/projects/diffs/_parallel_view.html.haml @@ -20,9 +20,6 @@ %td.old_line.diff-line-num.js-avatar-container{ class: left.type, data: { linenumber: left.old_pos } } = add_diff_note_button(left_line_code, left_position, 'old') %a{ href: "##{left_line_code}", data: { linenumber: left.old_pos } } - - discussion_left = discussions_left.try(:first) - - if discussion_left && discussion_left.resolvable? - %diff-note-avatars{ "discussion-id" => discussion_left.id } %td.line_content.parallel.left-side{ id: left_line_code, class: left.type }= diff_line_content(left.rich_text) - else %td.old_line.diff-line-num.empty-cell @@ -41,9 +38,6 @@ %td.new_line.diff-line-num.js-avatar-container{ class: right.type, data: { linenumber: right.new_pos } } = add_diff_note_button(right_line_code, right_position, 'new') %a{ href: "##{right_line_code}", data: { linenumber: right.new_pos } } - - discussion_right = discussions_right.try(:first) - - if discussion_right && discussion_right.resolvable? - %diff-note-avatars{ "discussion-id" => discussion_right.id } %td.line_content.parallel.right-side{ id: right_line_code, class: right.type }= diff_line_content(right.rich_text) - else %td.old_line.diff-line-num.empty-cell diff --git a/app/views/projects/diffs/_stats.html.haml b/app/views/projects/diffs/_stats.html.haml index b438fbbf446..cee479aab0a 100644 --- a/app/views/projects/diffs/_stats.html.haml +++ b/app/views/projects/diffs/_stats.html.haml @@ -1,5 +1,5 @@ -- sum_added_lines = diff_files.sum(&:added_lines) # rubocop: disable CodeReuse/ActiveRecord -- sum_removed_lines = diff_files.sum(&:removed_lines) # rubocop: disable CodeReuse/ActiveRecord +- sum_added_lines = diff_files.sum(&:added_lines) +- sum_removed_lines = diff_files.sum(&:removed_lines) .commit-stat-summary.dropdown Showing %button.diff-stats-summary-toggler.js-diff-stats-dropdown{ type: "button", data: { toggle: "dropdown", display: "static" } }< diff --git a/app/views/projects/diffs/_text_file.html.haml b/app/views/projects/diffs/_text_file.html.haml index 641a0689c26..a945ff5aedf 100644 --- a/app/views/projects/diffs/_text_file.html.haml +++ b/app/views/projects/diffs/_text_file.html.haml @@ -1,4 +1,4 @@ -- too_big = diff_file.diff_lines.count > Commit::DIFF_SAFE_LINES +- too_big = diff_file.diff_lines.count > Commit.diff_safe_lines - if too_big .suppressed-container %a.show-suppressed-diff.cursor-pointer.js-show-suppressed-diff= _("Changes suppressed. Click to show.") diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index e5c4cfcbd72..a9693f985db 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -21,10 +21,9 @@ %input{ name: 'update_section', type: 'hidden', value: 'js-shared-permissions' } %template.js-project-permissions-form-data{ type: "application/json" }= project_permissions_panel_data_json(@project) .js-project-permissions-form - .gl-display-flex.gl-justify-content-end - - if show_visibility_confirm_modal?(@project) - = render "visibility_modal" - = f.submit _('Save changes'), class: "btn btn-success #{('js-confirm-danger' if show_visibility_confirm_modal?(@project))}", data: { qa_selector: 'visibility_features_permissions_save_button', check_field_name: ("project[visibility_level]" if show_visibility_confirm_modal?(@project)), check_compare_value: @project.visibility_level } + - if show_visibility_confirm_modal?(@project) + = render "visibility_modal" + = f.submit _('Save changes'), class: "btn btn-success #{('js-confirm-danger' if show_visibility_confirm_modal?(@project))}", data: { qa_selector: 'visibility_features_permissions_save_button', check_field_name: ("project[visibility_level]" if show_visibility_confirm_modal?(@project)), check_compare_value: @project.visibility_level } %section.qa-merge-request-settings.rspec-merge-request-settings.settings.merge-requests-feature.no-animate#js-merge-request-settings{ class: [('expanded' if expanded), ('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)] } .settings-header @@ -38,8 +37,7 @@ = form_for @project, remote: true, html: { multipart: true, class: "merge-request-settings-form js-mr-settings-form" }, authenticity_token: true do |f| %input{ name: 'update_section', type: 'hidden', value: 'js-merge-request-settings' } = render 'projects/merge_request_settings', form: f - .gl-display-flex.gl-justify-content-end - = f.submit _('Save changes'), class: "btn btn-success qa-save-merge-request-changes rspec-save-merge-request-changes" + = f.submit _('Save changes'), class: "btn btn-succes qa-save-merge-request-changes rspec-save-merge-request-changes" = render_if_exists 'projects/merge_request_approvals_settings', expanded: expanded @@ -70,9 +68,8 @@ .sub-section %h4= _('Housekeeping') %p= _('Runs a number of housekeeping tasks within the current repository, such as compressing file revisions and removing unreachable objects.') - .gl-display-flex.gl-justify-content-end - = link_to _('Run housekeeping'), housekeeping_project_path(@project), - method: :post, class: "btn btn-default" + = link_to _('Run housekeeping'), housekeeping_project_path(@project), + method: :post, class: "btn btn-default" = render 'export', project: @project @@ -94,8 +91,7 @@ %li= _('You will need to update your local repositories to point to the new location.') - if @project.deployment_platform.present? %li= _('Your deployment services will be broken, you will need to manually fix the services after renaming.') - .gl-display-flex.gl-justify-content-end - = f.submit _('Change path'), class: "btn btn-warning qa-change-path-button" + = f.submit _('Change path'), class: "btn btn-warning qa-change-path-button" - if can?(current_user, :change_namespace, @project) .sub-section @@ -111,8 +107,7 @@ %li= _('You can only transfer the project to namespaces you manage.') %li= _('You will need to update your local repositories to point to the new location.') %li= _('Project visibility level will be changed to match namespace rules when transferring to a group.') - .gl-display-flex.gl-justify-content-end - = f.submit 'Transfer project', class: "btn btn-remove js-confirm-danger qa-transfer-button", data: { "confirm-danger-message" => transfer_project_message(@project) } + = f.submit 'Transfer project', class: "btn btn-remove js-confirm-danger qa-transfer-button", data: { "confirm-danger-message" => transfer_project_message(@project) } - if @project.forked? && can?(current_user, :remove_fork_project, @project) .sub-section diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml index c9edc3c12ec..3f19d4db1cc 100644 --- a/app/views/projects/empty.html.haml +++ b/app/views/projects/empty.html.haml @@ -20,7 +20,7 @@ .project-clone-holder.d-none.d-md-inline-block.mt-2.mr-2.float-left = render "projects/buttons/clone" - = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_buttons + = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_buttons, project_buttons: true - if can?(current_user, :push_code, @project) .empty-wrapper.gl-mt-7 diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml index 929015023d2..a774e3b61cc 100644 --- a/app/views/projects/environments/show.html.haml +++ b/app/views/projects/environments/show.html.haml @@ -1,9 +1,7 @@ - add_to_breadcrumbs _("Environments"), project_environments_path(@project) - breadcrumb_title @environment.name - page_title _("Environments") - -- content_for :page_specific_javascripts do - = stylesheet_link_tag 'page_bundles/xterm' +- add_page_specific_style 'page_bundles/xterm' #environments-detail-view{ data: { name: @environment.name, id: @environment.id, delete_path: environment_delete_path(@environment)} } - if @environment.available? && can?(current_user, :stop_environment, @environment) @@ -67,7 +65,7 @@ %p.blank-state-text = html_escape(_("Define environments in the deploy stage(s) in %{code_open}.gitlab-ci.yml%{code_close} to track deployments here.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } .text-center - = link_to _("Read more"), help_page_path("ci/environments"), class: "btn btn-success" + = link_to _("Read more"), help_page_path("ci/environments/index.md"), class: "btn btn-success" - else .table-holder.gl-overflow-visible .ci-table.environments{ role: 'grid' } diff --git a/app/views/projects/feature_flags/_errors.html.haml b/app/views/projects/feature_flags/_errors.html.haml deleted file mode 100644 index a32245640be..00000000000 --- a/app/views/projects/feature_flags/_errors.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -#error_explanation - .alert.alert-danger - - @feature_flag.errors.full_messages.each do |message| - %p= message diff --git a/app/views/projects/feature_flags/edit.html.haml b/app/views/projects/feature_flags/edit.html.haml index 4de41ca4080..67b1a8398d3 100644 --- a/app/views/projects/feature_flags/edit.html.haml +++ b/app/views/projects/feature_flags/edit.html.haml @@ -1,4 +1,4 @@ -- @gfm_form = Feature.enabled?(:feature_flags_issue_links, @project, default_enabled: true) +- @gfm_form = true - add_to_breadcrumbs s_('FeatureFlags|Feature Flags'), project_feature_flags_path(@project) - breadcrumb_title @feature_flag.name diff --git a/app/views/projects/forks/_fork_button.html.haml b/app/views/projects/forks/_fork_button.html.haml index dd49e8bdb4b..cfef2a19420 100644 --- a/app/views/projects/forks/_fork_button.html.haml +++ b/app/views/projects/forks/_fork_button.html.haml @@ -10,11 +10,11 @@ %h5.gl-mt-3 = namespace.human_name - if forked_project = namespace.find_fork_of(@project) - = link_to _("Go to project"), project_path(forked_project), class: "btn" + = link_to _("Go to project"), project_path(forked_project), class: "btn gl-button btn-default" - else %div{ class: ('has-tooltip' unless can_create_project), title: (_('You have reached your project limit') unless can_create_project) } = link_to _("Select"), project_forks_path(@project, namespace_key: namespace.id), data: { qa_selector: 'fork_namespace_button', qa_name: namespace.human_name }, method: "POST", - class: ["btn btn-success", ("disabled" unless can_create_project)] + class: ["btn gl-button btn-success", ("disabled" unless can_create_project)] diff --git a/app/views/projects/forks/index.html.haml b/app/views/projects/forks/index.html.haml index 8384561891a..67dc07fb785 100644 --- a/app/views/projects/forks/index.html.haml +++ b/app/views/projects/forks/index.html.haml @@ -30,11 +30,11 @@ - if current_user && can?(current_user, :fork_project, @project) - if current_user.already_forked?(@project) && current_user.manageable_namespaces.size < 2 - = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: _('Go to your fork'), class: 'btn btn-success' do + = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: _('Go to your fork'), class: 'btn gl-button btn-success' do = sprite_icon('fork', size: 12) %span= _('Fork') - else - = link_to new_project_fork_path(@project), title: _("Fork project"), class: 'btn btn-success' do + = link_to new_project_fork_path(@project), title: _("Fork project"), class: 'btn gl-button btn-success' do = sprite_icon('fork', size: 12) %span= _('Fork') diff --git a/app/views/projects/group_links/update.js.haml b/app/views/projects/group_links/update.js.haml deleted file mode 100644 index 55520fda494..00000000000 --- a/app/views/projects/group_links/update.js.haml +++ /dev/null @@ -1,4 +0,0 @@ -:plain - var $listItem = $('#{escape_javascript(render('shared/members/group', group_link: @group_link))}'); - $("#group_member_#{@group_link.id} .list-item-name").replaceWith($listItem.find('.list-item-name')); - gl.utils.localTimeAgo($('.js-timeago'), $("#group_member_#{@group_link.id}")); diff --git a/app/views/projects/hook_logs/show.html.haml b/app/views/projects/hook_logs/show.html.haml index 8a8c396a9e4..ebe179c3454 100644 --- a/app/views/projects/hook_logs/show.html.haml +++ b/app/views/projects/hook_logs/show.html.haml @@ -7,6 +7,6 @@ %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 gl-ml-3" + = link_to 'Resend Request', @hook_log.present.retry_path, method: :post, class: "btn gl-button 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 f728ef5ac1a..fb19b251d41 100644 --- a/app/views/projects/hooks/edit.html.haml +++ b/app/views/projects/hooks/edit.html.haml @@ -10,9 +10,9 @@ = form_for [@project, @hook], as: :hook, url: project_hook_path(@project, @hook) do |f| = render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook } - %span>= f.submit 'Save changes', class: 'btn btn-success gl-mr-3' + = f.submit 'Save changes', class: 'btn gl-button btn-success gl-mr-3' = render 'shared/web_hooks/test_button', hook: @hook - = link_to _('Delete'), project_hook_path(@project, @hook), method: :delete, class: 'btn btn-remove float-right', data: { confirm: _('Are you sure?') } + = link_to _('Delete'), project_hook_path(@project, @hook), method: :delete, class: 'btn gl-button btn-danger float-right', data: { confirm: _('Are you sure?') } %hr diff --git a/app/views/projects/hooks/index.html.haml b/app/views/projects/hooks/index.html.haml index 5c6a87ddb26..e40c36da29d 100644 --- a/app/views/projects/hooks/index.html.haml +++ b/app/views/projects/hooks/index.html.haml @@ -9,7 +9,6 @@ .col-lg-8.gl-mb-3 = form_for @hook, as: :hook, url: polymorphic_path([@project, :hooks]) do |f| = render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook } - .gl-display-flex.gl-justify-content-end - = f.submit 'Add webhook', class: 'btn btn-success' + = f.submit 'Add webhook', class: 'btn btn-success' = render 'shared/web_hooks/index', hooks: @hooks, hook_class: @hook.class diff --git a/app/views/projects/incidents/_new_branch.html.haml b/app/views/projects/incidents/_new_branch.html.haml new file mode 100644 index 00000000000..f250fbc4b8b --- /dev/null +++ b/app/views/projects/incidents/_new_branch.html.haml @@ -0,0 +1 @@ += render 'projects/issues/new_branch' diff --git a/app/views/projects/incidents/index.html.haml b/app/views/projects/incidents/index.html.haml index 3d66c254601..a89e93618bc 100644 --- a/app/views/projects/incidents/index.html.haml +++ b/app/views/projects/incidents/index.html.haml @@ -1,3 +1,3 @@ - page_title _('Incidents') -#js-incidents{ data: incidents_data(@project) } +#js-incidents{ data: incidents_data(@project, params) } diff --git a/app/views/projects/incidents/show.html.haml b/app/views/projects/incidents/show.html.haml new file mode 100644 index 00000000000..b0ddc85df5d --- /dev/null +++ b/app/views/projects/incidents/show.html.haml @@ -0,0 +1 @@ += render template: 'projects/issues/show' diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml index 4273130bbc2..e1f1d8bb8f7 100644 --- a/app/views/projects/issues/_discussion.html.haml +++ b/app/views/projects/issues/_discussion.html.haml @@ -1,4 +1,5 @@ - add_page_startup_api_call discussions_path(@issue) +- add_page_startup_api_call notes_url - @gfm_form = true diff --git a/app/views/projects/issues/_issues.html.haml b/app/views/projects/issues/_issues.html.haml index 1a557cce33c..f3c431de1ea 100644 --- a/app/views/projects/issues/_issues.html.haml +++ b/app/views/projects/issues/_issues.html.haml @@ -4,12 +4,14 @@ - data_endpoint = local_assigns.fetch(:data_endpoint, expose_path(api_v4_projects_issues_path(id: @project.id))) - default_empty_state_meta = { create_issue_path: new_project_issue_path(@project), svg_path: image_path('illustrations/issues.svg') } - data_empty_state_meta = local_assigns.fetch(:data_empty_state_meta, default_empty_state_meta) - - type = local_assigns.fetch(:type, '') + - type = local_assigns.fetch(:type, 'issues') + - if type == 'issues' && use_startup_call? + - add_page_startup_api_call(api_v4_projects_issues_path(id: @project.id, params: startup_call_params)) .js-issuables-list{ data: { endpoint: data_endpoint, 'empty-state-meta': data_empty_state_meta.to_json, 'can-bulk-edit': @can_bulk_update.to_json, 'sort-key': @sort, - 'type': type } } + type: type } } - 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') } diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml index 9bbab925f6a..aa95cecb5fe 100644 --- a/app/views/projects/issues/_new_branch.html.haml +++ b/app/views/projects/issues/_new_branch.html.haml @@ -43,7 +43,7 @@ %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') } } + #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/index.md') } } .form-group %label{ for: 'new-branch-name' } = _('Branch name') diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml index cfc423da57a..7fa158e0024 100644 --- a/app/views/projects/issues/index.html.haml +++ b/app/views/projects/issues/index.html.haml @@ -2,6 +2,7 @@ - page_title _("Issues") - new_issue_email = @project.new_issuable_address(current_user, 'issue') +- add_page_specific_style 'page_bundles/issues' = content_for :meta_tags do = auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@project.name} issues") diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index c762b044c3e..7ee6c2b137a 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -2,14 +2,17 @@ - add_to_breadcrumbs _("Issues"), project_issues_path(@project) - breadcrumb_title @issue.to_reference - page_title "#{@issue.title} (#{@issue.to_reference})", _("Issues") -- page_description @issue.description +- page_description @issue.description_html - page_card_attributes @issue.card_attributes +- if @issue.relocation_target + - page_canonical_link @issue.relocation_target.present(current_user: current_user).web_url - can_update_issue = can?(current_user, :update_issue, @issue) - can_reopen_issue = can?(current_user, :reopen_issue, @issue) - can_report_spam = @issue.submittable_as_spam_by?(current_user) - can_create_issue = show_new_issue_link?(@project) - related_branches_path = related_branches_project_issue_path(@project, @issue) +- add_page_specific_style 'page_bundles/issues' = render_if_exists "projects/issues/alert_blocked", issue: @issue, current_user: current_user = render "projects/issues/alert_moved_from_service_desk", issue: @issue @@ -61,15 +64,12 @@ .issue-details.issuable-details .detail-page-description.content-block - -# haml-lint:disable InlineJavaScript - %script#js-issuable-app-initial-data{ type: "application/json" }= issuable_initial_data(@issue).to_json - #js-issuable-app + #js-issuable-app{ data: { initial: issuable_initial_data(@issue).to_json} } .title-container %h2.title= markdown_field(@issue, :title) - if @issue.description.present? - .description{ class: can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '' } + .description .md= markdown_field(@issue, :description) - %textarea.hidden.js-task-list-field= @issue.description = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue-edited-ago js-issue-edited-ago') diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml index df98a1c7cce..d7a778088ee 100644 --- a/app/views/projects/jobs/show.html.haml +++ b/app/views/projects/jobs/show.html.haml @@ -1,9 +1,7 @@ - add_to_breadcrumbs _("Jobs"), project_jobs_path(@project) - breadcrumb_title "##{@build.id}" - page_title "#{@build.name} (##{@build.id})", _("Jobs") - -- content_for :page_specific_javascripts do - = stylesheet_link_tag 'page_bundles/xterm' +- add_page_specific_style 'page_bundles/xterm' = render_if_exists "shared/shared_runners_minutes_limit_flash_message" diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml index 8d8270847a3..2699192adc9 100644 --- a/app/views/projects/labels/index.html.haml +++ b/app/views/projects/labels/index.html.haml @@ -52,5 +52,5 @@ = render 'shared/empty_states/labels' %template#js-badge-item-template - %li.label-link-item.js-priority-badge.inline.gl-ml-3 - .label-badge.label-badge-blue= _('Prioritized label') + %li.js-priority-badge.inline.gl-ml-3 + .label-badge.gl-bg-blue-50= _('Prioritized label') diff --git a/app/views/projects/merge_requests/_description.html.haml b/app/views/projects/merge_requests/_description.html.haml index 354a384b647..c20479662dd 100644 --- a/app/views/projects/merge_requests/_description.html.haml +++ b/app/views/projects/merge_requests/_description.html.haml @@ -3,7 +3,6 @@ .description.qa-description{ class: can?(current_user, :update_merge_request, @merge_request) ? 'js-task-list-container' : '' } .md = markdown_field(@merge_request, :description) - %textarea.hidden.js-task-list-field - = @merge_request.description + %textarea.hidden.js-task-list-field{ data: { value: @merge_request.description } } = edited_time_ago_with_tooltip(@merge_request, placement: 'bottom') diff --git a/app/views/projects/merge_requests/_discussion.html.haml b/app/views/projects/merge_requests/_discussion.html.haml deleted file mode 100644 index ecb51aca847..00000000000 --- a/app/views/projects/merge_requests/_discussion.html.haml +++ /dev/null @@ -1,11 +0,0 @@ -- content_for :note_actions do - - if can?(current_user, :update_merge_request, @merge_request) - - if @merge_request.open? - = link_to 'Close merge request', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, class: "btn btn-nr btn-comment btn-close close-mr-link js-note-target-close", title: "Close merge request", data: { original_text: "Close merge request", alternative_text: "Comment & close merge request"} - - 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.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/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml index 454a0694355..b56e2c3f985 100644 --- a/app/views/projects/merge_requests/_mr_title.html.haml +++ b/app/views/projects/merge_requests/_mr_title.html.haml @@ -32,16 +32,19 @@ %ul - if can_update_merge_request %li= link_to 'Edit', edit_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) - - unless current_user == @merge_request.author - %li= link_to 'Report abuse', new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request)) - if can_update_merge_request + - unless @merge_request.closed? + %li + = link_to @merge_request.work_in_progress? ? _('Mark as ready') : _('Mark as draft'), toggle_draft_issuable_path(@merge_request), method: :put, class: "js-draft-toggle-button" %li{ class: [merge_request_button_visibility(@merge_request, true), 'js-close-item'] } = link_to 'Close', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, title: 'Close merge request' - if can_reopen_merge_request %li{ class: merge_request_button_visibility(@merge_request, false) } = link_to 'Reopen', merge_request_path(@merge_request, merge_request: { state_event: :reopen }), method: :put, class: 'reopen-mr-link', title: 'Reopen merge request' + - unless current_user == @merge_request.author + %li= link_to 'Report abuse', new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request)) - if can_update_merge_request - = link_to 'Edit', edit_project_merge_request_path(@project, @merge_request), class: "d-none d-sm-none d-md-block btn btn-grouped js-issuable-edit qa-edit-button" + = link_to 'Edit', edit_project_merge_request_path(@project, @merge_request), class: "d-none d-sm-none d-md-block btn gl-button btn-grouped js-issuable-edit qa-edit-button" = render 'shared/issuable/close_reopen_button', issuable: @merge_request, can_update: can_update_merge_request, can_reopen: can_reopen_merge_request diff --git a/app/views/projects/merge_requests/diffs/_commit_widget.html.haml b/app/views/projects/merge_requests/diffs/_commit_widget.html.haml deleted file mode 100644 index c022d2c70d8..00000000000 --- a/app/views/projects/merge_requests/diffs/_commit_widget.html.haml +++ /dev/null @@ -1,11 +0,0 @@ --#----------------------------------------------------------------- - WARNING: Please keep changes up-to-date with the following files: - - `assets/javascripts/diffs/components/commit_widget.vue` --#----------------------------------------------------------------- -- collapsible = local_assigns.fetch(:collapsible, true) - -- if @commit - .info-well.mw-100.mx-0 - .well-segment - %ul.blob-commit-info - = render 'projects/commits/commit', commit: @commit, merge_request: @merge_request, view_details: true, collapsible: collapsible diff --git a/app/views/projects/merge_requests/diffs/_different_base.html.haml b/app/views/projects/merge_requests/diffs/_different_base.html.haml deleted file mode 100644 index 06a15b96653..00000000000 --- a/app/views/projects/merge_requests/diffs/_different_base.html.haml +++ /dev/null @@ -1,11 +0,0 @@ -- if @merge_request_diff && different_base?(@start_version, @merge_request_diff) - .mr-version-controls - .content-block - = sprite_icon('information-o') - Selected versions have different base commits. - Changes will include - = link_to project_compare_path(@project, from: @start_version.base_commit_sha, to: @merge_request_diff.base_commit_sha) do - new commits - from - = succeed '.' do - %code.ref-name= @merge_request.target_branch diff --git a/app/views/projects/merge_requests/diffs/_diffs.html.haml b/app/views/projects/merge_requests/diffs/_diffs.html.haml deleted file mode 100644 index 9ebd91dea0b..00000000000 --- a/app/views/projects/merge_requests/diffs/_diffs.html.haml +++ /dev/null @@ -1,21 +0,0 @@ -= render 'projects/merge_requests/diffs/version_controls' -= render 'projects/merge_requests/diffs/different_base' -= render 'projects/merge_requests/diffs/not_all_comments_displayed' -= render 'projects/merge_requests/diffs/commit_widget' - -- if @merge_request_diff&.empty? - .row.empty-state.nothing-here-block - .col-12 - .svg-content= image_tag 'illustrations/merge_request_changes_empty.svg' - .col-12 - .text-content.text-center - %p - No changes between - %span.ref-name= @merge_request.source_branch - and - %span.ref-name= @merge_request.target_branch - .text-center= link_to 'Create commit', project_new_blob_path(@project, @merge_request.source_branch), class: 'btn btn-success' -- else - - diff_viewable = @merge_request_diff ? @merge_request_diff.viewable? : true - - if diff_viewable - = render "projects/diffs/diffs", diffs: @diffs, environment: @environment, merge_request: true diff --git a/app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml b/app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml deleted file mode 100644 index b9dc37c9b54..00000000000 --- a/app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml +++ /dev/null @@ -1,17 +0,0 @@ -- if @commit || @start_version || (@merge_request_diff && !@merge_request_diff.latest?) - .mr-version-controls - .content-block.comments-disabled-notif.clearfix - = sprite_icon('information-o') - = succeed '.' do - - if @commit - Only comments from the following commit are shown below - - else - Not all comments are displayed because you're - - if @start_version - comparing two versions of the diff - - else - viewing an old version of the diff - .float-right - = link_to diffs_project_merge_request_path(@merge_request.project, @merge_request), class: 'btn btn-sm' do - Show latest version - = "of the diff" if @commit diff --git a/app/views/projects/merge_requests/diffs/_version_controls.html.haml b/app/views/projects/merge_requests/diffs/_version_controls.html.haml deleted file mode 100644 index 52bf584d550..00000000000 --- a/app/views/projects/merge_requests/diffs/_version_controls.html.haml +++ /dev/null @@ -1,73 +0,0 @@ -- if @merge_request_diff && @merge_request_diffs.size > 1 - .mr-version-controls - .mr-version-menus-container.content-block - Changes between - %span.dropdown.inline.mr-version-dropdown - %a.dropdown-toggle.btn.btn-default{ data: { toggle: :dropdown, display: 'static' } } - %span - - if @merge_request_diff.latest? - latest version - - else - version #{version_index(@merge_request_diff)} - = icon('caret-down') - .dropdown-menu.dropdown-select.dropdown-menu-selectable - .dropdown-title - %span Version: - %button.dropdown-title-button.dropdown-menu-close{ aria: { label: "Close" } } - = icon('times', class: 'dropdown-menu-close-icon') - .dropdown-content - %ul - - @merge_request_diffs.each do |merge_request_diff| - %li - = link_to merge_request_version_path(@project, @merge_request, merge_request_diff, @start_sha), class: ('is-active' if merge_request_diff == @merge_request_diff) do - %div - %strong - - if merge_request_diff.latest? - latest version - - else - version #{version_index(merge_request_diff)} - %div - %small.commit-sha= short_sha(merge_request_diff.head_commit_sha) - %div - %small - #{number_with_delimiter(merge_request_diff.commits_count)} #{'commit'.pluralize(merge_request_diff.commits_count)}, - = time_ago_with_tooltip(merge_request_diff.created_at) - - - if @merge_request_diff.base_commit_sha - and - %span.dropdown.inline.mr-version-compare-dropdown - %a.btn.btn-default.dropdown-toggle{ data: { toggle: :dropdown, display: 'static' } } - - if @start_version - version #{version_index(@start_version)} - - else - %span.ref-name= @merge_request.target_branch - = icon('caret-down') - .dropdown-menu.dropdown-select.dropdown-menu-selectable - .dropdown-title - %span Compared with: - %button.dropdown-title-button.dropdown-menu-close{ aria: { label: "Close" } } - = icon('times', class: 'dropdown-menu-close-icon') - .dropdown-content - %ul - - @comparable_diffs.each do |merge_request_diff| - %li - = link_to merge_request_version_path(@project, @merge_request, @merge_request_diff, merge_request_diff.head_commit_sha), class: ('is-active' if merge_request_diff == @start_version) do - %div - %strong - - if merge_request_diff.latest? - latest version - - else - version #{version_index(merge_request_diff)} - %div - %small.commit-sha= short_sha(merge_request_diff.head_commit_sha) - %div - %small - = time_ago_with_tooltip(merge_request_diff.created_at) - %li - = link_to merge_request_version_path(@project, @merge_request, @merge_request_diff), class: ('is-active' unless @start_version) do - %div - %strong - %span.ref-name= @merge_request.target_branch - (base) - %div - %strong.commit-sha= short_sha(@merge_request_diff.base_commit_sha) diff --git a/app/views/projects/merge_requests/invalid.html.haml b/app/views/projects/merge_requests/invalid.html.haml index 7b831aa2d01..df942c11883 100644 --- a/app/views/projects/merge_requests/invalid.html.haml +++ b/app/views/projects/merge_requests/invalid.html.haml @@ -1,25 +1,28 @@ - page_title "#{@merge_request.title} (#{@merge_request.to_reference}", _("Merge Requests") +- badge_css_classes = "badge gl-text-white" +- badge_info_css_classes = "#{badge_css_classes} badge-info" +- badge_inverse_css_classes = "#{badge_css_classes} badge-inverse" .merge-request = render "projects/merge_requests/mr_title" = render "projects/merge_requests/mr_box" - .alert.alert-danger + .gl-alert.gl-alert-danger + = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') %p We cannot render this merge request properly because - if @merge_request.for_fork? && !@merge_request.source_project fork project was removed - elsif !@merge_request.source_branch_exists? - %span.badge.badge-inverse= @merge_request.source_branch + %span{ class: badge_inverse_css_classes }= @merge_request.source_branch does not exist in - %span.badge.badge-info= @merge_request.source_project_path + %span{ class: badge_info_css_classes }= @merge_request.source_project_path - elsif !@merge_request.target_branch_exists? - %span.badge.badge-inverse= @merge_request.target_branch + %span{ class: badge_inverse_css_classes }= @merge_request.target_branch does not exist in - %span.badge.badge-info= @merge_request.target_project_path + %span{ class: badge_info_css_classes }= @merge_request.target_project_path - else of internal error %strong Please close Merge Request or change branches with existing one - diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index b579f7510f9..84b108d69ad 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -3,7 +3,7 @@ - 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_description @merge_request.description +- page_description @merge_request.description_html - 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 @@ -43,7 +43,6 @@ .tab-content#diff-notes-app #js-diff-file-finder - - if native_code_navigation_enabled?(@project) #js-code-navigation = render "projects/merge_requests/tabs/pane", id: "notes", class: "notes voting_notes" do .row @@ -92,7 +91,7 @@ .loading.hide .spinner.spinner-md -= render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @merge_request.assignees, source_branch: @merge_request.source_branch += render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @merge_request.assignees, reviewers: @merge_request.reviewers, source_branch: @merge_request.source_branch - if @merge_request.can_be_reverted?(current_user) = render "projects/commit/change", type: 'revert', commit: @merge_request.merge_commit, title: @merge_request.title diff --git a/app/views/projects/milestones/index.html.haml b/app/views/projects/milestones/index.html.haml index 2bab2a0fb03..6e81058df2a 100644 --- a/app/views/projects/milestones/index.html.haml +++ b/app/views/projects/milestones/index.html.haml @@ -1,4 +1,5 @@ - page_title _('Milestones') +- add_page_specific_style 'page_bundles/milestone' .top-area = render 'shared/milestones_filter', counts: milestone_counts(@project.milestones) diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml index 99e626161c4..2514d2cce32 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -1,7 +1,8 @@ - add_to_breadcrumbs _('Milestones'), project_milestones_path(@project) - breadcrumb_title @milestone.title - page_title @milestone.title, _('Milestones') -- page_description @milestone.description +- page_description @milestone.description_html +- add_page_specific_style 'page_bundles/milestone' = render 'shared/milestones/header', milestone: @milestone = render 'shared/milestones/description', milestone: @milestone diff --git a/app/views/projects/milestones/update.js.haml b/app/views/projects/milestones/update.js.haml deleted file mode 100644 index 3ff84915e97..00000000000 --- a/app/views/projects/milestones/update.js.haml +++ /dev/null @@ -1,2 +0,0 @@ -:plain - $('##{dom_id(@milestone)}').fadeOut(); diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml index d7098bbb69d..9c6b803210f 100644 --- a/app/views/projects/mirrors/_mirror_repos.html.haml +++ b/app/views/projects/mirrors/_mirror_repos.html.haml @@ -32,7 +32,7 @@ = label_tag :only_protected_branches, _('Only mirror protected branches'), class: 'form-check-label' = link_to sprite_icon('question-o'), help_page_path('user/project/protected_branches'), target: '_blank' - .panel-footer.gl-display-flex.gl-justify-content-end + .panel-footer = f.submit _('Mirror repository'), class: 'btn btn-success js-mirror-submit qa-mirror-repository-button', name: :update_remote_mirror - else .gl-alert.gl-alert-info{ role: 'alert' } diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml index 058366eb75d..a785e36fad5 100644 --- a/app/views/projects/notes/_actions.html.haml +++ b/app/views/projects/notes/_actions.html.haml @@ -2,7 +2,7 @@ - if note.noteable_author?(@noteable) %span{ class: 'note-role user-access-role has-tooltip d-none d-md-inline-block', title: _("This user is the author of this %{noteable}.") % { noteable: @noteable.human_class_name } }= _("Author") - if access - %span{ class: 'note-role user-access-role has-tooltip', title: _("This user is a %{access} of the %{name} project.") % { access: access.downcase, name: note.project_name } }= access + %span{ class: 'note-role user-access-role has-tooltip', title: _("This user has the %{access} role in the %{name} project.") % { access: access.downcase, name: note.project_name } }= access - elsif note.contributor? %span{ class: 'note-role user-access-role has-tooltip', title: _("This user has previously committed to the %{name} project.") % { name: note.project_name } }= _("Contributor") diff --git a/app/views/projects/pages/_destroy.haml b/app/views/projects/pages/_destroy.haml index 58dbbb5bcfc..0833eba2c27 100644 --- a/app/views/projects/pages/_destroy.haml +++ b/app/views/projects/pages/_destroy.haml @@ -8,7 +8,7 @@ %p = s_('GitLabPages|Removing pages will prevent them from being exposed to the outside world.') .form-actions - = link_to s_('GitLabPages|Remove pages'), project_pages_path(@project), data: { confirm: s_('GitLabPages|Are you sure?')}, method: :delete, class: "btn btn-remove" + = link_to s_('GitLabPages|Remove pages'), project_pages_path(@project), data: { confirm: s_('GitLabPages|Are you sure?')}, method: :delete, class: "btn gl-button btn-remove" - else .nothing-here-block = s_('GitLabPages|Only project maintainers can remove pages') diff --git a/app/views/projects/pages/_list.html.haml b/app/views/projects/pages/_list.html.haml index af6de10b2a0..05e9e569a04 100644 --- a/app/views/projects/pages/_list.html.haml +++ b/app/views/projects/pages/_list.html.haml @@ -21,8 +21,8 @@ %span.badge.badge-danger = s_('GitLabPages|Expired') %div - = link_to s_('GitLabPages|Edit'), project_pages_domain_path(@project, domain), class: "btn btn-sm btn-grouped btn-success btn-inverted" - = link_to s_('GitLabPages|Remove'), project_pages_domain_path(@project, domain), data: { confirm: s_('GitLabPages|Are you sure?')}, method: :delete, class: "btn btn-remove btn-sm btn-grouped" + = link_to s_('GitLabPages|Edit'), project_pages_domain_path(@project, domain), class: "btn gl-button btn-sm btn-grouped btn-success btn-inverted" + = link_to s_('GitLabPages|Remove'), project_pages_domain_path(@project, domain), data: { confirm: s_('GitLabPages|Are you sure?')}, method: :delete, class: "btn gl-button btn-remove btn-sm btn-grouped" - if domain.needs_verification? %li.list-group-item.bs-callout-warning - details_link_start = "<a href='#{project_pages_domain_path(@project, domain)}'>".html_safe diff --git a/app/views/projects/pages/show.html.haml b/app/views/projects/pages/show.html.haml index 8a01945ffac..4347cbdbd9b 100644 --- a/app/views/projects/pages/show.html.haml +++ b/app/views/projects/pages/show.html.haml @@ -5,7 +5,7 @@ = s_('GitLabPages|Pages') - if can?(current_user, :update_pages, @project) && (Gitlab.config.pages.external_http || Gitlab.config.pages.external_https) - = link_to new_project_pages_domain_path(@project), class: 'btn btn-success float-right', title: s_('GitLabPages|New Domain') do + = link_to new_project_pages_domain_path(@project), class: 'btn gl-button btn-success float-right', title: s_('GitLabPages|New Domain') do = s_('GitLabPages|New Domain') %p.light diff --git a/app/views/projects/pages_domains/_form.html.haml b/app/views/projects/pages_domains/_form.html.haml index 9e9f60a6f09..453134ce5ab 100644 --- a/app/views/projects/pages_domains/_form.html.haml +++ b/app/views/projects/pages_domains/_form.html.haml @@ -1,5 +1,6 @@ - if domain_presenter.errors.any? - .alert.alert-danger + .gl-alert.gl-alert-danger + = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - domain_presenter.errors.full_messages.each do |msg| = msg diff --git a/app/views/projects/pipeline_schedules/index.html.haml b/app/views/projects/pipeline_schedules/index.html.haml index 2b2b79d886b..91083cc0768 100644 --- a/app/views/projects/pipeline_schedules/index.html.haml +++ b/app/views/projects/pipeline_schedules/index.html.haml @@ -2,7 +2,7 @@ - page_title _("Pipeline Schedules") -#pipeline-schedules-callout{ data: { docs_url: help_page_path('ci/pipelines/schedules') } } +#pipeline-schedules-callout{ data: { docs_url: help_page_path('ci/pipelines/schedules'), image_url: image_path('illustrations/pipeline_schedule_callout.svg') } } .top-area - schedule_path_proc = ->(scope) { pipeline_schedules_path(@project, scope: scope) } = render "tabs", schedule_path_proc: schedule_path_proc, all_schedules: @all_schedules, scope: @scope diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index c54a19b8f61..a1dc721b900 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -43,8 +43,8 @@ placement: "top", html: "true", trigger: "focus", - title: "<div class='autodevops-title'>#{popover_title_text}</div>", - content: "<a class='autodevops-link' href='#{popover_content_url}' target='_blank' rel='noopener noreferrer nofollow'>#{popover_content_text}</a>", + title: "<div class='gl-font-weight-normal gl-line-height-normal'>#{popover_title_text}</div>", + content: "<a href='#{popover_content_url}' target='_blank' rel='noopener noreferrer nofollow'>#{popover_content_text}</a>", } } Auto DevOps - if @pipeline.detached_merge_request_pipeline? diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index f1ed67f8f82..40a52f76641 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -68,7 +68,7 @@ %td.responsive-table-cell.build-failure{ data: { column: _('Failure')} } = build.present.callout_failure_message %td.responsive-table-cell.build-actions - - if can?(current_user, :update_build, job) + - if can?(current_user, :update_build, job) && job.retryable? = link_to retry_project_job_path(build.project, build, return_to: request.original_url), method: :post, title: _('Retry'), class: 'btn btn-build gl-button btn-icon btn-default' do = sprite_icon('repeat', css_class: 'gl-icon') - if can?(current_user, :read_build, job) diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml index 2be75106000..29e5f2cf5b4 100644 --- a/app/views/projects/pipelines/new.html.haml +++ b/app/views/projects/pipelines/new.html.haml @@ -6,7 +6,7 @@ = s_('Pipeline|Run Pipeline') %hr -- if Feature.enabled?(:new_pipeline_form) +- if Feature.enabled?(:new_pipeline_form, @project) #js-new-pipeline{ data: { project_id: @project.id, pipelines_path: project_pipelines_path(@project), ref_param: params[:ref] || @project.default_branch, var_param: params[:var].to_json, file_param: params[:file_var].to_json, ref_names: @project.repository.ref_names.to_json.html_safe, settings_link: project_settings_ci_cd_path(@project), max_warnings: ::Gitlab::Ci::Warnings::MAX_LIMIT } } - else diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml index a9c140aee5f..5b8ab455ec8 100644 --- a/app/views/projects/pipelines/show.html.haml +++ b/app/views/projects/pipelines/show.html.haml @@ -4,8 +4,7 @@ - pipeline_has_errors = @pipeline.builds.empty? && @pipeline.yaml_errors.present? .js-pipeline-container{ data: { controller_action: "#{controller.action_name}" } } - #js-pipeline-header-vue.pipeline-header-container - + #js-pipeline-header-vue.pipeline-header-container{ data: {full_path: @project.full_path, retry_path: retry_project_pipeline_path(@pipeline.project, @pipeline), cancel_path: cancel_project_pipeline_path(@pipeline.project, @pipeline), delete_path: project_pipeline_path(@pipeline.project, @pipeline), pipeline_iid: @pipeline.iid, pipeline_id: @pipeline.id} } - if @pipeline.commit.present? = render "projects/pipelines/info", commit: @pipeline.commit diff --git a/app/views/projects/project_members/import.html.haml b/app/views/projects/project_members/import.html.haml index bcca943de6a..2f953db0d65 100644 --- a/app/views/projects/project_members/import.html.haml +++ b/app/views/projects/project_members/import.html.haml @@ -11,5 +11,5 @@ .col-sm-10= select_tag(:source_project_id, options_from_collection_for_select(@projects, :id, :name_with_namespace), prompt: "Select project", class: "select2 lg", required: true) .form-actions - = button_tag _('Import project members'), class: "btn btn-success" - = link_to _("Cancel"), project_project_members_path(@project), class: "btn btn-cancel" + = button_tag _('Import project members'), class: "btn gl-button btn-success" + = link_to _("Cancel"), project_project_members_path(@project), class: "btn gl-button btn-cancel" diff --git a/app/views/projects/protected_branches/_create_protected_branch.html.haml b/app/views/projects/protected_branches/_create_protected_branch.html.haml index ee359a01e74..33be875d9a6 100644 --- a/app/views/projects/protected_branches/_create_protected_branch.html.haml +++ b/app/views/projects/protected_branches/_create_protected_branch.html.haml @@ -1,3 +1,5 @@ +- select_mode_for_dropdown = Feature.enabled?(:deploy_keys_on_protected_branches, @project) ? 'js-multiselect' : '' + - content_for :merge_access_levels do .merge_access_levels-container = dropdown_tag('Select', @@ -7,7 +9,7 @@ - content_for :push_access_levels do .push_access_levels-container = dropdown_tag('Select', - options: { toggle_class: 'js-allowed-to-push qa-allowed-to-push-select wide', + options: { toggle_class: "js-allowed-to-push qa-allowed-to-push-select #{select_mode_for_dropdown} wide", dropdown_class: 'dropdown-menu-selectable qa-allowed-to-push-dropdown rspec-allowed-to-push-dropdown capitalize-header', data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes' }}) 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 dc7514badb6..c4bf2d20ecf 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 @@ -24,5 +24,5 @@ .create_access_levels-container = yield :create_access_levels - .card-footer.gl-display-flex.gl-justify-content-end + .card-footer = f.submit _('Protect'), class: 'btn-success btn', disabled: true, data: { qa_selector: 'protect_tag_button' } diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml index 8540ce30060..9ac1fda169f 100644 --- a/app/views/projects/registry/repositories/index.html.haml +++ b/app/views/projects/registry/repositories/index.html.haml @@ -15,5 +15,8 @@ "registry_host_url_with_port" => escape_once(registry_config.host_port), "expiration_policy_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'expiration-policy'), "garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'), + "run_cleanup_policies_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'run-the-cleanup-policy-now'), + "cleanup_policies_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'how-the-cleanup-policy-works'), + "is_admin": current_user&.admin.to_s, character_error: @character_error.to_s } } diff --git a/app/views/projects/registry/settings/_index.haml b/app/views/projects/registry/settings/_index.haml index c0cef8503e0..b53fac83830 100644 --- a/app/views/projects/registry/settings/_index.haml +++ b/app/views/projects/registry/settings/_index.haml @@ -1,4 +1,5 @@ #js-registry-settings{ data: { project_id: @project.id, + project_path: @project.full_path, cadence_options: cadence_options.to_json, keep_n_options: keep_n_options.to_json, older_than_options: older_than_options.to_json, diff --git a/app/views/projects/releases/show.html.haml b/app/views/projects/releases/show.html.haml index 188262fb34c..550a37dabcb 100644 --- a/app/views/projects/releases/show.html.haml +++ b/app/views/projects/releases/show.html.haml @@ -1,4 +1,5 @@ - add_to_breadcrumbs _("Releases"), project_releases_path(@project) - page_title @release.name +- page_description @release.description_html #js-show-release-page{ data: { project_id: @project.id, tag_name: @release.tag } } diff --git a/app/views/projects/services/prometheus/_configuration_banner.html.haml b/app/views/projects/services/prometheus/_configuration_banner.html.haml index b4e8458d8b9..717df405fa7 100644 --- a/app/views/projects/services/prometheus/_configuration_banner.html.haml +++ b/app/views/projects/services/prometheus/_configuration_banner.html.haml @@ -14,13 +14,13 @@ .col-sm-10 %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' + = link_to s_('PrometheusService|Manage clusters'), project_clusters_path(project), class: 'btn gl-button' - else .col-sm-2 = image_tag 'illustrations/monitoring/loading.svg' .col-sm-10 %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' + = link_to s_('PrometheusService|Install Prometheus on clusters'), project_clusters_path(project), class: 'btn gl-button btn-success' %hr diff --git a/app/views/projects/services/prometheus/_custom_metrics.html.haml b/app/views/projects/services/prometheus/_custom_metrics.html.haml index 57100282c34..70685a8a9eb 100644 --- a/app/views/projects/services/prometheus/_custom_metrics.html.haml +++ b/app/views/projects/services/prometheus/_custom_metrics.html.haml @@ -13,7 +13,7 @@ -# haml-lint:disable NoPlainNodes %span.badge.badge-pill.js-custom-monitored-count 0 -# haml-lint:enable NoPlainNodes - = link_to s_('PrometheusService|New metric'), new_project_prometheus_metric_path(project), class: 'btn btn-success js-new-metric-button hidden', data: { qa_selector: 'new_metric_button' } + = link_to s_('PrometheusService|New metric'), new_project_prometheus_metric_path(project), class: 'btn gl-button btn-success js-new-metric-button hidden', data: { qa_selector: 'new_metric_button' } .card-body .flash-container.hidden .flash-warning diff --git a/app/views/projects/settings/_archive.html.haml b/app/views/projects/settings/_archive.html.haml index cbeedbd080c..4133129fde2 100644 --- a/app/views/projects/settings/_archive.html.haml +++ b/app/views/projects/settings/_archive.html.haml @@ -13,7 +13,6 @@ method: :post, class: "btn btn-success" - else %p= _("Archiving the project will make it entirely read only. It is hidden from the dashboard and doesn't show up in searches. %{strong_start}The repository cannot be committed to, and no issues, comments, or other entities can be created.%{strong_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } - .gl-display-flex.gl-justify-content-end - = link_to _('Archive project'), archive_project_path(@project), - data: { confirm: _("Are you sure that you want to archive this project?"), qa_selector: 'archive_project_link' }, - method: :post, class: "btn btn-warning" + = link_to _('Archive project'), archive_project_path(@project), + data: { confirm: _("Are you sure that you want to archive this project?"), qa_selector: 'archive_project_link' }, + method: :post, class: "btn btn-warning" diff --git a/app/views/projects/settings/_general.html.haml b/app/views/projects/settings/_general.html.haml index 50f80fd1e2f..5d5f1d54439 100644 --- a/app/views/projects/settings/_general.html.haml +++ b/app/views/projects/settings/_general.html.haml @@ -40,5 +40,4 @@ %hr = link_to _('Remove avatar'), project_avatar_path(@project), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'btn btn-link' - .gl-display-flex.gl-justify-content-end - = f.submit _('Save changes'), class: "btn btn-success mt-4 qa-save-naming-topics-avatar-button" + = f.submit _('Save changes'), class: "btn btn-success mt-4 qa-save-naming-topics-avatar-button" diff --git a/app/views/projects/settings/operations/_alert_management.html.haml b/app/views/projects/settings/operations/_alert_management.html.haml index f8f3ecb6273..5c16a5e2758 100644 --- a/app/views/projects/settings/operations/_alert_management.html.haml +++ b/app/views/projects/settings/operations/_alert_management.html.haml @@ -9,6 +9,6 @@ = _('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' + = link_to _('More information'), help_page_path('operations/incident_management/index.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content .js-alerts-settings{ data: alerts_settings_data } diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml index dba9b20fcff..d7231e758c7 100644 --- a/app/views/projects/tags/_tag.html.haml +++ b/app/views/projects/tags/_tag.html.haml @@ -1,6 +1,7 @@ - commit = @repository.commit(tag.dereferenced_target) - release = @releases.find { |release| release.tag == tag.name } -%li.flex-row.allow-wrap + +%li.flex-row.allow-wrap.js-tag-list .row-main-content = sprite_icon('tag') = link_to tag.name, project_tag_path(@project, tag.name), class: 'item-title ref-name' @@ -24,7 +25,7 @@ .text-secondary = sprite_icon("rocket", size: 12) = _("Release") - = link_to release.name, project_releases_path(@project, anchor: release.tag), class: 'tag-release-link' + = link_to release.name, project_releases_path(@project, anchor: release.tag), class: 'gl-text-blue-600!' - if release.description.present? .md.gl-mt-3 = markdown_field(release, :description) @@ -38,5 +39,4 @@ - 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 = sprite_icon("pencil") - = 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 - = sprite_icon("remove") + = render 'projects/buttons/remove_tag', project: @project, tag: tag diff --git a/app/views/projects/tags/destroy.js.haml b/app/views/projects/tags/destroy.js.haml deleted file mode 100644 index 59d359bbf10..00000000000 --- a/app/views/projects/tags/destroy.js.haml +++ /dev/null @@ -1,4 +0,0 @@ -- if @error.present? - new Flash({ message: '#{escape_javascript(@error)}', type: 'alert' }); -- elsif @repository.tags.empty? - $('.tags').load(document.URL + ' .nothing-here-block').hide().fadeIn(1000) diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml index 25a560da5c6..d726d2ab233 100644 --- a/app/views/projects/tags/show.html.haml +++ b/app/views/projects/tags/show.html.haml @@ -52,8 +52,7 @@ = render 'projects/buttons/download', project: @project, ref: @tag.name - if can?(current_user, :admin_tag, @project) .btn-container.controls-item-full - = link_to project_tag_path(@project, @tag.name), class: "btn btn-icon btn-danger gl-button remove-row has-tooltip #{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 } } do - = sprite_icon('remove', css_class: 'gl-icon') + = render 'projects/buttons/remove_tag', project: @project, tag: @tag - if @tag.message.present? %pre.wrap{ data: { qa_selector: 'tag_message_content' } } diff --git a/app/views/projects/triggers/_index.html.haml b/app/views/projects/triggers/_index.html.haml index 4e097f345c2..4f39c839630 100644 --- a/app/views/projects/triggers/_index.html.haml +++ b/app/views/projects/triggers/_index.html.haml @@ -6,23 +6,26 @@ .card-body = render "projects/triggers/form", btn_text: "Add trigger" %hr - - if @triggers.any? - .table-responsive.triggers-list - %table.table - %thead - %th - %strong Token - %th - %strong Description - %th - %strong Owner - %th - %strong Last used - %th - = render partial: 'projects/triggers/trigger', collection: @triggers, as: :trigger + - if Feature.enabled?(:ci_pipeline_triggers_settings_vue_ui, @project) + #js-ci-pipeline-triggers-list.triggers-list{ data: { triggers: @triggers_json } } - else - %p.settings-message.text-center.gl-mb-3 - No triggers have been created yet. Add one using the form above. + - if @triggers.any? + .table-responsive.triggers-list + %table.table + %thead + %th + %strong Token + %th + %strong Description + %th + %strong Owner + %th + %strong Last used + %th + = render partial: 'projects/triggers/trigger', collection: @triggers, as: :trigger + - else + %p.settings-message.text-center.gl-mb-3{ data: { testid: 'no_triggers_content' } } + No triggers have been created yet. Add one using the form above. .card-footer diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml index 579b8ba2766..b25199b405a 100644 --- a/app/views/projects/triggers/_trigger.html.haml +++ b/app/views/projects/triggers/_trigger.html.haml @@ -2,7 +2,7 @@ %td - if trigger.has_token_exposed? %span= trigger.token - = clipboard_button(text: trigger.token, title: _("Copy trigger token")) + = clipboard_button(text: trigger.token, title: _("Copy trigger token"), testid: 'clipboard-btn') - else %span= trigger.short_token @@ -33,5 +33,5 @@ = link_to edit_project_trigger_path(@project, trigger), method: :get, title: "Edit", class: "btn btn-default btn-sm" do = 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 + = link_to project_trigger_path(@project, trigger), data: { confirm: revoke_trigger_confirmation, testid: 'trigger_revoke_button' }, method: :delete, title: "Revoke", class: "btn btn-default btn-warning btn-sm btn-trigger-revoke" do = sprite_icon('remove') diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml index d6e38ddd5c6..f094a6f5e3b 100644 --- a/app/views/search/_category.html.haml +++ b/app/views/search/_category.html.haml @@ -30,5 +30,6 @@ = search_filter_link 'issues', _("Issues") = search_filter_link 'merge_requests', _("Merge requests") = search_filter_link 'milestones', _("Milestones") + = render_if_exists 'search/epics_filter_link' = render_if_exists 'search/category_elasticsearch' = users diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml index e0dbb5135e9..95c378bff7c 100644 --- a/app/views/search/_results.html.haml +++ b/app/views/search/_results.html.haml @@ -1,7 +1,7 @@ - if @search_objects.to_a.empty? + = render partial: "search/results/filters" = render partial: "search/results/empty" = render_if_exists 'shared/promotions/promote_advanced_search' - = render_if_exists 'search/form_revert_to_basic' - else .row-content-block.d-md-flex.text-left.align-items-center - unless @search_objects.is_a?(Kaminari::PaginatableWithoutCount) @@ -10,10 +10,9 @@ - if @project - link_to_project = link_to(@project.full_name, @project, class: 'ml-md-1') - if @scope == 'blobs' - - repository_ref = params[:repository_ref].to_s.presence || @project.default_branch = s_("SearchCodeResults|in") .mx-md-1 - = render partial: "shared/ref_switcher", locals: { ref: repository_ref, form_path: request.fullpath, field_name: 'repository_ref' } + = render partial: "shared/ref_switcher", locals: { ref: repository_ref(@project), form_path: request.fullpath, field_name: 'repository_ref' } = s_('SearchCodeResults|of %{link_to_project}').html_safe % { link_to_project: link_to_project } - else = _("in project %{link_to_project}").html_safe % { link_to_project: link_to_project } @@ -21,8 +20,7 @@ - link_to_group = link_to(@group.name, @group, class: 'ml-md-1') = _("in group %{link_to_group}").html_safe % { link_to_group: link_to_group } = render_if_exists 'shared/promotions/promote_advanced_search' - - #js-search-filter-by-state{ 'v-cloak': true, data: { scope: @scope, state: params[:state] } } + = render partial: "search/results/filters" .results.gl-mt-3 - if @scope == 'commits' @@ -34,7 +32,7 @@ .term = render 'shared/projects/list', projects: @search_objects, pipeline_status: false - else - = render partial: "search/results/#{@scope.singularize}", collection: @search_objects + = render_if_exists partial: "search/results/#{@scope.singularize}", collection: @search_objects - if @scope != 'projects' = paginate_collection(@search_objects) diff --git a/app/views/search/results/_blob.html.haml b/app/views/search/results/_blob.html.haml index 6e17a25c713..aeb37022f99 100644 --- a/app/views/search/results/_blob.html.haml +++ b/app/views/search/results/_blob.html.haml @@ -1,5 +1,5 @@ - project = blob.project - return unless project -- blob_link = project_blob_path(project, tree_join(blob.ref, blob.path)) +- blob_link = project_blob_path(project, tree_join(repository_ref(project), blob.path)) = render partial: 'search/results/blob_data', locals: { blob: blob, project: project, path: blob.path, blob_link: blob_link } diff --git a/app/views/search/results/_filters.html.haml b/app/views/search/results/_filters.html.haml new file mode 100644 index 00000000000..8c402ddb3d1 --- /dev/null +++ b/app/views/search/results/_filters.html.haml @@ -0,0 +1,7 @@ +.d-lg-flex.align-items-end + #js-search-filter-by-state{ 'v-cloak': true, data: { scope: @scope, filter: params[:state]} } + - if Feature.enabled?(:search_filter_by_confidential, @group) + #js-search-filter-by-confidential{ 'v-cloak': true, data: { scope: @scope, filter: params[:confidential] } } + + - if %w(issues merge_requests).include?(@scope) + %hr.gl-mt-4.gl-mb-4 diff --git a/app/views/shared/_confirm_modal.html.haml b/app/views/shared/_confirm_modal.html.haml index dc95bcdc756..ecb462205b0 100644 --- a/app/views/shared/_confirm_modal.html.haml +++ b/app/views/shared/_confirm_modal.html.haml @@ -17,5 +17,5 @@ .form-group = text_field_tag 'confirm_name_input', '', class: 'form-control js-confirm-danger-input qa-confirm-input' - .form-actions.gl-display-flex.gl-justify-content-end + .form-actions = submit_tag _('Confirm'), class: "btn btn-danger js-confirm-danger-submit qa-confirm-button" diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml index c4b7ef481fd..9fcb71ec2b9 100644 --- a/app/views/shared/_label.html.haml +++ b/app/views/shared/_label.html.haml @@ -11,7 +11,7 @@ %ul.label-actions-list - if @project %li.inline - .label-badge.label-badge-gray= label.model_name.human.capitalize + .label-badge.gl-bg-gray-50= label.model_name.human.capitalize - if can?(current_user, :admin_label, @project) %li.inline.js-toggle-priority{ data: { url: remove_priority_project_label_path(@project, label), dom_id: dom_id(label), type: label.type } } diff --git a/app/views/shared/_label_row.html.haml b/app/views/shared/_label_row.html.haml index 3d2ae772135..f2b257f9776 100644 --- a/app/views/shared/_label_row.html.haml +++ b/app/views/shared/_label_row.html.haml @@ -3,23 +3,22 @@ - show_label_issues_link = subject_or_group_defined && show_label_issuables_link?(label, :issues) - show_label_merge_requests_link = subject_or_group_defined && show_label_issuables_link?(label, :merge_requests) -.label-name +.label-name.gl-flex-shrink-0.gl-mr-3 = render_label(label, tooltip: false) -.label-description - .label-description-wrapper - - if label.description.present? - .description-text - = markdown_field(label, :description) - %ul.label-links - - if show_label_issues_link - %li.label-link-item.inline - = link_to_label(label) { _('Issues') } - - if show_label_merge_requests_link - · - %li.label-link-item.inline - = link_to_label(label, type: :merge_request) { _('Merge requests') } - - if force_priority - · - %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 +.label-description.gl-flex-grow-1.gl-mr-3.gl-w-full + - if label.description.present? + .description-text.gl-mb-3 + = markdown_field(label, :description) + %ul.label-links.gl-m-0.gl-p-0.gl-white-space-nowrap + - if show_label_issues_link + %li.inline.gl-text-blue-600 + = link_to_label(label, css_class: 'gl-text-blue-600!') { _('Issues') } + - if show_label_merge_requests_link + · + %li.inline.gl-text-blue-600 + = link_to_label(label, type: :merge_request, css_class: 'gl-text-blue-600!') { _('Merge requests') } + = render_if_exists 'shared/label_row_epics_link', label: label + - if force_priority + · + %li.js-priority-badge.inline.gl-ml-3 + .label-badge.gl-bg-blue-50= _('Prioritized label') diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml index e5808bfe878..879afff0474 100644 --- a/app/views/shared/boards/_show.html.haml +++ b/app/views/shared/boards/_show.html.haml @@ -8,6 +8,7 @@ - @content_class = "issue-boards-content js-focus-mode-board" - breadcrumb_title _("Issue Boards") - page_title("#{board.name}", _("Boards")) +- add_page_specific_style 'page_bundles/boards' - content_for :page_specific_javascripts do diff --git a/app/views/shared/deploy_keys/_project_group_form.html.haml b/app/views/shared/deploy_keys/_project_group_form.html.haml index 815967b0372..179ec33ee65 100644 --- a/app/views/shared/deploy_keys/_project_group_form.html.haml +++ b/app/views/shared/deploy_keys/_project_group_form.html.haml @@ -20,5 +20,5 @@ %p.light.gl-mb-0 = _('Allow this key to push to repository as well? (Default only allows pull access.)') - .form-group.row.gl-display-flex.gl-justify-content-end + .form-group.row = f.submit _("Add key"), class: "btn-success btn" diff --git a/app/views/shared/deploy_tokens/_form.html.haml b/app/views/shared/deploy_tokens/_form.html.haml index cc5addaa3a0..da634d37c55 100644 --- a/app/views/shared/deploy_tokens/_form.html.haml +++ b/app/views/shared/deploy_tokens/_form.html.haml @@ -46,5 +46,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') - .gl-mt-3.gl-display-flex.gl-justify-content-end + .gl-mt-3 = f.submit s_('DeployTokens|Create deploy token'), class: 'btn btn-success qa-create-deploy-token' diff --git a/app/views/shared/icons/_next_discussion.svg b/app/views/shared/icons/_next_discussion.svg deleted file mode 100644 index 43559a60cb0..00000000000 --- a/app/views/shared/icons/_next_discussion.svg +++ /dev/null @@ -1 +0,0 @@ -<svg viewBox="0 0 20 19" ><path d="M15.21 7.783h-3.317c-.268 0-.472.218-.472.486v.953c0 .28.212.486.473.486h3.318v1.575c0 .36.233.452.52.23l3.06-2.37c.274-.213.286-.582 0-.804l-3.06-2.37c-.275-.213-.52-.12-.52.23v1.583zm.57-3.66c-1.558-1.22-3.783-1.98-6.254-1.98C4.816 2.143 1 4.91 1 8.333c0 1.964 1.256 3.715 3.216 4.846-.447 1.615-1.132 2.195-1.732 2.882-.142.174-.304.32-.256.56v.01c.047.213.218.368.41.368h.046c.37-.048.743-.116 1.085-.213 1.645-.425 3.13-1.22 4.377-2.34.447.048.913.077 1.38.077 2.092 0 4.01-.546 5.492-1.454-.416-.208-.798-.475-1.134-.792-1.227.63-2.743 1.008-4.36 1.008-.41 0-.828-.03-1.237-.078l-.543-.058-.41.368c-.78.696-1.655 1.248-2.616 1.654.248-.445.486-.977.667-1.664l.257-.928-.828-.484c-1.646-.948-2.598-2.32-2.598-3.763 0-2.69 3.35-4.952 7.308-4.952 1.893 0 3.647.518 4.962 1.353.393-.266.827-.473 1.29-.61z" /></svg> diff --git a/app/views/shared/issuable/_approved_by_dropdown.html.haml b/app/views/shared/issuable/_approved_by_dropdown.html.haml new file mode 100644 index 00000000000..8014545ab85 --- /dev/null +++ b/app/views/shared/issuable/_approved_by_dropdown.html.haml @@ -0,0 +1,16 @@ +#js-dropdown-approved-by.filtered-search-input-dropdown-menu.dropdown-menu + %ul{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { value: 'None' } } + %button.btn.btn-link{ type: 'button' } + = _('None') + %li.filter-dropdown-item{ data: { value: 'Any' } } + %button.btn.btn-link{ type: 'button' } + = _('Any') + %li.divider.droplab-item-ignore + - if current_user + = render 'shared/issuable/user_dropdown_item', + user: current_user + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + = render 'shared/issuable/user_dropdown_item', + user: User.new(username: '{{username}}', name: '{{name}}'), + avatar: { lazy: true, url: '{{avatar_url}}' } diff --git a/app/views/shared/issuable/_close_reopen_button.html.haml b/app/views/shared/issuable/_close_reopen_button.html.haml index 59d0c46b92f..9fb64ff19a9 100644 --- a/app/views/shared/issuable/_close_reopen_button.html.haml +++ b/app/views/shared/issuable/_close_reopen_button.html.haml @@ -5,7 +5,7 @@ - if defined? warn_before_close - add_blocked_class = warn_before_close -- if is_current_user +- if is_current_user && !issuable.is_a?(MergeRequest) - if can_update %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' } } @@ -16,7 +16,10 @@ = _("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 + - if issuable.is_a?(MergeRequest) + = render 'shared/issuable/close_reopen_draft_report_toggle', issuable: issuable + - else + = render 'shared/issuable/close_reopen_report_toggle', issuable: issuable, warn_before_close: add_blocked_class - else = link_to _('Report abuse'), new_abuse_report_path(user_id: issuable.author.id, ref_url: issuable_url(issuable)), class: 'd-none d-sm-none d-md-block btn btn-grouped btn-close-color', title: _('Report abuse') diff --git a/app/views/shared/issuable/_close_reopen_draft_report_toggle.html.haml b/app/views/shared/issuable/_close_reopen_draft_report_toggle.html.haml new file mode 100644 index 00000000000..bdb53dfe323 --- /dev/null +++ b/app/views/shared/issuable/_close_reopen_draft_report_toggle.html.haml @@ -0,0 +1,37 @@ +- display_issuable_type = issuable_display_type(issuable) +- button_action_class = issuable.closed? ? 'btn-default' : 'btn-warning btn-warning-secondary' +- button_class = "btn gl-button #{!issuable.closed? && 'js-draft-toggle-button'}" +- toggle_class = "btn gl-button dropdown-toggle" + +.float-left.btn-group.gl-ml-3.issuable-close-dropdown.d-none.d-md-inline-flex.js-issuable-close-dropdown + = link_to issuable.closed? ? reopen_issuable_path(issuable) : toggle_draft_issuable_path(issuable), method: :put, class: "#{button_class} #{button_action_class}" do + - if issuable.closed? + = _('Reopen') + = display_issuable_type + - else + = issuable.work_in_progress? ? _('Mark as ready') : _('Mark as draft') + + - if !issuable.closed? || !issuable_author_is_current_user(issuable) + = button_tag type: 'button', class: "#{toggle_class} #{button_action_class}", data: { 'toggle' => 'dropdown' } do + %span.sr-only= _('Toggle dropdown') + = sprite_icon "angle-down", size: 12 + + %ul.js-issuable-close-menu.dropdown-menu.dropdown-menu-right + - if issuable.open? + %li + = link_to close_issuable_path(issuable), method: :put do + .description + %strong.title + = _('Close') + = display_issuable_type + + - unless issuable_author_is_current_user(issuable) + - unless issuable.closed? + %li.divider.droplab-item-ignore + + %li.report-item + %a.report-abuse-link{ href: new_abuse_report_path(user_id: issuable.author.id, ref_url: issuable_url(issuable)) } + .description + %strong.title= _('Report abuse') + %p.text + = _('Report %{display_issuable_type} that are abusive, inappropriate or spam.') % { display_issuable_type: display_issuable_type.pluralize } diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 620e9b5ea31..014ada03686 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -5,6 +5,7 @@ - signed_in = !!issuable_sidebar.dig(:current_user, :id) - can_edit_issuable = issuable_sidebar.dig(:current_user, :can_edit) - add_page_startup_api_call "#{issuable_sidebar[:issuable_json_path]}?serializer=sidebar_extras" +- reviewers = local_assigns.fetch(:reviewers, nil) - if Feature.enabled?(:vue_issuable_sidebar, @project.group) %aside#js-vue-issuable-sidebar{ data: { signed_in: signed_in, @@ -28,6 +29,10 @@ .block.assignee.qa-assignee-block = render "shared/issuable/sidebar_assignees", issuable_sidebar: issuable_sidebar, assignees: assignees + - if Feature.enabled?(:merge_request_reviewers, @project) && reviewers + .block.reviewer.qa-reviewer-block + = render "shared/issuable/sidebar_reviewers", issuable_sidebar: issuable_sidebar, reviewers: reviewers + = render_if_exists 'shared/issuable/sidebar_item_epic', issuable_sidebar: issuable_sidebar - if issuable_sidebar[:supports_milestone] @@ -116,7 +121,7 @@ selected_labels: issuable_sidebar[:labels].to_json } } - else - selected_labels = issuable_sidebar[:labels] - .block.labels + .block.labels{ data: { qa_selector: 'labels_block' } } .sidebar-collapsed-icon.js-sidebar-labels-tooltip{ title: issuable_labels_tooltip(selected_labels), data: { placement: "left", container: "body", boundary: 'viewport' } } = sprite_icon('labels') %span @@ -125,11 +130,11 @@ = _('Labels') = loading_icon(css_class: 'gl-vertical-align-text-bottom hidden block-loading') - if can_edit_issuable - = 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' } } + = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { qa_selector: "labels_edit_button", 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?) } - if selected_labels.any? - selected_labels.each do |label_hash| - = render_label(label_from_hash(label_hash).present(issuable_subject: nil), link: sidebar_label_filter_path(issuable_sidebar[:project_issuables_path], label_hash[:title]), dataset: { qa_selector: 'label', qa_label_name: label_hash[:title] }) + = render_label(label_from_hash(label_hash).present(issuable_subject: nil), link: sidebar_label_filter_path(issuable_sidebar[:project_issuables_path], label_hash[:title]), dataset: { qa_selector: 'selected_label_content', qa_label_name: label_hash[:title] }) - else %span.no-value = _('None') @@ -141,7 +146,7 @@ %span.dropdown-toggle-text{ class: ("is-default" if selected_labels.empty?) } = multi_label_name(selected_labels, "Labels") = icon('chevron-down', 'aria-hidden': 'true') - .dropdown-menu.dropdown-select.dropdown-menu-paging.qa-dropdown-menu-labels.dropdown-menu-labels.dropdown-menu-selectable.dropdown-extended-height + .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable.dropdown-extended-height{ data: { qa_selector: "labels_dropdown_content"} } = render partial: "shared/issuable/label_page_default" - if issuable_sidebar.dig(:current_user, :can_admin_label) = render partial: "shared/issuable/label_page_create" diff --git a/app/views/shared/issuable/_sidebar_reviewers.html.haml b/app/views/shared/issuable/_sidebar_reviewers.html.haml new file mode 100644 index 00000000000..8b546d5e344 --- /dev/null +++ b/app/views/shared/issuable/_sidebar_reviewers.html.haml @@ -0,0 +1,56 @@ +- issuable_type = issuable_sidebar[:type] +- signed_in = !!issuable_sidebar.dig(:current_user, :id) + +#js-vue-sidebar-reviewers{ data: { field: issuable_type, signed_in: signed_in } } + .title.hide-collapsed + = _('Reviewer') + = loading_icon(css_class: 'gl-vertical-align-text-bottom') + +.selectbox.hide-collapsed + - if reviewers.none? + = hidden_field_tag "#{issuable_type}[reviewer_ids][]", 0, id: nil + - else + - reviewers.each do |reviewer| + = hidden_field_tag "#{issuable_type}[reviewer_ids][]", reviewer.id, id: nil, data: reviewer_sidebar_data(reviewer, merge_request: @merge_request) + + - options = { toggle_class: 'js-reviewer-search js-author-search', + title: _('Request review from'), + filter: true, + dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', + placeholder: _('Search users'), + data: { first_user: issuable_sidebar.dig(:current_user, :username), + current_user: true, + iid: issuable_sidebar[:iid], + issuable_type: issuable_type, + project_id: issuable_sidebar[:project_id], + author_id: issuable_sidebar[:author_id], + field_name: "#{issuable_type}[reviewer_ids][]", + issue_update: issuable_sidebar[:issuable_json_path], + ability_name: issuable_type, + null_user: true, + display: 'static' } } + + - dropdown_options = reviewers_dropdown_options(issuable_type) + - title = dropdown_options[:title] + - options[:toggle_class] += ' js-multiselect js-save-user-data' + - data = { field_name: "#{issuable_type}[reviewer_ids][]" } + - data[:multi_select] = true + - data['dropdown-title'] = title + - data['dropdown-header'] = dropdown_options[:data][:'dropdown-header'] + - data['max-select'] = dropdown_options[:data][:'max-select'] if dropdown_options[:data][:'max-select'] + - options[:data].merge!(data) + + - if experiment_enabled?(:invite_members_version_a) && can_import_members? + - options[:dropdown_class] += ' dropdown-extended-height' + - options[:footer_content] = true + - options[:wrapper_class] = 'js-sidebar-reviewer-dropdown' + + = dropdown_tag(title, options: options) do + %ul.dropdown-footer-list + %li + = link_to _('Invite Members'), + project_project_members_path(@project), + title: _('Invite Members'), + data: { 'is-link': true, 'track-event': 'click_invite_members', 'track-label': 'edit_reviewer' } + - else + = dropdown_tag(title, options: options) diff --git a/app/views/shared/issuable/form/_type_selector.html.haml b/app/views/shared/issuable/form/_type_selector.html.haml index 7a8120d2d02..9f818787848 100644 --- a/app/views/shared/issuable/form/_type_selector.html.haml +++ b/app/views/shared/issuable/form/_type_selector.html.haml @@ -20,7 +20,7 @@ %li.js-filter-issuable-type = link_to new_project_issue_path(@project), class: ("is-active" if issuable.issue?) do = _("Issue") - %li.js-filter-issuable-type + %li.js-filter-issuable-type{ data: { track: { event: "select_issue_type_incident", label: "select_issue_type_incident_dropdown_option" } } } = link_to new_project_issue_path(@project, { issuable_template: 'incident', issue: { issue_type: 'incident' } }), class: ("is-active" if issuable.incident?) do = _("Incident") - if issuable.incident? diff --git a/app/views/shared/labels/_form.html.haml b/app/views/shared/labels/_form.html.haml index 78ff225daad..2df6c3a6afd 100644 --- a/app/views/shared/labels/_form.html.haml +++ b/app/views/shared/labels/_form.html.haml @@ -28,7 +28,7 @@ = render_suggested_colors .form-actions - if @label.persisted? - = f.submit 'Save changes', class: 'btn btn-success js-save-button' + = f.submit 'Save changes', class: 'btn gl-button btn-success js-save-button' - else - = f.submit 'Create label', class: 'btn btn-success js-save-button qa-label-create-button' - = link_to 'Cancel', back_path, class: 'btn btn-cancel' + = f.submit 'Create label', class: 'btn gl-button btn-success js-save-button qa-label-create-button' + = link_to 'Cancel', back_path, class: 'btn gl-button btn-cancel' diff --git a/app/views/shared/labels/_nav.html.haml b/app/views/shared/labels/_nav.html.haml index d613ea466fa..cc43174dc19 100644 --- a/app/views/shared/labels/_nav.html.haml +++ b/app/views/shared/labels/_nav.html.haml @@ -15,10 +15,10 @@ .input-group = search_field_tag :search, params[:search], { placeholder: _('Filter'), id: 'label-search', class: 'form-control search-text-input input-short', spellcheck: false, autofocus: true } %span.input-group-append - %button.btn.btn-default{ type: "submit", "aria-label" => _('Submit search') } + %button.btn.gl-button.btn-default{ type: "submit", "aria-label" => _('Submit search') } = icon("search") = render 'shared/labels/sort_dropdown' - if labels_or_filters && can_admin_label && @project - = link_to _('New label'), new_project_label_path(@project), class: "btn btn-success qa-label-create-new" + = link_to _('New label'), new_project_label_path(@project), class: "btn gl-button btn-success qa-label-create-new" - if labels_or_filters && can_admin_label && @group - = link_to _('New label'), new_group_label_path(@group), class: "btn btn-success qa-label-create-new" + = link_to _('New label'), new_group_label_path(@group), class: "btn gl-button btn-success qa-label-create-new" diff --git a/app/views/shared/members/_group.html.haml b/app/views/shared/members/_group.html.haml index 8e5763842d9..cd24942616c 100644 --- a/app/views/shared/members/_group.html.haml +++ b/app/views/shared/members/_group.html.haml @@ -6,17 +6,18 @@ -# Note this is just for groups. For individual members please see shared/members/_member -%li.member.group_member.py-2.px-3.d-flex.flex-column.flex-md-row{ id: dom_id, data: { qa_selector: 'group_row' } } +%li.member.js-member.group_member.py-2.px-3.d-flex.flex-column.flex-md-row{ id: dom_id, data: { qa_selector: 'group_row' } } %span.list-item-name.mb-2.m-md-0 = group_icon(group, class: "avatar s40 flex-shrink-0 flex-grow-0", alt: '') .user-info = link_to group.full_name, group_path(group), class: 'member' .cgray Given access #{time_ago_with_tooltip(group_link.created_at)} - - if group_link.expires? - · - %span{ class: ('text-warning' if group_link.expires_soon?) } - = _("Expires in %{expires_at}").html_safe % { expires_at: distance_of_time_in_words_to_now(group_link.expires_at) } + %span.js-expires-in{ class: ('gl-display-none' unless group_link.expires?) } + · + %span.js-expires-in-text{ class: ('text-warning' if group_link.expires_soon?) } + - if group_link.expires? + = _("Expires in %{expires_at}").html_safe % { expires_at: distance_of_time_in_words_to_now(group_link.expires_at) } .controls.member-controls.align-items-center = form_tag group_link_path, method: :put, remote: true, class: 'js-edit-member-form form-group d-sm-flex' do = hidden_field_tag "group_link[group_access]", group_link.group_access diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index 7573c2f6d56..679a460eeb3 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -8,7 +8,7 @@ -# Note this is just for individual members. For groups please see shared/members/_group -%li.member.py-2.px-3.d-flex.flex-column{ class: [dom_class(member), ("is-overridden" if override), ("flex-md-row" unless force_mobile_view)], id: dom_id(member), data: { qa_selector: 'member_row' } } +%li.member.js-member.py-2.px-3.d-flex.flex-column{ class: [dom_class(member), ("is-overridden" if override), ("flex-md-row" unless force_mobile_view)], id: dom_id(member), data: { qa_selector: 'member_row' } } %span.list-item-name.mb-2.m-md-0 - if user = image_tag avatar_icon_for_user(user, 40), class: "avatar s40 flex-shrink-0 flex-grow-0", alt: '' diff --git a/app/views/shared/milestones/_issuable.html.haml b/app/views/shared/milestones/_issuable.html.haml index f8bf3e7ad6a..a62ed009552 100644 --- a/app/views/shared/milestones/_issuable.html.haml +++ b/app/views/shared/milestones/_issuable.html.haml @@ -10,8 +10,6 @@ %span - if show_project_name %strong #{project.name} · - - elsif show_full_project_name - %strong #{project.full_name} · - if issuable.is_a?(Issue) = confidential_icon(issuable) = link_to issuable.title, issuable_url_args, title: issuable.title diff --git a/app/views/shared/milestones/_issuables.html.haml b/app/views/shared/milestones/_issuables.html.haml index ee97f0172da..9147e1c50e3 100644 --- a/app/views/shared/milestones/_issuables.html.haml +++ b/app/views/shared/milestones/_issuables.html.haml @@ -15,4 +15,4 @@ = render partial: 'shared/milestones/issuable', collection: issuables, as: :issuable, - locals: { show_project_name: show_project_name, show_full_project_name: show_full_project_name } + locals: { show_project_name: show_project_name } diff --git a/app/views/shared/milestones/_issues_tab.html.haml b/app/views/shared/milestones/_issues_tab.html.haml index dc54eefbaa9..76ef636ec96 100644 --- a/app/views/shared/milestones/_issues_tab.html.haml +++ b/app/views/shared/milestones/_issues_tab.html.haml @@ -1,5 +1,4 @@ -- args = { show_project_name: local_assigns.fetch(:show_project_name, false), - show_full_project_name: local_assigns.fetch(:show_full_project_name, false) } +- args = { show_project_name: local_assigns.fetch(:show_project_name, false) } - if display_issues_count_warning?(@milestone) .flash-container diff --git a/app/views/shared/milestones/_merge_requests_tab.haml b/app/views/shared/milestones/_merge_requests_tab.haml index 0dbf2b27c8d..a78440600ad 100644 --- a/app/views/shared/milestones/_merge_requests_tab.haml +++ b/app/views/shared/milestones/_merge_requests_tab.haml @@ -1,5 +1,4 @@ -- args = { show_project_name: local_assigns.fetch(:show_project_name, false), - show_full_project_name: local_assigns.fetch(:show_full_project_name, false) } +- args = { show_project_name: local_assigns.fetch(:show_project_name, false) } .row.gl-mt-3 .col-md-3 diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index 27b771b281b..f28aa406784 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -29,10 +29,10 @@ %div = render('shared/milestone_expired', milestone: milestone) - if milestone.group_milestone? - .label-badge.label-badge-blue.d-inline-block + .label-badge.gl-bg-blue-50.d-inline-block = milestone.group.full_name - if milestone.project_milestone? - .label-badge.label-badge-gray.d-inline-block + .label-badge.gl-bg-gray-50.d-inline-block = milestone.project.full_name .col-sm-4.milestone-progress diff --git a/app/views/shared/milestones/_tabs.html.haml b/app/views/shared/milestones/_tabs.html.haml index 34f476241c6..33e634c3e7b 100644 --- a/app/views/shared/milestones/_tabs.html.haml +++ b/app/views/shared/milestones/_tabs.html.haml @@ -1,14 +1,16 @@ +- show_project_name = local_assigns.fetch(:show_project_name, false) + .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller .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 + = link_to '#tab-issues', class: 'nav-link active', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'issues', show_project_name: show_project_name) } do = _('Issues') %span.badge.badge-pill= milestone.issues_visible_to_user(current_user).size - if milestone.merge_requests_enabled? %li.nav-item - = link_to '#tab-merge-requests', class: 'nav-link', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'merge_requests') } do + = link_to '#tab-merge-requests', class: 'nav-link', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'merge_requests', show_project_name: show_project_name) } do = _('Merge Requests') %span.badge.badge-pill= milestone.merge_requests_visible_to_user(current_user).size %li.nav-item @@ -20,20 +22,13 @@ = _('Labels') %span.badge.badge-pill= milestone.issue_labels_visible_by_user(current_user).count -- issues = milestone.sorted_issues(current_user) -- show_project_name = local_assigns.fetch(:show_project_name, false) -- show_full_project_name = local_assigns.fetch(:show_full_project_name, false) - .tab-content.milestone-content - .tab-pane.active#tab-issues{ data: { sort_endpoint: (sort_issues_project_milestone_path(@project, @milestone) if @project && current_user) } } - = render 'shared/milestones/issues_tab', issues: issues, show_project_name: show_project_name, show_full_project_name: show_full_project_name + .tab-pane.active#tab-issues + = render "shared/milestones/tab_loading" - if milestone.merge_requests_enabled? .tab-pane#tab-merge-requests - -# loaded async = render "shared/milestones/tab_loading" .tab-pane#tab-participants - -# loaded async = render "shared/milestones/tab_loading" .tab-pane#tab-labels - -# loaded async = render "shared/milestones/tab_loading" diff --git a/app/views/shared/notes/_edit.html.haml b/app/views/shared/notes/_edit.html.haml index 84a3ef9d8fe..9cfb3f3b576 100644 --- a/app/views/shared/notes/_edit.html.haml +++ b/app/views/shared/notes/_edit.html.haml @@ -1 +1 @@ -%textarea.hidden.js-task-list-field.original-task-list{ data: { update_url: note_url(note) } }= note.note +%textarea.hidden.js-task-list-field.original-task-list{ data: { update_url: note_url(note), value: note.note } } diff --git a/app/views/shared/notifications/_button.html.haml b/app/views/shared/notifications/_button.html.haml index f2c7ab648c0..d7b53810f76 100644 --- a/app/views/shared/notifications/_button.html.haml +++ b/app/views/shared/notifications/_button.html.haml @@ -17,7 +17,7 @@ .js-notification-toggle-btns %div{ class: ("btn-group" if notification_setting.custom?) } - if notification_setting.custom? - %button.dropdown-new.btn.btn-defaul.btn-icon.gl-button.has-tooltip.notifications-btn.text-left#notifications-button{ type: "button", title: button_title, class: "#{btn_class}", "aria-label" => aria_label, data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } } + %button.dropdown-new.btn.btn-default.btn-icon.gl-button.has-tooltip.notifications-btn.text-left#notifications-button{ type: "button", title: button_title, class: "#{btn_class}", "aria-label" => aria_label, data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } } = sprite_icon("notifications", css_class: "js-notification-loading") = notification_title(notification_setting.level) %button.btn.dropdown-toggle.d-flex{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml index 198735df5ee..c325e8d4a16 100644 --- a/app/views/shared/snippets/_form.html.haml +++ b/app/views/shared/snippets/_form.html.haml @@ -10,7 +10,7 @@ .form-group = f.label :title, class: 'label-bold' - = f.text_field :title, class: 'form-control', required: true, autofocus: true, data: { qa_selector: 'snippet_title_field' } + = f.text_field :title, class: 'form-control', required: true, autofocus: true .form-group.js-description-input - description_placeholder = s_('Snippets|Optionally add a description about what your snippet does or how to use it...') @@ -18,17 +18,17 @@ = f.label :description, s_("Snippets|Description (optional)"), class: 'label-bold' .js-collapsible-input .js-collapsed{ class: ('d-none' if is_expanded) } - = text_field_tag nil, nil, class: 'form-control', placeholder: description_placeholder, data: { qa_selector: 'description_placeholder' } + = text_field_tag nil, nil, class: 'form-control', placeholder: description_placeholder .js-expanded{ class: ('d-none' if !is_expanded) } = render layout: 'shared/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do - = render 'shared/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: description_placeholder, qa_selector: 'snippet_description_field' + = render 'shared/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: description_placeholder = render 'shared/notes/hints' .form-group.file-editor = f.label :file_name, s_('Snippets|File') .file-holder.snippet .js-file-title.file-title-flex-parent - = f.text_field :file_name, placeholder: s_("Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby"), class: 'form-control js-snippet-file-name', data: { qa_selector: 'file_name_field' } + = f.text_field :file_name, placeholder: s_("Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby"), class: 'form-control js-snippet-file-name' .file-content.code #editor{ data: { 'editor-loading': true } }< %pre.editor-loading-content= @snippet.content @@ -46,11 +46,11 @@ .form-actions - if @snippet.new_record? - = f.submit 'Create snippet', class: "btn-success btn", data: { qa_selector: 'submit_button' } + = f.submit 'Create snippet', class: "btn-success btn gl-button" - else - = f.submit 'Save changes', class: "btn-success btn", data: { qa_selector: 'submit_button' } + = f.submit 'Save changes', class: "btn-success btn gl-button" - if @snippet.project_id - = link_to "Cancel", project_snippets_path(@project), class: "btn btn-cancel" + = link_to "Cancel", project_snippets_path(@project), class: "btn gl-button btn-default" - else - = link_to "Cancel", snippets_path(@project), class: "btn btn-cancel" + = link_to "Cancel", snippets_path(@project), class: "btn gl-button btn-default" diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml index a9226117727..e2680fac019 100644 --- a/app/views/shared/snippets/_header.html.haml +++ b/app/views/shared/snippets/_header.html.haml @@ -21,8 +21,6 @@ .description .md = markdown_field(@snippet, :description) - %textarea.hidden.js-task-list-field - = @snippet.description - if @snippet.updated_at != @snippet.created_at = edited_time_ago_with_tooltip(@snippet, placement: 'bottom', exclude_author: true) @@ -31,15 +29,15 @@ .embed-snippet .input-group .input-group-prepend - %button.btn.btn-svg.embed-toggle.input-group-text{ 'data-toggle': 'dropdown', type: 'button' } + %button.btn.gl-button.btn-svg.embed-toggle.input-group-text{ 'data-toggle': 'dropdown', type: 'button' } %span.js-embed-action= _("Embed") = sprite_icon('angle-down', size: 12, css_class: 'caret-down') %ul.dropdown-menu.dropdown-menu-selectable.embed-toggle-list %li - %button.js-embed-btn.btn.btn-transparent.is-active{ type: 'button' } + %button.js-embed-btn.btn.gl-button.btn-default-tertiary.is-active{ type: 'button' } %strong.embed-toggle-list-item= _("Embed") %li - %button.js-share-btn.btn.btn-transparent{ type: 'button' } + %button.js-share-btn.btn.gl-button.btn-default-tertiary{ type: 'button' } %strong.embed-toggle-list-item= _("Share") = snippet_embed_input(@snippet) .input-group-append diff --git a/app/views/shared/snippets/_snippet.html.haml b/app/views/shared/snippets/_snippet.html.haml index 25e31fd519b..5f0ecb2ee79 100644 --- a/app/views/shared/snippets/_snippet.html.haml +++ b/app/views/shared/snippets/_snippet.html.haml @@ -1,7 +1,7 @@ - link_project = local_assigns.fetch(:link_project, false) - notes_count = @noteable_meta_data[snippet.id].user_notes_count -%li.snippet-row.py-3 +%li.snippet-row.py-3{ data: { qa_selector: 'snippet_link', qa_snippet_title: snippet.title } } = image_tag avatar_icon_for_user(snippet.author), class: "avatar s40 d-none d-sm-block", alt: '' .title diff --git a/app/views/shared/wikis/_pages_wiki_page.html.haml b/app/views/shared/wikis/_pages_wiki_page.html.haml index b56ae2bf9b1..fb6f58d044d 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), data: { qa_selector: 'wiki_page_link', qa_page_name: wiki_page.slug } + = link_to wiki_page.human_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/_wiki_directory.html.haml b/app/views/shared/wikis/_wiki_directory.html.haml index 21e829d86a6..a492d1e5aa0 100644 --- a/app/views/shared/wikis/_wiki_directory.html.haml +++ b/app/views/shared/wikis/_wiki_directory.html.haml @@ -1,4 +1,4 @@ %li{ data: { qa_selector: 'wiki_directory_content' } } - = wiki_directory.slug + = wiki_directory.title %ul - = render wiki_directory.pages, context: context + = render wiki_directory.entries, context: context diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index fbda9b79e82..f1733ce2b51 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -2,7 +2,7 @@ - @hide_breadcrumbs = true - @no_container = true - page_title @user.blocked? ? s_('UserProfile|Blocked user') : @user.name -- page_description @user.bio +- page_description @user.bio_html - header_title @user.name, user_path(@user) - link_classes = "flex-grow-1 mx-1 " diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 11bf797fb90..bdcb31b8d46 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -211,6 +211,14 @@ :weight: 1 :idempotent: :tags: [] +- :name: cronjob:member_invitation_reminder_emails + :feature_category: :subgroups + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: + :tags: [] - :name: cronjob:metrics_dashboard_schedule_annotations_prune :feature_category: :metrics :has_external_dependencies: @@ -723,6 +731,14 @@ :weight: 2 :idempotent: true :tags: [] +- :name: incident_management:incident_management_add_severity_system_note + :feature_category: :incident_management + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 2 + :idempotent: + :tags: [] - :name: incident_management:incident_management_pager_duty_process_incident :feature_category: :incident_management :has_external_dependencies: @@ -1324,6 +1340,14 @@ :weight: 1 :idempotent: :tags: [] +- :name: design_management_copy_design_collection + :feature_category: :design_management + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: design_management_new_version :feature_category: :design_management :has_external_dependencies: @@ -1532,6 +1556,14 @@ :weight: 1 :idempotent: true :tags: [] +- :name: metrics_dashboard_sync_dashboards + :feature_category: :metrics + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: migrate_external_diffs :feature_category: :source_code_management :has_external_dependencies: @@ -1708,6 +1740,30 @@ :weight: 1 :idempotent: true :tags: [] +- :name: propagate_integration_group + :feature_category: :integrations + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] +- :name: propagate_integration_inherit + :feature_category: :integrations + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] +- :name: propagate_integration_project + :feature_category: :integrations + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: propagate_service_template :feature_category: :integrations :has_external_dependencies: diff --git a/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb b/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb index a9976c6e5cb..01bddfea7de 100644 --- a/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb +++ b/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb @@ -17,10 +17,9 @@ module Analytics return if Feature.disabled?(:store_instance_statistics_measurements, default_enabled: true) recorded_at = Time.zone.now - measurement_identifiers = Analytics::InstanceStatistics::Measurement.identifiers worker_arguments = Gitlab::Analytics::InstanceStatistics::WorkersArgumentBuilder.new( - measurement_identifiers: measurement_identifiers.values, + measurement_identifiers: ::Analytics::InstanceStatistics::Measurement.measurement_identifier_values, recorded_at: recorded_at ).execute diff --git a/app/workers/authorized_project_update/periodic_recalculate_worker.rb b/app/workers/authorized_project_update/periodic_recalculate_worker.rb index 0d1ad67d7bb..78ffdbca4d6 100644 --- a/app/workers/authorized_project_update/periodic_recalculate_worker.rb +++ b/app/workers/authorized_project_update/periodic_recalculate_worker.rb @@ -12,9 +12,7 @@ module AuthorizedProjectUpdate idempotent! def perform - if ::Feature.enabled?(:periodic_project_authorization_recalculation, default_enabled: true) - AuthorizedProjectUpdate::PeriodicRecalculateService.new.execute - end + AuthorizedProjectUpdate::PeriodicRecalculateService.new.execute end end end diff --git a/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb b/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb index 336b1c5443e..9bd1ad2ed30 100644 --- a/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb +++ b/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb @@ -12,9 +12,7 @@ module AuthorizedProjectUpdate idempotent! def perform(start_user_id, end_user_id) - if ::Feature.enabled?(:periodic_project_authorization_recalculation, default_enabled: true) - AuthorizedProjectUpdate::RecalculateForUserRangeService.new(start_user_id, end_user_id).execute - end + AuthorizedProjectUpdate::RecalculateForUserRangeService.new(start_user_id, end_user_id).execute end end end diff --git a/app/workers/cleanup_container_repository_worker.rb b/app/workers/cleanup_container_repository_worker.rb index 4469ea8cff9..80cc296fff5 100644 --- a/app/workers/cleanup_container_repository_worker.rb +++ b/app/workers/cleanup_container_repository_worker.rb @@ -16,9 +16,17 @@ class CleanupContainerRepositoryWorker # rubocop:disable Scalability/IdempotentW return unless valid? - Projects::ContainerRepository::CleanupTagsService + if run_by_container_expiration_policy? + container_repository.start_expiration_policy! + end + + result = Projects::ContainerRepository::CleanupTagsService .new(project, current_user, params) .execute(container_repository) + + if run_by_container_expiration_policy? && result[:status] == :success + container_repository.reset_expiration_policy_started_at! + end end private @@ -30,7 +38,7 @@ class CleanupContainerRepositoryWorker # rubocop:disable Scalability/IdempotentW end def run_by_container_expiration_policy? - @params['container_expiration_policy'] && container_repository && project + @params['container_expiration_policy'] && container_repository.present? && project.present? end def project diff --git a/app/workers/concerns/limited_capacity/job_tracker.rb b/app/workers/concerns/limited_capacity/job_tracker.rb new file mode 100644 index 00000000000..96b6e1a2024 --- /dev/null +++ b/app/workers/concerns/limited_capacity/job_tracker.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true +module LimitedCapacity + class JobTracker # rubocop:disable Scalability/IdempotentWorker + include Gitlab::Utils::StrongMemoize + + def initialize(namespace) + @namespace = namespace + end + + def register(jid) + _added, @count = with_redis_pipeline do |redis| + register_job_keys(redis, jid) + get_job_count(redis) + end + end + + def remove(jid) + _removed, @count = with_redis_pipeline do |redis| + remove_job_keys(redis, jid) + get_job_count(redis) + end + end + + def clean_up + completed_jids = Gitlab::SidekiqStatus.completed_jids(running_jids) + return unless completed_jids.any? + + _removed, @count = with_redis_pipeline do |redis| + remove_job_keys(redis, completed_jids) + get_job_count(redis) + end + end + + def count + @count ||= with_redis { |redis| get_job_count(redis) } + end + + def running_jids + with_redis do |redis| + redis.smembers(counter_key) + end + end + + private + + attr_reader :namespace + + def counter_key + "worker:#{namespace.to_s.underscore}:running" + end + + def get_job_count(redis) + redis.scard(counter_key) + end + + def register_job_keys(redis, keys) + redis.sadd(counter_key, keys) + end + + def remove_job_keys(redis, keys) + redis.srem(counter_key, keys) + end + + def with_redis(&block) + Gitlab::Redis::Queues.with(&block) # rubocop: disable CodeReuse/ActiveRecord + end + + def with_redis_pipeline(&block) + with_redis do |redis| + redis.pipelined(&block) + end + end + end +end diff --git a/app/workers/concerns/limited_capacity/worker.rb b/app/workers/concerns/limited_capacity/worker.rb new file mode 100644 index 00000000000..c0d6bfff2f5 --- /dev/null +++ b/app/workers/concerns/limited_capacity/worker.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +# Usage: +# +# Worker that performs the tasks: +# +# class DummyWorker +# include ApplicationWorker +# include LimitedCapacity::Worker +# +# # For each job that raises any error, a worker instance will be disabled +# # until the next schedule-run. +# # If you wish to get around this, exceptions must by handled by the implementer. +# # +# def perform_work(*args) +# end +# +# def remaining_work_count(*args) +# 5 +# end +# +# def max_running_jobs +# 25 +# end +# end +# +# Cron worker to fill the pool of regular workers: +# +# class ScheduleDummyCronWorker +# include ApplicationWorker +# include CronjobQueue +# +# def perform(*args) +# DummyWorker.perform_with_capacity(*args) +# end +# end +# + +module LimitedCapacity + module Worker + extend ActiveSupport::Concern + include Gitlab::Utils::StrongMemoize + + included do + # Disable Sidekiq retries, log the error, and send the job to the dead queue. + # This is done to have only one source that produces jobs and because the slot + # would be occupied by a job that will be performed in the distant future. + # We let the cron worker enqueue new jobs, this could be seen as our retry and + # back off mechanism because the job might fail again if executed immediately. + sidekiq_options retry: 0 + deduplicate :none + end + + class_methods do + def perform_with_capacity(*args) + worker = self.new + worker.remove_failed_jobs + worker.report_prometheus_metrics(*args) + required_jobs_count = worker.required_jobs_count(*args) + + arguments = Array.new(required_jobs_count) { args } + self.bulk_perform_async(arguments) # rubocop:disable Scalability/BulkPerformWithContext + end + end + + def perform(*args) + return unless has_capacity? + + job_tracker.register(jid) + perform_work(*args) + rescue => exception + raise + ensure + job_tracker.remove(jid) + report_prometheus_metrics + re_enqueue(*args) unless exception + end + + def perform_work(*args) + raise NotImplementedError + end + + def remaining_work_count(*args) + raise NotImplementedError + end + + def max_running_jobs + raise NotImplementedError + end + + def has_capacity? + remaining_capacity > 0 + end + + def remaining_capacity + [ + max_running_jobs - running_jobs_count - self.class.queue_size, + 0 + ].max + end + + def has_work?(*args) + remaining_work_count(*args) > 0 + end + + def remove_failed_jobs + job_tracker.clean_up + end + + def report_prometheus_metrics(*args) + running_jobs_gauge.set(prometheus_labels, running_jobs_count) + remaining_work_gauge.set(prometheus_labels, remaining_work_count(*args)) + max_running_jobs_gauge.set(prometheus_labels, max_running_jobs) + end + + def required_jobs_count(*args) + [ + remaining_work_count(*args), + remaining_capacity + ].min + end + + private + + def running_jobs_count + job_tracker.count + end + + def job_tracker + strong_memoize(:job_tracker) do + JobTracker.new(self.class.name) + end + end + + def re_enqueue(*args) + return unless has_capacity? + return unless has_work?(*args) + + self.class.perform_async(*args) + end + + def running_jobs_gauge + strong_memoize(:running_jobs_gauge) do + Gitlab::Metrics.gauge(:limited_capacity_worker_running_jobs, 'Number of running jobs') + end + end + + def max_running_jobs_gauge + strong_memoize(:max_running_jobs_gauge) do + Gitlab::Metrics.gauge(:limited_capacity_worker_max_running_jobs, 'Maximum number of running jobs') + end + end + + def remaining_work_gauge + strong_memoize(:remaining_work_gauge) do + Gitlab::Metrics.gauge(:limited_capacity_worker_remaining_work_count, 'Number of jobs waiting to be enqueued') + end + end + + def prometheus_labels + { worker: self.class.name } + end + end +end diff --git a/app/workers/design_management/copy_design_collection_worker.rb b/app/workers/design_management/copy_design_collection_worker.rb new file mode 100644 index 00000000000..0a6e23fe9da --- /dev/null +++ b/app/workers/design_management/copy_design_collection_worker.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module DesignManagement + class CopyDesignCollectionWorker + include ApplicationWorker + + feature_category :design_management + idempotent! + urgency :low + + def perform(user_id, issue_id, target_issue_id) + user = User.find(user_id) + issue = Issue.find(issue_id) + target_issue = Issue.find(target_issue_id) + + response = DesignManagement::CopyDesignCollection::CopyService.new( + target_issue.project, + user, + issue: issue, + target_issue: target_issue + ).execute + + Gitlab::AppLogger.warn(response.message) if response.error? + end + end +end diff --git a/app/workers/design_management/new_version_worker.rb b/app/workers/design_management/new_version_worker.rb index 3634dcbcebd..4fbf2067be4 100644 --- a/app/workers/design_management/new_version_worker.rb +++ b/app/workers/design_management/new_version_worker.rb @@ -9,10 +9,10 @@ module DesignManagement # `GenerateImageVersionsService` resizing designs worker_resource_boundary :memory - def perform(version_id) + def perform(version_id, skip_system_notes = false) version = DesignManagement::Version.find(version_id) - add_system_note(version) + add_system_note(version) unless skip_system_notes generate_image_versions(version) rescue ActiveRecord::RecordNotFound => e Sidekiq.logger.warn(e) diff --git a/app/workers/git_garbage_collect_worker.rb b/app/workers/git_garbage_collect_worker.rb index b0307571448..e66bad3962f 100644 --- a/app/workers/git_garbage_collect_worker.rb +++ b/app/workers/git_garbage_collect_worker.rb @@ -91,10 +91,12 @@ class GitGarbageCollectWorker # rubocop:disable Scalability/IdempotentWorker end def cleanup_orphan_lfs_file_references(project) - return unless Feature.enabled?(:cleanup_lfs_during_gc, project) return if Gitlab::Database.read_only? # GitGarbageCollectWorker may be run on a Geo secondary ::Gitlab::Cleanup::OrphanLfsFileReferences.new(project, dry_run: false, logger: logger).run! + rescue => err + Gitlab::GitLogger.warn(message: "Cleaning up orphan LFS objects files failed", error: err.message) + Gitlab::ErrorTracking.track_and_raise_for_dev_exception(err) end def flush_ref_caches(project) diff --git a/app/workers/group_import_worker.rb b/app/workers/group_import_worker.rb index 36d81468d55..494d9a3e46f 100644 --- a/app/workers/group_import_worker.rb +++ b/app/workers/group_import_worker.rb @@ -9,7 +9,7 @@ class GroupImportWorker # rubocop:disable Scalability/IdempotentWorker def perform(user_id, group_id) current_user = User.find(user_id) group = Group.find(group_id) - group_import_state = group.import_state || group.build_import_state + group_import_state = group.import_state group_import_state.jid = self.jid group_import_state.start! diff --git a/app/workers/incident_management/add_severity_system_note_worker.rb b/app/workers/incident_management/add_severity_system_note_worker.rb new file mode 100644 index 00000000000..9f132531562 --- /dev/null +++ b/app/workers/incident_management/add_severity_system_note_worker.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module IncidentManagement + class AddSeveritySystemNoteWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + + queue_namespace :incident_management + feature_category :incident_management + + def perform(incident_id, user_id) + return if incident_id.blank? || user_id.blank? + + incident = Issue.with_issue_type(:incident).find_by_id(incident_id) + return unless incident + + user = User.find_by_id(user_id) + return unless user + + SystemNoteService.change_incident_severity(incident, user) + end + end +end diff --git a/app/workers/issue_placement_worker.rb b/app/workers/issue_placement_worker.rb index a8d59e9125c..5b547ab0c8d 100644 --- a/app/workers/issue_placement_worker.rb +++ b/app/workers/issue_placement_worker.rb @@ -36,14 +36,14 @@ class IssuePlacementWorker Gitlab::ErrorTracking.log_exception(e, issue_id: issue_id, project_id: project_id) IssueRebalancingWorker.perform_async(nil, project_id.presence || issue.project_id) end - # rubocop: enable CodeReuse/ActiveRecord def find_issue(issue_id, project_id) - return Issue.id_in(issue_id).first if issue_id + return Issue.id_in(issue_id).take if issue_id - project = Project.id_in(project_id).first + project = Project.id_in(project_id).take return unless project - project.issues.first + project.issues.take end + # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/issue_rebalancing_worker.rb b/app/workers/issue_rebalancing_worker.rb index 032ba5534e6..a9ad66198f3 100644 --- a/app/workers/issue_rebalancing_worker.rb +++ b/app/workers/issue_rebalancing_worker.rb @@ -11,7 +11,8 @@ class IssueRebalancingWorker return if project_id.nil? project = Project.find(project_id) - issue = project.issues.first # All issues are equivalent as far as we are concerned + # All issues are equivalent as far as we are concerned + issue = project.issues.take # rubocop: disable CodeReuse/ActiveRecord IssueRebalancingService.new(issue).execute rescue ActiveRecord::RecordNotFound, IssueRebalancingService::TooManyIssues => e diff --git a/app/workers/member_invitation_reminder_emails_worker.rb b/app/workers/member_invitation_reminder_emails_worker.rb new file mode 100644 index 00000000000..69d7f8ac8f6 --- /dev/null +++ b/app/workers/member_invitation_reminder_emails_worker.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class MemberInvitationReminderEmailsWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + include CronjobQueue # rubocop:disable Scalability/CronWorkerContext + + feature_category :subgroups + urgency :low + + def perform + return unless Gitlab::Experimentation.enabled?(:invitation_reminders) + + # To keep this MR small, implementation will be done in another MR: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/42981/diffs?commit_id=8063606e0f83957b2dd38d660ee986f24dee6138 + end +end diff --git a/app/workers/metrics/dashboard/sync_dashboards_worker.rb b/app/workers/metrics/dashboard/sync_dashboards_worker.rb new file mode 100644 index 00000000000..7a124a33f9e --- /dev/null +++ b/app/workers/metrics/dashboard/sync_dashboards_worker.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Metrics + module Dashboard + class SyncDashboardsWorker + include ApplicationWorker + + feature_category :metrics + + idempotent! + + def perform(project_id) + project = Project.find(project_id) + dashboard_paths = ::Gitlab::Metrics::Dashboard::RepoDashboardFinder.list_dashboards(project) + + dashboard_paths.each do |dashboard_path| + ::Gitlab::Metrics::Dashboard::Importer.new(dashboard_path, project).execute! + end + end + end + end +end diff --git a/app/workers/propagate_integration_group_worker.rb b/app/workers/propagate_integration_group_worker.rb new file mode 100644 index 00000000000..e539c6d4719 --- /dev/null +++ b/app/workers/propagate_integration_group_worker.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class PropagateIntegrationGroupWorker + include ApplicationWorker + + feature_category :integrations + idempotent! + + # rubocop: disable CodeReuse/ActiveRecord + def perform(integration_id, min_id, max_id) + integration = Service.find_by_id(integration_id) + return unless integration + + batch = Group.where(id: min_id..max_id).without_integration(integration) + + BulkCreateIntegrationService.new(integration, batch, 'group').execute + end + # rubocop: enable CodeReuse/ActiveRecord +end diff --git a/app/workers/propagate_integration_inherit_worker.rb b/app/workers/propagate_integration_inherit_worker.rb new file mode 100644 index 00000000000..ef3132202f6 --- /dev/null +++ b/app/workers/propagate_integration_inherit_worker.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class PropagateIntegrationInheritWorker + include ApplicationWorker + + feature_category :integrations + idempotent! + + # rubocop: disable CodeReuse/ActiveRecord + def perform(integration_id, min_id, max_id) + integration = Service.find_by_id(integration_id) + return unless integration + + services = Service.where(id: min_id..max_id).by_type(integration.type).inherit_from_id(integration.id) + + BulkUpdateIntegrationService.new(integration, services).execute + end + # rubocop: enable CodeReuse/ActiveRecord +end diff --git a/app/workers/propagate_integration_project_worker.rb b/app/workers/propagate_integration_project_worker.rb new file mode 100644 index 00000000000..c1e286b24fc --- /dev/null +++ b/app/workers/propagate_integration_project_worker.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class PropagateIntegrationProjectWorker + include ApplicationWorker + + feature_category :integrations + idempotent! + + # rubocop: disable CodeReuse/ActiveRecord + def perform(integration_id, min_id, max_id) + integration = Service.find_by_id(integration_id) + return unless integration + + batch = Project.where(id: min_id..max_id).without_integration(integration) + + BulkCreateIntegrationService.new(integration, batch, 'project').execute + end + # rubocop: enable CodeReuse/ActiveRecord +end diff --git a/app/workers/propagate_integration_worker.rb b/app/workers/propagate_integration_worker.rb index 68e38386372..bb954b12a25 100644 --- a/app/workers/propagate_integration_worker.rb +++ b/app/workers/propagate_integration_worker.rb @@ -7,7 +7,8 @@ class PropagateIntegrationWorker idempotent! loggable_arguments 1 - # Keep overwrite parameter for backwards compatibility. + # TODO: Keep overwrite parameter for backwards compatibility. Remove after >= 14.0 + # https://gitlab.com/gitlab-org/gitlab/-/issues/255382 def perform(integration_id, overwrite = nil) Admin::PropagateIntegrationService.propagate(Service.find(integration_id)) end |