diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-10-05 16:54:15 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-10-05 16:54:15 +0300 |
commit | be834a25982746ffd85252ff502df42bb88cb9d5 (patch) | |
tree | b4d6a8ba0931e12fac08f05abea33a3b8ec2c8a2 /app/assets/javascripts | |
parent | ee925a3597f27e92f83a50937a64068109675b3d (diff) |
Add latest changes from gitlab-org/gitlab@13-5-stable-eev13.5.0-rc32
Diffstat (limited to 'app/assets/javascripts')
519 files changed, 11848 insertions, 4766 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)); + } }, }; |