diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-07-20 12:26:25 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-07-20 12:26:25 +0000 |
commit | a09983ae35713f5a2bbb100981116d31ce99826e (patch) | |
tree | 2ee2af7bd104d57086db360a7e6d8c9d5d43667a /app/assets/javascripts | |
parent | 18c5ab32b738c0b6ecb4d0df3994000482f34bd8 (diff) | |
download | gitlab-ce-a09983ae35713f5a2bbb100981116d31ce99826e.tar.gz |
Add latest changes from gitlab-org/gitlab@13-2-stable-ee
Diffstat (limited to 'app/assets/javascripts')
683 files changed, 17709 insertions, 4960 deletions
diff --git a/app/assets/javascripts/alert_management/components/alert_details.vue b/app/assets/javascripts/alert_management/components/alert_details.vue index ed6b4b7fdb2..0731349630c 100644 --- a/app/assets/javascripts/alert_management/components/alert_details.vue +++ b/app/assets/javascripts/alert_management/components/alert_details.vue @@ -12,18 +12,21 @@ import { GlTable, } from '@gitlab/ui'; import { s__ } from '~/locale'; -import query from '../graphql/queries/details.query.graphql'; +import alertQuery from '../graphql/queries/details.query.graphql'; +import sidebarStatusQuery from '../graphql/queries/sidebar_status.query.graphql'; import { fetchPolicies } from '~/lib/graphql'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user'; import initUserPopovers from '~/user_popovers'; import { ALERTS_SEVERITY_LABELS, trackAlertsDetailsViewsOptions } from '../constants'; -import createIssueQuery from '../graphql/mutations/create_issue_from_alert.graphql'; +import createIssueMutation from '../graphql/mutations/create_issue_from_alert.mutation.graphql'; +import toggleSidebarStatusMutation from '../graphql/mutations/toggle_sidebar_status.mutation.graphql'; import { visitUrl, joinPaths } from '~/lib/utils/url_utility'; import Tracking from '~/tracking'; import { toggleContainerClasses } from '~/lib/utils/dom_utils'; import SystemNote from './system_notes/system_note.vue'; import AlertSidebar from './alert_sidebar.vue'; +import AlertMetrics from './alert_metrics.vue'; const containerEl = document.querySelector('.page-with-contextual-sidebar'); @@ -34,6 +37,7 @@ export default { ), fullAlertDetailsTitle: s__('AlertManagement|Alert details'), overviewTitle: s__('AlertManagement|Overview'), + metricsTitle: s__('AlertManagement|Metrics'), reportedAt: s__('AlertManagement|Reported %{when}'), reportedAtWithTool: s__('AlertManagement|Reported %{when} by %{tool}'), }, @@ -51,25 +55,29 @@ export default { TimeAgoTooltip, AlertSidebar, SystemNote, + AlertMetrics, }, - props: { + inject: { + projectPath: { + default: '', + }, alertId: { type: String, - required: true, + default: '', }, - projectPath: { + projectId: { type: String, - required: true, + default: '', }, projectIssuesPath: { type: String, - required: true, + default: '', }, }, apollo: { alert: { fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, - query, + query: alertQuery, variables() { return { fullPath: this.projectPath, @@ -84,15 +92,18 @@ export default { Sentry.captureException(error); }, }, + sidebarStatus: { + query: sidebarStatusQuery, + }, }, data() { return { alert: null, errored: false, + sidebarStatus: false, isErrorDismissed: false, createIssueError: '', issueCreationInProgress: false, - sidebarCollapsed: false, sidebarErrorMessage: '', }; }, @@ -128,10 +139,10 @@ export default { this.sidebarErrorMessage = ''; }, toggleSidebar() { - this.sidebarCollapsed = !this.sidebarCollapsed; + this.$apollo.mutate({ mutation: toggleSidebarStatusMutation }); toggleContainerClasses(containerEl, { - 'right-sidebar-collapsed': this.sidebarCollapsed, - 'right-sidebar-expanded': !this.sidebarCollapsed, + 'right-sidebar-collapsed': !this.sidebarStatus, + 'right-sidebar-expanded': this.sidebarStatus, }); }, handleAlertSidebarError(errorMessage) { @@ -143,7 +154,7 @@ export default { this.$apollo .mutate({ - mutation: createIssueQuery, + mutation: createIssueMutation, variables: { iid: this.alert.iid, projectPath: this.projectPath, @@ -169,9 +180,6 @@ export default { const { category, action } = trackAlertsDetailsViewsOptions; Tracking.event(category, action); }, - alertRefresh() { - this.$apollo.queries.alert.refetch(); - }, }, }; </script> @@ -179,7 +187,7 @@ export default { <template> <div> <gl-alert v-if="showErrorMsg" variant="danger" @dismiss="dismissError"> - {{ sidebarErrorMessage || $options.i18n.errorMsg }} + <p v-html="sidebarErrorMessage || $options.i18n.errorMsg"></p> </gl-alert> <gl-alert v-if="createIssueError" @@ -193,10 +201,10 @@ export default { <div v-if="alert" class="alert-management-details gl-relative" - :class="{ 'pr-sm-8': sidebarCollapsed }" + :class="{ 'pr-sm-8': sidebarStatus }" > <div - class="gl-display-flex gl-justify-content-space-between gl-align-items-baseline gl-px-1 py-3 py-md-4 gl-border-b-1 gl-border-b-gray-200 gl-border-b-solid flex-column flex-sm-row" + class="gl-display-flex gl-justify-content-space-between gl-align-items-baseline gl-px-1 py-3 py-md-4 gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid flex-column flex-sm-row" > <div data-testid="alert-header" @@ -324,14 +332,14 @@ export default { </template> </gl-table> </gl-tab> + <gl-tab data-testId="metricsTab" :title="$options.i18n.metricsTitle"> + <alert-metrics :dashboard-url="alert.metricsDashboardUrl" /> + </gl-tab> </gl-tabs> <alert-sidebar - :project-path="projectPath" :alert="alert" - :sidebar-collapsed="sidebarCollapsed" - @alert-refresh="alertRefresh" @toggle-sidebar="toggleSidebar" - @alert-sidebar-error="handleAlertSidebarError" + @alert-error="handleAlertSidebarError" /> </div> </div> diff --git a/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue b/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue new file mode 100644 index 00000000000..13b6a8e6653 --- /dev/null +++ b/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue @@ -0,0 +1,90 @@ +<script> +import { GlEmptyState, GlButton } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + i18n: { + emptyState: { + opsgenie: { + title: s__('AlertManagement|Opsgenie is enabled'), + info: s__( + 'AlertManagement|You have enabled the Opsgenie integration. Your alerts will be visible directly in Opsgenie.', + ), + buttonText: s__('AlertManagement|View alerts in Opsgenie'), + }, + gitlab: { + title: s__('AlertManagement|Surface alerts in GitLab'), + info: s__( + 'AlertManagement|Display alerts from all your monitoring tools directly within GitLab. Streamline the investigation of your alerts and the escalation of alerts to incidents.', + ), + buttonText: s__('AlertManagement|Authorize external service'), + }, + }, + moreInformation: s__('AlertManagement|More information'), + }, + components: { + GlEmptyState, + GlButton, + }, + props: { + enableAlertManagementPath: { + type: String, + required: true, + }, + userCanEnableAlertManagement: { + type: Boolean, + required: true, + }, + emptyAlertSvgPath: { + type: String, + required: true, + }, + opsgenieMvcEnabled: { + type: Boolean, + required: false, + default: false, + }, + opsgenieMvcTargetUrl: { + type: String, + required: false, + default: '', + }, + }, + computed: { + emptyState() { + return { + ...(this.opsgenieMvcEnabled + ? this.$options.i18n.emptyState.opsgenie + : this.$options.i18n.emptyState.gitlab), + link: this.opsgenieMvcEnabled ? this.opsgenieMvcTargetUrl : this.enableAlertManagementPath, + }; + }, + alertsCanBeEnabled() { + return this.userCanEnableAlertManagement || this.opsgenieMvcEnabled; + }, + }, +}; +</script> +<template> + <div> + <gl-empty-state :title="emptyState.title" :svg-path="emptyAlertSvgPath"> + <template #description> + <div class="gl-display-block"> + <span>{{ emptyState.info }}</span> + <a + v-if="!opsgenieMvcEnabled" + href="/help/user/project/operations/alert_management.html" + target="_blank" + > + {{ $options.i18n.moreInformation }} + </a> + </div> + <div v-if="alertsCanBeEnabled" class="gl-display-block center gl-pt-4"> + <gl-button category="primary" variant="success" :href="emptyState.link"> + {{ emptyState.buttonText }} + </gl-button> + </div> + </template> + </gl-empty-state> + </div> +</template> diff --git a/app/assets/javascripts/alert_management/components/alert_management_list_wrapper.vue b/app/assets/javascripts/alert_management/components/alert_management_list_wrapper.vue new file mode 100644 index 00000000000..094f33fed3b --- /dev/null +++ b/app/assets/javascripts/alert_management/components/alert_management_list_wrapper.vue @@ -0,0 +1,75 @@ +<script> +import Tracking from '~/tracking'; +import { trackAlertListViewsOptions } from '../constants'; +import AlertManagementEmptyState from './alert_management_empty_state.vue'; +import AlertManagementTable from './alert_management_table.vue'; + +export default { + components: { + AlertManagementEmptyState, + AlertManagementTable, + }, + props: { + projectPath: { + type: String, + required: true, + }, + alertManagementEnabled: { + type: Boolean, + required: true, + }, + enableAlertManagementPath: { + type: String, + required: true, + }, + populatingAlertsHelpUrl: { + type: String, + required: true, + }, + userCanEnableAlertManagement: { + type: Boolean, + required: true, + }, + emptyAlertSvgPath: { + type: String, + required: true, + }, + opsgenieMvcEnabled: { + type: Boolean, + required: false, + default: false, + }, + opsgenieMvcTargetUrl: { + type: String, + required: false, + default: '', + }, + }, + mounted() { + this.trackPageViews(); + }, + methods: { + trackPageViews() { + const { category, action } = trackAlertListViewsOptions; + Tracking.event(category, action); + }, + }, +}; +</script> +<template> + <div> + <alert-management-table + v-if="alertManagementEnabled" + :populating-alerts-help-url="populatingAlertsHelpUrl" + :project-path="projectPath" + /> + <alert-management-empty-state + v-else + :empty-alert-svg-path="emptyAlertSvgPath" + :enable-alert-management-path="enableAlertManagementPath" + :user-can-enable-alert-management="userCanEnableAlertManagement" + :opsgenie-mvc-enabled="opsgenieMvcEnabled" + :opsgenie-mvc-target-url="opsgenieMvcTargetUrl" + /> + </div> +</template> diff --git a/app/assets/javascripts/alert_management/components/alert_management_list.vue b/app/assets/javascripts/alert_management/components/alert_management_table.vue index 37901c21f9b..7dd3d7b5dc3 100644 --- a/app/assets/javascripts/alert_management/components/alert_management_list.vue +++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue @@ -1,23 +1,24 @@ <script> import { - GlEmptyState, - GlDeprecatedButton, GlLoadingIcon, GlTable, GlAlert, GlIcon, - GlDropdown, - GlDropdownItem, + GlLink, GlTabs, GlTab, GlBadge, GlPagination, + GlSearchBoxByType, + GlSprintf, } from '@gitlab/ui'; -import createFlash from '~/flash'; -import { s__ } from '~/locale'; +import { __, s__ } from '~/locale'; +import { debounce, trim } from 'lodash'; import { joinPaths, visitUrl } from '~/lib/utils/url_utility'; import { fetchPolicies } from '~/lib/graphql'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import { convertToSnakeCase } from '~/lib/utils/text_utility'; +import Tracking from '~/tracking'; import getAlerts from '../graphql/queries/get_alerts.query.graphql'; import getAlertsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql'; import { @@ -27,11 +28,10 @@ import { trackAlertListViewsOptions, trackAlertStatusUpdateOptions, } from '../constants'; -import updateAlertStatus from '../graphql/mutations/update_alert_status.graphql'; -import { convertToSnakeCase } from '~/lib/utils/text_utility'; -import Tracking from '~/tracking'; +import AlertStatus from './alert_status.vue'; -const tdClass = 'table-col gl-display-flex d-md-table-cell gl-align-items-center'; +const tdClass = + 'table-col gl-display-flex d-md-table-cell gl-align-items-center gl-white-space-nowrap'; const thClass = 'gl-hover-bg-blue-50'; const bodyTrClass = 'gl-border-1 gl-border-t-solid gl-border-gray-100 gl-hover-bg-blue-50 gl-hover-cursor-pointer gl-hover-border-b-solid gl-hover-border-blue-200'; @@ -44,54 +44,57 @@ const initialPaginationState = { lastPageSize: null, }; +const TWELVE_HOURS_IN_MS = 12 * 60 * 60 * 1000; + export default { i18n: { noAlertsMsg: s__( - "AlertManagement|No alerts available to display. If you think you're seeing this message in error, refresh the page.", + 'AlertManagement|No alerts available to display. See %{linkStart}enabling alert management%{linkEnd} for more information on adding alerts to the list.', ), errorMsg: s__( "AlertManagement|There was an error displaying the alerts. Confirm your endpoint's configuration details to ensure alerts appear.", ), + searchPlaceholder: __('Search or filter results...'), }, fields: [ { key: 'severity', label: s__('AlertManagement|Severity'), tdClass: `${tdClass} rounded-top text-capitalize`, - thClass, + thClass: `${thClass} gl-w-eighth`, sortable: true, }, { key: 'startedAt', label: s__('AlertManagement|Start time'), - thClass: `${thClass} js-started-at`, - tdClass, - sortable: true, - }, - { - key: 'endedAt', - label: s__('AlertManagement|End time'), - thClass, + thClass: `${thClass} js-started-at w-15p`, tdClass, sortable: true, }, { key: 'title', label: s__('AlertManagement|Alert'), - thClass: `${thClass} w-30p gl-pointer-events-none`, + thClass: `gl-pointer-events-none`, tdClass, - sortable: false, }, { key: 'eventCount', label: s__('AlertManagement|Events'), - thClass: `${thClass} text-right gl-pr-9 w-3rem`, + thClass: `${thClass} text-right gl-w-12`, tdClass: `${tdClass} text-md-right`, sortable: true, }, { + key: 'issue', + label: s__('AlertManagement|Issue'), + thClass: 'gl-w-12 gl-pointer-events-none', + tdClass, + sortable: false, + }, + { key: 'assignees', label: s__('AlertManagement|Assignees'), + thClass: 'gl-w-eighth gl-pointer-events-none', tdClass, }, { @@ -102,46 +105,29 @@ export default { sortable: true, }, ], - statuses: { - TRIGGERED: s__('AlertManagement|Triggered'), - ACKNOWLEDGED: s__('AlertManagement|Acknowledged'), - RESOLVED: s__('AlertManagement|Resolved'), - }, severityLabels: ALERTS_SEVERITY_LABELS, statusTabs: ALERTS_STATUS_TABS, components: { - GlEmptyState, GlLoadingIcon, GlTable, GlAlert, - GlDeprecatedButton, TimeAgo, - GlDropdown, - GlDropdownItem, GlIcon, + GlLink, GlTabs, GlTab, GlBadge, GlPagination, + GlSearchBoxByType, + GlSprintf, + AlertStatus, }, props: { projectPath: { type: String, required: true, }, - alertManagementEnabled: { - type: Boolean, - required: true, - }, - enableAlertManagementPath: { - type: String, - required: true, - }, - userCanEnableAlertManagement: { - type: Boolean, - required: true, - }, - emptyAlertSvgPath: { + populatingAlertsHelpUrl: { type: String, required: true, }, @@ -152,6 +138,7 @@ export default { query: getAlerts, variables() { return { + searchTerm: this.searchTerm, projectPath: this.projectPath, statuses: this.statusFilter, sort: this.sort, @@ -164,9 +151,20 @@ export default { update(data) { const { alertManagementAlerts: { nodes: list = [], pageInfo = {} } = {} } = data.project || {}; + const now = new Date(); + + const listWithData = list.map(alert => { + const then = new Date(alert.startedAt); + const diff = now - then; + + return { + ...alert, + isNew: diff < TWELVE_HOURS_IN_MS, + }; + }); return { - list, + list: listWithData, pageInfo, }; }, @@ -178,6 +176,7 @@ export default { query: getAlertsCountByStatus, variables() { return { + searchTerm: this.searchTerm, projectPath: this.projectPath, }; }, @@ -188,7 +187,9 @@ export default { }, data() { return { + searchTerm: '', errored: false, + errorMessage: '', isAlertDismissed: false, isErrorAlertDismissed: false, sort: 'STARTED_AT_DESC', @@ -203,7 +204,11 @@ export default { computed: { showNoAlertsMsg() { return ( - !this.errored && !this.loading && this.alertsCount?.all === 0 && !this.isAlertDismissed + !this.errored && + !this.loading && + this.alertsCount?.all === 0 && + !this.searchTerm && + !this.isAlertDismissed ); }, showErrorMsg() { @@ -215,9 +220,6 @@ export default { hasAlerts() { return this.alerts?.list?.length; }, - tbodyTrClass() { - return !this.loading && this.hasAlerts ? bodyTrClass : ''; - }, showPaginationControls() { return Boolean(this.prevPage || this.nextPage); }, @@ -249,30 +251,13 @@ export default { this.resetPagination(); this.sort = `${sortingColumn}_${sortingDirection}`; }, - updateAlertStatus(status, iid) { - this.$apollo - .mutate({ - mutation: updateAlertStatus, - variables: { - iid, - status: status.toUpperCase(), - projectPath: this.projectPath, - }, - }) - .then(() => { - this.trackStatusUpdate(status); - this.$apollo.queries.alerts.refetch(); - this.$apollo.queries.alertsCount.refetch(); - this.resetPagination(); - }) - .catch(() => { - createFlash( - s__( - 'AlertManagement|There was an error while updating the status of the alert. Please try again.', - ), - ); - }); - }, + onInputChange: debounce(function debounceSearch(input) { + const trimmedInput = trim(input); + if (trimmedInput !== this.searchTerm) { + this.resetPagination(); + this.searchTerm = trimmedInput; + } + }, 500), navigateToAlertDetails({ iid }) { return visitUrl(joinPaths(window.location.pathname, iid, 'details')); }, @@ -290,6 +275,9 @@ export default { ? assignees.nodes[0]?.username : s__('AlertManagement|Unassigned'); }, + getIssueLink(item) { + return joinPaths('/', this.projectPath, '-', 'issues', item.issueIid); + }, handlePageChange(page) { const { startCursor, endCursor } = this.alerts.pageInfo; @@ -312,20 +300,49 @@ export default { resetPagination() { this.pagination = initialPaginationState; }, + tbodyTrClass(item) { + return { + [bodyTrClass]: !this.loading && this.hasAlerts, + 'new-alert': item?.isNew, + }; + }, + handleAlertError(errorMessage) { + this.errored = true; + this.errorMessage = errorMessage; + }, + dismissError() { + this.isErrorAlertDismissed = true; + this.errorMessage = ''; + }, }, }; </script> <template> <div> - <div v-if="alertManagementEnabled" class="alert-management-list"> + <div class="alert-management-list"> <gl-alert v-if="showNoAlertsMsg" @dismiss="isAlertDismissed = true"> - {{ $options.i18n.noAlertsMsg }} + <gl-sprintf :message="$options.i18n.noAlertsMsg"> + <template #link="{ content }"> + <gl-link + class="gl-display-inline-block" + :href="populatingAlertsHelpUrl" + target="_blank" + > + {{ content }} + </gl-link> + </template> + </gl-sprintf> </gl-alert> - <gl-alert v-if="showErrorMsg" variant="danger" @dismiss="isErrorAlertDismissed = true"> - {{ $options.i18n.errorMsg }} + <gl-alert + v-if="showErrorMsg" + variant="danger" + data-testid="alert-error" + @dismiss="dismissError" + > + <p v-html="errorMessage || $options.i18n.errorMsg"></p> </gl-alert> - <gl-tabs @input="filterAlertsByStatus"> + <gl-tabs content-class="gl-p-0" @input="filterAlertsByStatus"> <gl-tab v-for="tab in $options.statusTabs" :key="tab.status"> <template slot="title"> <span>{{ tab.title }}</span> @@ -336,11 +353,19 @@ export default { </gl-tab> </gl-tabs> + <div class="gl-bg-gray-10 gl-p-5 gl-border-b-solid gl-border-b-1 gl-border-gray-100"> + <gl-search-box-by-type + class="gl-bg-white" + :placeholder="$options.i18n.searchPlaceholder" + @input="onInputChange" + /> + </div> + <h4 class="d-block d-md-none my-3"> {{ s__('AlertManagement|Alerts') }} </h4> <gl-table - class="alert-management-table mt-3" + class="alert-management-table" :items="alerts ? alerts.list : []" :fields="$options.fields" :show-empty="true" @@ -352,6 +377,7 @@ export default { :sort-desc.sync="sortDesc" :sort-by.sync="sortBy" sort-icon-left + fixed @row-clicked="navigateToAlertDetails" @sort-changed="fetchSortedData" > @@ -374,16 +400,19 @@ export default { <time-ago v-if="item.startedAt" :time="item.startedAt" /> </template> - <template #cell(endedAt)="{ item }"> - <time-ago v-if="item.endedAt" :time="item.endedAt" /> - </template> - <template #cell(eventCount)="{ item }"> {{ item.eventCount }} </template> <template #cell(title)="{ item }"> - <div class="gl-max-w-full text-truncate">{{ item.title }}</div> + <div class="gl-max-w-full text-truncate" :title="item.title">{{ item.title }}</div> + </template> + + <template #cell(issue)="{ item }"> + <gl-link v-if="item.issueIid" data-testid="issueField" :href="getIssueLink(item)"> + #{{ item.issueIid }} + </gl-link> + <div v-else data-testid="issueField">{{ s__('AlertManagement|None') }}</div> </template> <template #cell(assignees)="{ item }"> @@ -393,22 +422,12 @@ export default { </template> <template #cell(status)="{ item }"> - <gl-dropdown :text="$options.statuses[item.status]" class="w-100" right> - <gl-dropdown-item - v-for="(label, field) in $options.statuses" - :key="field" - @click="updateAlertStatus(label, item.iid)" - > - <span class="d-flex"> - <gl-icon - class="flex-shrink-0 append-right-4" - :class="{ invisible: label.toUpperCase() !== item.status }" - name="mobile-issue-close" - /> - {{ label }} - </span> - </gl-dropdown-item> - </gl-dropdown> + <alert-status + :alert="item" + :project-path="projectPath" + :is-sidebar="false" + @alert-error="handleAlertError" + /> </template> <template #empty> @@ -426,36 +445,9 @@ export default { :prev-page="prevPage" :next-page="nextPage" align="center" - class="gl-pagination prepend-top-default" + class="gl-pagination gl-mt-3" @input="handlePageChange" /> </div> - <gl-empty-state - v-else - :title="s__('AlertManagement|Surface alerts in GitLab')" - :svg-path="emptyAlertSvgPath" - > - <template #description> - <div class="d-block"> - <span>{{ - s__( - 'AlertManagement|Display alerts from all your monitoring tools directly within GitLab. Streamline the investigation of your alerts and the escalation of alerts to incidents.', - ) - }}</span> - <a href="/help/user/project/operations/alert_management.html" target="_blank"> - {{ s__('AlertManagement|More information') }} - </a> - </div> - <div v-if="userCanEnableAlertManagement" class="d-block center pt-4"> - <gl-deprecated-button - category="primary" - variant="success" - :href="enableAlertManagementPath" - > - {{ s__('AlertManagement|Authorize external service') }} - </gl-deprecated-button> - </div> - </template> - </gl-empty-state> </div> </template> diff --git a/app/assets/javascripts/alert_management/components/alert_metrics.vue b/app/assets/javascripts/alert_management/components/alert_metrics.vue new file mode 100644 index 00000000000..c5b40edc672 --- /dev/null +++ b/app/assets/javascripts/alert_management/components/alert_metrics.vue @@ -0,0 +1,56 @@ +<script> +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as Sentry from '@sentry/browser'; + +Vue.use(Vuex); + +export default { + props: { + dashboardUrl: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + metricEmbedComponent: null, + namespace: 'alertMetrics', + }; + }, + mounted() { + if (this.dashboardUrl) { + Promise.all([ + import('~/monitoring/components/embeds/metric_embed.vue'), + import('~/monitoring/stores'), + ]) + .then(([{ default: MetricEmbed }, { monitoringDashboard }]) => { + this.$store = new Vuex.Store({ + modules: { + [this.namespace]: monitoringDashboard, + }, + }); + this.metricEmbedComponent = MetricEmbed; + }) + .catch(e => Sentry.captureException(e)); + } + }, +}; +</script> + +<template> + <div class="gl-py-3"> + <div v-if="dashboardUrl" ref="metricsChart"> + <component + :is="metricEmbedComponent" + v-if="metricEmbedComponent" + :dashboard-url="dashboardUrl" + :namespace="namespace" + /> + </div> + <div v-else ref="emptyState"> + {{ s__("AlertManagement|Metrics weren't available in the alerts payload.") }} + </div> + </div> +</template> diff --git a/app/assets/javascripts/alert_management/components/alert_sidebar.vue b/app/assets/javascripts/alert_management/components/alert_sidebar.vue index dcd22e2062e..64e4089c85a 100644 --- a/app/assets/javascripts/alert_management/components/alert_sidebar.vue +++ b/app/assets/javascripts/alert_management/components/alert_sidebar.vue @@ -4,6 +4,8 @@ import SidebarTodo from './sidebar/sidebar_todo.vue'; import SidebarStatus from './sidebar/sidebar_status.vue'; import SidebarAssignees from './sidebar/sidebar_assignees.vue'; +import sidebarStatusQuery from '../graphql/queries/sidebar_status.query.graphql'; + export default { components: { SidebarAssignees, @@ -11,23 +13,34 @@ export default { SidebarTodo, SidebarStatus, }, - props: { - sidebarCollapsed: { - type: Boolean, - required: true, - }, + inject: { projectPath: { + default: '', + }, + projectId: { type: String, - required: true, + default: '', }, + }, + props: { alert: { type: Object, required: true, }, }, + apollo: { + sidebarStatus: { + query: sidebarStatusQuery, + }, + }, + data() { + return { + sidebarStatus: false, + }; + }, computed: { sidebarCollapsedClass() { - return this.sidebarCollapsed ? 'right-sidebar-collapsed' : 'right-sidebar-expanded'; + return this.sidebarStatus ? 'right-sidebar-collapsed' : 'right-sidebar-expanded'; }, }, }; @@ -37,23 +50,32 @@ export default { <aside :class="sidebarCollapsedClass" class="right-sidebar alert-sidebar"> <div class="issuable-sidebar js-issuable-update"> <sidebar-header - :sidebar-collapsed="sidebarCollapsed" + :sidebar-collapsed="sidebarStatus" + :project-path="projectPath" + :alert="alert" @toggle-sidebar="$emit('toggle-sidebar')" + @alert-error="$emit('alert-error', $event)" + /> + <sidebar-todo + v-if="sidebarStatus" + :project-path="projectPath" + :alert="alert" + :sidebar-collapsed="sidebarStatus" + @alert-error="$emit('alert-error', $event)" /> - <sidebar-todo v-if="sidebarCollapsed" :sidebar-collapsed="sidebarCollapsed" /> <sidebar-status :project-path="projectPath" :alert="alert" @toggle-sidebar="$emit('toggle-sidebar')" - @alert-sidebar-error="$emit('alert-sidebar-error', $event)" + @alert-error="$emit('alert-error', $event)" /> <sidebar-assignees :project-path="projectPath" + :project-id="projectId" :alert="alert" - :sidebar-collapsed="sidebarCollapsed" - @alert-refresh="$emit('alert-refresh')" + :sidebar-collapsed="sidebarStatus" @toggle-sidebar="$emit('toggle-sidebar')" - @alert-sidebar-error="$emit('alert-sidebar-error', $event)" + @alert-error="$emit('alert-error', $event)" /> <div class="block"></div> </div> diff --git a/app/assets/javascripts/alert_management/components/alert_status.vue b/app/assets/javascripts/alert_management/components/alert_status.vue new file mode 100644 index 00000000000..9b726fe2944 --- /dev/null +++ b/app/assets/javascripts/alert_management/components/alert_status.vue @@ -0,0 +1,129 @@ +<script> +import { GlDropdown, GlDropdownItem, GlButton } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import Tracking from '~/tracking'; +import { trackAlertStatusUpdateOptions } from '../constants'; +import updateAlertStatus from '../graphql/mutations/update_alert_status.mutation.graphql'; + +export default { + i18n: { + UPDATE_ALERT_STATUS_ERROR: s__( + 'AlertManagement|There was an error while updating the status of the alert.', + ), + UPDATE_ALERT_STATUS_INSTRUCTION: s__('AlertManagement|Please try again.'), + }, + statuses: { + TRIGGERED: s__('AlertManagement|Triggered'), + ACKNOWLEDGED: s__('AlertManagement|Acknowledged'), + RESOLVED: s__('AlertManagement|Resolved'), + }, + components: { + GlDropdown, + GlDropdownItem, + GlButton, + }, + props: { + projectPath: { + type: String, + required: true, + }, + alert: { + type: Object, + required: true, + }, + isDropdownShowing: { + type: Boolean, + required: false, + }, + isSidebar: { + type: Boolean, + required: true, + }, + }, + computed: { + dropdownClass() { + // eslint-disable-next-line no-nested-ternary + return this.isSidebar ? (this.isDropdownShowing ? 'show' : 'gl-display-none') : ''; + }, + }, + methods: { + updateAlertStatus(status) { + this.$emit('handle-updating', true); + this.$apollo + .mutate({ + mutation: updateAlertStatus, + variables: { + iid: this.alert.iid, + status: status.toUpperCase(), + projectPath: this.projectPath, + }, + }) + .then(resp => { + this.trackStatusUpdate(status); + this.$emit('hide-dropdown'); + + const errors = resp.data?.updateAlertStatus?.errors || []; + + if (errors[0]) { + this.$emit( + 'alert-error', + `${this.$options.i18n.UPDATE_ALERT_STATUS_ERROR} ${errors[0]}`, + ); + } + }) + .catch(() => { + this.$emit( + 'alert-error', + `${this.$options.i18n.UPDATE_ALERT_STATUS_ERROR} ${this.$options.i18n.UPDATE_ALERT_STATUS_INSTRUCTION}`, + ); + }) + .finally(() => { + this.$emit('handle-updating', false); + }); + }, + trackStatusUpdate(status) { + const { category, action, label } = trackAlertStatusUpdateOptions; + Tracking.event(category, action, { label, property: status }); + }, + }, +}; +</script> + +<template> + <div class="dropdown dropdown-menu-selectable" :class="dropdownClass"> + <gl-dropdown + ref="dropdown" + right + :text="$options.statuses[alert.status]" + class="w-100" + toggle-class="dropdown-menu-toggle" + variant="outline-default" + @keydown.esc.native="$emit('hide-dropdown')" + @hide="$emit('hide-dropdown')" + > + <div v-if="isSidebar" class="dropdown-title text-center"> + <span class="alert-title">{{ s__('AlertManagement|Assign status') }}</span> + <gl-button + :aria-label="__('Close')" + variant="link" + class="dropdown-title-button dropdown-menu-close" + icon="close" + @click="$emit('hide-dropdown')" + /> + </div> + <div class="dropdown-content dropdown-body"> + <gl-dropdown-item + v-for="(label, field) in $options.statuses" + :key="field" + data-testid="statusDropdownItem" + class="gl-vertical-align-middle" + :active="label.toUpperCase() === alert.status" + :active-class="'is-active'" + @click="updateAlertStatus(label)" + > + {{ label }} + </gl-dropdown-item> + </div> + </gl-dropdown> + </div> +</template> diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue index 453a3901665..cb32a5ffd4f 100644 --- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue +++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue @@ -11,20 +11,26 @@ import { GlSprintf, } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; -import { s__ } from '~/locale'; -import alertSetAssignees from '../../graphql/mutations/alert_set_assignees.graphql'; +import { s__, __ } from '~/locale'; +import alertSetAssignees from '../../graphql/mutations/alert_set_assignees.mutation.graphql'; import SidebarAssignee from './sidebar_assignee.vue'; import { debounce } from 'lodash'; const DATA_REFETCH_DELAY = 250; export default { - FETCH_USERS_ERROR: s__( - 'AlertManagement|There was an error while updating the assignee(s) list. Please try again.', - ), - UPDATE_ALERT_ASSIGNEES_ERROR: s__( - 'AlertManagement|There was an error while updating the assignee(s) of the alert. Please try again.', - ), + i18n: { + FETCH_USERS_ERROR: s__( + 'AlertManagement|There was an error while updating the assignee(s) list. Please try again.', + ), + UPDATE_ALERT_ASSIGNEES_ERROR: s__( + 'AlertManagement|There was an error while updating the assignee(s) of the alert. Please try again.', + ), + UPDATE_ALERT_ASSIGNEES_GRAPHQL_ERROR: s__( + 'AlertManagement|This assignee cannot be assigned to this alert.', + ), + ASSIGNEES_BLOCK: s__('AlertManagement|Alert assignee(s): %{assignees}'), + }, components: { GlIcon, GlDropdown, @@ -38,6 +44,10 @@ export default { SidebarAssignee, }, props: { + projectId: { + type: String, + required: true, + }, projectPath: { type: String, required: true, @@ -73,7 +83,7 @@ export default { return this.alert?.assignees?.nodes[0]?.username; }, assignedUser() { - return this.userName || s__('AlertManagement|None'); + return this.userName || __('None'); }, sortedUsers() { return this.users @@ -122,20 +132,20 @@ export default { updateAssigneesDropdown() { this.isDropdownSearching = true; return axios - .get(this.buildUrl(gon.relative_url_root, '/autocomplete/users.json'), { + .get(this.buildUrl(gon.relative_url_root, '/-/autocomplete/users.json'), { params: { search: this.search, per_page: 20, active: true, current_user: true, - project_id: gon?.current_project_id, + project_id: this.projectId, }, }) .then(({ data }) => { this.users = data; }) .catch(() => { - this.$emit('alert-sidebar-error', this.$options.FETCH_USERS_ERROR); + this.$emit('alert-error', this.$options.i18n.FETCH_USERS_ERROR); }) .finally(() => { this.isDropdownSearching = false; @@ -152,12 +162,18 @@ export default { projectPath: this.projectPath, }, }) - .then(() => { + .then(({ data: { alertSetAssignees: { errors } = [] } = {} } = {}) => { this.hideDropdown(); - this.$emit('alert-refresh'); + + if (errors[0]) { + this.$emit( + 'alert-error', + `${this.$options.i18n.UPDATE_ALERT_ASSIGNEES_GRAPHQL_ERROR} ${errors[0]}.`, + ); + } }) .catch(() => { - this.$emit('alert-sidebar-error', this.$options.UPDATE_ALERT_ASSIGNEES_ERROR); + this.$emit('alert-error', this.$options.i18n.UPDATE_ALERT_ASSIGNEES_ERROR); }) .finally(() => { this.isUpdating = false; @@ -174,7 +190,7 @@ export default { <gl-loading-icon v-if="isUpdating" /> </div> <gl-tooltip :target="() => $refs.status" boundary="viewport" placement="left"> - <gl-sprintf :message="s__('AlertManagement|Alert assignee(s): %{assignees}')"> + <gl-sprintf :message="$options.i18n.ASSIGNEES_BLOCK"> <template #assignees> {{ assignedUser }} </template> @@ -183,7 +199,7 @@ export default { <div class="hide-collapsed"> <p class="title gl-display-flex gl-justify-content-space-between"> - {{ s__('AlertManagement|Assignee') }} + {{ __('Assignee') }} <a v-if="isEditable" ref="editButton" @@ -192,7 +208,7 @@ export default { @click="toggleFormDropdown" @keydown.esc="hideDropdown" > - {{ s__('AlertManagement|Edit') }} + {{ __('Edit') }} </a> </p> @@ -207,7 +223,7 @@ export default { @hide="hideDropdown" > <div class="dropdown-title"> - <span class="alert-title">{{ s__('AlertManagement|Assign To') }}</span> + <span class="alert-title">{{ __('Assign To') }}</span> <gl-button :aria-label="__('Close')" variant="link" @@ -232,12 +248,12 @@ export default { active-class="is-active" @click="updateAlertAssignees('')" > - {{ s__('AlertManagement|Unassigned') }} + {{ __('Unassigned') }} </gl-dropdown-item> <gl-dropdown-divider /> <gl-dropdown-header class="mt-0"> - {{ s__('AlertManagement|Assignee') }} + {{ __('Assignee') }} </gl-dropdown-header> <sidebar-assignee v-for="user in sortedUsers" @@ -248,7 +264,7 @@ export default { /> </template> <gl-dropdown-item v-else-if="userListEmpty"> - {{ s__('AlertManagement|No Matching Results') }} + {{ __('No Matching Results') }} </gl-dropdown-item> <gl-loading-icon v-else /> </div> @@ -261,7 +277,7 @@ export default { assignedUser }}</span> <span v-else class="gl-display-flex gl-align-items-center"> - {{ s__('AlertManagement|None -') }} + {{ __('None') }} - <gl-button class="gl-pl-2" href="#" @@ -269,7 +285,7 @@ export default { data-testid="unassigned-users" @click="updateAlertAssignees(currentUser)" > - {{ s__('AlertManagement| assign yourself') }} + {{ __('assign yourself') }} </gl-button> </span> </p> diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_header.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_header.vue index 047793d8cee..fd40b5d9f65 100644 --- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_header.vue +++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_header.vue @@ -8,6 +8,14 @@ export default { SidebarTodo, }, props: { + alert: { + type: Object, + required: true, + }, + projectPath: { + type: String, + required: true, + }, sidebarCollapsed: { type: Boolean, required: true, @@ -17,18 +25,17 @@ export default { </script> <template> - <div class="block d-flex justify-content-between"> + <div class="block gl-display-flex gl-justify-content-space-between"> <span class="issuable-header-text hide-collapsed"> - {{ __('Quick actions') }} + {{ __('To Do') }} </span> - <toggle-sidebar - :collapsed="sidebarCollapsed" - css-classes="ml-auto" - @toggle="$emit('toggle-sidebar')" + <sidebar-todo + v-if="!sidebarCollapsed" + :project-path="projectPath" + :alert="alert" + :sidebar-collapsed="sidebarCollapsed" + @alert-error="$emit('alert-error', $event)" /> - <!-- TODO: Implement after or as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/215946 --> - <template v-if="false"> - <sidebar-todo v-if="!sidebarCollapsed" :sidebar-collapsed="sidebarCollapsed" /> - </template> + <toggle-sidebar :collapsed="sidebarCollapsed" @toggle="$emit('toggle-sidebar')" /> </div> </template> diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_status.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_status.vue index 89dbbedd9c1..44a81aba828 100644 --- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_status.vue +++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_status.vue @@ -1,17 +1,7 @@ <script> -import { - GlIcon, - GlDropdown, - GlDropdownItem, - GlLoadingIcon, - GlTooltip, - GlButton, - GlSprintf, -} from '@gitlab/ui'; +import { GlIcon, GlLoadingIcon, GlTooltip, GlSprintf } from '@gitlab/ui'; import { s__ } from '~/locale'; -import Tracking from '~/tracking'; -import { trackAlertStatusUpdateOptions } from '../../constants'; -import updateAlertStatus from '../../graphql/mutations/update_alert_status.graphql'; +import AlertStatus from '../alert_status.vue'; export default { statuses: { @@ -21,12 +11,10 @@ export default { }, components: { GlIcon, - GlDropdown, - GlDropdownItem, GlLoadingIcon, GlTooltip, - GlButton, GlSprintf, + AlertStatus, }, props: { projectPath: { @@ -60,44 +48,13 @@ export default { }, toggleFormDropdown() { this.isDropdownShowing = !this.isDropdownShowing; - const { dropdown } = this.$refs.dropdown.$refs; + const { dropdown } = this.$children[2].$refs.dropdown.$refs; if (dropdown && this.isDropdownShowing) { dropdown.show(); } }, - isSelected(status) { - return this.alert.status === status; - }, - updateAlertStatus(status) { - this.isUpdating = true; - this.$apollo - .mutate({ - mutation: updateAlertStatus, - variables: { - iid: this.alert.iid, - status: status.toUpperCase(), - projectPath: this.projectPath, - }, - }) - .then(() => { - this.trackStatusUpdate(status); - this.hideDropdown(); - }) - .catch(() => { - this.$emit( - 'alert-sidebar-error', - s__( - 'AlertManagement|There was an error while updating the status of the alert. Please try again.', - ), - ); - }) - .finally(() => { - this.isUpdating = false; - }); - }, - trackStatusUpdate(status) { - const { category, action, label } = trackAlertStatusUpdateOptions; - Tracking.event(category, action, { label, property: status }); + handleUpdating(updating) { + this.isUpdating = updating; }, }, }; @@ -132,41 +89,15 @@ export default { </a> </p> - <div class="dropdown dropdown-menu-selectable" :class="dropdownClass"> - <gl-dropdown - ref="dropdown" - :text="$options.statuses[alert.status]" - class="w-100" - toggle-class="dropdown-menu-toggle" - variant="outline-default" - @keydown.esc.native="hideDropdown" - @hide="hideDropdown" - > - <div class="dropdown-title"> - <span class="alert-title">{{ s__('AlertManagement|Assign status') }}</span> - <gl-button - :aria-label="__('Close')" - variant="link" - class="dropdown-title-button dropdown-menu-close" - icon="close" - @click="hideDropdown" - /> - </div> - <div class="dropdown-content dropdown-body"> - <gl-dropdown-item - v-for="(label, field) in $options.statuses" - :key="field" - data-testid="statusDropdownItem" - class="gl-vertical-align-middle" - :active="label.toUpperCase() === alert.status" - :active-class="'is-active'" - @click="updateAlertStatus(label)" - > - {{ label }} - </gl-dropdown-item> - </div> - </gl-dropdown> - </div> + <alert-status + :alert="alert" + :project-path="projectPath" + :is-dropdown-showing="isDropdownShowing" + :is-sidebar="true" + @alert-error="$emit('alert-error', $event)" + @hide-dropdown="hideDropdown" + @handle-updating="handleUpdating" + /> <gl-loading-icon v-if="isUpdating" :inline="true" /> <p diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_todo.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_todo.vue index 87090165f82..7d3135ad50d 100644 --- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_todo.vue +++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_todo.vue @@ -1,29 +1,123 @@ <script> +import { s__ } from '~/locale'; import Todo from '~/sidebar/components/todo_toggle/todo.vue'; +import axios from '~/lib/utils/axios_utils'; +import createAlertTodo from '../../graphql/mutations/alert_todo_create.graphql'; export default { + i18n: { + UPDATE_ALERT_TODO_ERROR: s__( + 'AlertManagement|There was an error while updating the To Do of the alert.', + ), + }, components: { Todo, }, props: { + alert: { + type: Object, + required: true, + }, + projectPath: { + type: String, + required: true, + }, sidebarCollapsed: { type: Boolean, required: true, }, }, + data() { + return { + isUpdating: false, + isTodo: false, + todo: '', + }; + }, + computed: { + alertID() { + return parseInt(this.alert.iid, 10); + }, + }, + methods: { + updateToDoCount(add) { + const oldCount = parseInt(document.querySelector('.todos-count').innerText, 10); + const count = add ? oldCount + 1 : oldCount - 1; + const headerTodoEvent = new CustomEvent('todo:toggle', { + detail: { + count, + }, + }); + + return document.dispatchEvent(headerTodoEvent); + }, + toggleTodo() { + if (this.todo) { + return this.markAsDone(); + } + + this.isUpdating = true; + return this.$apollo + .mutate({ + mutation: createAlertTodo, + variables: { + iid: this.alert.iid, + projectPath: this.projectPath, + }, + }) + .then(({ data: { alertTodoCreate: { todo = {}, errors = [] } } = {} } = {}) => { + if (errors[0]) { + return this.$emit( + 'alert-error', + `${this.$options.i18n.UPDATE_ALERT_TODO_ERROR} ${errors[0]}.`, + ); + } + + this.todo = todo.id; + return this.updateToDoCount(true); + }) + .catch(() => { + this.$emit( + 'alert-error', + `${this.$options.i18n.UPDATE_ALERT_TODO_ERROR} ${s__( + 'AlertManagement|Please try again.', + )}`, + ); + }) + .finally(() => { + this.isUpdating = false; + }); + }, + markAsDone() { + this.isUpdating = true; + + return axios + .delete(`/dashboard/todos/${this.todo.split('/').pop()}`) + .then(() => { + this.todo = ''; + return this.updateToDoCount(false); + }) + .catch(() => { + this.$emit('alert-error', this.$options.i18n.UPDATE_ALERT_TODO_ERROR); + }) + .finally(() => { + this.isUpdating = false; + }); + }, + }, }; </script> -<!-- TODO: Implement after or as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/215946 --> <template> - <div v-if="false" :class="{ 'block todo': sidebarCollapsed }"> + <div :class="{ 'block todo': sidebarCollapsed, 'gl-ml-auto': !sidebarCollapsed }"> <todo + data-testid="alert-todo-button" :collapsed="sidebarCollapsed" - :issuable-id="1" - :is-todo="false" - :is-action-active="false" + :issuable-id="alertID" + :is-todo="todo !== ''" + :is-action-active="isUpdating" issuable-type="alert" - @toggleTodo="() => {}" + @toggleTodo="toggleTodo" /> </div> </template> diff --git a/app/assets/javascripts/alert_management/components/system_notes/system_note.vue b/app/assets/javascripts/alert_management/components/system_notes/system_note.vue index 9042d51aecf..39717ab609f 100644 --- a/app/assets/javascripts/alert_management/components/system_notes/system_note.vue +++ b/app/assets/javascripts/alert_management/components/system_notes/system_note.vue @@ -24,7 +24,7 @@ export default { return { ...author, id: id?.split('/').pop() }; }, iconHtml() { - return spriteIcon('user'); + return spriteIcon(this.note?.systemNoteIconName); }, }, }; diff --git a/app/assets/javascripts/alert_management/details.js b/app/assets/javascripts/alert_management/details.js index aa8a839ea3f..2820bcb9665 100644 --- a/app/assets/javascripts/alert_management/details.js +++ b/app/assets/javascripts/alert_management/details.js @@ -3,45 +3,59 @@ import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; import AlertDetails from './components/alert_details.vue'; +import sidebarStatusQuery from './graphql/queries/sidebar_status.query.graphql'; Vue.use(VueApollo); export default selector => { const domEl = document.querySelector(selector); - const { alertId, projectPath, projectIssuesPath } = domEl.dataset; + const { alertId, projectPath, projectIssuesPath, projectId } = domEl.dataset; + + const resolvers = { + Mutation: { + toggleSidebarStatus: (_, __, { cache }) => { + const data = cache.readQuery({ query: sidebarStatusQuery }); + data.sidebarStatus = !data.sidebarStatus; + cache.writeQuery({ query: sidebarStatusQuery, data }); + }, + }, + }; const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient( - {}, - { - cacheConfig: { - dataIdFromObject: object => { - // eslint-disable-next-line no-underscore-dangle - if (object.__typename === 'AlertManagementAlert') { - return object.iid; - } - return defaultDataIdFromObject(object); - }, + defaultClient: createDefaultClient(resolvers, { + cacheConfig: { + dataIdFromObject: object => { + // eslint-disable-next-line no-underscore-dangle + if (object.__typename === 'AlertManagementAlert') { + return object.iid; + } + return defaultDataIdFromObject(object); }, }, - ), + }), + }); + + apolloProvider.clients.defaultClient.cache.writeData({ + data: { + sidebarStatus: false, + }, }); // eslint-disable-next-line no-new new Vue({ el: selector, + provide: { + projectPath, + alertId, + projectIssuesPath, + projectId, + }, apolloProvider, components: { AlertDetails, }, render(createElement) { - return createElement('alert-details', { - props: { - alertId, - projectPath, - projectIssuesPath, - }, - }); + return createElement('alert-details', {}); }, }); }; diff --git a/app/assets/javascripts/alert_management/graphql/fragments/alert_note.fragment.graphql b/app/assets/javascripts/alert_management/graphql/fragments/alert_note.fragment.graphql index c72300e9757..74b425717a0 100644 --- a/app/assets/javascripts/alert_management/graphql/fragments/alert_note.fragment.graphql +++ b/app/assets/javascripts/alert_management/graphql/fragments/alert_note.fragment.graphql @@ -1,16 +1,17 @@ #import "~/graphql_shared/fragments/author.fragment.graphql" fragment AlertNote on Note { + id + author { id - author { - id - state - ...Author - } - body - bodyHtml - createdAt - discussion { - id - } + state + ...Author + } + body + bodyHtml + createdAt + discussion { + id + } + systemNoteIconName } diff --git a/app/assets/javascripts/alert_management/graphql/fragments/detail_item.fragment.graphql b/app/assets/javascripts/alert_management/graphql/fragments/detail_item.fragment.graphql index cbe7e169be3..18fab429164 100644 --- a/app/assets/javascripts/alert_management/graphql/fragments/detail_item.fragment.graphql +++ b/app/assets/javascripts/alert_management/graphql/fragments/detail_item.fragment.graphql @@ -5,9 +5,11 @@ fragment AlertDetailItem on AlertManagementAlert { ...AlertListItem createdAt monitoringTool + metricsDashboardUrl service description updatedAt + endedAt details notes { nodes { diff --git a/app/assets/javascripts/alert_management/graphql/fragments/list_item.fragment.graphql b/app/assets/javascripts/alert_management/graphql/fragments/list_item.fragment.graphql index 746c4435f38..c37f29c74fc 100644 --- a/app/assets/javascripts/alert_management/graphql/fragments/list_item.fragment.graphql +++ b/app/assets/javascripts/alert_management/graphql/fragments/list_item.fragment.graphql @@ -4,7 +4,6 @@ fragment AlertListItem on AlertManagementAlert { severity status startedAt - endedAt eventCount issueIid assignees { diff --git a/app/assets/javascripts/alert_management/graphql/mutations/alert_set_assignees.graphql b/app/assets/javascripts/alert_management/graphql/mutations/alert_set_assignees.mutation.graphql index efeaf8fa372..40b4b6ae854 100644 --- a/app/assets/javascripts/alert_management/graphql/mutations/alert_set_assignees.graphql +++ b/app/assets/javascripts/alert_management/graphql/mutations/alert_set_assignees.mutation.graphql @@ -1,4 +1,6 @@ -mutation($projectPath: ID!, $assigneeUsernames: [String!]!, $iid: String!) { +#import "../fragments/alert_note.fragment.graphql" + +mutation alertSetAssignees($projectPath: ID!, $assigneeUsernames: [String!]!, $iid: String!) { alertSetAssignees( input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $projectPath } ) { @@ -10,6 +12,11 @@ mutation($projectPath: ID!, $assigneeUsernames: [String!]!, $iid: String!) { username } } + notes { + nodes { + ...AlertNote + } + } } } } diff --git a/app/assets/javascripts/alert_management/graphql/mutations/alert_todo_create.graphql b/app/assets/javascripts/alert_management/graphql/mutations/alert_todo_create.graphql new file mode 100644 index 00000000000..cdf3d763302 --- /dev/null +++ b/app/assets/javascripts/alert_management/graphql/mutations/alert_todo_create.graphql @@ -0,0 +1,11 @@ +mutation($projectPath: ID!, $iid: String!) { + alertTodoCreate(input: { iid: $iid, projectPath: $projectPath }) { + errors + alert { + iid + } + todo { + id + } + } +} diff --git a/app/assets/javascripts/alert_management/graphql/mutations/create_issue_from_alert.graphql b/app/assets/javascripts/alert_management/graphql/mutations/create_issue_from_alert.graphql deleted file mode 100644 index 664596ab88f..00000000000 --- a/app/assets/javascripts/alert_management/graphql/mutations/create_issue_from_alert.graphql +++ /dev/null @@ -1,8 +0,0 @@ -mutation ($projectPath: ID!, $iid: String!) { - createAlertIssue(input: { iid: $iid, projectPath: $projectPath }) { - errors - issue { - iid - } - } -} diff --git a/app/assets/javascripts/alert_management/graphql/mutations/create_issue_from_alert.mutation.graphql b/app/assets/javascripts/alert_management/graphql/mutations/create_issue_from_alert.mutation.graphql new file mode 100644 index 00000000000..bc4d91a51d1 --- /dev/null +++ b/app/assets/javascripts/alert_management/graphql/mutations/create_issue_from_alert.mutation.graphql @@ -0,0 +1,8 @@ +mutation createAlertIssue($projectPath: ID!, $iid: String!) { + createAlertIssue(input: { iid: $iid, projectPath: $projectPath }) { + errors + issue { + iid + } + } +} diff --git a/app/assets/javascripts/alert_management/graphql/mutations/toggle_sidebar_status.mutation.graphql b/app/assets/javascripts/alert_management/graphql/mutations/toggle_sidebar_status.mutation.graphql new file mode 100644 index 00000000000..f666fcd6782 --- /dev/null +++ b/app/assets/javascripts/alert_management/graphql/mutations/toggle_sidebar_status.mutation.graphql @@ -0,0 +1,3 @@ +mutation toggleSidebarStatus { + toggleSidebarStatus @client +} diff --git a/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.graphql b/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.graphql deleted file mode 100644 index 09151f233f5..00000000000 --- a/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.graphql +++ /dev/null @@ -1,10 +0,0 @@ -mutation ($projectPath: ID!, $status: AlertManagementStatus!, $iid: String!) { - updateAlertStatus(input: { iid: $iid, status: $status, projectPath: $projectPath }) { - errors - alert { - iid, - status, - endedAt - } - } -} diff --git a/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.mutation.graphql b/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.mutation.graphql new file mode 100644 index 00000000000..ba1e607bc10 --- /dev/null +++ b/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.mutation.graphql @@ -0,0 +1,17 @@ +#import "../fragments/alert_note.fragment.graphql" + +mutation updateAlertStatus($projectPath: ID!, $status: AlertManagementStatus!, $iid: String!) { + updateAlertStatus(input: { iid: $iid, status: $status, projectPath: $projectPath }) { + errors + alert { + iid + status + endedAt + notes { + nodes { + ...AlertNote + } + } + } + } +} diff --git a/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql index c02b8accdd1..8881f49b689 100644 --- a/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql +++ b/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql @@ -1,11 +1,11 @@ #import "../fragments/detail_item.fragment.graphql" query alertDetails($fullPath: ID!, $alertId: String) { - project(fullPath: $fullPath) { - alertManagementAlerts(iid: $alertId) { - nodes { - ...AlertDetailItem - } - } + project(fullPath: $fullPath) { + alertManagementAlerts(iid: $alertId) { + nodes { + ...AlertDetailItem + } } + } } diff --git a/app/assets/javascripts/alert_management/graphql/queries/get_alerts.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/get_alerts.query.graphql index 1d3c3c83cc1..8ac00bbc6b5 100644 --- a/app/assets/javascripts/alert_management/graphql/queries/get_alerts.query.graphql +++ b/app/assets/javascripts/alert_management/graphql/queries/get_alerts.query.graphql @@ -1,32 +1,34 @@ #import "../fragments/list_item.fragment.graphql" query getAlerts( - $projectPath: ID!, - $statuses: [AlertManagementStatus!], - $sort: AlertManagementAlertSort, - $firstPageSize: Int, - $lastPageSize: Int, - $prevPageCursor: String = "" - $nextPageCursor: String = "" + $searchTerm: String + $projectPath: ID! + $statuses: [AlertManagementStatus!] + $sort: AlertManagementAlertSort + $firstPageSize: Int + $lastPageSize: Int + $prevPageCursor: String = "" + $nextPageCursor: String = "" ) { - project(fullPath: $projectPath, ) { - alertManagementAlerts( - statuses: $statuses, - sort: $sort, - first: $firstPageSize - last: $lastPageSize, - after: $nextPageCursor, - before: $prevPageCursor - ) { - nodes { - ...AlertListItem - }, - pageInfo { - hasNextPage - endCursor - hasPreviousPage - startCursor - } - } + project(fullPath: $projectPath) { + alertManagementAlerts( + search: $searchTerm + statuses: $statuses + sort: $sort + first: $firstPageSize + last: $lastPageSize + after: $nextPageCursor + before: $prevPageCursor + ) { + nodes { + ...AlertListItem + } + pageInfo { + hasNextPage + endCursor + hasPreviousPage + startCursor + } } + } } diff --git a/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql index 1143050200c..5a6faea5cd8 100644 --- a/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql +++ b/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql @@ -1,11 +1,11 @@ -query getAlertsCount($projectPath: ID!) { - project(fullPath: $projectPath) { - alertManagementAlertStatusCounts { - all - open - acknowledged - resolved - triggered - } +query getAlertsCount($searchTerm: String, $projectPath: ID!) { + project(fullPath: $projectPath) { + alertManagementAlertStatusCounts(search: $searchTerm) { + all + open + acknowledged + resolved + triggered } + } } diff --git a/app/assets/javascripts/alert_management/graphql/queries/sidebar_status.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/sidebar_status.query.graphql new file mode 100644 index 00000000000..61c570c5cd0 --- /dev/null +++ b/app/assets/javascripts/alert_management/graphql/queries/sidebar_status.query.graphql @@ -0,0 +1,3 @@ +query sidebarStatus { + sidebarStatus @client +} diff --git a/app/assets/javascripts/alert_management/list.js b/app/assets/javascripts/alert_management/list.js index cae6a536b56..3f78ca66a59 100644 --- a/app/assets/javascripts/alert_management/list.js +++ b/app/assets/javascripts/alert_management/list.js @@ -3,7 +3,7 @@ import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; import { parseBoolean } from '~/lib/utils/common_utils'; -import AlertManagementList from './components/alert_management_list.vue'; +import AlertManagementList from './components/alert_management_list_wrapper.vue'; Vue.use(VueApollo); @@ -11,11 +11,18 @@ export default () => { const selector = '#js-alert_management'; const domEl = document.querySelector(selector); - const { projectPath, enableAlertManagementPath, emptyAlertSvgPath } = domEl.dataset; - let { alertManagementEnabled, userCanEnableAlertManagement } = domEl.dataset; + const { + projectPath, + enableAlertManagementPath, + emptyAlertSvgPath, + populatingAlertsHelpUrl, + opsgenieMvcTargetUrl, + } = domEl.dataset; + let { alertManagementEnabled, userCanEnableAlertManagement, opsgenieMvcEnabled } = domEl.dataset; alertManagementEnabled = parseBoolean(alertManagementEnabled); userCanEnableAlertManagement = parseBoolean(userCanEnableAlertManagement); + opsgenieMvcEnabled = parseBoolean(opsgenieMvcEnabled); const apolloProvider = new VueApollo({ defaultClient: createDefaultClient( @@ -45,9 +52,12 @@ export default () => { props: { projectPath, enableAlertManagementPath, + populatingAlertsHelpUrl, emptyAlertSvgPath, alertManagementEnabled, userCanEnableAlertManagement, + opsgenieMvcTargetUrl, + opsgenieMvcEnabled, }, }); }, diff --git a/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue b/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue index ac30b086875..a2d94fb8083 100644 --- a/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue +++ b/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue @@ -64,6 +64,11 @@ export default { type: Boolean, required: true, }, + isDisabled: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -142,7 +147,7 @@ export default { <gl-form-group :label="__('Active')" label-for="activated" label-class="label-bold"> <toggle-button id="activated" - :disabled-input="loadingActivated" + :disabled-input="loadingActivated || isDisabled" :is-loading="loadingActivated" :value="activated" @change="toggleActivated" @@ -152,7 +157,11 @@ export default { <div class="input-group"> <gl-form-input id="url" :readonly="true" :value="url" /> <span class="input-group-append"> - <clipboard-button :text="url" :title="$options.COPY_TO_CLIPBOARD" /> + <clipboard-button + :text="url" + :title="$options.COPY_TO_CLIPBOARD" + :disabled="isDisabled" + /> </span> </div> </gl-form-group> @@ -164,10 +173,16 @@ export default { <div class="input-group"> <gl-form-input id="authorization-key" :readonly="true" :value="authorizationKey" /> <span class="input-group-append"> - <clipboard-button :text="authorizationKey" :title="$options.COPY_TO_CLIPBOARD" /> + <clipboard-button + :text="authorizationKey" + :title="$options.COPY_TO_CLIPBOARD" + :disabled="isDisabled" + /> </span> </div> - <gl-button v-gl-modal.authKeyModal class="mt-2">{{ $options.RESET_KEY }}</gl-button> + <gl-button v-gl-modal.authKeyModal class="mt-2" :disabled="isDisabled">{{ + $options.RESET_KEY + }}</gl-button> <gl-modal modal-id="authKeyModal" :title="$options.RESET_KEY" diff --git a/app/assets/javascripts/alerts_service_settings/index.js b/app/assets/javascripts/alerts_service_settings/index.js index c26adf24a7f..fe83ced2ee7 100644 --- a/app/assets/javascripts/alerts_service_settings/index.js +++ b/app/assets/javascripts/alerts_service_settings/index.js @@ -14,8 +14,11 @@ export default el => { formPath, authorizationKey, url, + disabled, } = el.dataset; + const activated = parseBoolean(activatedStr); + const isDisabled = parseBoolean(disabled); return new Vue({ el, @@ -28,6 +31,7 @@ export default el => { formPath, initialAuthorizationKey: authorizationKey, url, + isDisabled, }, }); }, diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue new file mode 100644 index 00000000000..18c9f82f052 --- /dev/null +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue @@ -0,0 +1,563 @@ +<script> +import { + GlAlert, + GlButton, + GlForm, + GlFormGroup, + GlFormInput, + GlFormInputGroup, + GlFormTextarea, + GlLink, + GlModal, + GlModalDirective, + GlSprintf, + GlFormSelect, +} from '@gitlab/ui'; +import { debounce } from 'lodash'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import ToggleButton from '~/vue_shared/components/toggle_button.vue'; +import csrf from '~/lib/utils/csrf'; +import service from '../services'; +import { + i18n, + serviceOptions, + JSON_VALIDATE_DELAY, + targetPrometheusUrlPlaceholder, + targetOpsgenieUrlPlaceholder, +} from '../constants'; + +export default { + i18n, + csrf, + targetOpsgenieUrlPlaceholder, + targetPrometheusUrlPlaceholder, + components: { + GlAlert, + GlButton, + GlForm, + GlFormGroup, + GlFormInput, + GlFormInputGroup, + GlFormSelect, + GlFormTextarea, + GlLink, + GlModal, + GlSprintf, + ClipboardButton, + ToggleButton, + }, + directives: { + 'gl-modal': GlModalDirective, + }, + mixins: [glFeatureFlagsMixin()], + props: { + prometheus: { + type: Object, + required: true, + validator: ({ activated }) => { + return activated !== undefined; + }, + }, + generic: { + type: Object, + required: true, + validator: ({ formPath }) => { + return formPath !== undefined; + }, + }, + opsgenie: { + type: Object, + required: true, + }, + }, + data() { + return { + activated: { + generic: this.generic.activated, + prometheus: this.prometheus.activated, + opsgenie: this.opsgenie?.activated, + }, + loading: false, + authorizationKey: { + generic: this.generic.initialAuthorizationKey, + prometheus: this.prometheus.prometheusAuthorizationKey, + }, + selectedEndpoint: serviceOptions[0].value, + options: serviceOptions, + targetUrl: null, + feedback: { + variant: 'danger', + feedbackMessage: null, + isFeedbackDismissed: false, + }, + serverError: null, + testAlert: { + json: null, + error: null, + }, + canSaveForm: false, + }; + }, + computed: { + sections() { + return [ + { + text: this.$options.i18n.usageSection, + url: this.generic.alertsUsageUrl, + }, + { + text: this.$options.i18n.setupSection, + url: this.generic.alertsSetupUrl, + }, + ]; + }, + isPrometheus() { + return this.selectedEndpoint === 'prometheus'; + }, + isOpsgenie() { + return this.selectedEndpoint === 'opsgenie'; + }, + selectedService() { + switch (this.selectedEndpoint) { + case 'generic': { + return { + url: this.generic.url, + authKey: this.authorizationKey.generic, + active: this.activated.generic, + resetKey: this.resetGenericKey.bind(this), + }; + } + case 'prometheus': { + return { + url: this.prometheus.prometheusUrl, + authKey: this.authorizationKey.prometheus, + active: this.activated.prometheus, + resetKey: this.resetPrometheusKey.bind(this), + targetUrl: this.prometheus.prometheusApiUrl, + }; + } + case 'opsgenie': { + return { + targetUrl: this.opsgenie.opsgenieMvcTargetUrl, + active: this.activated.opsgenie, + }; + } + default: { + return {}; + } + } + }, + showFeedbackMsg() { + return this.feedback.feedbackMessage && !this.isFeedbackDismissed; + }, + showAlertSave() { + return ( + this.feedback.feedbackMessage === this.$options.i18n.testAlertFailed && + !this.isFeedbackDismissed + ); + }, + prometheusInfo() { + return this.isPrometheus ? this.$options.i18n.prometheusInfo : ''; + }, + jsonIsValid() { + return this.testAlert.error === null; + }, + canTestAlert() { + return this.selectedService.active && this.testAlert.json !== null; + }, + canSaveConfig() { + return !this.loading && this.canSaveForm; + }, + baseUrlPlaceholder() { + return this.isOpsgenie + ? this.$options.targetOpsgenieUrlPlaceholder + : this.$options.targetPrometheusUrlPlaceholder; + }, + }, + watch: { + 'testAlert.json': debounce(function debouncedJsonValidate() { + this.validateJson(); + }, JSON_VALIDATE_DELAY), + targetUrl(oldVal, newVal) { + if (newVal && oldVal !== this.selectedService.targetUrl) { + this.canSaveForm = true; + } + }, + }, + mounted() { + if ( + this.activated.prometheus || + this.activated.generic || + !this.opsgenie.opsgenieMvcIsAvailable + ) { + this.removeOpsGenieOption(); + } else if (this.activated.opsgenie) { + this.setOpsgenieAsDefault(); + } + }, + methods: { + createUserErrorMessage(errors) { + // eslint-disable-next-line prefer-destructuring + this.serverError = Object.values(errors)[0][0]; + }, + setOpsgenieAsDefault() { + this.options = this.options.map(el => { + if (el.value !== 'opsgenie') { + return { ...el, disabled: true }; + } + return { ...el, disabled: false }; + }); + this.selectedEndpoint = this.options.find(({ value }) => value === 'opsgenie').value; + if (this.targetUrl === null) { + this.targetUrl = this.selectedService.targetUrl; + } + }, + removeOpsGenieOption() { + this.options = this.options.map(el => { + if (el.value !== 'opsgenie') { + return { ...el, disabled: false }; + } + return { ...el, disabled: true }; + }); + }, + resetFormValues() { + this.testAlert.json = null; + this.targetUrl = this.selectedService.targetUrl; + }, + dismissFeedback() { + this.serverError = null; + this.feedback = { ...this.feedback, feedbackMessage: null }; + this.isFeedbackDismissed = false; + }, + resetGenericKey() { + return service + .updateGenericKey({ endpoint: this.generic.formPath, params: { service: { token: '' } } }) + .then(({ data: { token } }) => { + this.authorizationKey.generic = token; + this.setFeedback({ feedbackMessage: this.$options.i18n.authKeyRest, variant: 'success' }); + }) + .catch(() => { + this.setFeedback({ feedbackMessage: this.$options.i18n.errorKeyMsg, variant: 'danger' }); + }); + }, + resetPrometheusKey() { + return service + .updatePrometheusKey({ endpoint: this.prometheus.prometheusResetKeyPath }) + .then(({ data: { token } }) => { + this.authorizationKey.prometheus = token; + this.setFeedback({ feedbackMessage: this.$options.i18n.authKeyRest, variant: 'success' }); + }) + .catch(() => { + this.setFeedback({ feedbackMessage: this.$options.i18n.errorKeyMsg, variant: 'danger' }); + }); + }, + toggleService(value) { + this.canSaveForm = true; + if (this.isPrometheus) { + this.activated.prometheus = value; + } else { + this.activated[this.selectedEndpoint] = value; + } + }, + toggle(value) { + return this.isPrometheus ? this.togglePrometheusActive(value) : this.toggleActivated(value); + }, + toggleActivated(value) { + this.loading = true; + return service + .updateGenericActive({ + endpoint: this[this.selectedEndpoint].formPath, + params: this.isOpsgenie + ? { service: { opsgenie_mvc_target_url: this.targetUrl, opsgenie_mvc_enabled: value } } + : { service: { active: value } }, + }) + .then(() => { + this.activated[this.selectedEndpoint] = value; + this.toggleSuccess(value); + + if (!this.isOpsgenie && value) { + if (!this.selectedService.authKey) { + return window.location.reload(); + } + + return this.removeOpsGenieOption(); + } + + if (this.isOpsgenie && value) { + return this.setOpsgenieAsDefault(); + } + + // eslint-disable-next-line no-return-assign + return (this.options = serviceOptions); + }) + .catch(({ response: { data: { errors } = {} } = {} }) => { + this.createUserErrorMessage(errors); + this.setFeedback({ + feedbackMessage: `${this.$options.i18n.errorMsg}.`, + variant: 'danger', + }); + }) + .finally(() => { + this.loading = false; + this.canSaveForm = false; + }); + }, + togglePrometheusActive(value) { + this.loading = true; + return service + .updatePrometheusActive({ + endpoint: this.prometheus.prometheusFormPath, + params: { + token: this.$options.csrf.token, + config: value, + url: this.targetUrl, + redirect: window.location, + }, + }) + .then(() => { + this.activated.prometheus = value; + this.toggleSuccess(value); + this.removeOpsGenieOption(); + }) + .catch(({ response: { data: { errors } = {} } = {} }) => { + this.createUserErrorMessage(errors); + this.setFeedback({ + feedbackMessage: `${this.$options.i18n.errorMsg}.`, + variant: 'danger', + }); + }) + .finally(() => { + this.loading = false; + this.canSaveForm = false; + }); + }, + toggleSuccess(value) { + if (value) { + this.setFeedback({ + feedbackMessage: this.$options.i18n.endPointActivated, + variant: 'info', + }); + } else { + this.setFeedback({ + feedbackMessage: this.$options.i18n.changesSaved, + variant: 'info', + }); + } + }, + setFeedback({ feedbackMessage, variant }) { + this.feedback = { feedbackMessage, variant }; + }, + validateJson() { + this.testAlert.error = null; + try { + JSON.parse(this.testAlert.json); + } catch (e) { + this.testAlert.error = JSON.stringify(e.message); + } + }, + validateTestAlert() { + this.loading = true; + this.validateJson(); + return service + .updateTestAlert({ + endpoint: this.selectedService.url, + data: this.testAlert.json, + authKey: this.selectedService.authKey, + }) + .then(() => { + this.setFeedback({ + feedbackMessage: this.$options.i18n.testAlertSuccess, + variant: 'success', + }); + }) + .catch(() => { + this.setFeedback({ + feedbackMessage: this.$options.i18n.testAlertFailed, + variant: 'danger', + }); + }) + .finally(() => { + this.loading = false; + }); + }, + onSubmit() { + this.toggle(this.selectedService.active); + }, + onReset() { + this.testAlert.json = null; + this.dismissFeedback(); + this.targetUrl = this.selectedService.targetUrl; + + if (this.canSaveForm) { + this.canSaveForm = false; + this.activated[this.selectedEndpoint] = this[this.selectedEndpoint].activated; + } + }, + }, +}; +</script> + +<template> + <div> + <gl-alert v-if="showFeedbackMsg" :variant="feedback.variant" @dismiss="dismissFeedback"> + {{ feedback.feedbackMessage }} + <br /> + <i v-if="serverError">{{ __('Error message:') }} {{ serverError }}</i> + <gl-button + v-if="showAlertSave" + variant="danger" + category="primary" + class="gl-display-block gl-mt-3" + @click="toggle(selectedService.active)" + > + {{ __('Save anyway') }} + </gl-button> + </gl-alert> + <div data-testid="alert-settings-description" class="gl-mt-5"> + <p v-for="section in sections" :key="section.text"> + <gl-sprintf :message="section.text"> + <template #link="{ content }"> + <gl-link :href="section.url" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + </div> + <gl-form @submit.prevent="onSubmit" @reset.prevent="onReset"> + <gl-form-group + :label="$options.i18n.integrationsLabel" + label-for="integrations" + label-class="label-bold" + > + <gl-form-select + v-model="selectedEndpoint" + :options="options" + data-testid="alert-settings-select" + @change="resetFormValues" + /> + <span class="gl-text-gray-400"> + <gl-sprintf :message="$options.i18n.integrationsInfo"> + <template #link="{ content }"> + <gl-link + class="gl-display-inline-block" + href="https://gitlab.com/groups/gitlab-org/-/epics/3362" + target="_blank" + >{{ content }}</gl-link + > + </template> + </gl-sprintf> + </span> + </gl-form-group> + <gl-form-group + :label="$options.i18n.activeLabel" + label-for="activated" + label-class="label-bold" + > + <toggle-button + id="activated" + :disabled-input="loading" + :is-loading="loading" + :value="selectedService.active" + @change="toggleService" + /> + </gl-form-group> + <gl-form-group + v-if="isOpsgenie || isPrometheus" + :label="$options.i18n.apiBaseUrlLabel" + label-for="api-url" + label-class="label-bold" + > + <gl-form-input + id="api-url" + v-model="targetUrl" + type="url" + :placeholder="baseUrlPlaceholder" + :disabled="!selectedService.active" + /> + <span class="gl-text-gray-400"> + {{ $options.i18n.apiBaseUrlHelpText }} + </span> + </gl-form-group> + <template v-if="!isOpsgenie"> + <gl-form-group :label="$options.i18n.urlLabel" label-for="url" label-class="label-bold"> + <gl-form-input-group id="url" readonly :value="selectedService.url"> + <template #append> + <clipboard-button + :text="selectedService.url" + :title="$options.i18n.copyToClipboard" + class="gl-m-0!" + /> + </template> + </gl-form-input-group> + <span class="gl-text-gray-400"> + {{ prometheusInfo }} + </span> + </gl-form-group> + <gl-form-group + :label="$options.i18n.authKeyLabel" + label-for="authorization-key" + label-class="label-bold" + > + <gl-form-input-group + id="authorization-key" + class="gl-mb-2" + readonly + :value="selectedService.authKey" + > + <template #append> + <clipboard-button + :text="selectedService.authKey || ''" + :title="$options.i18n.copyToClipboard" + class="gl-m-0!" + /> + </template> + </gl-form-input-group> + <gl-button v-gl-modal.authKeyModal :disabled="!selectedService.active" class="gl-mt-3">{{ + $options.i18n.resetKey + }}</gl-button> + <gl-modal + modal-id="authKeyModal" + :title="$options.i18n.resetKey" + :ok-title="$options.i18n.resetKey" + ok-variant="danger" + @ok="selectedService.resetKey" + > + {{ $options.i18n.restKeyInfo }} + </gl-modal> + </gl-form-group> + <gl-form-group + :label="$options.i18n.alertJson" + label-for="alert-json" + label-class="label-bold" + :invalid-feedback="testAlert.error" + > + <gl-form-textarea + id="alert-json" + v-model.trim="testAlert.json" + :disabled="!selectedService.active" + :state="jsonIsValid" + :placeholder="$options.i18n.alertJsonPlaceholder" + rows="6" + max-rows="10" + /> + </gl-form-group> + <gl-button :disabled="!canTestAlert" @click="validateTestAlert">{{ + $options.i18n.testAlertInfo + }}</gl-button> + </template> + <div class="footer-block row-content-block gl-display-flex gl-justify-content-space-between"> + <gl-button + variant="success" + category="primary" + :disabled="!canSaveConfig" + @click="onSubmit" + > + {{ __('Save changes') }} + </gl-button> + <gl-button variant="default" category="primary" :disabled="!canSaveConfig" @click="onReset"> + {{ __('Cancel') }} + </gl-button> + </div> + </gl-form> + </div> +</template> diff --git a/app/assets/javascripts/alerts_settings/constants.js b/app/assets/javascripts/alerts_settings/constants.js new file mode 100644 index 00000000000..d15e8619df4 --- /dev/null +++ b/app/assets/javascripts/alerts_settings/constants.js @@ -0,0 +1,50 @@ +import { s__ } from '~/locale'; + +export const i18n = { + usageSection: s__( + 'AlertSettings|You must provide this URL and authorization key to authorize an external service to send alerts to GitLab. You can provide this URL and key to multiple services. After configuring an external service, alerts from your service will display on the GitLab %{linkStart}Alerts%{linkEnd} page.', + ), + setupSection: s__( + "AlertSettings|Review your external service's documentation to learn where to provide this information to your external service, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.", + ), + errorMsg: s__('AlertSettings|There was an error updating the alert settings'), + errorKeyMsg: s__( + 'AlertSettings|There was an error while trying to reset the key. Please refresh the page to try again.', + ), + restKeyInfo: s__( + 'AlertSettings|Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.', + ), + endPointActivated: s__('AlertSettings|Alerts endpoint successfully activated.'), + changesSaved: s__('AlertSettings|Your changes were successfully updated.'), + prometheusInfo: s__('AlertSettings|Add URL and auth key to your Prometheus config file'), + integrationsInfo: s__( + 'AlertSettings|Learn more about our %{linkStart}upcoming integrations%{linkEnd}', + ), + resetKey: s__('AlertSettings|Reset key'), + copyToClipboard: s__('AlertSettings|Copy'), + integrationsLabel: s__('AlertSettings|Integrations'), + apiBaseUrlLabel: s__('AlertSettings|API URL'), + authKeyLabel: s__('AlertSettings|Authorization key'), + urlLabel: s__('AlertSettings|Webhook URL'), + activeLabel: s__('AlertSettings|Active'), + apiBaseUrlHelpText: s__('AlertSettings|URL cannot be blank and must start with http or https'), + testAlertInfo: s__('AlertSettings|Test alert payload'), + alertJson: s__('AlertSettings|Alert test payload'), + alertJsonPlaceholder: s__('AlertSettings|Enter test alert JSON....'), + testAlertFailed: s__('AlertSettings|Test failed. Do you still want to save your changes anyway?'), + testAlertSuccess: s__( + 'AlertSettings|Test alert sent successfully. If you have made other changes, please save them now.', + ), + authKeyRest: s__('AlertSettings|Authorization key has been successfully reset'), +}; + +export const serviceOptions = [ + { value: 'generic', text: s__('AlertSettings|Generic') }, + { value: 'prometheus', text: s__('AlertSettings|External Prometheus') }, + { value: 'opsgenie', text: s__('AlertSettings|Opsgenie') }, +]; + +export const JSON_VALIDATE_DELAY = 250; + +export const targetPrometheusUrlPlaceholder = 'http://prometheus.example.com/'; +export const targetOpsgenieUrlPlaceholder = 'https://app.opsgenie.com/alert/list/'; diff --git a/app/assets/javascripts/alerts_settings/index.js b/app/assets/javascripts/alerts_settings/index.js new file mode 100644 index 00000000000..a4c2bf6b18e --- /dev/null +++ b/app/assets/javascripts/alerts_settings/index.js @@ -0,0 +1,67 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import AlertSettingsForm from './components/alerts_settings_form.vue'; + +export default el => { + if (!el) { + return null; + } + + const { + prometheusActivated, + prometheusUrl, + prometheusAuthorizationKey, + prometheusFormPath, + prometheusResetKeyPath, + prometheusApiUrl, + activated: activatedStr, + alertsSetupUrl, + alertsUsageUrl, + formPath, + authorizationKey, + url, + opsgenieMvcAvailable, + opsgenieMvcFormPath, + opsgenieMvcEnabled, + opsgenieMvcTargetUrl, + } = el.dataset; + + const genericActivated = parseBoolean(activatedStr); + const prometheusIsActivated = parseBoolean(prometheusActivated); + const opsgenieMvcActivated = parseBoolean(opsgenieMvcEnabled); + const opsgenieMvcIsAvailable = parseBoolean(opsgenieMvcAvailable); + + const props = { + prometheus: { + activated: prometheusIsActivated, + prometheusUrl, + prometheusAuthorizationKey, + prometheusFormPath, + prometheusResetKeyPath, + prometheusApiUrl, + }, + generic: { + alertsSetupUrl, + alertsUsageUrl, + activated: genericActivated, + formPath, + initialAuthorizationKey: authorizationKey, + url, + }, + opsgenie: { + formPath: opsgenieMvcFormPath, + activated: opsgenieMvcActivated, + opsgenieMvcTargetUrl, + opsgenieMvcIsAvailable, + }, + }; + + return new Vue({ + el, + render(createElement) { + return createElement(AlertSettingsForm, { + props, + }); + }, + }); +}; diff --git a/app/assets/javascripts/alerts_settings/services/index.js b/app/assets/javascripts/alerts_settings/services/index.js new file mode 100644 index 00000000000..c49992d4f57 --- /dev/null +++ b/app/assets/javascripts/alerts_settings/services/index.js @@ -0,0 +1,36 @@ +/* eslint-disable @gitlab/require-i18n-strings */ +import axios from '~/lib/utils/axios_utils'; + +export default { + updateGenericKey({ endpoint, params }) { + return axios.put(endpoint, params); + }, + updatePrometheusKey({ endpoint }) { + return axios.post(endpoint); + }, + updateGenericActive({ endpoint, params }) { + return axios.put(endpoint, params); + }, + updatePrometheusActive({ endpoint, params: { token, config, url, redirect } }) { + const data = new FormData(); + data.set('_method', 'put'); + data.set('authenticity_token', token); + data.set('service[manual_configuration]', config); + data.set('service[api_url]', url); + data.set('redirect_to', redirect); + + return axios.post(endpoint, data, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + }, + updateTestAlert({ endpoint, data, authKey }) { + return axios.post(endpoint, data, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authKey}`, + }, + }); + }, +}; diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 94d155840ea..c84e73ccdb4 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -11,6 +11,9 @@ const Api = { groupMembersPath: '/api/:version/groups/:id/members', subgroupsPath: '/api/:version/groups/:id/subgroups', namespacesPath: '/api/:version/namespaces.json', + groupPackagesPath: '/api/:version/groups/:id/packages', + projectPackagesPath: '/api/:version/projects/:id/packages', + projectPackagePath: '/api/:version/projects/:id/packages/:package_id', groupProjectsPath: '/api/:version/groups/:id/projects.json', projectsPath: '/api/:version/projects.json', projectPath: '/api/:version/projects/:id', @@ -36,7 +39,9 @@ const Api = { userStatusPath: '/api/:version/users/:id/status', userProjectsPath: '/api/:version/users/:id/projects', userPostStatusPath: '/api/:version/user/status', - commitPath: '/api/:version/projects/:id/repository/commits', + commitPath: '/api/:version/projects/:id/repository/commits/:sha', + commitsPath: '/api/:version/projects/:id/repository/commits', + applySuggestionPath: '/api/:version/suggestions/:id/apply', applySuggestionBatchPath: '/api/:version/suggestions/batch_apply', commitPipelinesPath: '/:project_id/commit/:sha/pipelines', @@ -64,6 +69,32 @@ const Api = { }); }, + groupPackages(id, options = {}) { + const url = Api.buildUrl(this.groupPackagesPath).replace(':id', id); + return axios.get(url, options); + }, + + projectPackages(id, options = {}) { + const url = Api.buildUrl(this.projectPackagesPath).replace(':id', id); + return axios.get(url, options); + }, + + buildProjectPackageUrl(projectId, packageId) { + return Api.buildUrl(this.projectPackagePath) + .replace(':id', projectId) + .replace(':package_id', packageId); + }, + + projectPackage(projectId, packageId) { + const url = this.buildProjectPackageUrl(projectId, packageId); + return axios.get(url); + }, + + deleteProjectPackage(projectId, packageId) { + const url = this.buildProjectPackageUrl(projectId, packageId); + return axios.delete(url); + }, + groupMembers(id, options) { const url = Api.buildUrl(this.groupMembersPath).replace(':id', encodeURIComponent(id)); @@ -308,9 +339,17 @@ const Api = { .catch(() => flash(__('Something went wrong while fetching projects'))); }, + commit(id, sha, params = {}) { + const url = Api.buildUrl(this.commitPath) + .replace(':id', encodeURIComponent(id)) + .replace(':sha', encodeURIComponent(sha)); + + return axios.get(url, { params }); + }, + commitMultiple(id, data) { // see https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions - const url = Api.buildUrl(Api.commitPath).replace(':id', encodeURIComponent(id)); + const url = Api.buildUrl(Api.commitsPath).replace(':id', encodeURIComponent(id)); return axios.post(url, JSON.stringify(data), { headers: { 'Content-Type': 'application/json; charset=utf-8', diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 8381b050900..0e83ba3d528 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -9,14 +9,10 @@ import { updateTooltipTitle } from './lib/utils/common_utils'; import { isInVueNoteablePage } from './lib/utils/dom_utils'; import flash from './flash'; import axios from './lib/utils/axios_utils'; +import * as Emoji from '~/emoji'; const animationEndEventString = 'animationend webkitAnimationEnd MSAnimationEnd oAnimationEnd'; const transitionEndEventString = 'transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd'; -const requestAnimationFrame = - window.requestAnimationFrame || - window.webkitRequestAnimationFrame || - window.mozRequestAnimationFrame || - window.setTimeout; const FROM_SENTENCE_REGEX = /(?:, and | and |, )/; // For separating lists produced by ruby's Array#toSentence @@ -619,7 +615,7 @@ export class AwardsHandler { let awardsHandlerPromise = null; export default function loadAwardsHandler(reload = false) { if (!awardsHandlerPromise || reload) { - awardsHandlerPromise = import(/* webpackChunkName: 'emoji' */ './emoji').then(Emoji => { + awardsHandlerPromise = Emoji.initEmojiMap().then(() => { const awardsHandler = new AwardsHandler(Emoji); awardsHandler.bindEvents(); return awardsHandler; diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue index dccc0b024ba..4145a4a4145 100644 --- a/app/assets/javascripts/badges/components/badge_form.vue +++ b/app/assets/javascripts/badges/components/badge_form.vue @@ -164,7 +164,7 @@ export default { <template> <form :class="{ 'was-validated': wasValidated }" - class="prepend-top-default append-bottom-default needs-validation" + class="gl-mt-3 gl-mb-3 needs-validation" novalidate @submit.prevent.stop="onSubmit" > diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue index 963d104b6b3..4c100ec7335 100644 --- a/app/assets/javascripts/batch_comments/components/draft_note.vue +++ b/app/assets/javascripts/batch_comments/components/draft_note.vue @@ -51,6 +51,7 @@ export default { 'scrollToDraft', 'toggleResolveDiscussion', ]), + ...mapActions(['setSelectedCommentPositionHover']), update(data) { this.updateDraft(data); }, @@ -67,12 +68,16 @@ export default { }; </script> <template> - <article class="draft-note-component note-wrapper"> + <article + class="draft-note-component note-wrapper" + @mouseenter="setSelectedCommentPositionHover(draft.position.line_range)" + @mouseleave="setSelectedCommentPositionHover()" + > <ul class="notes draft-notes"> <noteable-note :note="draft" - :diff-lines="diffFile.highlighted_diff_lines" :line="line" + :discussion-root="true" class="draft-note" @handleEdit="handleEditing" @cancelForm="handleNotEditing" @@ -81,7 +86,7 @@ export default { @handleUpdateNote="update" @toggleResolveStatus="toggleResolveDiscussion(draft.id)" > - <strong slot="note-header-info" class="badge draft-pending-label append-right-4"> + <strong slot="note-header-info" class="badge draft-pending-label gl-mr-2"> {{ __('Pending') }} </strong> </noteable-note> diff --git a/app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue b/app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue index 68fd20e56bc..b0916623cd2 100644 --- a/app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue +++ b/app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue @@ -35,11 +35,15 @@ export default { <tr :class="className" class="notes_holder"> <td class="notes_line old"></td> <td class="notes-content parallel old" colspan="2"> - <div v-if="leftDraft.isDraft" class="content"><draft-note :draft="leftDraft" /></div> + <div v-if="leftDraft.isDraft" class="content"> + <draft-note :draft="leftDraft" :line="line.left" /> + </div> </td> <td class="notes_line new"></td> <td class="notes-content parallel new" colspan="2"> - <div v-if="rightDraft.isDraft" class="content"><draft-note :draft="rightDraft" /></div> + <div v-if="rightDraft.isDraft" class="content"> + <draft-note :draft="rightDraft" :line="line.right" /> + </div> </td> </tr> </template> diff --git a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue index 195e1b7ec5c..7520cc2401b 100644 --- a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue +++ b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue @@ -96,7 +96,7 @@ export default { <preview-item :draft="draft" :is-last="isLast(index)" /> </li> </ul> - <gl-loading-icon v-else size="lg" class="prepend-top-default append-bottom-default" /> + <gl-loading-icon v-else size="lg" class="gl-mt-3 gl-mb-3" /> </div> <div class="dropdown-footer"> <publish-button diff --git a/app/assets/javascripts/batch_comments/components/preview_item.vue b/app/assets/javascripts/batch_comments/components/preview_item.vue index 22495eb4d7d..3162a83f099 100644 --- a/app/assets/javascripts/batch_comments/components/preview_item.vue +++ b/app/assets/javascripts/batch_comments/components/preview_item.vue @@ -52,14 +52,12 @@ export default { }); }, linePosition() { - if (this.draft.position && this.draft.position.position_type === IMAGE_DIFF_POSITION_TYPE) { + if (this.position?.position_type === IMAGE_DIFF_POSITION_TYPE) { // eslint-disable-next-line @gitlab/require-i18n-strings - return `${this.draft.position.x}x ${this.draft.position.y}y`; + return `${this.position.x}x ${this.position.y}y`; } - const position = this.discussion ? this.discussion.position : this.draft.position; - - return position?.new_line || position?.old_line; + return this.position?.new_line || this.position?.old_line; }, content() { const el = document.createElement('div'); @@ -70,11 +68,14 @@ export default { showLinePosition() { return this.draft.file_hash || this.isDiffDiscussion; }, + position() { + return this.draft.position || this.discussion.position; + }, startLineNumber() { - return getStartLineNumber(this.draft.position?.line_range); + return getStartLineNumber(this.position?.line_range); }, endLineNumber() { - return getEndLineNumber(this.draft.position?.line_range); + return getEndLineNumber(this.position?.line_range); }, }, methods: { diff --git a/app/assets/javascripts/behaviors/collapse_sidebar_on_window_resize.js b/app/assets/javascripts/behaviors/collapse_sidebar_on_window_resize.js new file mode 100644 index 00000000000..d9164f6204a --- /dev/null +++ b/app/assets/javascripts/behaviors/collapse_sidebar_on_window_resize.js @@ -0,0 +1,41 @@ +import $ from 'jquery'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; + +/** + * This behavior collapses the right sidebar + * if the window size changes + * + * @sentrify + */ +export default () => { + const $sidebarGutterToggle = $('.js-sidebar-toggle'); + let bootstrapBreakpoint = bp.getBreakpointSize(); + + $(window).on('resize.app', () => { + const oldBootstrapBreakpoint = bootstrapBreakpoint; + bootstrapBreakpoint = bp.getBreakpointSize(); + + if (bootstrapBreakpoint !== oldBootstrapBreakpoint) { + const breakpointSizes = ['md', 'sm', 'xs']; + + if (breakpointSizes.includes(bootstrapBreakpoint)) { + const $gutterIcon = $sidebarGutterToggle.find('i'); + if ($gutterIcon.hasClass('fa-angle-double-right')) { + $sidebarGutterToggle.trigger('click'); + } + + const sidebarGutterVueToggleEl = document.querySelector('.js-sidebar-vue-toggle'); + + // Sidebar has an icon which corresponds to collapsing the sidebar + // only then trigger the click. + if (sidebarGutterVueToggleEl) { + const collapseIcon = sidebarGutterVueToggleEl.querySelector('i.fa-angle-double-right'); + + if (collapseIcon) { + collapseIcon.click(); + } + } + } + } + }); +}; diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js index d1d75658181..bcf732e9522 100644 --- a/app/assets/javascripts/behaviors/gl_emoji.js +++ b/app/assets/javascripts/behaviors/gl_emoji.js @@ -1,47 +1,69 @@ import 'document-register-element'; import isEmojiUnicodeSupported from '../emoji/support'; +import { initEmojiMap, getEmojiInfo, emojiFallbackImageSrc, emojiImageTag } from '../emoji'; class GlEmoji extends HTMLElement { constructor() { super(); - const emojiUnicode = this.textContent.trim(); - const { name, unicodeVersion, fallbackSrc, fallbackSpriteClass } = this.dataset; - - const isEmojiUnicode = - this.childNodes && - Array.prototype.every.call(this.childNodes, childNode => childNode.nodeType === 3); - const hasImageFallback = fallbackSrc && fallbackSrc.length > 0; - const hasCssSpriteFalback = fallbackSpriteClass && fallbackSpriteClass.length > 0; - - if (emojiUnicode && isEmojiUnicode && !isEmojiUnicodeSupported(emojiUnicode, unicodeVersion)) { - // CSS sprite fallback takes precedence over image fallback - if (hasCssSpriteFalback) { - if (!gon.emoji_sprites_css_added && gon.emoji_sprites_css_path) { - const emojiSpriteLinkTag = document.createElement('link'); - emojiSpriteLinkTag.setAttribute('rel', 'stylesheet'); - emojiSpriteLinkTag.setAttribute('href', gon.emoji_sprites_css_path); - document.head.appendChild(emojiSpriteLinkTag); - gon.emoji_sprites_css_added = true; + this.initialize(); + } + initialize() { + let emojiUnicode = this.textContent.trim(); + const { fallbackSpriteClass, fallbackSrc } = this.dataset; + let { name, unicodeVersion } = this.dataset; + + return initEmojiMap().then(() => { + if (!unicodeVersion) { + const emojiInfo = getEmojiInfo(name); + + if (emojiInfo) { + if (name !== emojiInfo.name) { + ({ name } = emojiInfo); + this.dataset.name = emojiInfo.name; + } + unicodeVersion = emojiInfo.u; + this.dataset.unicodeVersion = unicodeVersion; + + emojiUnicode = emojiInfo.e; + this.innerHTML = emojiInfo.e; + + this.title = emojiInfo.d; + } + } + + const isEmojiUnicode = + this.childNodes && + Array.prototype.every.call(this.childNodes, childNode => childNode.nodeType === 3); + + if ( + emojiUnicode && + isEmojiUnicode && + !isEmojiUnicodeSupported(emojiUnicode, unicodeVersion) + ) { + const hasImageFallback = fallbackSrc && fallbackSrc.length > 0; + const hasCssSpriteFallback = fallbackSpriteClass && fallbackSpriteClass.length > 0; + + // CSS sprite fallback takes precedence over image fallback + if (hasCssSpriteFallback) { + if (!gon.emoji_sprites_css_added && gon.emoji_sprites_css_path) { + const emojiSpriteLinkTag = document.createElement('link'); + emojiSpriteLinkTag.setAttribute('rel', 'stylesheet'); + emojiSpriteLinkTag.setAttribute('href', gon.emoji_sprites_css_path); + document.head.appendChild(emojiSpriteLinkTag); + gon.emoji_sprites_css_added = true; + } + // IE 11 doesn't like adding multiple at once :( + this.classList.add('emoji-icon'); + this.classList.add(fallbackSpriteClass); + } else if (hasImageFallback) { + this.innerHTML = emojiImageTag(name, fallbackSrc); + } else { + const src = emojiFallbackImageSrc(name); + this.innerHTML = emojiImageTag(name, src); } - // IE 11 doesn't like adding multiple at once :( - this.classList.add('emoji-icon'); - this.classList.add(fallbackSpriteClass); - } else { - import(/* webpackChunkName: 'emoji' */ '../emoji') - .then(({ emojiImageTag, emojiFallbackImageSrc }) => { - if (hasImageFallback) { - this.innerHTML = emojiImageTag(name, fallbackSrc); - } else { - const src = emojiFallbackImageSrc(name); - this.innerHTML = emojiImageTag(name, src); - } - }) - .catch(() => { - // do nothing - }); } - } + }); } } diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js index 8c4eccc34a3..8060938c72a 100644 --- a/app/assets/javascripts/behaviors/index.js +++ b/app/assets/javascripts/behaviors/index.js @@ -11,9 +11,13 @@ import './requires_input'; import initPageShortcuts from './shortcuts'; import './toggler_behavior'; import './preview_markdown'; +import initCollapseSidebarOnWindowResize from './collapse_sidebar_on_window_resize'; +import initSelect2Dropdowns from './select2'; installGlEmojiElement(); initGFMInput(); initCopyAsGFM(); initCopyToClipboard(); initPageShortcuts(); +initCollapseSidebarOnWindowResize(); +initSelect2Dropdowns(); diff --git a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js index 03c1b5a0169..bbcfa50ba35 100644 --- a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import { getSelectedFragment } from '~/lib/utils/common_utils'; +import { getSelectedFragment, insertText } from '~/lib/utils/common_utils'; export class CopyAsGFM { constructor() { @@ -79,7 +79,7 @@ export class CopyAsGFM { } static insertPastedText(target, text, gfm) { - window.gl.utils.insertText(target, textBefore => { + insertText(target, textBefore => { // If the text before the cursor contains an odd number of backticks, // we are either inside an inline code span that starts with 1 backtick // or a code block that starts with 3 backticks. diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js index e4c69a114e0..94033e914ef 100644 --- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js +++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js @@ -174,7 +174,7 @@ export default function renderMermaid($els) { if (!$els.length) return; const visibleMermaids = $els.filter(function filter() { - return $(this).closest('details').length === 0; + return $(this).closest('details').length === 0 && $(this).is(':visible'); }); renderMermaids(visibleMermaids); diff --git a/app/assets/javascripts/behaviors/select2.js b/app/assets/javascripts/behaviors/select2.js new file mode 100644 index 00000000000..37b75bb5e56 --- /dev/null +++ b/app/assets/javascripts/behaviors/select2.js @@ -0,0 +1,23 @@ +import $ from 'jquery'; + +export default () => { + if ($('select.select2').length) { + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + $('select.select2').select2({ + width: 'resolve', + minimumResultsForSearch: 10, + dropdownAutoWidth: true, + }); + + // Close select2 on escape + $('.js-select2').on('select2-close', () => { + setTimeout(() => { + $('.select2-container-active').removeClass('select2-container-active'); + $(':focus').blur(); + }, 1); + }); + }) + .catch(() => {}); + } +}; diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue b/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue index a53b1b06be9..8418c0f66ac 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue @@ -1,11 +1,10 @@ <script> -import { GlToggle, GlSprintf } from '@gitlab/ui'; +import { GlToggle } from '@gitlab/ui'; import AccessorUtilities from '~/lib/utils/accessor'; import { disableShortcuts, enableShortcuts, shouldDisableShortcuts } from './shortcuts_toggle'; export default { components: { - GlSprintf, GlToggle, }, data() { @@ -32,29 +31,10 @@ export default { <gl-toggle v-model="shortcutsEnabled" aria-describedby="shortcutsToggle" - class="prepend-left-10 mb-0" - label-position="right" + label="Keyboard shortcuts" + label-position="left" @change="onChange" - > - <template #labelOn> - <gl-sprintf - :message="__('%{screenreaderOnlyStart}Keyboard shorcuts%{screenreaderOnlyEnd} Enabled')" - > - <template #screenreaderOnly="{ content }"> - <span class="sr-only">{{ content }}</span> - </template> - </gl-sprintf> - </template> - <template #labelOff> - <gl-sprintf - :message="__('%{screenreaderOnlyStart}Keyboard shorcuts%{screenreaderOnlyEnd} Disabled')" - > - <template #screenreaderOnly="{ content }"> - <span class="sr-only">{{ content }}</span> - </template> - </gl-sprintf> - </template> - </gl-toggle> + /> <div id="shortcutsToggle" class="sr-only">{{ __('Enable or disable keyboard shortcuts') }}</div> </div> </template> diff --git a/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue b/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue index ed03213d7cf..5b15fe2d7cc 100644 --- a/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue +++ b/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue @@ -1,5 +1,5 @@ <script> -import { GlDeprecatedButton, GlButtonGroup, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlButton, GlButtonGroup, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { RICH_BLOB_VIEWER, RICH_BLOB_VIEWER_TITLE, @@ -11,7 +11,7 @@ export default { components: { GlIcon, GlButtonGroup, - GlDeprecatedButton, + GlButton, }, directives: { GlTooltip: GlTooltipDirective, @@ -46,7 +46,7 @@ export default { </script> <template> <gl-button-group class="js-blob-viewer-switcher mx-2"> - <gl-deprecated-button + <gl-button v-gl-tooltip.hover :aria-label="$options.SIMPLE_BLOB_VIEWER_TITLE" :title="$options.SIMPLE_BLOB_VIEWER_TITLE" @@ -55,8 +55,8 @@ export default { @click="switchToViewer($options.SIMPLE_BLOB_VIEWER)" > <gl-icon name="code" :size="14" /> - </gl-deprecated-button> - <gl-deprecated-button + </gl-button> + <gl-button v-gl-tooltip.hover :aria-label="$options.RICH_BLOB_VIEWER_TITLE" :title="$options.RICH_BLOB_VIEWER_TITLE" @@ -65,6 +65,6 @@ export default { @click="switchToViewer($options.RICH_BLOB_VIEWER)" > <gl-icon name="document" :size="14" /> - </gl-deprecated-button> + </gl-button> </gl-button-group> </template> diff --git a/app/assets/javascripts/blob/components/constants.js b/app/assets/javascripts/blob/components/constants.js index 93dceacabdd..0137bd38d28 100644 --- a/app/assets/javascripts/blob/components/constants.js +++ b/app/assets/javascripts/blob/components/constants.js @@ -25,7 +25,7 @@ export const BLOB_RENDER_ERRORS = { TOO_LARGE: { id: 'too_large', text: sprintf(__('it is larger than %{limit}'), { - limit: numberToHumanSize(104857600), // 100MB in bytes + limit: numberToHumanSize(10485760), // 10MB in bytes }), }, EXTERNAL: { diff --git a/app/assets/javascripts/blob/notebook/notebook_viewer.vue b/app/assets/javascripts/blob/notebook/notebook_viewer.vue index 401fe9beb62..b1713989997 100644 --- a/app/assets/javascripts/blob/notebook/notebook_viewer.vue +++ b/app/assets/javascripts/blob/notebook/notebook_viewer.vue @@ -62,9 +62,7 @@ export default { </script> <template> - <div - class="js-notebook-viewer-mounted container-fluid md prepend-top-default append-bottom-default" - > + <div class="js-notebook-viewer-mounted container-fluid md gl-mt-3 gl-mb-3"> <div v-if="loading && !error" class="text-center loading"> <gl-loading-icon class="mt-5" size="lg" /> </div> diff --git a/app/assets/javascripts/blob/pdf/pdf_viewer.vue b/app/assets/javascripts/blob/pdf/pdf_viewer.vue index 5eaddfc099a..64fc832ee54 100644 --- a/app/assets/javascripts/blob/pdf/pdf_viewer.vue +++ b/app/assets/javascripts/blob/pdf/pdf_viewer.vue @@ -34,7 +34,7 @@ export default { </script> <template> - <div class="js-pdf-viewer container-fluid md prepend-top-default append-bottom-default"> + <div class="js-pdf-viewer container-fluid md gl-mt-3 gl-mb-3"> <div v-if="loading && !error" class="text-center loading"> <gl-loading-icon class="mt-5" size="lg" /> </div> diff --git a/app/assets/javascripts/blob/sketch/index.js b/app/assets/javascripts/blob/sketch/index.js index dbff03dc734..767e205fcaa 100644 --- a/app/assets/javascripts/blob/sketch/index.js +++ b/app/assets/javascripts/blob/sketch/index.js @@ -56,7 +56,7 @@ export default class SketchLoader { error() { const errorMsg = document.createElement('p'); - errorMsg.className = 'prepend-top-default append-bottom-default text-center'; + errorMsg.className = 'gl-mt-3 gl-mb-3 text-center'; errorMsg.textContent = __(` Cannot show preview. For previews on sketch files, they must have the file format introduced by Sketch version 43 and above. diff --git a/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue b/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue index 1e9e36feecc..932b6e8a0f7 100644 --- a/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue +++ b/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue @@ -2,7 +2,6 @@ import { GlPopover, GlSprintf, GlDeprecatedButton, GlIcon } from '@gitlab/ui'; import { parseBoolean, scrollToElement, setCookie, getCookie } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; -import { glEmojiTag } from '~/emoji'; import Tracking from '~/tracking'; const trackingMixin = Tracking.mixin(); @@ -11,14 +10,16 @@ const popoverStates = { suggest_gitlab_ci_yml: { title: s__(`suggestPipeline|1/2: Choose a template`), content: s__( - `suggestPipeline|We recommend the %{boldStart}Code Quality%{boldEnd} template, which will add a report widget to your Merge Requests. This way you’ll learn about code quality degradations much sooner. %{footerStart} Goodbye technical debt! %{footerEnd}`, + `suggestPipeline|We’re adding a GitLab CI configuration file to add a pipeline to the project. You could create it manually, but we recommend that you start with a GitLab template that works out of the box.`, + ), + footer: s__( + `suggestPipeline|Choose %{boldStart}Code Quality%{boldEnd} to add a pipeline that tests the quality of your code.`, ), - emoji: glEmojiTag('wave'), }, suggest_commit_first_project_gitlab_ci_yml: { title: s__(`suggestPipeline|2/2: Commit your changes`), content: s__( - `suggestPipeline|Commit the changes and your pipeline will automatically run for the first time.`, + `suggestPipeline|The template is ready! You can now commit it to create your first pipeline.`, ), }, }; @@ -66,6 +67,9 @@ export default { suggestContent() { return popoverStates[this.trackLabel].content || ''; }, + suggestFooter() { + return popoverStates[this.trackLabel].footer || ''; + }, emoji() { return popoverStates[this.trackLabel].emoji || ''; }, @@ -123,16 +127,13 @@ export default { </span> </template> - <gl-sprintf :message="suggestContent"> - <template #bold="{content}"> - <strong> {{ content }} </strong> - </template> - <template #footer="{content}"> - <div class="mt-3"> - {{ content }} - <span v-html="emoji"></span> - </div> - </template> - </gl-sprintf> + <gl-sprintf :message="suggestContent" /> + <div class="mt-3"> + <gl-sprintf :message="suggestFooter"> + <template #bold="{ content }"> + <strong> {{ content }} </strong> + </template> + </gl-sprintf> + </div> </gl-popover> </template> diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js index 3ac419557eb..b18faea628a 100644 --- a/app/assets/javascripts/blob/viewer/index.js +++ b/app/assets/javascripts/blob/viewer/index.js @@ -3,6 +3,7 @@ import '~/behaviors/markdown/render_gfm'; import Flash from '../../flash'; import { handleLocationHash } from '../../lib/utils/common_utils'; import axios from '../../lib/utils/axios_utils'; +import eventHub from '../../notes/event_hub'; import { __ } from '~/locale'; const loadRichBlobViewer = type => { @@ -178,6 +179,10 @@ export default class BlobViewer { viewer.innerHTML = data.html; viewer.setAttribute('data-loaded', 'true'); + if (window.gon?.features?.codeNavigation) { + eventHub.$emit('showBlobInteractionZones', viewer.dataset.path); + } + return viewer; }); } diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js index 95b84497de3..9b9ade28623 100644 --- a/app/assets/javascripts/blob_edit/blob_bundle.js +++ b/app/assets/javascripts/blob_edit/blob_bundle.js @@ -5,7 +5,7 @@ import NewCommitForm from '../new_commit_form'; import EditBlob from './edit_blob'; import BlobFileDropzone from '../blob/blob_file_dropzone'; import initPopover from '~/blob/suggest_gitlab_ci_yml'; -import { setCookie } from '~/lib/utils/common_utils'; +import { disableButtonIfEmptyField, setCookie } from '~/lib/utils/common_utils'; import Tracking from '~/tracking'; export default () => { @@ -51,10 +51,7 @@ export default () => { new BlobFileDropzone(uploadBlobForm, method); new NewCommitForm(uploadBlobForm); - window.gl.utils.disableButtonIfEmptyField( - uploadBlobForm.find('.js-commit-message'), - '.btn-upload-file', - ); + disableButtonIfEmptyField(uploadBlobForm.find('.js-commit-message'), '.btn-upload-file'); } if (deleteBlobForm.length) { diff --git a/app/assets/javascripts/blob_edit/constants.js b/app/assets/javascripts/blob_edit/constants.js new file mode 100644 index 00000000000..a19da2098cf --- /dev/null +++ b/app/assets/javascripts/blob_edit/constants.js @@ -0,0 +1,4 @@ +import { __ } from '~/locale'; + +export const BLOB_EDITOR_ERROR = __('An error occurred while rendering the editor'); +export const BLOB_PREVIEW_ERROR = __('An error occurred previewing the blob'); diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js index 011898a5e7a..7e5be8454fe 100644 --- a/app/assets/javascripts/blob_edit/edit_blob.js +++ b/app/assets/javascripts/blob_edit/edit_blob.js @@ -3,39 +3,87 @@ import $ from 'jquery'; import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; -import { __ } from '~/locale'; +import { BLOB_EDITOR_ERROR, BLOB_PREVIEW_ERROR } from './constants'; import TemplateSelectorMediator from '../blob/file_template_mediator'; import getModeByFileExtension from '~/lib/utils/ace_utils'; import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown'; +const monacoEnabledGlobally = window.gon.features?.monacoBlobs; + export default class EditBlob { // The options object has: // assetsPath, filePath, currentAction, projectId, isMarkdown constructor(options) { this.options = options; - this.configureAceEditor(); - this.initModePanesAndLinks(); - this.initSoftWrap(); - this.initFileSelectors(); + this.options.monacoEnabled = this.options.monacoEnabled ?? monacoEnabledGlobally; + const { isMarkdown, monacoEnabled } = this.options; + return Promise.resolve() + .then(() => { + return monacoEnabled ? this.configureMonacoEditor() : this.configureAceEditor(); + }) + .then(() => { + this.initModePanesAndLinks(); + this.initFileSelectors(); + this.initSoftWrap(); + if (isMarkdown) { + addEditorMarkdownListeners(this.editor); + } + this.editor.focus(); + }) + .catch(() => createFlash(BLOB_EDITOR_ERROR)); + } + + configureMonacoEditor() { + const EditorPromise = import( + /* webpackChunkName: 'monaco_editor_lite' */ '~/editor/editor_lite' + ); + const MarkdownExtensionPromise = this.options.isMarkdown + ? import('~/editor/editor_markdown_ext') + : Promise.resolve(false); + + return Promise.all([EditorPromise, MarkdownExtensionPromise]) + .then(([EditorModule, MarkdownExtension]) => { + const EditorLite = EditorModule.default; + const editorEl = document.getElementById('editor'); + const fileNameEl = + document.getElementById('file_path') || document.getElementById('file_name'); + const fileContentEl = document.getElementById('file-content'); + const form = document.querySelector('.js-edit-blob-form'); + + this.editor = new EditorLite(); + + if (MarkdownExtension) { + this.editor.use(MarkdownExtension.default); + } + + this.editor.createInstance({ + el: editorEl, + blobPath: fileNameEl.value, + blobContent: editorEl.innerText, + }); + + fileNameEl.addEventListener('change', () => { + this.editor.updateModelLanguage(fileNameEl.value); + }); + + form.addEventListener('submit', () => { + fileContentEl.value = this.editor.getValue(); + }); + }) + .catch(() => createFlash(BLOB_EDITOR_ERROR)); } configureAceEditor() { - const { filePath, assetsPath, isMarkdown } = this.options; + const { filePath, assetsPath } = this.options; ace.config.set('modePath', `${assetsPath}/ace`); ace.config.loadModule('ace/ext/searchbox'); ace.config.loadModule('ace/ext/modelist'); this.editor = ace.edit('editor'); - if (isMarkdown) { - addEditorMarkdownListeners(this.editor); - } - // This prevents warnings re: automatic scrolling being logged this.editor.$blockScrolling = Infinity; - this.editor.focus(); - if (filePath) { this.editor.getSession().setMode(getModeByFileExtension(filePath)); } @@ -81,7 +129,7 @@ export default class EditBlob { currentPane.empty().append(data); currentPane.renderGFM(); }) - .catch(() => createFlash(__('An error occurred previewing the blob'))); + .catch(() => createFlash(BLOB_PREVIEW_ERROR)); } this.$toggleButton.show(); @@ -90,14 +138,19 @@ export default class EditBlob { } initSoftWrap() { - this.isSoftWrapped = false; + this.isSoftWrapped = Boolean(this.options.monacoEnabled); this.$toggleButton = $('.soft-wrap-toggle'); + this.$toggleButton.toggleClass('soft-wrap-active', this.isSoftWrapped); this.$toggleButton.on('click', () => this.toggleSoftWrap()); } toggleSoftWrap() { this.isSoftWrapped = !this.isSoftWrapped; this.$toggleButton.toggleClass('soft-wrap-active', this.isSoftWrapped); - this.editor.getSession().setUseWrapMode(this.isSoftWrapped); + if (this.options.monacoEnabled) { + this.editor.updateOptions({ wordWrap: this.isSoftWrapped ? 'on' : 'off' }); + } else { + this.editor.getSession().setUseWrapMode(this.isSoftWrapped); + } } } diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js deleted file mode 100644 index 517a13ceb27..00000000000 --- a/app/assets/javascripts/boards/components/board.js +++ /dev/null @@ -1,192 +0,0 @@ -import $ from 'jquery'; -import Sortable from 'sortablejs'; -import Vue from 'vue'; -import { GlButtonGroup, GlDeprecatedButton, GlLabel, GlTooltip } from '@gitlab/ui'; -import isWipLimitsOn from 'ee_else_ce/boards/mixins/is_wip_limits'; -import { s__, __, sprintf } from '~/locale'; -import Icon from '~/vue_shared/components/icon.vue'; -import Tooltip from '~/vue_shared/directives/tooltip'; -import AccessorUtilities from '../../lib/utils/accessor'; -import BoardBlankState from './board_blank_state.vue'; -import BoardDelete from './board_delete'; -import BoardList from './board_list.vue'; -import IssueCount from './issue_count.vue'; -import boardsStore from '../stores/boards_store'; -import { getBoardSortableDefaultOptions, sortableEnd } from '../mixins/sortable_default_options'; -import { ListType } from '../constants'; -import { isScopedLabel } from '~/lib/utils/common_utils'; - -/** - * Please don't edit this file, have a look at: - * ./board_column.vue - * https://gitlab.com/gitlab-org/gitlab/-/issues/212300 - * - * This file here will be deleted soon - * @deprecated - */ -export default Vue.extend({ - components: { - BoardBlankState, - BoardDelete, - BoardList, - Icon, - GlButtonGroup, - IssueCount, - GlDeprecatedButton, - GlLabel, - GlTooltip, - }, - directives: { - Tooltip, - }, - mixins: [isWipLimitsOn], - props: { - list: { - type: Object, - default: () => ({}), - required: false, - }, - disabled: { - type: Boolean, - required: true, - }, - issueLinkBase: { - type: String, - required: true, - }, - rootPath: { - type: String, - required: true, - }, - boardId: { - type: String, - required: true, - }, - // Does not do anything but is used - // to support the API of the new board_column.vue - canAdminList: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - detailIssue: boardsStore.detail, - filter: boardsStore.filter, - }; - }, - computed: { - isLoggedIn() { - return Boolean(gon.current_user_id); - }, - showListHeaderButton() { - return ( - !this.disabled && this.list.type !== ListType.closed && this.list.type !== ListType.blank - ); - }, - issuesTooltip() { - const { issuesSize } = this.list; - - return sprintf(__('%{issuesSize} issues'), { issuesSize }); - }, - // Only needed to make karma pass. - weightCountToolTip() {}, // eslint-disable-line vue/return-in-computed-property - caretTooltip() { - return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand'); - }, - isNewIssueShown() { - return this.list.type === ListType.backlog || this.showListHeaderButton; - }, - isSettingsShown() { - return ( - this.list.type !== ListType.backlog && - this.showListHeaderButton && - this.list.isExpanded && - this.isWipLimitsOn - ); - }, - showBoardListAndBoardInfo() { - return this.list.type !== ListType.blank && this.list.type !== ListType.promotion; - }, - uniqueKey() { - // eslint-disable-next-line @gitlab/require-i18n-strings - return `boards.${this.boardId}.${this.list.type}.${this.list.id}`; - }, - }, - watch: { - filter: { - handler() { - this.list.page = 1; - this.list.getIssues(true).catch(() => { - // TODO: handle request error - }); - }, - deep: true, - }, - }, - mounted() { - const instance = this; - - const sortableOptions = getBoardSortableDefaultOptions({ - disabled: this.disabled, - group: 'boards', - draggable: '.is-draggable', - handle: '.js-board-handle', - onEnd(e) { - sortableEnd(); - - const sortable = this; - - if (e.newIndex !== undefined && e.oldIndex !== e.newIndex) { - const order = sortable.toArray(); - const list = boardsStore.findList('id', parseInt(e.item.dataset.id, 10)); - - instance.$nextTick(() => { - boardsStore.moveList(list, order); - }); - } - }, - }); - - Sortable.create(this.$el.parentNode, sortableOptions); - }, - created() { - if ( - this.list.isExpandable && - AccessorUtilities.isLocalStorageAccessSafe() && - !this.isLoggedIn - ) { - const isCollapsed = localStorage.getItem(`${this.uniqueKey}.expanded`) === 'false'; - - this.list.isExpanded = !isCollapsed; - } - }, - methods: { - showScopedLabels(label) { - return boardsStore.scopedLabels.enabled && isScopedLabel(label); - }, - - showNewIssueForm() { - this.$refs['board-list'].showIssueForm = !this.$refs['board-list'].showIssueForm; - }, - toggleExpanded() { - if (this.list.isExpandable) { - this.list.isExpanded = !this.list.isExpanded; - - if (AccessorUtilities.isLocalStorageAccessSafe() && !this.isLoggedIn) { - localStorage.setItem(`${this.uniqueKey}.expanded`, this.list.isExpanded); - } - - if (this.isLoggedIn) { - this.list.update(); - } - - // When expanding/collapsing, the tooltip on the caret button sometimes stays open. - // Close all tooltips manually to prevent dangling tooltips. - $('.tooltip').tooltip('hide'); - } - }, - }, - template: '#js-board-template', -}); diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index f0497ea0b64..6ac7fdce6a7 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -54,7 +54,7 @@ export default { <div> <div v-if="!isSwimlanesOn" - class="boards-list w-100 py-3 px-2 text-nowrap" + class="boards-list gl-w-full gl-py-5 gl-px-3 gl-white-space-nowrap" data-qa-selector="boards_list" > <board-column @@ -77,6 +77,7 @@ export default { :can-admin-list="canAdminList" :disabled="disabled" :board-id="boardId" + :group-id="groupId" /> </div> </template> diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index eb12617a66e..02a04cb4e46 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -5,10 +5,11 @@ import { GlLabel, GlTooltip, GlIcon, + GlSprintf, GlTooltipDirective, } from '@gitlab/ui'; import isWipLimitsOn from 'ee_else_ce/boards/mixins/is_wip_limits'; -import { s__, __, sprintf } from '~/locale'; +import { n__, s__ } from '~/locale'; import AccessorUtilities from '../../lib/utils/accessor'; import BoardDelete from './board_delete'; import IssueCount from './issue_count.vue'; @@ -25,6 +26,7 @@ export default { GlLabel, GlTooltip, GlIcon, + GlSprintf, IssueCount, }, directives: { @@ -82,10 +84,20 @@ export default { this.listType !== ListType.promotion ); }, - issuesTooltip() { + showMilestoneListDetails() { + return ( + this.list.type === 'milestone' && + this.list.milestone && + (this.list.isExpanded || !this.isSwimlanesHeader) + ); + }, + showAssigneeListDetails() { + return this.list.type === 'assignee' && (this.list.isExpanded || !this.isSwimlanesHeader); + }, + issuesTooltipLabel() { const { issuesSize } = this.list; - return sprintf(__('%{issuesSize} issues'), { issuesSize }); + return n__(`%d issue`, `%d issues`, issuesSize); }, chevronTooltip() { return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand'); @@ -111,6 +123,9 @@ export default { // eslint-disable-next-line @gitlab/require-i18n-strings return `boards.${this.boardId}.${this.listType}.${this.list.id}`; }, + collapsedTooltipTitle() { + return this.listTitle || this.listAssignee; + }, }, methods: { showScopedLabels(label) { @@ -147,7 +162,7 @@ export default { 'has-border': list.label && list.label.color, 'gl-relative': list.isExpanded, 'gl-h-full': !list.isExpanded, - 'board-inner gl-rounded-base gl-border-b-0': isSwimlanesHeader, + 'board-inner gl-rounded-top-left-base gl-rounded-top-right-base': isSwimlanesHeader, }" :style="{ borderTopColor: list.label && list.label.color ? list.label.color : null }" class="board-header gl-relative" @@ -157,7 +172,9 @@ export default { <h3 :class="{ 'user-can-drag': !disabled && !list.preset, - 'gl-border-b-0': !list.isExpanded, + 'gl-py-3': !list.isExpanded && !isSwimlanesHeader, + 'gl-border-b-0': !list.isExpanded || isSwimlanesHeader, + 'gl-py-2': !list.isExpanded && isSwimlanesHeader, }" class="board-title gl-m-0 gl-display-flex js-board-handle" > @@ -167,21 +184,17 @@ export default { :aria-label="chevronTooltip" :title="chevronTooltip" :icon="chevronIcon" - class="board-title-caret no-drag" + class="board-title-caret no-drag gl-cursor-pointer" variant="link" @click="toggleExpanded" /> <!-- The following is only true in EE and if it is a milestone --> - <span - v-if="list.type === 'milestone' && list.milestone" - aria-hidden="true" - class="gl-mr-2 milestone-icon" - > + <span v-if="showMilestoneListDetails" aria-hidden="true" class="gl-mr-2 milestone-icon"> <gl-icon name="timer" /> </span> <a - v-if="list.type === 'assignee'" + v-if="showAssigneeListDetails" :href="list.assignee.path" class="user-avatar-link js-no-trigger" > @@ -195,7 +208,10 @@ export default { width="20" /> </a> - <div class="board-title-text"> + <div + class="board-title-text" + :class="{ 'gl-display-none': !list.isExpanded && isSwimlanesHeader }" + > <span v-if="list.type !== 'label'" v-gl-tooltip.hover @@ -208,7 +224,7 @@ export default { {{ list.title }} </span> <span v-if="list.type === 'assignee'" class="board-title-sub-text gl-ml-2"> - @{{ list.assignee.username }} + @{{ listAssignee }} </span> <gl-label v-if="list.type === 'label'" @@ -220,6 +236,33 @@ export default { :title="list.label.title" /> </div> + + <span + v-if="isSwimlanesHeader && !list.isExpanded" + ref="collapsedInfo" + aria-hidden="true" + class="board-header-collapsed-info-icon gl-mt-2 gl-cursor-pointer gl-text-gray-700" + > + <gl-icon name="information" /> + </span> + <gl-tooltip v-if="isSwimlanesHeader && !list.isExpanded" :target="() => $refs.collapsedInfo"> + <div class="gl-font-weight-bold gl-pb-2">{{ collapsedTooltipTitle }}</div> + <div v-if="list.maxIssueCount !== 0"> + • + <gl-sprintf :message="__('%{issuesSize} with a limit of %{maxIssueCount}')"> + <template #issuesSize>{{ issuesTooltipLabel }}</template> + <template #maxIssueCount>{{ list.maxIssueCount }}</template> + </gl-sprintf> + </div> + <div v-else>• {{ issuesTooltipLabel }}</div> + <div v-if="weightFeatureAvailable"> + • + <gl-sprintf :message="__('%{totalWeight} total weight')"> + <template #totalWeight>{{ list.totalWeight }}</template> + </gl-sprintf> + </div> + </gl-tooltip> + <board-delete v-if="canAdminList && !list.preset && list.id" :list="list" @@ -229,7 +272,7 @@ export default { v-gl-tooltip.hover.bottom :class="{ 'gl-display-none': !list.isExpanded }" :aria-label="__('Delete list')" - class="board-delete no-drag gl-pr-0 gl-shadow-none gl-mr-3" + class="board-delete no-drag gl-pr-0 gl-shadow-none! gl-mr-3" :title="__('Delete list')" icon="remove" size="small" @@ -238,10 +281,11 @@ export default { </board-delete> <div v-if="showBoardListAndBoardInfo" - class="issue-count-badge gl-pr-0 no-drag text-secondary" + class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag text-secondary" + :class="{ 'gl-display-none': !list.isExpanded && isSwimlanesHeader }" > <span class="gl-display-inline-flex"> - <gl-tooltip :target="() => $refs.issueCount" :title="issuesTooltip" /> + <gl-tooltip :target="() => $refs.issueCount" :title="issuesTooltipLabel" /> <span ref="issueCount" class="issue-count-badge-count"> <gl-icon class="gl-mr-2" name="issues" /> <issue-count :issues-size="list.issuesSize" :max-issue-count="list.maxIssueCount" /> diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index c72fb7b30f9..02ac45f8ef9 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -1,6 +1,6 @@ <script> import $ from 'jquery'; -import { GlDeprecatedButton } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { getMilestone } from 'ee_else_ce/boards/boards_util'; import ListIssue from 'ee_else_ce/boards/models/issue'; import eventHub from '../eventhub'; @@ -11,7 +11,7 @@ export default { name: 'BoardNewIssue', components: { ProjectSelect, - GlDeprecatedButton, + GlButton, }, props: { groupId: { @@ -120,21 +120,18 @@ export default { /> <project-select v-if="groupId" :group-id="groupId" :list="list" /> <div class="clearfix prepend-top-10"> - <gl-deprecated-button + <gl-button ref="submit-button" :disabled="disabled" class="float-left" variant="success" + category="primary" type="submit" - >{{ __('Submit issue') }}</gl-deprecated-button - > - <gl-deprecated-button - class="float-right" - type="button" - variant="default" - @click="cancel" - >{{ __('Cancel') }}</gl-deprecated-button + >{{ __('Submit issue') }}</gl-button > + <gl-button class="float-right" type="button" variant="default" @click="cancel">{{ + __('Cancel') + }}</gl-button> </div> </form> </div> diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index 80db9930259..dbe3e0790f6 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -233,7 +233,7 @@ export default { </script> <template> - <div class="boards-switcher js-boards-selector append-right-10"> + <div class="boards-switcher js-boards-selector gl-mr-3"> <span class="boards-selector-wrapper js-boards-selector-wrapper"> <gl-dropdown data-qa-selector="boards_dropdown" diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index f2e198eaedb..d90928f35b6 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -153,7 +153,7 @@ export default { v-gl-tooltip name="issue-block" :title="__('Blocked issue')" - class="issue-blocked-icon append-right-4" + class="issue-blocked-icon gl-mr-2" :aria-label="__('Blocked issue')" /> <icon @@ -161,7 +161,7 @@ export default { v-gl-tooltip name="eye-slash" :title="__('Confidential')" - class="confidential-icon append-right-4" + class="confidential-icon gl-mr-2" :aria-label="__('Confidential')" /> <a :href="issue.path" :title="issue.title" class="js-no-trigger" @mousemove.stop>{{ diff --git a/app/assets/javascripts/boards/components/modal/header.vue b/app/assets/javascripts/boards/components/modal/header.vue index a42e691dcf3..8eae8e4726f 100644 --- a/app/assets/javascripts/boards/components/modal/header.vue +++ b/app/assets/javascripts/boards/components/modal/header.vue @@ -72,7 +72,7 @@ export default { <button ref="selectAllBtn" type="button" - class="btn btn-success btn-inverted prepend-left-10" + class="btn btn-success btn-inverted gl-ml-3" @click="toggleAll" > {{ selectAllText }} diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index a882cd1cdfa..5b4a1d262dd 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -80,15 +80,7 @@ export default () => { el: $boardApp, components: { BoardContent, - Board: () => - window?.gon?.features?.sfcIssueBoards - ? import('ee_else_ce/boards/components/board_column.vue') - : /** - * Please have a look at, we are moving to the SFC soon: - * https://gitlab.com/gitlab-org/gitlab/-/issues/212300 - * @deprecated - */ - import('ee_else_ce/boards/components/board'), + Board: () => import('ee_else_ce/boards/components/board_column.vue'), BoardSidebar, BoardAddIssuesModal, BoardSettingsSidebar: () => @@ -360,7 +352,7 @@ export default () => { template: ` <div class="board-extra-actions"> <button - class="btn btn-success prepend-left-10" + class="btn btn-success gl-ml-3" type="button" data-placement="bottom" ref="addIssuesButton" diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index 0bd606c6297..2aa92f86125 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -119,16 +119,12 @@ class List { } moveMultipleIssues({ issues, oldIndicies, newIndex, moveBeforeId, moveAfterId }) { - oldIndicies.reverse().forEach(index => { - this.issues.splice(index, 1); - }); - this.issues.splice(newIndex, 0, ...issues); - boardsStore - .moveMultipleIssues({ - ids: issues.map(issue => issue.id), - fromListId: null, - toListId: null, + .moveListMultipleIssues({ + list: this, + issues, + oldIndicies, + newIndex, moveBeforeId, moveAfterId, }) @@ -170,12 +166,7 @@ class List { } onNewIssueResponse(issue, data) { - issue.refreshData(data); - - if (this.issuesSize > 1) { - const moveBeforeId = this.issues[1].id; - boardsStore.moveIssue(issue.id, null, null, null, moveBeforeId); - } + boardsStore.onNewListIssueResponse(this, issue, data); } } diff --git a/app/assets/javascripts/boards/queries/board.fragment.graphql b/app/assets/javascripts/boards/queries/board.fragment.graphql index 48f55e899bf..872a4c4afbc 100644 --- a/app/assets/javascripts/boards/queries/board.fragment.graphql +++ b/app/assets/javascripts/boards/queries/board.fragment.graphql @@ -1,4 +1,4 @@ fragment BoardFragment on Board { - id, + id name } diff --git a/app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql b/app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql index 6ba6c05d6d9..5b532906f6a 100644 --- a/app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql +++ b/app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql @@ -1,15 +1,15 @@ fragment BoardListShared on BoardList { - id, - title, - position, - listType, - collapsed, + id + title + position + listType + collapsed label { - id, - title, - color, - textColor, - description, + id + title + color + textColor + description descriptionHtml } } diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index a930f39189e..da7d2e19ec1 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -296,6 +296,15 @@ const boardsStore = { Object.assign(this.moving, { list, issue }); }, + onNewListIssueResponse(list, issue, data) { + issue.refreshData(data); + + if (list.issuesSize > 1) { + const moveBeforeId = list.issues[1].id; + this.moveIssue(issue.id, null, null, null, moveBeforeId); + } + }, + moveMultipleIssuesToList({ listFrom, listTo, issues, newIndex }) { const issueTo = issues.map(issue => listTo.findIssue(issue.id)); const issueLists = issues.map(issue => issue.getLists()).flat(); @@ -675,6 +684,21 @@ const boardsStore = { }); }, + moveListMultipleIssues({ list, issues, oldIndicies, newIndex, moveBeforeId, moveAfterId }) { + oldIndicies.reverse().forEach(index => { + list.issues.splice(index, 1); + }); + list.issues.splice(newIndex, 0, ...issues); + + return this.moveMultipleIssues({ + ids: issues.map(issue => issue.id), + fromListId: null, + toListId: null, + moveBeforeId, + moveAfterId, + }); + }, + newIssue(id, issue) { return axios.post(this.generateIssuesPath(id), { issue, diff --git a/app/assets/javascripts/boards/toggle_focus.js b/app/assets/javascripts/boards/toggle_focus.js index a437a34c948..e60e7059192 100644 --- a/app/assets/javascripts/boards/toggle_focus.js +++ b/app/assets/javascripts/boards/toggle_focus.js @@ -25,7 +25,7 @@ export default (ModalStore, boardsStore) => { <div class="board-extra-actions"> <a href="#" - class="btn btn-default has-tooltip prepend-left-10 js-focus-mode-btn" + class="btn btn-default has-tooltip gl-ml-3 js-focus-mode-btn" data-qa-selector="focus_mode_button" role="button" aria-label="Toggle focus mode" diff --git a/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue b/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue deleted file mode 100644 index c15d638d92b..00000000000 --- a/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue +++ /dev/null @@ -1,169 +0,0 @@ -<script> -import { uniqueId } from 'lodash'; -import { GlButton, GlFormGroup, GlFormInput } from '@gitlab/ui'; - -export default { - name: 'CiKeyField', - components: { - GlButton, - GlFormGroup, - GlFormInput, - }, - model: { - prop: 'value', - event: 'input', - }, - props: { - tokenList: { - type: Array, - required: true, - }, - value: { - type: String, - required: true, - }, - }, - data() { - return { - results: [], - arrowCounter: -1, - userDismissedResults: false, - suggestionsId: uniqueId('token-suggestions-'), - }; - }, - computed: { - showAutocomplete() { - return this.showSuggestions ? 'off' : 'on'; - }, - showSuggestions() { - return this.results.length > 0; - }, - }, - mounted() { - document.addEventListener('click', this.handleClickOutside); - }, - destroyed() { - document.removeEventListener('click', this.handleClickOutside); - }, - methods: { - closeSuggestions() { - this.results = []; - this.arrowCounter = -1; - }, - handleClickOutside(event) { - if (!this.$el.contains(event.target)) { - this.closeSuggestions(); - } - }, - onArrowDown() { - const newCount = this.arrowCounter + 1; - - if (newCount >= this.results.length) { - this.arrowCounter = 0; - return; - } - - this.arrowCounter = newCount; - }, - onArrowUp() { - const newCount = this.arrowCounter - 1; - - if (newCount < 0) { - this.arrowCounter = this.results.length - 1; - return; - } - - this.arrowCounter = newCount; - }, - onEnter() { - const currentToken = this.results[this.arrowCounter] || this.value; - this.selectToken(currentToken); - }, - onEsc() { - if (!this.showSuggestions) { - this.$emit('input', ''); - } - this.closeSuggestions(); - this.userDismissedResults = true; - }, - onEntry(value) { - this.$emit('input', value); - this.userDismissedResults = false; - - // short circuit so that we don't false match on empty string - if (value.length < 1) { - this.closeSuggestions(); - return; - } - - const filteredTokens = this.tokenList.filter(token => - token.toLowerCase().includes(value.toLowerCase()), - ); - - if (filteredTokens.length) { - this.openSuggestions(filteredTokens); - } else { - this.closeSuggestions(); - } - }, - openSuggestions(filteredResults) { - this.results = filteredResults; - }, - selectToken(value) { - this.$emit('input', value); - this.closeSuggestions(); - this.$emit('key-selected'); - }, - }, -}; -</script> -<template> - <div> - <div class="dropdown position-relative" role="combobox" aria-owns="token-suggestions"> - <gl-form-group :label="__('Key')" label-for="ci-variable-key"> - <gl-form-input - id="ci-variable-key" - :value="value" - type="text" - role="searchbox" - class="form-control pl-2 js-env-input" - :autocomplete="showAutocomplete" - aria-autocomplete="list" - aria-controls="token-suggestions" - aria-haspopup="listbox" - :aria-expanded="showSuggestions" - data-qa-selector="ci_variable_key_field" - @input="onEntry" - @keydown.down="onArrowDown" - @keydown.up="onArrowUp" - @keydown.enter.prevent="onEnter" - @keydown.esc.stop="onEsc" - @keydown.tab="closeSuggestions" - /> - </gl-form-group> - - <div - v-show="showSuggestions && !userDismissedResults" - id="ci-variable-dropdown" - class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width" - :class="{ 'd-block': showSuggestions }" - > - <div class="dropdown-content"> - <ul :id="suggestionsId"> - <li - v-for="(result, i) in results" - :key="i" - role="option" - :class="{ 'gl-bg-gray-50': i === arrowCounter }" - :aria-selected="i === arrowCounter" - > - <gl-button tabindex="-1" class="btn-transparent pl-2" @click="selectToken(result)">{{ - result - }}</gl-button> - </li> - </ul> - </div> - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js b/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js index 9022bf51514..3f25e3df305 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js @@ -1,28 +1,14 @@ -import { __ } from '~/locale'; - import { AWS_ACCESS_KEY_ID, AWS_DEFAULT_REGION, AWS_SECRET_ACCESS_KEY } from '../constants'; export const awsTokens = { [AWS_ACCESS_KEY_ID]: { name: AWS_ACCESS_KEY_ID, - /* Checks for exactly twenty characters that match key. - Based on greps suggested by Amazon at: - https://aws.amazon.com/blogs/security/a-safer-way-to-distribute-aws-credentials-to-ec2/ - */ - validation: val => /^[A-Za-z0-9]{20}$/.test(val), - invalidMessage: __('This variable does not match the expected pattern.'), }, [AWS_DEFAULT_REGION]: { name: AWS_DEFAULT_REGION, }, [AWS_SECRET_ACCESS_KEY]: { name: AWS_SECRET_ACCESS_KEY, - /* Checks for exactly forty characters that match secret. - Based on greps suggested by Amazon at: - https://aws.amazon.com/blogs/security/a-safer-way-to-distribute-aws-credentials-to-ec2/ - */ - validation: val => /^[A-Za-z0-9/+=]{40}$/.test(val), - invalidMessage: __('This variable does not match the expected pattern.'), }, }; diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue index 6531b945212..0ba58430de1 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue @@ -5,6 +5,7 @@ import { GlCollapse, GlDeprecatedButton, GlFormCheckbox, + GlFormCombobox, GlFormGroup, GlFormInput, GlFormSelect, @@ -16,6 +17,7 @@ import { } from '@gitlab/ui'; import Cookies from 'js-cookie'; import { mapActions, mapState } from 'vuex'; +import { mapComputed } from '~/vuex_shared/bindings'; import { __ } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { @@ -25,19 +27,21 @@ import { AWS_TIP_MESSAGE, } from '../constants'; import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens'; -import CiKeyField from './ci_key_field.vue'; import CiEnvironmentsDropdown from './ci_environments_dropdown.vue'; export default { modalId: ADD_CI_VARIABLE_MODAL_ID, + tokens: awsTokens, + tokenList: awsTokenList, + awsTipMessage: AWS_TIP_MESSAGE, components: { CiEnvironmentsDropdown, - CiKeyField, GlAlert, GlButton, GlCollapse, GlDeprecatedButton, GlFormCheckbox, + GlFormCombobox, GlFormGroup, GlFormInput, GlFormSelect, @@ -48,9 +52,6 @@ export default { GlSprintf, }, mixins: [glFeatureFlagsMixin()], - tokens: awsTokens, - tokenList: awsTokenList, - awsTipMessage: AWS_TIP_MESSAGE, data() { return { isTipDismissed: Cookies.get(AWS_TIP_DISMISSED_COOKIE_NAME) === 'true', @@ -74,22 +75,34 @@ export default { 'protectedEnvironmentVariablesLink', 'maskedEnvironmentVariablesLink', ]), + ...mapComputed( + [ + { key: 'key', updateFn: 'updateVariableKey' }, + { key: 'secret_value', updateFn: 'updateVariableValue' }, + { key: 'variable_type', updateFn: 'updateVariableType' }, + { key: 'environment_scope', updateFn: 'setEnvironmentScope' }, + { key: 'protected_variable', updateFn: 'updateVariableProtected' }, + { key: 'masked', updateFn: 'updateVariableMasked' }, + ], + false, + 'variable', + ), isTipVisible() { - return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variableData.key); + return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variable.key); }, canSubmit() { return ( this.variableValidationState && - this.variableData.key !== '' && - this.variableData.secret_value !== '' + this.variable.key !== '' && + this.variable.secret_value !== '' ); }, canMask() { const regex = RegExp(this.maskableRegex); - return regex.test(this.variableData.secret_value); + return regex.test(this.variable.secret_value); }, displayMaskedError() { - return !this.canMask && this.variableData.masked; + return !this.canMask && this.variable.masked; }, maskedState() { if (this.displayMaskedError) { @@ -97,9 +110,6 @@ export default { } return true; }, - variableData() { - return this.variableBeingEdited || this.variable; - }, modalActionText() { return this.variableBeingEdited ? __('Update variable') : __('Add variable'); }, @@ -107,7 +117,7 @@ export default { return this.displayMaskedError ? __('This variable can not be masked.') : ''; }, tokenValidationFeedback() { - const tokenSpecificFeedback = this.$options.tokens?.[this.variableData.key]?.invalidMessage; + const tokenSpecificFeedback = this.$options.tokens?.[this.variable.key]?.invalidMessage; if (!this.tokenValidationState && tokenSpecificFeedback) { return tokenSpecificFeedback; } @@ -119,10 +129,10 @@ export default { return true; } - const validator = this.$options.tokens?.[this.variableData.key]?.validation; + const validator = this.$options.tokens?.[this.variable.key]?.validation; if (validator) { - return validator(this.variableData.secret_value); + return validator(this.variable.secret_value); } return true; @@ -131,14 +141,7 @@ export default { return `${this.tokenValidationFeedback} ${this.maskedFeedback}`; }, variableValidationState() { - if ( - this.variableData.secret_value === '' || - (this.tokenValidationState && this.maskedState) - ) { - return true; - } - - return false; + return this.variable.secret_value === '' || (this.tokenValidationState && this.maskedState); }, }, methods: { @@ -160,7 +163,7 @@ export default { this.isTipDismissed = true; }, deleteVarAndClose() { - this.deleteVariable(this.variableBeingEdited); + this.deleteVariable(); this.hideModal(); }, hideModal() { @@ -169,14 +172,14 @@ export default { resetModalHandler() { if (this.variableBeingEdited) { this.resetEditing(); - } else { - this.clearModal(); } + + this.clearModal(); this.resetSelectedEnvironment(); }, updateOrAddVariable() { if (this.variableBeingEdited) { - this.updateVariable(this.variableBeingEdited); + this.updateVariable(); } else { this.addVariable(); } @@ -202,16 +205,17 @@ export default { @shown="setVariableProtectedByDefault" > <form> - <ci-key-field + <gl-form-combobox v-if="glFeatures.ciKeyAutocomplete" - v-model="variableData.key" + v-model="key" :token-list="$options.tokenList" + :label-text="__('Key')" /> <gl-form-group v-else :label="__('Key')" label-for="ci-variable-key"> <gl-form-input id="ci-variable-key" - v-model="variableData.key" + v-model="key" data-qa-selector="ci_variable_key_field" /> </gl-form-group> @@ -225,11 +229,12 @@ export default { <gl-form-textarea id="ci-variable-value" ref="valueField" - v-model="variableData.secret_value" + v-model="secret_value" :state="variableValidationState" rows="3" max-rows="6" data-qa-selector="ci_variable_value_field" + class="gl-font-monospace!" /> </gl-form-group> @@ -240,11 +245,7 @@ export default { class="w-50 append-right-15" :class="{ 'w-100': isGroup }" > - <gl-form-select - id="ci-variable-type" - v-model="variableData.variable_type" - :options="typeOptions" - /> + <gl-form-select id="ci-variable-type" v-model="variable_type" :options="typeOptions" /> </gl-form-group> <gl-form-group @@ -255,7 +256,7 @@ export default { > <ci-environments-dropdown class="w-100" - :value="variableData.environment_scope" + :value="environment_scope" @selectEnvironment="setEnvironmentScope" @createClicked="addWildCardScope" /> @@ -263,7 +264,7 @@ export default { </div> <gl-form-group :label="__('Flags')" label-for="ci-variable-flags"> - <gl-form-checkbox v-model="variableData.protected" class="mb-0"> + <gl-form-checkbox v-model="protected_variable" class="mb-0"> {{ __('Protect variable') }} <gl-link target="_blank" :href="protectedEnvironmentVariablesLink"> <gl-icon name="question" :size="12" /> @@ -275,7 +276,7 @@ export default { <gl-form-checkbox ref="masked-ci-variable" - v-model="variableData.masked" + v-model="masked" data-qa-selector="ci_variable_masked_checkbox" > {{ __('Mask variable') }} diff --git a/app/assets/javascripts/ci_variable_list/store/actions.js b/app/assets/javascripts/ci_variable_list/store/actions.js index d9129c919f8..60c7a480769 100644 --- a/app/assets/javascripts/ci_variable_list/store/actions.js +++ b/app/assets/javascripts/ci_variable_list/store/actions.js @@ -65,10 +65,10 @@ export const receiveUpdateVariableError = ({ commit }, error) => { commit(types.RECEIVE_UPDATE_VARIABLE_ERROR, error); }; -export const updateVariable = ({ state, dispatch }, variable) => { +export const updateVariable = ({ state, dispatch }) => { dispatch('requestUpdateVariable'); - const updatedVariable = prepareDataForApi(variable); + const updatedVariable = prepareDataForApi(state.variable); updatedVariable.secrect_value = updateVariable.value; return axios @@ -121,13 +121,13 @@ export const receiveDeleteVariableError = ({ commit }, error) => { commit(types.RECEIVE_DELETE_VARIABLE_ERROR, error); }; -export const deleteVariable = ({ dispatch, state }, variable) => { +export const deleteVariable = ({ dispatch, state }) => { dispatch('requestDeleteVariable'); const destroy = true; return axios - .patch(state.endpoint, { variables_attributes: [prepareDataForApi(variable, destroy)] }) + .patch(state.endpoint, { variables_attributes: [prepareDataForApi(state.variable, destroy)] }) .then(() => { dispatch('receiveDeleteVariableSuccess'); dispatch('fetchVariables'); @@ -176,3 +176,23 @@ export const resetSelectedEnvironment = ({ commit }) => { export const setSelectedEnvironment = ({ commit }, environment) => { commit(types.SET_SELECTED_ENVIRONMENT, environment); }; + +export const updateVariableKey = ({ commit }, { key }) => { + commit(types.UPDATE_VARIABLE_KEY, key); +}; + +export const updateVariableValue = ({ commit }, { secret_value }) => { + commit(types.UPDATE_VARIABLE_VALUE, secret_value); +}; + +export const updateVariableType = ({ commit }, { variable_type }) => { + commit(types.UPDATE_VARIABLE_TYPE, variable_type); +}; + +export const updateVariableProtected = ({ commit }, { protected_variable }) => { + commit(types.UPDATE_VARIABLE_PROTECTED, protected_variable); +}; + +export const updateVariableMasked = ({ commit }, { masked }) => { + commit(types.UPDATE_VARIABLE_MASKED, masked); +}; diff --git a/app/assets/javascripts/ci_variable_list/store/mutation_types.js b/app/assets/javascripts/ci_variable_list/store/mutation_types.js index ccf8fbd3cb5..5db8f610192 100644 --- a/app/assets/javascripts/ci_variable_list/store/mutation_types.js +++ b/app/assets/javascripts/ci_variable_list/store/mutation_types.js @@ -25,3 +25,9 @@ export const SET_ENVIRONMENT_SCOPE = 'SET_ENVIRONMENT_SCOPE'; export const ADD_WILD_CARD_SCOPE = 'ADD_WILD_CARD_SCOPE'; export const RESET_SELECTED_ENVIRONMENT = 'RESET_SELECTED_ENVIRONMENT'; export const SET_SELECTED_ENVIRONMENT = 'SET_SELECTED_ENVIRONMENT'; + +export const UPDATE_VARIABLE_KEY = 'UPDATE_VARIABLE_KEY'; +export const UPDATE_VARIABLE_VALUE = 'UPDATE_VARIABLE_VALUE'; +export const UPDATE_VARIABLE_TYPE = 'UPDATE_VARIABLE_TYPE'; +export const UPDATE_VARIABLE_PROTECTED = 'UPDATE_VARIABLE_PROTECTED'; +export const UPDATE_VARIABLE_MASKED = 'UPDATE_VARIABLE_MASKED'; diff --git a/app/assets/javascripts/ci_variable_list/store/mutations.js b/app/assets/javascripts/ci_variable_list/store/mutations.js index 7d9cd0dd727..961cecee298 100644 --- a/app/assets/javascripts/ci_variable_list/store/mutations.js +++ b/app/assets/javascripts/ci_variable_list/store/mutations.js @@ -65,7 +65,8 @@ export default { }, [types.VARIABLE_BEING_EDITED](state, variable) { - state.variableBeingEdited = variable; + state.variableBeingEdited = true; + state.variable = variable; }, [types.CLEAR_MODAL](state) { @@ -73,23 +74,19 @@ export default { variable_type: displayText.variableText, key: '', secret_value: '', - protected: false, + protected_variable: false, masked: false, environment_scope: displayText.allEnvironmentsText, }; }, [types.RESET_EDITING](state) { - state.variableBeingEdited = null; + state.variableBeingEdited = false; state.showInputValue = false; }, [types.SET_ENVIRONMENT_SCOPE](state, environment) { - if (state.variableBeingEdited) { - state.variableBeingEdited.environment_scope = environment; - } else { - state.variable.environment_scope = environment; - } + state.variable.environment_scope = environment; }, [types.ADD_WILD_CARD_SCOPE](state, environment) { @@ -106,6 +103,26 @@ export default { }, [types.SET_VARIABLE_PROTECTED](state) { - state.variable.protected = true; + state.variable.protected_variable = true; + }, + + [types.UPDATE_VARIABLE_KEY](state, key) { + state.variable.key = key; + }, + + [types.UPDATE_VARIABLE_VALUE](state, value) { + state.variable.secret_value = value; + }, + + [types.UPDATE_VARIABLE_TYPE](state, type) { + state.variable.variable_type = type; + }, + + [types.UPDATE_VARIABLE_PROTECTED](state, bool) { + state.variable.protected_variable = bool; + }, + + [types.UPDATE_VARIABLE_MASKED](state, bool) { + state.variable.masked = bool; }, }; diff --git a/app/assets/javascripts/ci_variable_list/store/state.js b/app/assets/javascripts/ci_variable_list/store/state.js index 2fffd115589..96b27792664 100644 --- a/app/assets/javascripts/ci_variable_list/store/state.js +++ b/app/assets/javascripts/ci_variable_list/store/state.js @@ -12,7 +12,7 @@ export default () => ({ variable_type: displayText.variableText, key: '', secret_value: '', - protected: false, + protected_variable: false, masked: false, environment_scope: displayText.allEnvironmentsText, }, @@ -21,6 +21,6 @@ export default () => ({ error: null, environments: [], typeOptions: [displayText.variableText, displayText.fileText], - variableBeingEdited: null, + variableBeingEdited: false, selectedEnvironment: '', }); diff --git a/app/assets/javascripts/ci_variable_list/store/utils.js b/app/assets/javascripts/ci_variable_list/store/utils.js index 3cd8c85024b..f04530359e7 100644 --- a/app/assets/javascripts/ci_variable_list/store/utils.js +++ b/app/assets/javascripts/ci_variable_list/store/utils.js @@ -18,6 +18,7 @@ export const prepareDataForDisplay = variables => { if (variableCopy.environment_scope === types.allEnvironmentsType) { variableCopy.environment_scope = displayText.allEnvironmentsText; } + variableCopy.protected_variable = variableCopy.protected; variablesToDisplay.push(variableCopy); }); return variablesToDisplay; @@ -25,7 +26,8 @@ export const prepareDataForDisplay = variables => { export const prepareDataForApi = (variable, destroy = false) => { const variableCopy = cloneDeep(variable); - variableCopy.protected = variableCopy.protected.toString(); + variableCopy.protected = variableCopy.protected_variable.toString(); + delete variableCopy.protected_variable; variableCopy.masked = variableCopy.masked.toString(); variableCopy.variable_type = variableTypeHandler(variableCopy.variable_type); if (variableCopy.environment_scope === displayText.allEnvironmentsText) { diff --git a/app/assets/javascripts/close_reopen_report_toggle.js b/app/assets/javascripts/close_reopen_report_toggle.js index bcddce6e727..9bbbe07e7a1 100644 --- a/app/assets/javascripts/close_reopen_report_toggle.js +++ b/app/assets/javascripts/close_reopen_report_toggle.js @@ -80,12 +80,7 @@ class CloseReopenReportToggle { { input: this.button, valueAttribute: 'data-url', - inputAttribute: 'href', - }, - { - input: this.button, - valueAttribute: 'data-method', - inputAttribute: 'data-method', + inputAttribute: 'data-endpoint', }, ], }; diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index f15efb2fdeb..83bdea15e62 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -222,7 +222,7 @@ export default class Clusters { initRemoveClusterActions() { const el = document.querySelector('#js-cluster-remove-actions'); if (el && el.dataset) { - const { clusterName, clusterPath } = el.dataset; + const { clusterName, clusterPath, hasManagementProject } = el.dataset; this.removeClusterAction = new Vue({ el, @@ -231,6 +231,7 @@ export default class Clusters { props: { clusterName, clusterPath, + hasManagementProject, }, }); }, diff --git a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue index 54f5468bdd0..87c3225085f 100644 --- a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue +++ b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue @@ -198,13 +198,7 @@ export default { </strong> </p> <div class="form-check form-check-inline mt-3"> - <gl-toggle - v-model="modSecurityEnabled" - :label-on="__('Enabled')" - :label-off="__('Disabled')" - :disabled="saveButtonDisabled" - label-position="right" - /> + <gl-toggle v-model="modSecurityEnabled" :disabled="saveButtonDisabled" /> </div> <div v-if="ingress.modsecurity_enabled" diff --git a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue index c5375cbfbdc..45f2dd48961 100644 --- a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue +++ b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue @@ -1,7 +1,7 @@ <script> import { escape } from 'lodash'; import SplitButton from '~/vue_shared/components/split_button.vue'; -import { GlModal, GlDeprecatedButton, GlFormInput } from '@gitlab/ui'; +import { GlModal, GlButton, GlDeprecatedButton, GlFormInput } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; import csrf from '~/lib/utils/csrf'; @@ -27,6 +27,7 @@ export default { components: { SplitButton, GlModal, + GlButton, GlDeprecatedButton, GlFormInput, }, @@ -39,6 +40,10 @@ export default { type: String, required: true, }, + hasManagementProject: { + type: Boolean, + required: false, + }, }, data() { return { @@ -90,6 +95,9 @@ export default { canSubmit() { return this.enteredClusterName === this.clusterName; }, + canCleanupResources() { + return !this.hasManagementProject; + }, }, methods: { handleClickRemoveCluster(cleanup = false) { @@ -112,12 +120,21 @@ export default { <template> <div> <split-button + v-if="canCleanupResources" :action-items="$options.splitButtonActionItems" menu-class="dropdown-menu-large" variant="danger" @remove-cluster="handleClickRemoveCluster(false)" @remove-cluster-and-cleanup="handleClickRemoveCluster(true)" /> + <gl-button + v-else + variant="danger" + data-testid="btnRemove" + @click="handleClickRemoveCluster(false)" + > + {{ s__('ClusterIntegration|Remove integration') }} + </gl-button> <gl-modal ref="modal" size="lg" diff --git a/app/assets/javascripts/clusters_list/components/ancestor_notice.vue b/app/assets/javascripts/clusters_list/components/ancestor_notice.vue new file mode 100644 index 00000000000..7954fc61785 --- /dev/null +++ b/app/assets/javascripts/clusters_list/components/ancestor_notice.vue @@ -0,0 +1,34 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { mapState } from 'vuex'; + +export default { + components: { + GlLink, + GlSprintf, + }, + computed: { + ...mapState(['ancestorHelperPath', 'hasAncestorClusters']), + }, +}; +</script> + +<template> + <div v-if="hasAncestorClusters" class="bs-callout bs-callout-info"> + <p> + <gl-sprintf + :message=" + s__( + 'ClusterIntegration|Clusters are utilized by selecting the nearest ancestor with a matching environment scope. For example, project clusters will override group clusters. %{linkStart}More information%{linkEnd}', + ) + " + > + <template #link="{ content }"> + <gl-link :href="ancestorHelperPath"> + <strong>{{ content }}</strong> + </gl-link> + </template> + </gl-sprintf> + </p> + </div> +</template> diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue index a3104038c17..7e9b720d269 100644 --- a/app/assets/javascripts/clusters_list/components/clusters.vue +++ b/app/assets/javascripts/clusters_list/components/clusters.vue @@ -1,14 +1,15 @@ <script> -import * as Sentry from '@sentry/browser'; import { mapState, mapActions } from 'vuex'; import { GlDeprecatedBadge as GlBadge, GlLink, GlLoadingIcon, GlPagination, + GlSkeletonLoading, GlSprintf, GlTable, } from '@gitlab/ui'; +import AncestorNotice from './ancestor_notice.vue'; import tooltip from '~/vue_shared/directives/tooltip'; import { CLUSTER_TYPES, STATUSES } from '../constants'; import { __, sprintf } from '~/locale'; @@ -17,10 +18,12 @@ export default { nodeMemoryText: __('%{totalMemory} (%{freeSpacePercentage}%{percentSymbol} free)'), nodeCpuText: __('%{totalCpu} (%{freeSpacePercentage}%{percentSymbol} free)'), components: { + AncestorNotice, GlBadge, GlLink, GlLoadingIcon, GlPagination, + GlSkeletonLoading, GlSprintf, GlTable, }, @@ -28,7 +31,18 @@ export default { tooltip, }, computed: { - ...mapState(['clusters', 'clustersPerPage', 'loading', 'page', 'providers', 'totalCulsters']), + ...mapState([ + 'clusters', + 'clustersPerPage', + 'loadingClusters', + 'loadingNodes', + 'page', + 'providers', + 'totalCulsters', + ]), + contentAlignClasses() { + return 'gl-display-flex gl-align-items-center gl-justify-content-end gl-justify-content-md-start'; + }, currentPage: { get() { return this.page; @@ -75,7 +89,7 @@ export default { this.fetchClusters(); }, methods: { - ...mapActions(['fetchClusters', 'setPage']), + ...mapActions(['fetchClusters', 'reportSentryError', 'setPage']), k8sQuantityToGb(quantity) { if (!quantity) { return 0; @@ -137,7 +151,7 @@ export default { }; } } catch (error) { - Sentry.captureException(error); + this.reportSentryError({ error, tag: 'totalMemoryAndUsageError' }); } return { totalMemory: null, freeSpacePercentage: null }; @@ -170,7 +184,7 @@ export default { }; } } catch (error) { - Sentry.captureException(error); + this.reportSentryError({ error, tag: 'totalCpuAndUsageError' }); } return { totalCpu: null, freeSpacePercentage: null }; @@ -180,14 +194,14 @@ export default { </script> <template> - <gl-loading-icon v-if="loading" size="md" class="mt-3" /> + <gl-loading-icon v-if="loadingClusters" size="md" class="gl-mt-3" /> <section v-else> + <ancestor-notice /> + <gl-table :items="clusters" :fields="fields" stacked="md" class="qa-clusters-table"> <template #cell(name)="{ item }"> - <div - class="gl-display-flex gl-align-items-center gl-justify-content-end gl-justify-content-md-start js-status" - > + <div :class="[contentAlignClasses, 'js-status']"> <img :src="selectedProvider(item.provider_type).path" :alt="selectedProvider(item.provider_type).text" @@ -214,6 +228,9 @@ export default { <template #cell(node_size)="{ item }"> <span v-if="item.nodes">{{ item.nodes.length }}</span> + + <gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" /> + <small v-else class="gl-font-sm gl-font-style-italic gl-text-gray-400">{{ __('Unknown') }}</small> @@ -231,6 +248,8 @@ export default { > </gl-sprintf> </span> + + <gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" /> </template> <template #cell(total_memory)="{ item }"> @@ -245,6 +264,8 @@ export default { > </gl-sprintf> </span> + + <gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" /> </template> <template #cell(cluster_type)="{value}"> diff --git a/app/assets/javascripts/clusters_list/store/actions.js b/app/assets/javascripts/clusters_list/store/actions.js index 5245c307c8c..dddcfb3d975 100644 --- a/app/assets/javascripts/clusters_list/store/actions.js +++ b/app/assets/javascripts/clusters_list/store/actions.js @@ -16,9 +16,18 @@ const allNodesPresent = (clusters, retryCount) => { return retryCount > MAX_REQUESTS || clusters.every(cluster => cluster.nodes != null); }; -export const fetchClusters = ({ state, commit }) => { +export const reportSentryError = (_store, { error, tag }) => { + Sentry.withScope(scope => { + scope.setTag('javascript_clusters_list', tag); + Sentry.captureException(error); + }); +}; + +export const fetchClusters = ({ state, commit, dispatch }) => { let retryCount = 0; + commit(types.SET_LOADING_NODES, true); + const poll = new Poll({ resource: { fetchClusters: paginatedEndPoint => axios.get(paginatedEndPoint), @@ -34,31 +43,30 @@ export const fetchClusters = ({ state, commit }) => { const paginationInformation = parseIntPagination(normalizedHeaders); commit(types.SET_CLUSTERS_DATA, { data, paginationInformation }); - commit(types.SET_LOADING_STATE, false); + commit(types.SET_LOADING_CLUSTERS, false); if (allNodesPresent(data.clusters, retryCount)) { poll.stop(); + commit(types.SET_LOADING_NODES, false); } } } catch (error) { poll.stop(); - Sentry.withScope(scope => { - scope.setTag('javascript_clusters_list', 'fetchClustersSuccessCallback'); - Sentry.captureException(error); - }); + commit(types.SET_LOADING_CLUSTERS, false); + commit(types.SET_LOADING_NODES, false); + + dispatch('reportSentryError', { error, tag: 'fetchClustersSuccessCallback' }); } }, errorCallback: response => { poll.stop(); - commit(types.SET_LOADING_STATE, false); + commit(types.SET_LOADING_CLUSTERS, false); + commit(types.SET_LOADING_NODES, false); flash(__('Clusters|An error occurred while loading clusters')); - Sentry.withScope(scope => { - scope.setTag('javascript_clusters_list', 'fetchClustersErrorCallback'); - Sentry.captureException(response); - }); + dispatch('reportSentryError', { error: response, tag: 'fetchClustersErrorCallback' }); }, }); diff --git a/app/assets/javascripts/clusters_list/store/mutation_types.js b/app/assets/javascripts/clusters_list/store/mutation_types.js index a5275f28c13..beb4388c93e 100644 --- a/app/assets/javascripts/clusters_list/store/mutation_types.js +++ b/app/assets/javascripts/clusters_list/store/mutation_types.js @@ -1,3 +1,4 @@ export const SET_CLUSTERS_DATA = 'SET_CLUSTERS_DATA'; -export const SET_LOADING_STATE = 'SET_LOADING_STATE'; +export const SET_LOADING_CLUSTERS = 'SET_LOADING_CLUSTERS'; +export const SET_LOADING_NODES = 'SET_LOADING_NODES'; export const SET_PAGE = 'SET_PAGE'; diff --git a/app/assets/javascripts/clusters_list/store/mutations.js b/app/assets/javascripts/clusters_list/store/mutations.js index 2a9df9f38f0..5b462928518 100644 --- a/app/assets/javascripts/clusters_list/store/mutations.js +++ b/app/assets/javascripts/clusters_list/store/mutations.js @@ -1,8 +1,11 @@ import * as types from './mutation_types'; export default { - [types.SET_LOADING_STATE](state, value) { - state.loading = value; + [types.SET_LOADING_CLUSTERS](state, value) { + state.loadingClusters = value; + }, + [types.SET_LOADING_NODES](state, value) { + state.loadingNodes = value; }, [types.SET_CLUSTERS_DATA](state, { data, paginationInformation }) { Object.assign(state, { diff --git a/app/assets/javascripts/clusters_list/store/state.js b/app/assets/javascripts/clusters_list/store/state.js index 0023b43ed92..51fafd49479 100644 --- a/app/assets/javascripts/clusters_list/store/state.js +++ b/app/assets/javascripts/clusters_list/store/state.js @@ -1,9 +1,11 @@ export default (initialState = {}) => ({ + ancestorHelperPath: initialState.ancestorHelpPath, endpoint: initialState.endpoint, hasAncestorClusters: false, - loading: true, clusters: [], clustersPerPage: 0, + loadingClusters: true, + loadingNodes: true, page: 1, providers: { aws: { path: initialState.imgTagsAwsPath, text: initialState.imgTagsAwsText }, diff --git a/app/assets/javascripts/code_navigation/components/popover.vue b/app/assets/javascripts/code_navigation/components/popover.vue index df5f89e4faf..b7fa3242fbf 100644 --- a/app/assets/javascripts/code_navigation/components/popover.vue +++ b/app/assets/javascripts/code_navigation/components/popover.vue @@ -1,10 +1,14 @@ <script> -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlTabs, GlTab, GlLink, GlBadge } from '@gitlab/ui'; import DocLine from './doc_line.vue'; export default { components: { GlButton, + GlTabs, + GlTab, + GlLink, + GlBadge, DocLine, }, props: { @@ -31,6 +35,9 @@ export default { }; }, computed: { + isCurrentDefinition() { + return this.data.definitionLineNumber - 1 === this.position.lineIndex; + }, positionStyles() { return { left: `${this.position.x - this.offsetLeft}px`, @@ -43,7 +50,7 @@ export default { } if (this.isDefinitionCurrentBlob) { - return `#${this.data.definition_path.split('#').pop()}`; + return `#L${this.data.definitionLineNumber}`; } return `${this.definitionPathPrefix}/${this.data.definition_path}`; @@ -51,6 +58,9 @@ export default { isDefinitionCurrentBlob() { return this.data.definition_path.indexOf(this.blobPath) === 0; }, + references() { + return this.data.references || []; + }, }, watch: { position: { @@ -79,27 +89,61 @@ export default { class="popover code-navigation-popover popover-font-size-normal gl-popover bs-popover-bottom show" > <div :style="{ left: `${offsetLeft}px` }" class="arrow"></div> - <div v-for="(hover, index) in data.hover" :key="index" class="border-bottom"> - <pre - v-if="hover.language" - ref="code-output" - :class="$options.colorScheme" - class="border-0 bg-transparent m-0 code highlight" - ><doc-line v-for="(tokens, tokenIndex) in hover.tokens" :key="tokenIndex" :language="hover.language" :tokens="tokens"/></pre> - <p v-else ref="doc-output" class="p-3 m-0"> - {{ hover.value }} - </p> - </div> - <div v-if="definitionPath" class="popover-body"> - <gl-button - :href="definitionPath" - :target="isDefinitionCurrentBlob ? null : '_blank'" - class="w-100" - variant="default" - data-testid="go-to-definition-btn" - > - {{ __('Go to definition') }} - </gl-button> - </div> + <gl-tabs nav-class="gl-hidden" content-class="gl-py-0"> + <gl-tab :title="__('Definition')"> + <div class="overflow-auto code-navigation-popover-container"> + <div + v-for="(hover, index) in data.hover" + :key="index" + :class="{ 'border-bottom': index !== data.hover.length - 1 }" + > + <pre + v-if="hover.language" + ref="code-output" + :class="$options.colorScheme" + class="border-0 bg-transparent m-0 code highlight text-wrap" + ><doc-line v-for="(tokens, tokenIndex) in hover.tokens" :key="tokenIndex" :language="hover.language" :tokens="tokens"/></pre> + <p v-else ref="doc-output" class="p-3 m-0"> + {{ hover.value }} + </p> + </div> + </div> + <div v-if="definitionPath || isCurrentDefinition" class="popover-body border-top"> + <span v-if="isCurrentDefinition" class="gl-font-weight-bold gl-font-base"> + {{ s__('CodeIntelligence|This is the definition') }} + </span> + <gl-button + v-else + :href="definitionPath" + :target="isDefinitionCurrentBlob ? null : '_blank'" + class="w-100" + variant="default" + data-testid="go-to-definition-btn" + > + {{ __('Go to definition') }} + </gl-button> + </div> + </gl-tab> + <gl-tab data-testid="references-tab" class="py-2"> + <template #title> + {{ __('References') }} + <gl-badge size="sm" class="gl-tab-counter-badge">{{ references.length }}</gl-badge> + </template> + <template v-if="references.length"> + <div v-for="(reference, index) in references" :key="index" class="gl-dropdown-item"> + <gl-link + :href="`${definitionPathPrefix}/${reference.path}`" + class="dropdown-item" + data-testid="reference-link" + > + {{ reference.path }} + </gl-link> + </div> + </template> + <p v-else class="gl-my-4 gl-px-4"> + {{ s__('CodeNavigation|No references found') }} + </p> + </gl-tab> + </gl-tabs> </div> </template> diff --git a/app/assets/javascripts/code_navigation/store/actions.js b/app/assets/javascripts/code_navigation/store/actions.js index 7b2669691bd..9a472ca014f 100644 --- a/app/assets/javascripts/code_navigation/store/actions.js +++ b/app/assets/javascripts/code_navigation/store/actions.js @@ -18,7 +18,10 @@ export default { .then(({ data }) => { const normalizedData = data.reduce((acc, d) => { if (d.hover) { - acc[`${d.start_line}:${d.start_char}`] = d; + acc[`${d.start_line}:${d.start_char}`] = { + ...d, + definitionLineNumber: parseInt(d.definition_path?.split('#L').pop() || 0, 10), + }; addInteractionClass(path, d); } return acc; @@ -67,6 +70,7 @@ export default { x: x || 0, y: y + window.scrollY || 0, height: el.offsetHeight, + lineIndex: parseInt(lineIndex, 10), }; definition = data[`${lineIndex}:${charIndex}`]; diff --git a/app/assets/javascripts/code_navigation/utils/index.js b/app/assets/javascripts/code_navigation/utils/index.js index 4d118852a94..bb33bc556af 100644 --- a/app/assets/javascripts/code_navigation/utils/index.js +++ b/app/assets/javascripts/code_navigation/utils/index.js @@ -22,6 +22,7 @@ export const addInteractionClass = (path, d) => { el.setAttribute('data-char-index', d.start_char); el.setAttribute('data-line-index', d.start_line); el.classList.add('cursor-pointer', 'code-navigation', 'js-code-navigation'); + el.closest('.line').classList.add('code-navigation-line'); } }); }; diff --git a/app/assets/javascripts/commit_merge_requests.js b/app/assets/javascripts/commit_merge_requests.js index 3a0ab119df6..3cdb1587a3b 100644 --- a/app/assets/javascripts/commit_merge_requests.js +++ b/app/assets/javascripts/commit_merge_requests.js @@ -15,14 +15,14 @@ export function createHeader(childElementCount, mergeRequestCount) { const headerText = getHeaderText(childElementCount, mergeRequestCount); return $('<span />', { - class: 'append-right-5', + class: 'gl-mr-2', text: headerText, }); } export function createLink(mergeRequest) { return $('<a />', { - class: 'append-right-5', + class: 'gl-mr-2', href: mergeRequest.path, text: `!${mergeRequest.iid}`, }); diff --git a/app/assets/javascripts/commons/polyfills.js b/app/assets/javascripts/commons/polyfills.js index fdeb64a7644..655109bad9a 100644 --- a/app/assets/javascripts/commons/polyfills.js +++ b/app/assets/javascripts/commons/polyfills.js @@ -1,27 +1,24 @@ -// Browser polyfills - -/** - * Polyfill: fetch - * @what https://fetch.spec.whatwg.org/ - * @why Because Apollo GraphQL client relies on fetch - * @browsers Internet Explorer 11 - * @see https://caniuse.com/#feat=fetch - */ -import 'unfetch/polyfill/index'; - /** - * Polyfill: FormData APIs - * @what delete(), get(), getAll(), has(), set(), entries(), keys(), values(), - * and support for for...of - * @why Because Apollo GraphQL client relies on fetch - * @browsers Internet Explorer 11, Edge < 18 - * @see https://caniuse.com/#feat=mdn-api_formdata and subfeatures + * Polyfill + * @what requestIdleCallback + * @why To align browser features + * @browsers Safari (all versions) + * @see https://caniuse.com/#feat=requestidlecallback */ -import 'formdata-polyfill'; +window.requestIdleCallback = + window.requestIdleCallback || + function requestShim(cb) { + const start = Date.now(); + return setTimeout(() => { + cb({ + didTimeout: false, + timeRemaining: () => Math.max(0, 50 - (Date.now() - start)), + }); + }, 1); + }; -import './polyfills/custom_event'; -import './polyfills/element'; -import './polyfills/event'; -import './polyfills/nodelist'; -import './polyfills/request_idle_callback'; -import './polyfills/svg'; +window.cancelIdleCallback = + window.cancelIdleCallback || + function cancelShim(id) { + clearTimeout(id); + }; diff --git a/app/assets/javascripts/commons/polyfills/custom_event.js b/app/assets/javascripts/commons/polyfills/custom_event.js deleted file mode 100644 index 6b14eff6f05..00000000000 --- a/app/assets/javascripts/commons/polyfills/custom_event.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Polyfill: CustomEvent constructor - * @what new CustomEvent() - * @why Certain features, e.g. notes utilize this - * @browsers Internet Explorer 11 - * @see https://caniuse.com/#feat=customevent - */ -if (typeof window.CustomEvent !== 'function') { - window.CustomEvent = function CustomEvent(event, params) { - const evt = document.createEvent('CustomEvent'); - const evtParams = { - bubbles: false, - cancelable: false, - detail: undefined, - ...params, - }; - evt.initCustomEvent(event, evtParams.bubbles, evtParams.cancelable, evtParams.detail); - return evt; - }; - window.CustomEvent.prototype = Event; -} diff --git a/app/assets/javascripts/commons/polyfills/element.js b/app/assets/javascripts/commons/polyfills/element.js deleted file mode 100644 index b13ceccf511..00000000000 --- a/app/assets/javascripts/commons/polyfills/element.js +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Polyfill - * @what Element.classList - * @why In order to align browser features - * @browsers Internet Explorer 11 - * @see https://caniuse.com/#feat=classlist - */ -import 'classlist-polyfill'; - -/** - * Polyfill - * @what Element.closest - * @why In order to align browser features - * @browsers Internet Explorer 11 - * @see https://caniuse.com/#feat=element-closest - */ -Element.prototype.closest = - Element.prototype.closest || - function closest(selector, selectedElement = this) { - if (!selectedElement) return null; - return selectedElement.matches(selector) - ? selectedElement - : Element.prototype.closest(selector, selectedElement.parentElement); - }; - -/** - * Polyfill - * @what Element.matches - * @why In order to align browser features - * @browsers Internet Explorer 11 - * @see https://caniuse.com/#feat=mdn-api_element_matches - */ -Element.prototype.matches = - Element.prototype.matches || - Element.prototype.matchesSelector || - Element.prototype.mozMatchesSelector || - Element.prototype.msMatchesSelector || - Element.prototype.oMatchesSelector || - Element.prototype.webkitMatchesSelector || - function matches(selector) { - const elms = (this.document || this.ownerDocument).querySelectorAll(selector); - let i = elms.length - 1; - while (i >= 0 && elms.item(i) !== this) { - i -= 1; - } - return i > -1; - }; - -/** - * Polyfill - * @what ChildNode.remove, Element.remove, CharacterData.remove, DocumentType.remove - * @why In order to align browser features - * @browsers Internet Explorer 11 - * @see https://caniuse.com/#feat=childnode-remove - * - * From the polyfill on MDN, https://developer.mozilla.org/en-US/docs/Web/API/ChildNode/remove#Polyfill - */ -(arr => { - arr.forEach(item => { - if (Object.prototype.hasOwnProperty.call(item, 'remove')) { - return; - } - Object.defineProperty(item, 'remove', { - configurable: true, - enumerable: true, - writable: true, - value: function remove() { - if (this.parentNode !== null) { - this.parentNode.removeChild(this); - } - }, - }); - }); -})([Element.prototype, CharacterData.prototype, DocumentType.prototype]); diff --git a/app/assets/javascripts/commons/polyfills/event.js b/app/assets/javascripts/commons/polyfills/event.js deleted file mode 100644 index 543dd5f9a93..00000000000 --- a/app/assets/javascripts/commons/polyfills/event.js +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Polyfill: Event constructor - * @what new Event() - * @why To align browser support - * @browsers Internet Explorer 11 - * @see https://caniuse.com/#feat=mdn-api_event_event - * - * Although `initEvent` is deprecated for modern browsers it is the one supported by IE - */ -if (typeof window.Event !== 'function') { - window.Event = function Event(event, params) { - const evt = document.createEvent('Event'); - const evtParams = { - bubbles: false, - cancelable: false, - ...params, - }; - evt.initEvent(event, evtParams.bubbles, evtParams.cancelable); - return evt; - }; - window.Event.prototype = Event; -} diff --git a/app/assets/javascripts/commons/polyfills/nodelist.js b/app/assets/javascripts/commons/polyfills/nodelist.js deleted file mode 100644 index 3a9111e64f8..00000000000 --- a/app/assets/javascripts/commons/polyfills/nodelist.js +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Polyfill - * @what NodeList.forEach - * @why To align browser support - * @browsers Internet Explorer 11 - * @see https://caniuse.com/#feat=mdn-api_nodelist_foreach - */ -if (window.NodeList && !NodeList.prototype.forEach) { - NodeList.prototype.forEach = function forEach(callback, thisArg = window) { - for (let i = 0; i < this.length; i += 1) { - callback.call(thisArg, this[i], i, this); - } - }; -} diff --git a/app/assets/javascripts/commons/polyfills/request_idle_callback.js b/app/assets/javascripts/commons/polyfills/request_idle_callback.js deleted file mode 100644 index 51dc82e593a..00000000000 --- a/app/assets/javascripts/commons/polyfills/request_idle_callback.js +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Polyfill - * @what requestIdleCallback - * @why To align browser features - * @browsers Safari (all versions), Internet Explorer 11 - * @see https://caniuse.com/#feat=requestidlecallback - */ -window.requestIdleCallback = - window.requestIdleCallback || - function requestShim(cb) { - const start = Date.now(); - return setTimeout(() => { - cb({ - didTimeout: false, - timeRemaining: () => Math.max(0, 50 - (Date.now() - start)), - }); - }, 1); - }; - -window.cancelIdleCallback = - window.cancelIdleCallback || - function cancelShim(id) { - clearTimeout(id); - }; diff --git a/app/assets/javascripts/commons/polyfills/svg.js b/app/assets/javascripts/commons/polyfills/svg.js deleted file mode 100644 index 92a8b03fbb4..00000000000 --- a/app/assets/javascripts/commons/polyfills/svg.js +++ /dev/null @@ -1,11 +0,0 @@ -/** - * polyfill support for external SVG file references via <use xlink:href> - * @what polyfill support for external SVG file references via <use xlink:href> - * @why This is used in our GitLab SVG icon library - * @browsers Internet Explorer 11 - * @see https://caniuse.com/#feat=mdn-svg_elements_use_external_uri - * @see https//css-tricks.com/svg-use-external-source/ - */ -import svg4everybody from 'svg4everybody'; - -svg4everybody(); diff --git a/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue b/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue index 3c18608eb75..4b15bd55cbd 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue +++ b/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue @@ -25,11 +25,6 @@ export default { default: '', required: false, }, - canEdit: { - type: Boolean, - default: false, - required: false, - }, }, computed: { hasValue() { diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue index 5505704f430..1b8668b533e 100644 --- a/app/assets/javascripts/deploy_keys/components/app.vue +++ b/app/assets/javascripts/deploy_keys/components/app.vue @@ -7,12 +7,14 @@ import eventHub from '../eventhub'; import DeployKeysService from '../service'; import DeployKeysStore from '../store'; import KeysPanel from './keys_panel.vue'; +import Icon from '~/vue_shared/components/icon.vue'; export default { components: { KeysPanel, NavigationTabs, GlLoadingIcon, + Icon, }, props: { endpoint: { @@ -115,7 +117,7 @@ export default { </script> <template> - <div class="append-bottom-default deploy-keys"> + <div class="gl-mb-3 deploy-keys"> <gl-loading-icon v-if="isLoading && !hasKeys" :label="s__('DeployKeys|Loading deploy keys')" @@ -123,8 +125,8 @@ export default { /> <template v-else-if="hasKeys"> <div class="top-area scrolling-tabs-container inner-page-scroll-tabs"> - <div class="fade-left"><i class="fa fa-angle-left" aria-hidden="true"> </i></div> - <div class="fade-right"><i class="fa fa-angle-right" aria-hidden="true"> </i></div> + <div class="fade-left"><icon name="chevron-lg-left" :size="12" /></div> + <div class="fade-right"><icon name="chevron-lg-right" :size="12" /></div> <navigation-tabs :tabs="tabs" scope="deployKeys" @onChangeTab="onChangeTab" /> </div> diff --git a/app/assets/javascripts/design_management/components/design_destroyer.vue b/app/assets/javascripts/design_management/components/design_destroyer.vue index ad3f2736c4a..62460ca551c 100644 --- a/app/assets/javascripts/design_management/components/design_destroyer.vue +++ b/app/assets/javascripts/design_management/components/design_destroyer.vue @@ -1,7 +1,7 @@ <script> import { ApolloMutation } from 'vue-apollo'; import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql'; -import destroyDesignMutation from '../graphql/mutations/destroyDesign.mutation.graphql'; +import destroyDesignMutation from '../graphql/mutations/destroy_design.mutation.graphql'; import { updateStoreAfterDesignsDelete } from '../utils/cache_update'; export default { diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue index 7e442bb295f..4aaf43e3a5b 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue @@ -5,9 +5,9 @@ import { s__ } from '~/locale'; import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import allVersionsMixin from '../../mixins/all_versions'; -import createNoteMutation from '../../graphql/mutations/createNote.mutation.graphql'; +import createNoteMutation from '../../graphql/mutations/create_note.mutation.graphql'; import toggleResolveDiscussionMutation from '../../graphql/mutations/toggle_resolve_discussion.mutation.graphql'; -import getDesignQuery from '../../graphql/queries/getDesign.query.graphql'; +import getDesignQuery from '../../graphql/queries/get_design.query.graphql'; import activeDiscussionQuery from '../../graphql/queries/active_discussion.query.graphql'; import DesignNote from './design_note.vue'; import DesignReplyForm from './design_reply_form.vue'; diff --git a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue index 756da7f55aa..969034909f2 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue @@ -62,7 +62,7 @@ export default { }, }, mounted() { - this.$refs.textarea.focus(); + this.focusInput(); }, methods: { submitForm() { @@ -75,6 +75,9 @@ export default { this.$emit('cancelForm'); } }, + focusInput() { + this.$refs.textarea.focus(); + }, }, }; </script> diff --git a/app/assets/javascripts/design_management/components/toolbar/index.vue b/app/assets/javascripts/design_management/components/toolbar/index.vue index ea9f7300981..b998dfc47b8 100644 --- a/app/assets/javascripts/design_management/components/toolbar/index.vue +++ b/app/assets/javascripts/design_management/components/toolbar/index.vue @@ -6,7 +6,7 @@ import timeagoMixin from '~/vue_shared/mixins/timeago'; import Pagination from './pagination.vue'; import DeleteButton from '../delete_button.vue'; import permissionsQuery from '../../graphql/queries/design_permissions.query.graphql'; -import appDataQuery from '../../graphql/queries/appData.query.graphql'; +import appDataQuery from '../../graphql/queries/app_data.query.graphql'; import { DESIGNS_ROUTE_NAME } from '../../router/constants'; export default { diff --git a/app/assets/javascripts/design_management/components/upload/design_dropzone.vue b/app/assets/javascripts/design_management/components/upload/design_dropzone.vue index e2e1fc8bfad..33261134c15 100644 --- a/app/assets/javascripts/design_management/components/upload/design_dropzone.vue +++ b/app/assets/javascripts/design_management/components/upload/design_dropzone.vue @@ -1,7 +1,7 @@ <script> import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; import createFlash from '~/flash'; -import uploadDesignMutation from '../../graphql/mutations/uploadDesign.mutation.graphql'; +import uploadDesignMutation from '../../graphql/mutations/upload_design.mutation.graphql'; import { UPLOAD_DESIGN_INVALID_FILETYPE_ERROR } from '../../utils/error_messages'; import { isValidDesignFile } from '../../utils/design_management_utils'; import { VALID_DATA_TRANSFER_TYPE, VALID_DESIGN_FILE_MIMETYPE } from '../../constants'; diff --git a/app/assets/javascripts/design_management/graphql/fragments/design.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/design.fragment.graphql index c1439c56ff5..4b1703e41c3 100644 --- a/app/assets/javascripts/design_management/graphql/fragments/design.fragment.graphql +++ b/app/assets/javascripts/design_management/graphql/fragments/design.fragment.graphql @@ -1,6 +1,6 @@ -#import "./designNote.fragment.graphql" -#import "./designList.fragment.graphql" -#import "./diffRefs.fragment.graphql" +#import "./design_note.fragment.graphql" +#import "./design_list.fragment.graphql" +#import "./diff_refs.fragment.graphql" #import "./discussion_resolved_status.fragment.graphql" fragment DesignItem on Design { diff --git a/app/assets/javascripts/design_management/graphql/fragments/designList.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/design_list.fragment.graphql index bc3132f9b42..bc3132f9b42 100644 --- a/app/assets/javascripts/design_management/graphql/fragments/designList.fragment.graphql +++ b/app/assets/javascripts/design_management/graphql/fragments/design_list.fragment.graphql diff --git a/app/assets/javascripts/design_management/graphql/fragments/designNote.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/design_note.fragment.graphql index cb7cfd89abf..26edd2c0be1 100644 --- a/app/assets/javascripts/design_management/graphql/fragments/designNote.fragment.graphql +++ b/app/assets/javascripts/design_management/graphql/fragments/design_note.fragment.graphql @@ -1,4 +1,4 @@ -#import "./diffRefs.fragment.graphql" +#import "./diff_refs.fragment.graphql" #import "~/graphql_shared/fragments/author.fragment.graphql" #import "./note_permissions.fragment.graphql" diff --git a/app/assets/javascripts/design_management/graphql/fragments/diffRefs.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/diff_refs.fragment.graphql index 984a55814b0..984a55814b0 100644 --- a/app/assets/javascripts/design_management/graphql/fragments/diffRefs.fragment.graphql +++ b/app/assets/javascripts/design_management/graphql/fragments/diff_refs.fragment.graphql diff --git a/app/assets/javascripts/design_management/graphql/mutations/createImageDiffNote.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/create_image_diff_note.mutation.graphql index 9e2931b23f2..c8ade328120 100644 --- a/app/assets/javascripts/design_management/graphql/mutations/createImageDiffNote.mutation.graphql +++ b/app/assets/javascripts/design_management/graphql/mutations/create_image_diff_note.mutation.graphql @@ -1,4 +1,4 @@ -#import "../fragments/designNote.fragment.graphql" +#import "../fragments/design_note.fragment.graphql" mutation createImageDiffNote($input: CreateImageDiffNoteInput!) { createImageDiffNote(input: $input) { diff --git a/app/assets/javascripts/design_management/graphql/mutations/createNote.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/create_note.mutation.graphql index 3ae478d658e..184ee6955dc 100644 --- a/app/assets/javascripts/design_management/graphql/mutations/createNote.mutation.graphql +++ b/app/assets/javascripts/design_management/graphql/mutations/create_note.mutation.graphql @@ -1,4 +1,4 @@ -#import "../fragments/designNote.fragment.graphql" +#import "../fragments/design_note.fragment.graphql" mutation createNote($input: CreateNoteInput!) { createNote(input: $input) { diff --git a/app/assets/javascripts/design_management/graphql/mutations/destroyDesign.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/destroy_design.mutation.graphql index 0b3cf636cdb..0b3cf636cdb 100644 --- a/app/assets/javascripts/design_management/graphql/mutations/destroyDesign.mutation.graphql +++ b/app/assets/javascripts/design_management/graphql/mutations/destroy_design.mutation.graphql diff --git a/app/assets/javascripts/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql index d5f54ec9b58..1157fc05d5f 100644 --- a/app/assets/javascripts/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql +++ b/app/assets/javascripts/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql @@ -1,4 +1,4 @@ -#import "../fragments/designNote.fragment.graphql" +#import "../fragments/design_note.fragment.graphql" #import "../fragments/discussion_resolved_status.fragment.graphql" mutation toggleResolveDiscussion($id: ID!, $resolve: Boolean!) { diff --git a/app/assets/javascripts/design_management/graphql/mutations/update_active_discussion.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/update_active_discussion.mutation.graphql index 343de4e3025..a24b6737159 100644 --- a/app/assets/javascripts/design_management/graphql/mutations/update_active_discussion.mutation.graphql +++ b/app/assets/javascripts/design_management/graphql/mutations/update_active_discussion.mutation.graphql @@ -1,3 +1,3 @@ mutation updateActiveDiscussion($id: String, $source: String) { - updateActiveDiscussion (id: $id, source: $source ) @client + updateActiveDiscussion(id: $id, source: $source) @client } diff --git a/app/assets/javascripts/design_management/graphql/mutations/updateImageDiffNote.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/update_image_diff_note.mutation.graphql index cdb2264d233..5562ca9d89f 100644 --- a/app/assets/javascripts/design_management/graphql/mutations/updateImageDiffNote.mutation.graphql +++ b/app/assets/javascripts/design_management/graphql/mutations/update_image_diff_note.mutation.graphql @@ -1,4 +1,4 @@ -#import "../fragments/designNote.fragment.graphql" +#import "../fragments/design_note.fragment.graphql" mutation updateImageDiffNote($input: UpdateImageDiffNoteInput!) { updateImageDiffNote(input: $input) { diff --git a/app/assets/javascripts/design_management/graphql/mutations/update_note.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/update_note.mutation.graphql index d96b2f3934a..b995e99fb6a 100644 --- a/app/assets/javascripts/design_management/graphql/mutations/update_note.mutation.graphql +++ b/app/assets/javascripts/design_management/graphql/mutations/update_note.mutation.graphql @@ -1,4 +1,4 @@ -#import "../fragments/designNote.fragment.graphql" +#import "../fragments/design_note.fragment.graphql" mutation updateNote($input: UpdateNoteInput!) { updateNote(input: $input) { diff --git a/app/assets/javascripts/design_management/graphql/mutations/uploadDesign.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql index 904acef599b..d694e6558a0 100644 --- a/app/assets/javascripts/design_management/graphql/mutations/uploadDesign.mutation.graphql +++ b/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql @@ -11,7 +11,7 @@ mutation uploadDesign($files: [Upload!]!, $projectPath: ID!, $iid: ID!) { sha } } - }, + } } skippedDesigns { filename diff --git a/app/assets/javascripts/design_management/graphql/queries/appData.query.graphql b/app/assets/javascripts/design_management/graphql/queries/app_data.query.graphql index e1269761206..e1269761206 100644 --- a/app/assets/javascripts/design_management/graphql/queries/appData.query.graphql +++ b/app/assets/javascripts/design_management/graphql/queries/app_data.query.graphql diff --git a/app/assets/javascripts/design_management/graphql/queries/getDesign.query.graphql b/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql index 07a9af55787..07a9af55787 100644 --- a/app/assets/javascripts/design_management/graphql/queries/getDesign.query.graphql +++ b/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql diff --git a/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql b/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql index 857f205ab07..121a50555b3 100644 --- a/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql +++ b/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql @@ -1,4 +1,4 @@ -#import "../fragments/designList.fragment.graphql" +#import "../fragments/design_list.fragment.graphql" #import "../fragments/version.fragment.graphql" query getDesignList($fullPath: ID!, $iid: String!, $atVersion: ID) { diff --git a/app/assets/javascripts/design_management/index.js b/app/assets/javascripts/design_management/index.js index eb00e1742ea..1fc5779515a 100644 --- a/app/assets/javascripts/design_management/index.js +++ b/app/assets/javascripts/design_management/index.js @@ -1,3 +1,6 @@ +// This application is being moved, please do not touch this files +// Please see https://gitlab.com/gitlab-org/gitlab/-/issues/14744#note_364468096 for details + import $ from 'jquery'; import Vue from 'vue'; import createRouter from './router'; diff --git a/app/assets/javascripts/design_management/mixins/all_versions.js b/app/assets/javascripts/design_management/mixins/all_versions.js index 41c93064c26..3966fe71732 100644 --- a/app/assets/javascripts/design_management/mixins/all_versions.js +++ b/app/assets/javascripts/design_management/mixins/all_versions.js @@ -1,5 +1,5 @@ import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql'; -import appDataQuery from '../graphql/queries/appData.query.graphql'; +import appDataQuery from '../graphql/queries/app_data.query.graphql'; import { findVersionId } from '../utils/design_management_utils'; export default { diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue index fe121b6530a..9a959222e22 100644 --- a/app/assets/javascripts/design_management/pages/design/index.vue +++ b/app/assets/javascripts/design_management/pages/design/index.vue @@ -11,10 +11,10 @@ import DesignScaler from '../../components/design_scaler.vue'; import DesignPresentation from '../../components/design_presentation.vue'; import DesignReplyForm from '../../components/design_notes/design_reply_form.vue'; import DesignSidebar from '../../components/design_sidebar.vue'; -import getDesignQuery from '../../graphql/queries/getDesign.query.graphql'; -import appDataQuery from '../../graphql/queries/appData.query.graphql'; -import createImageDiffNoteMutation from '../../graphql/mutations/createImageDiffNote.mutation.graphql'; -import updateImageDiffNoteMutation from '../../graphql/mutations/updateImageDiffNote.mutation.graphql'; +import getDesignQuery from '../../graphql/queries/get_design.query.graphql'; +import appDataQuery from '../../graphql/queries/app_data.query.graphql'; +import createImageDiffNoteMutation from '../../graphql/mutations/create_image_diff_note.mutation.graphql'; +import updateImageDiffNoteMutation from '../../graphql/mutations/update_image_diff_note.mutation.graphql'; import updateActiveDiscussionMutation from '../../graphql/mutations/update_active_discussion.mutation.graphql'; import { extractDiscussions, @@ -254,6 +254,9 @@ export default { }, openCommentForm(annotationCoordinates) { this.annotationCoordinates = annotationCoordinates; + if (this.$refs.newDiscussionForm) { + this.$refs.newDiscussionForm.focusInput(); + } }, closeCommentForm() { this.comment = ''; @@ -361,6 +364,7 @@ export default { @error="onCreateImageDiffNoteError" > <design-reply-form + ref="newDiscussionForm" v-model="comment" :is-saving="loading" :markdown-preview-path="markdownPreviewPath" diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue index 922c800009f..d14a1fc8c1c 100644 --- a/app/assets/javascripts/design_management/pages/index.vue +++ b/app/assets/javascripts/design_management/pages/index.vue @@ -8,7 +8,7 @@ import Design from '../components/list/item.vue'; import DesignDestroyer from '../components/design_destroyer.vue'; import DesignVersionDropdown from '../components/upload/design_version_dropdown.vue'; import DesignDropzone from '../components/upload/design_dropzone.vue'; -import uploadDesignMutation from '../graphql/mutations/uploadDesign.mutation.graphql'; +import uploadDesignMutation from '../graphql/mutations/upload_design.mutation.graphql'; import permissionsQuery from '../graphql/queries/design_permissions.query.graphql'; import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql'; import allDesignsMixin from '../mixins/all_designs'; diff --git a/app/assets/javascripts/design_management/utils/tracking.js b/app/assets/javascripts/design_management/utils/tracking.js index 39c20376271..b3ecc1453a6 100644 --- a/app/assets/javascripts/design_management/utils/tracking.js +++ b/app/assets/javascripts/design_management/utils/tracking.js @@ -1,18 +1,9 @@ import Tracking from '~/tracking'; -function assembleDesignPayload(payloadArr) { - return { - value: { - 'internal-object-refrerer': payloadArr[0], - 'design-collection-owner': payloadArr[1], - 'design-version-number': payloadArr[2], - 'design-is-current-version': payloadArr[3], - }, - }; -} - // Tracking Constants +const DESIGN_TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/design_management_context/jsonschema/1-0-0'; const DESIGN_TRACKING_PAGE_NAME = 'projects:issues:design'; +const DESIGN_TRACKING_EVENT_NAME = 'view_design'; // eslint-disable-next-line import/prefer-default-export export function trackDesignDetailView( @@ -21,8 +12,16 @@ export function trackDesignDetailView( designVersion = 1, latestVersion = false, ) { - Tracking.event(DESIGN_TRACKING_PAGE_NAME, 'design_viewed', { - label: 'design_viewed', - ...assembleDesignPayload([referer, owner, designVersion, latestVersion]), + Tracking.event(DESIGN_TRACKING_PAGE_NAME, DESIGN_TRACKING_EVENT_NAME, { + label: DESIGN_TRACKING_EVENT_NAME, + context: { + schema: DESIGN_TRACKING_CONTEXT_SCHEMA, + data: { + 'design-version-number': designVersion, + 'design-is-current-version': latestVersion, + 'internal-object-referrer': referer, + 'design-collection-owner': owner, + }, + }, }); } diff --git a/app/assets/javascripts/design_management_new/components/app.vue b/app/assets/javascripts/design_management_new/components/app.vue new file mode 100644 index 00000000000..98240aef810 --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/app.vue @@ -0,0 +1,3 @@ +<template> + <router-view /> +</template> diff --git a/app/assets/javascripts/design_management_new/components/delete_button.vue b/app/assets/javascripts/design_management_new/components/delete_button.vue new file mode 100644 index 00000000000..77e1b97a227 --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/delete_button.vue @@ -0,0 +1,81 @@ +<script> +import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui'; +import { uniqueId } from 'lodash'; +import { s__ } from '~/locale'; + +export default { + name: 'DeleteButton', + components: { + GlButton, + GlModal, + }, + directives: { + GlModalDirective, + }, + props: { + isDeleting: { + type: Boolean, + required: false, + default: false, + }, + buttonClass: { + type: String, + required: false, + default: '', + }, + buttonVariant: { + type: String, + required: false, + default: 'info', + }, + buttonSize: { + type: String, + required: false, + default: 'medium', + }, + hasSelectedDesigns: { + type: Boolean, + required: false, + default: true, + }, + }, + data() { + return { + modalId: uniqueId('design-deletion-confirmation-'), + }; + }, + modal: { + title: s__('DesignManagement|Delete designs confirmation'), + actionPrimary: { + text: s__('Delete'), + attributes: { variant: 'danger' }, + }, + actionCancel: { + text: s__('Cancel'), + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-align-items-center gl-h-full"> + <gl-modal + :modal-id="modalId" + :title="$options.modal.title" + :action-primary="$options.modal.actionPrimary" + :action-cancel="$options.modal.actionCancel" + @ok="$emit('deleteSelectedDesigns')" + > + <p>{{ s__('DesignManagement|Are you sure you want to delete the selected designs?') }}</p> + </gl-modal> + <gl-button + v-gl-modal-directive="modalId" + :variant="buttonVariant" + :size="buttonSize" + :class="buttonClass" + :disabled="isDeleting || !hasSelectedDesigns" + > + <slot></slot> + </gl-button> + </div> +</template> diff --git a/app/assets/javascripts/design_management_new/components/design_destroyer.vue b/app/assets/javascripts/design_management_new/components/design_destroyer.vue new file mode 100644 index 00000000000..7ae569216f0 --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/design_destroyer.vue @@ -0,0 +1,67 @@ +<script> +import { ApolloMutation } from 'vue-apollo'; +import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql'; +import destroyDesignMutation from '../graphql/mutations/destroy_design.mutation.graphql'; +import { updateStoreAfterDesignsDelete } from '../utils/cache_update'; + +export default { + components: { + ApolloMutation, + }, + props: { + filenames: { + type: Array, + required: true, + }, + }, + inject: { + projectPath: { + default: '', + }, + iid: { + from: 'issueIid', + defaut: '', + }, + }, + computed: { + projectQueryBody() { + return { + query: getDesignListQuery, + variables: { fullPath: this.projectPath, iid: this.iid, atVersion: null }, + }; + }, + }, + methods: { + updateStoreAfterDelete( + store, + { + data: { designManagementDelete }, + }, + ) { + updateStoreAfterDesignsDelete( + store, + designManagementDelete, + this.projectQueryBody, + this.filenames, + ); + }, + }, + destroyDesignMutation, +}; +</script> + +<template> + <apollo-mutation + #default="{ mutate, loading, error }" + :mutation="$options.destroyDesignMutation" + :variables="{ + filenames, + projectPath, + iid, + }" + :update="updateStoreAfterDelete" + v-on="$listeners" + > + <slot v-bind="{ mutate, loading, error }"></slot> + </apollo-mutation> +</template> diff --git a/app/assets/javascripts/design_management_new/components/design_note_pin.vue b/app/assets/javascripts/design_management_new/components/design_note_pin.vue new file mode 100644 index 00000000000..0811397fbad --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/design_note_pin.vue @@ -0,0 +1,61 @@ +<script> +import { __, sprintf } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + name: 'DesignNotePin', + components: { + Icon, + }, + props: { + position: { + type: Object, + required: true, + }, + label: { + type: Number, + required: false, + default: null, + }, + repositioning: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + isNewNote() { + return this.label === null; + }, + pinStyle() { + return this.repositioning ? { ...this.position, cursor: 'move' } : this.position; + }, + pinLabel() { + return this.isNewNote + ? __('Comment form position') + : sprintf(__("Comment '%{label}' position"), { label: this.label }); + }, + }, +}; +</script> + +<template> + <button + :style="pinStyle" + :aria-label="pinLabel" + :class="{ + 'btn-transparent comment-indicator': isNewNote, + 'js-image-badge badge badge-pill': !isNewNote, + }" + class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center" + type="button" + @mousedown="$emit('mousedown', $event)" + @mouseup="$emit('mouseup', $event)" + @click="$emit('click', $event)" + > + <icon v-if="isNewNote" name="image-comment-dark" /> + <template v-else> + {{ label }} + </template> + </button> +</template> diff --git a/app/assets/javascripts/design_management_new/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management_new/components/design_notes/design_discussion.vue new file mode 100644 index 00000000000..4aaf43e3a5b --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/design_notes/design_discussion.vue @@ -0,0 +1,297 @@ +<script> +import { ApolloMutation } from 'vue-apollo'; +import { GlTooltipDirective, GlIcon, GlLoadingIcon, GlLink } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import allVersionsMixin from '../../mixins/all_versions'; +import createNoteMutation from '../../graphql/mutations/create_note.mutation.graphql'; +import toggleResolveDiscussionMutation from '../../graphql/mutations/toggle_resolve_discussion.mutation.graphql'; +import getDesignQuery from '../../graphql/queries/get_design.query.graphql'; +import activeDiscussionQuery from '../../graphql/queries/active_discussion.query.graphql'; +import DesignNote from './design_note.vue'; +import DesignReplyForm from './design_reply_form.vue'; +import { updateStoreAfterAddDiscussionComment } from '../../utils/cache_update'; +import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants'; +import ToggleRepliesWidget from './toggle_replies_widget.vue'; + +export default { + components: { + ApolloMutation, + DesignNote, + ReplyPlaceholder, + DesignReplyForm, + GlIcon, + GlLoadingIcon, + GlLink, + ToggleRepliesWidget, + TimeAgoTooltip, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [allVersionsMixin], + props: { + discussion: { + type: Object, + required: true, + }, + noteableId: { + type: String, + required: true, + }, + designId: { + type: String, + required: true, + }, + markdownPreviewPath: { + type: String, + required: false, + default: '', + }, + resolvedDiscussionsExpanded: { + type: Boolean, + required: true, + }, + discussionWithOpenForm: { + type: String, + required: true, + }, + }, + apollo: { + activeDiscussion: { + query: activeDiscussionQuery, + result({ data }) { + const discussionId = data.activeDiscussion.id; + if (this.discussion.resolved && !this.resolvedDiscussionsExpanded) { + return; + } + // We watch any changes to the active discussion from the design pins and scroll to this discussion if it exists + // We don't want scrollIntoView to be triggered from the discussion click itself + if ( + discussionId && + data.activeDiscussion.source === ACTIVE_DISCUSSION_SOURCE_TYPES.pin && + discussionId === this.discussion.notes[0].id + ) { + this.$el.scrollIntoView({ + behavior: 'smooth', + inline: 'start', + }); + } + }, + }, + }, + data() { + return { + discussionComment: '', + isFormRendered: false, + activeDiscussion: {}, + isResolving: false, + shouldChangeResolvedStatus: false, + areRepliesCollapsed: this.discussion.resolved, + }; + }, + computed: { + mutationPayload() { + return { + noteableId: this.noteableId, + body: this.discussionComment, + discussionId: this.discussion.id, + }; + }, + designVariables() { + return { + fullPath: this.projectPath, + iid: this.issueIid, + filenames: [this.$route.params.id], + atVersion: this.designsVersion, + }; + }, + isDiscussionHighlighted() { + return this.discussion.notes[0].id === this.activeDiscussion.id; + }, + resolveCheckboxText() { + return this.discussion.resolved + ? s__('DesignManagement|Unresolve thread') + : s__('DesignManagement|Resolve thread'); + }, + firstNote() { + return this.discussion.notes[0]; + }, + discussionReplies() { + return this.discussion.notes.slice(1); + }, + areRepliesShown() { + return !this.discussion.resolved || !this.areRepliesCollapsed; + }, + resolveIconName() { + return this.discussion.resolved ? 'check-circle-filled' : 'check-circle'; + }, + isRepliesWidgetVisible() { + return this.discussion.resolved && this.discussionReplies.length > 0; + }, + isReplyPlaceholderVisible() { + return this.areRepliesShown || !this.discussionReplies.length; + }, + isFormVisible() { + return this.isFormRendered && this.discussionWithOpenForm === this.discussion.id; + }, + }, + methods: { + addDiscussionComment( + store, + { + data: { createNote }, + }, + ) { + updateStoreAfterAddDiscussionComment( + store, + createNote, + getDesignQuery, + this.designVariables, + this.discussion.id, + ); + }, + onDone() { + this.discussionComment = ''; + this.hideForm(); + if (this.shouldChangeResolvedStatus) { + this.toggleResolvedStatus(); + } + }, + onCreateNoteError(err) { + this.$emit('createNoteError', err); + }, + hideForm() { + this.isFormRendered = false; + this.discussionComment = ''; + }, + showForm() { + this.$emit('openForm', this.discussion.id); + this.isFormRendered = true; + }, + toggleResolvedStatus() { + this.isResolving = true; + this.$apollo + .mutate({ + mutation: toggleResolveDiscussionMutation, + variables: { id: this.discussion.id, resolve: !this.discussion.resolved }, + }) + .then(({ data }) => { + if (data.errors?.length > 0) { + this.$emit('resolveDiscussionError', data.errors[0]); + } + }) + .catch(err => { + this.$emit('resolveDiscussionError', err); + }) + .finally(() => { + this.isResolving = false; + }); + }, + }, + createNoteMutation, +}; +</script> + +<template> + <div class="design-discussion-wrapper"> + <div + class="badge badge-pill gl-display-flex gl-align-items-center gl-justify-content-center" + :class="{ resolved: discussion.resolved }" + type="button" + > + {{ discussion.index }} + </div> + <ul + class="design-discussion bordered-box gl-relative gl-p-0 gl-list-style-none" + data-qa-selector="design_discussion_content" + > + <design-note + :note="firstNote" + :markdown-preview-path="markdownPreviewPath" + :is-resolving="isResolving" + :class="{ 'gl-bg-blue-50': isDiscussionHighlighted }" + @error="$emit('updateNoteError', $event)" + > + <template v-if="discussion.resolvable" #resolveDiscussion> + <button + v-gl-tooltip + :class="{ 'is-active': discussion.resolved }" + :title="resolveCheckboxText" + :aria-label="resolveCheckboxText" + type="button" + class="line-resolve-btn note-action-button gl-mr-3" + data-testid="resolve-button" + @click.stop="toggleResolvedStatus" + > + <gl-icon v-if="!isResolving" :name="resolveIconName" data-testid="resolve-icon" /> + <gl-loading-icon v-else inline /> + </button> + </template> + <template v-if="discussion.resolved" #resolvedStatus> + <p class="gl-text-gray-700 gl-font-sm gl-m-0 gl-mt-5" data-testid="resolved-message"> + {{ __('Resolved by') }} + <gl-link + class="gl-text-gray-700 gl-text-decoration-none gl-font-sm link-inherit-color" + :href="discussion.resolvedBy.webUrl" + target="_blank" + >{{ discussion.resolvedBy.name }}</gl-link + > + <time-ago-tooltip :time="discussion.resolvedAt" tooltip-placement="bottom" /> + </p> + </template> + </design-note> + <toggle-replies-widget + v-if="isRepliesWidgetVisible" + :collapsed="areRepliesCollapsed" + :replies="discussionReplies" + @toggle="areRepliesCollapsed = !areRepliesCollapsed" + /> + <design-note + v-for="note in discussionReplies" + v-show="areRepliesShown" + :key="note.id" + :note="note" + :markdown-preview-path="markdownPreviewPath" + :is-resolving="isResolving" + :class="{ 'gl-bg-blue-50': isDiscussionHighlighted }" + @error="$emit('updateNoteError', $event)" + /> + <li v-show="isReplyPlaceholderVisible" class="reply-wrapper"> + <reply-placeholder + v-if="!isFormVisible" + class="qa-discussion-reply" + :button-text="__('Reply...')" + @onClick="showForm" + /> + <apollo-mutation + v-else + #default="{ mutate, loading }" + :mutation="$options.createNoteMutation" + :variables="{ + input: mutationPayload, + }" + :update="addDiscussionComment" + @done="onDone" + @error="onCreateNoteError" + > + <design-reply-form + v-model="discussionComment" + :is-saving="loading" + :markdown-preview-path="markdownPreviewPath" + @submitForm="mutate" + @cancelForm="hideForm" + > + <template v-if="discussion.resolvable" #resolveCheckbox> + <label data-testid="resolve-checkbox"> + <input v-model="shouldChangeResolvedStatus" type="checkbox" /> + {{ resolveCheckboxText }} + </label> + </template> + </design-reply-form> + </apollo-mutation> + </li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/design_management_new/components/design_notes/design_note.vue b/app/assets/javascripts/design_management_new/components/design_notes/design_note.vue new file mode 100644 index 00000000000..172e61920ef --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/design_notes/design_note.vue @@ -0,0 +1,156 @@ +<script> +import { ApolloMutation } from 'vue-apollo'; +import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import updateNoteMutation from '../../graphql/mutations/update_note.mutation.graphql'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import DesignReplyForm from './design_reply_form.vue'; +import { findNoteId } from '../../utils/design_management_utils'; +import { hasErrors } from '../../utils/cache_update'; + +export default { + components: { + UserAvatarLink, + TimelineEntryItem, + TimeAgoTooltip, + DesignReplyForm, + ApolloMutation, + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + note: { + type: Object, + required: true, + }, + markdownPreviewPath: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + noteText: this.note.body, + isEditing: false, + }; + }, + computed: { + author() { + return this.note.author; + }, + noteAnchorId() { + return findNoteId(this.note.id); + }, + isNoteLinked() { + return this.$route.hash === `#note_${this.noteAnchorId}`; + }, + mutationPayload() { + return { + id: this.note.id, + body: this.noteText, + }; + }, + isEditButtonVisible() { + return !this.isEditing && this.note.userPermissions.adminNote; + }, + }, + mounted() { + if (this.isNoteLinked) { + this.$el.scrollIntoView({ behavior: 'smooth', inline: 'start' }); + } + }, + methods: { + hideForm() { + this.isEditing = false; + this.noteText = this.note.body; + }, + onDone({ data }) { + this.hideForm(); + if (hasErrors(data.updateNote)) { + this.$emit('error', data.errors[0]); + } + }, + }, + updateNoteMutation, +}; +</script> + +<template> + <timeline-entry-item :id="`note_${noteAnchorId}`" class="design-note note-form"> + <user-avatar-link + :link-href="author.webUrl" + :img-src="author.avatarUrl" + :img-alt="author.username" + :img-size="40" + /> + <div class="d-flex justify-content-between"> + <div> + <a + v-once + :href="author.webUrl" + class="js-user-link" + :data-user-id="author.id" + :data-username="author.username" + > + <span class="note-header-author-name bold">{{ author.name }}</span> + <span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span> + <span class="note-headline-light">@{{ author.username }}</span> + </a> + <span class="note-headline-light note-headline-meta"> + <span class="system-note-message"> <slot></slot> </span> + <template v-if="note.createdAt"> + <span class="system-note-separator"></span> + <a class="note-timestamp system-note-separator" :href="`#note_${noteAnchorId}`"> + <time-ago-tooltip :time="note.createdAt" tooltip-placement="bottom" /> + </a> + </template> + </span> + </div> + <div class="gl-display-flex"> + <slot name="resolveDiscussion"></slot> + <button + v-if="isEditButtonVisible" + v-gl-tooltip + type="button" + :title="__('Edit comment')" + class="note-action-button js-note-edit btn btn-transparent qa-note-edit-button" + @click="isEditing = true" + > + <gl-icon name="pencil" class="link-highlight" /> + </button> + </div> + </div> + <template v-if="!isEditing"> + <div + class="note-text js-note-text md" + data-qa-selector="note_content" + v-html="note.bodyHtml" + ></div> + <slot name="resolvedStatus"></slot> + </template> + <apollo-mutation + v-else + #default="{ mutate, loading }" + :mutation="$options.updateNoteMutation" + :variables="{ + input: mutationPayload, + }" + @error="$emit('error', $event)" + @done="onDone" + > + <design-reply-form + v-model="noteText" + :is-saving="loading" + :markdown-preview-path="markdownPreviewPath" + :is-new-comment="false" + class="mt-5" + @submitForm="mutate" + @cancelForm="hideForm" + /> + </apollo-mutation> + </timeline-entry-item> +</template> diff --git a/app/assets/javascripts/design_management_new/components/design_notes/design_reply_form.vue b/app/assets/javascripts/design_management_new/components/design_notes/design_reply_form.vue new file mode 100644 index 00000000000..969034909f2 --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/design_notes/design_reply_form.vue @@ -0,0 +1,141 @@ +<script> +import { GlDeprecatedButton, GlModal } from '@gitlab/ui'; +import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import { s__ } from '~/locale'; + +export default { + name: 'DesignReplyForm', + components: { + MarkdownField, + GlDeprecatedButton, + GlModal, + }, + props: { + markdownPreviewPath: { + type: String, + required: false, + default: '', + }, + value: { + type: String, + required: true, + }, + isSaving: { + type: Boolean, + required: true, + }, + isNewComment: { + type: Boolean, + required: false, + default: true, + }, + }, + data() { + return { + formText: this.value, + }; + }, + computed: { + hasValue() { + return this.value.trim().length > 0; + }, + modalSettings() { + if (this.isNewComment) { + return { + title: s__('DesignManagement|Cancel comment confirmation'), + okTitle: s__('DesignManagement|Discard comment'), + cancelTitle: s__('DesignManagement|Keep comment'), + content: s__('DesignManagement|Are you sure you want to cancel creating this comment?'), + }; + } + return { + title: s__('DesignManagement|Cancel comment update confirmation'), + okTitle: s__('DesignManagement|Cancel changes'), + cancelTitle: s__('DesignManagement|Keep changes'), + content: s__('DesignManagement|Are you sure you want to cancel changes to this comment?'), + }; + }, + buttonText() { + return this.isNewComment + ? s__('DesignManagement|Comment') + : s__('DesignManagement|Save comment'); + }, + }, + mounted() { + this.focusInput(); + }, + methods: { + submitForm() { + if (this.hasValue) this.$emit('submitForm'); + }, + cancelComment() { + if (this.hasValue && this.formText !== this.value) { + this.$refs.cancelCommentModal.show(); + } else { + this.$emit('cancelForm'); + } + }, + focusInput() { + this.$refs.textarea.focus(); + }, + }, +}; +</script> + +<template> + <form class="new-note common-note-form" @submit.prevent> + <markdown-field + :markdown-preview-path="markdownPreviewPath" + :can-attach-file="false" + :enable-autocomplete="true" + :textarea-value="value" + markdown-docs-path="/help/user/markdown" + class="bordered-box" + > + <template #textarea> + <textarea + ref="textarea" + :value="value" + class="note-textarea js-gfm-input js-autosize markdown-area" + dir="auto" + data-supports-quick-actions="false" + data-qa-selector="note_textarea" + :aria-label="__('Description')" + :placeholder="__('Write a comment…')" + @input="$emit('input', $event.target.value)" + @keydown.meta.enter="submitForm" + @keydown.ctrl.enter="submitForm" + @keyup.esc.stop="cancelComment" + > + </textarea> + </template> + </markdown-field> + <slot name="resolveCheckbox"></slot> + <div class="note-form-actions gl-display-flex gl-justify-content-space-between"> + <gl-deprecated-button + ref="submitButton" + :disabled="!hasValue || isSaving" + variant="success" + type="submit" + data-track-event="click_button" + data-qa-selector="save_comment_button" + @click="$emit('submitForm')" + > + {{ buttonText }} + </gl-deprecated-button> + <gl-deprecated-button ref="cancelButton" @click="cancelComment">{{ + __('Cancel') + }}</gl-deprecated-button> + </div> + <gl-modal + ref="cancelCommentModal" + ok-variant="danger" + :title="modalSettings.title" + :ok-title="modalSettings.okTitle" + :cancel-title="modalSettings.cancelTitle" + modal-id="cancel-comment-modal" + @ok="$emit('cancelForm')" + >{{ modalSettings.content }} + </gl-modal> + </form> +</template> diff --git a/app/assets/javascripts/design_management_new/components/design_notes/toggle_replies_widget.vue b/app/assets/javascripts/design_management_new/components/design_notes/toggle_replies_widget.vue new file mode 100644 index 00000000000..46c73e3eea8 --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/design_notes/toggle_replies_widget.vue @@ -0,0 +1,70 @@ +<script> +import { GlIcon, GlButton, GlLink } from '@gitlab/ui'; +import { __, n__ } from '~/locale'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + name: 'ToggleNotesWidget', + components: { + GlIcon, + GlButton, + GlLink, + TimeAgoTooltip, + }, + props: { + collapsed: { + type: Boolean, + required: true, + }, + replies: { + type: Array, + required: true, + }, + }, + computed: { + lastReply() { + return this.replies[this.replies.length - 1]; + }, + iconName() { + return this.collapsed ? 'chevron-right' : 'chevron-down'; + }, + toggleText() { + return this.collapsed + ? `${this.replies.length} ${n__('reply', 'replies', this.replies.length)}` + : __('Collapse replies'); + }, + }, +}; +</script> + +<template> + <li + class="toggle-comments gl-bg-gray-50 gl-display-flex gl-align-items-center gl-py-3" + :class="{ expanded: !collapsed }" + data-testid="toggle-comments-wrapper" + > + <gl-icon :name="iconName" class="gl-ml-3" @click.stop="$emit('toggle')" /> + <gl-button + variant="link" + class="toggle-comments-button gl-ml-2 gl-mr-2" + @click.stop="$emit('toggle')" + > + {{ toggleText }} + </gl-button> + <template v-if="collapsed"> + <span class="gl-text-gray-700">{{ __('Last reply by') }}</span> + <gl-link + :href="lastReply.author.webUrl" + target="_blank" + class="link-inherit-color gl-text-black-normal gl-text-decoration-none gl-font-weight-bold gl-ml-2 gl-mr-2" + > + {{ lastReply.author.name }} + </gl-link> + <time-ago-tooltip + :time="lastReply.createdAt" + tooltip-placement="bottom" + class="gl-text-gray-700" + /> + </template> + </li> +</template> diff --git a/app/assets/javascripts/design_management_new/components/design_overlay.vue b/app/assets/javascripts/design_management_new/components/design_overlay.vue new file mode 100644 index 00000000000..926e7c74802 --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/design_overlay.vue @@ -0,0 +1,287 @@ +<script> +import activeDiscussionQuery from '../graphql/queries/active_discussion.query.graphql'; +import updateActiveDiscussionMutation from '../graphql/mutations/update_active_discussion.mutation.graphql'; +import DesignNotePin from './design_note_pin.vue'; +import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../constants'; + +export default { + name: 'DesignOverlay', + components: { + DesignNotePin, + }, + props: { + dimensions: { + type: Object, + required: true, + }, + position: { + type: Object, + required: true, + }, + notes: { + type: Array, + required: false, + default: () => [], + }, + currentCommentForm: { + type: Object, + required: false, + default: null, + }, + disableCommenting: { + type: Boolean, + required: false, + default: false, + }, + resolvedDiscussionsExpanded: { + type: Boolean, + required: true, + }, + }, + apollo: { + activeDiscussion: { + query: activeDiscussionQuery, + }, + }, + data() { + return { + movingNoteNewPosition: null, + movingNoteStartPosition: null, + activeDiscussion: {}, + }; + }, + computed: { + overlayStyle() { + const cursor = this.disableCommenting ? 'unset' : undefined; + + return { + cursor, + width: `${this.dimensions.width}px`, + height: `${this.dimensions.height}px`, + ...this.position, + }; + }, + isMovingCurrentComment() { + return Boolean(this.movingNoteStartPosition && !this.movingNoteStartPosition.noteId); + }, + currentCommentPositionStyle() { + return this.isMovingCurrentComment && this.movingNoteNewPosition + ? this.getNotePositionStyle(this.movingNoteNewPosition) + : this.getNotePositionStyle(this.currentCommentForm); + }, + }, + methods: { + setNewNoteCoordinates({ x, y }) { + this.$emit('openCommentForm', { x, y }); + }, + getNoteRelativePosition(position) { + const { x, y, width, height } = position; + const widthRatio = this.dimensions.width / width; + const heightRatio = this.dimensions.height / height; + return { + left: Math.round(x * widthRatio), + top: Math.round(y * heightRatio), + }; + }, + getNotePositionStyle(position) { + const { left, top } = this.getNoteRelativePosition(position); + return { + left: `${left}px`, + top: `${top}px`, + }; + }, + getMovingNotePositionDelta(e) { + let deltaX = 0; + let deltaY = 0; + + if (this.movingNoteStartPosition) { + const { clientX, clientY } = this.movingNoteStartPosition; + deltaX = e.clientX - clientX; + deltaY = e.clientY - clientY; + } + + return { + deltaX, + deltaY, + }; + }, + isMovingNote(noteId) { + const movingNoteId = this.movingNoteStartPosition?.noteId; + return Boolean(movingNoteId && movingNoteId === noteId); + }, + canMoveNote(note) { + const { userPermissions } = note; + const { adminNote } = userPermissions || {}; + + return Boolean(adminNote); + }, + isPositionInOverlay(position) { + const { top, left } = this.getNoteRelativePosition(position); + const { height, width } = this.dimensions; + + return top >= 0 && top <= height && left >= 0 && left <= width; + }, + onNewNoteMove(e) { + if (!this.isMovingCurrentComment) return; + + const { deltaX, deltaY } = this.getMovingNotePositionDelta(e); + const x = this.currentCommentForm.x + deltaX; + const y = this.currentCommentForm.y + deltaY; + + const movingNoteNewPosition = { + x, + y, + width: this.dimensions.width, + height: this.dimensions.height, + }; + + if (!this.isPositionInOverlay(movingNoteNewPosition)) { + this.onNewNoteMouseup(); + return; + } + + this.movingNoteNewPosition = movingNoteNewPosition; + }, + onExistingNoteMove(e) { + const note = this.notes.find(({ id }) => id === this.movingNoteStartPosition.noteId); + if (!note || !this.canMoveNote(note)) return; + + const { position } = note; + const { width, height } = position; + const widthRatio = this.dimensions.width / width; + const heightRatio = this.dimensions.height / height; + + const { deltaX, deltaY } = this.getMovingNotePositionDelta(e); + const x = position.x * widthRatio + deltaX; + const y = position.y * heightRatio + deltaY; + + const movingNoteNewPosition = { + x, + y, + width: this.dimensions.width, + height: this.dimensions.height, + }; + + if (!this.isPositionInOverlay(movingNoteNewPosition)) { + this.onExistingNoteMouseup(); + return; + } + + this.movingNoteNewPosition = movingNoteNewPosition; + }, + onNewNoteMouseup() { + if (!this.movingNoteNewPosition) return; + + const { x, y } = this.movingNoteNewPosition; + this.setNewNoteCoordinates({ x, y }); + }, + onExistingNoteMouseup(note) { + if (!this.movingNoteStartPosition || !this.movingNoteNewPosition) { + this.updateActiveDiscussion(note.id); + this.$emit('closeCommentForm'); + return; + } + + const { x, y } = this.movingNoteNewPosition; + this.$emit('moveNote', { + noteId: this.movingNoteStartPosition.noteId, + discussionId: this.movingNoteStartPosition.discussionId, + coordinates: { x, y }, + }); + }, + onNoteMousedown({ clientX, clientY }, note) { + this.movingNoteStartPosition = { + noteId: note?.id, + discussionId: note?.discussion.id, + clientX, + clientY, + }; + }, + onOverlayMousemove(e) { + if (!this.movingNoteStartPosition) return; + + if (this.isMovingCurrentComment) { + this.onNewNoteMove(e); + } else { + this.onExistingNoteMove(e); + } + }, + onNoteMouseup(note) { + if (!this.movingNoteStartPosition) return; + + if (this.isMovingCurrentComment) { + this.onNewNoteMouseup(); + } else { + this.onExistingNoteMouseup(note); + } + + this.movingNoteStartPosition = null; + this.movingNoteNewPosition = null; + }, + onAddCommentMouseup({ offsetX, offsetY }) { + if (this.disableCommenting) return; + if (this.activeDiscussion.id) { + this.updateActiveDiscussion(); + } + + this.setNewNoteCoordinates({ x: offsetX, y: offsetY }); + }, + updateActiveDiscussion(id) { + this.$apollo.mutate({ + mutation: updateActiveDiscussionMutation, + variables: { + id, + source: ACTIVE_DISCUSSION_SOURCE_TYPES.pin, + }, + }); + }, + isNoteInactive(note) { + return this.activeDiscussion.id && this.activeDiscussion.id !== note.id; + }, + designPinClass(note) { + return { inactive: this.isNoteInactive(note), resolved: note.resolved }; + }, + }, +}; +</script> + +<template> + <div + class="position-absolute image-diff-overlay frame" + :style="overlayStyle" + @mousemove="onOverlayMousemove" + @mouseleave="onNoteMouseup" + > + <button + v-show="!disableCommenting" + type="button" + class="btn-transparent position-absolute image-diff-overlay-add-comment w-100 h-100 js-add-image-diff-note-button" + data-qa-selector="design_image_button" + @mouseup="onAddCommentMouseup" + ></button> + <template v-for="note in notes"> + <design-note-pin + v-if="resolvedDiscussionsExpanded || !note.resolved" + :key="note.id" + :label="note.index" + :repositioning="isMovingNote(note.id)" + :position=" + isMovingNote(note.id) && movingNoteNewPosition + ? getNotePositionStyle(movingNoteNewPosition) + : getNotePositionStyle(note.position) + " + :class="designPinClass(note)" + @mousedown.stop="onNoteMousedown($event, note)" + @mouseup.stop="onNoteMouseup(note)" + /> + </template> + + <design-note-pin + v-if="currentCommentForm" + :position="currentCommentPositionStyle" + :repositioning="isMovingCurrentComment" + @mousedown.stop="onNoteMousedown" + @mouseup.stop="onNoteMouseup" + /> + </div> +</template> diff --git a/app/assets/javascripts/design_management_new/components/design_presentation.vue b/app/assets/javascripts/design_management_new/components/design_presentation.vue new file mode 100644 index 00000000000..84dbb2809d9 --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/design_presentation.vue @@ -0,0 +1,322 @@ +<script> +import { throttle } from 'lodash'; +import DesignImage from './image.vue'; +import DesignOverlay from './design_overlay.vue'; + +const CLICK_DRAG_BUFFER_PX = 2; + +export default { + components: { + DesignImage, + DesignOverlay, + }, + props: { + image: { + type: String, + required: false, + default: '', + }, + imageName: { + type: String, + required: false, + default: '', + }, + discussions: { + type: Array, + required: true, + }, + isAnnotating: { + type: Boolean, + required: false, + default: false, + }, + scale: { + type: Number, + required: false, + default: 1, + }, + resolvedDiscussionsExpanded: { + type: Boolean, + required: true, + }, + }, + data() { + return { + overlayDimensions: null, + overlayPosition: null, + currentAnnotationPosition: null, + zoomFocalPoint: { + x: 0, + y: 0, + width: 0, + height: 0, + }, + initialLoad: true, + lastDragPosition: null, + isDraggingDesign: false, + }; + }, + computed: { + discussionStartingNotes() { + return this.discussions.map(discussion => ({ + ...discussion.notes[0], + index: discussion.index, + })); + }, + currentCommentForm() { + return (this.isAnnotating && this.currentAnnotationPosition) || null; + }, + presentationStyle() { + return { + cursor: this.isDraggingDesign ? 'grabbing' : undefined, + }; + }, + }, + beforeDestroy() { + const { presentationViewport } = this.$refs; + if (!presentationViewport) return; + + presentationViewport.removeEventListener('scroll', this.scrollThrottled, false); + }, + mounted() { + const { presentationViewport } = this.$refs; + if (!presentationViewport) return; + + this.scrollThrottled = throttle(() => { + this.shiftZoomFocalPoint(); + }, 400); + + presentationViewport.addEventListener('scroll', this.scrollThrottled, false); + }, + methods: { + syncCurrentAnnotationPosition() { + if (!this.currentAnnotationPosition) return; + + const widthRatio = this.overlayDimensions.width / this.currentAnnotationPosition.width; + const heightRatio = this.overlayDimensions.height / this.currentAnnotationPosition.height; + const x = this.currentAnnotationPosition.x * widthRatio; + const y = this.currentAnnotationPosition.y * heightRatio; + + this.currentAnnotationPosition = this.getAnnotationPositon({ x, y }); + }, + setOverlayDimensions(overlayDimensions) { + this.overlayDimensions = overlayDimensions; + + // every time we set overlay dimensions, we need to + // update the current annotation as well + this.syncCurrentAnnotationPosition(); + }, + setOverlayPosition() { + if (!this.overlayDimensions) { + this.overlayPosition = {}; + } + + const { presentationViewport } = this.$refs; + if (!presentationViewport) return; + + // default to center + this.overlayPosition = { + left: `calc(50% - ${this.overlayDimensions.width / 2}px)`, + top: `calc(50% - ${this.overlayDimensions.height / 2}px)`, + }; + + // if the overlay overflows, then don't center + if (this.overlayDimensions.width > presentationViewport.offsetWidth) { + this.overlayPosition.left = '0'; + } + if (this.overlayDimensions.height > presentationViewport.offsetHeight) { + this.overlayPosition.top = '0'; + } + }, + /** + * Return a point that represents the center of an + * overflowing child element w.r.t it's parent + */ + getViewportCenter() { + const { presentationViewport } = this.$refs; + if (!presentationViewport) return {}; + + // get height of scroll bars (i.e. the max values for scrollTop, scrollLeft) + const scrollBarWidth = presentationViewport.scrollWidth - presentationViewport.offsetWidth; + const scrollBarHeight = presentationViewport.scrollHeight - presentationViewport.offsetHeight; + + // determine how many child pixels have been scrolled + const xScrollRatio = + presentationViewport.scrollLeft > 0 ? presentationViewport.scrollLeft / scrollBarWidth : 0; + const yScrollRatio = + presentationViewport.scrollTop > 0 ? presentationViewport.scrollTop / scrollBarHeight : 0; + const xScrollOffset = + (presentationViewport.scrollWidth - presentationViewport.offsetWidth - 0) * xScrollRatio; + const yScrollOffset = + (presentationViewport.scrollHeight - presentationViewport.offsetHeight - 0) * yScrollRatio; + + const viewportCenterX = presentationViewport.offsetWidth / 2; + const viewportCenterY = presentationViewport.offsetHeight / 2; + const focalPointX = viewportCenterX + xScrollOffset; + const focalPointY = viewportCenterY + yScrollOffset; + + return { + x: focalPointX, + y: focalPointY, + }; + }, + /** + * Scroll the viewport such that the focal point is positioned centrally + */ + scrollToFocalPoint() { + const { presentationViewport } = this.$refs; + if (!presentationViewport) return; + + const scrollX = this.zoomFocalPoint.x - presentationViewport.offsetWidth / 2; + const scrollY = this.zoomFocalPoint.y - presentationViewport.offsetHeight / 2; + + presentationViewport.scrollTo(scrollX, scrollY); + }, + scaleZoomFocalPoint() { + const { x, y, width, height } = this.zoomFocalPoint; + const widthRatio = this.overlayDimensions.width / width; + const heightRatio = this.overlayDimensions.height / height; + + this.zoomFocalPoint = { + x: Math.round(x * widthRatio * 100) / 100, + y: Math.round(y * heightRatio * 100) / 100, + ...this.overlayDimensions, + }; + }, + shiftZoomFocalPoint() { + this.zoomFocalPoint = { + ...this.getViewportCenter(), + ...this.overlayDimensions, + }; + }, + onImageResize(imageDimensions) { + this.setOverlayDimensions(imageDimensions); + this.setOverlayPosition(); + + this.$nextTick(() => { + if (this.initialLoad) { + // set focal point on initial load + this.shiftZoomFocalPoint(); + this.initialLoad = false; + } else { + this.scaleZoomFocalPoint(); + this.scrollToFocalPoint(); + } + }); + }, + getAnnotationPositon(coordinates) { + const { x, y } = coordinates; + const { width, height } = this.overlayDimensions; + return { + x: Math.round(x), + y: Math.round(y), + width: Math.round(width), + height: Math.round(height), + }; + }, + openCommentForm(coordinates) { + this.currentAnnotationPosition = this.getAnnotationPositon(coordinates); + this.$emit('openCommentForm', this.currentAnnotationPosition); + }, + closeCommentForm() { + this.currentAnnotationPosition = null; + this.$emit('closeCommentForm'); + }, + moveNote({ noteId, discussionId, coordinates }) { + const position = this.getAnnotationPositon(coordinates); + this.$emit('moveNote', { noteId, discussionId, position }); + }, + onPresentationMousedown({ clientX, clientY }) { + if (!this.isDesignOverflowing()) return; + + this.lastDragPosition = { + x: clientX, + y: clientY, + }; + }, + getDragDelta(clientX, clientY) { + return { + deltaX: this.lastDragPosition.x - clientX, + deltaY: this.lastDragPosition.y - clientY, + }; + }, + exceedsDragThreshold(clientX, clientY) { + const { deltaX, deltaY } = this.getDragDelta(clientX, clientY); + + return Math.abs(deltaX) > CLICK_DRAG_BUFFER_PX || Math.abs(deltaY) > CLICK_DRAG_BUFFER_PX; + }, + shouldDragDesign(clientX, clientY) { + return ( + this.lastDragPosition && + (this.isDraggingDesign || this.exceedsDragThreshold(clientX, clientY)) + ); + }, + onPresentationMousemove({ clientX, clientY }) { + const { presentationViewport } = this.$refs; + if (!presentationViewport || !this.shouldDragDesign(clientX, clientY)) return; + + this.isDraggingDesign = true; + + const { scrollLeft, scrollTop } = presentationViewport; + const { deltaX, deltaY } = this.getDragDelta(clientX, clientY); + presentationViewport.scrollTo(scrollLeft + deltaX, scrollTop + deltaY); + + this.lastDragPosition = { + x: clientX, + y: clientY, + }; + }, + onPresentationMouseup() { + this.lastDragPosition = null; + this.isDraggingDesign = false; + }, + isDesignOverflowing() { + const { presentationViewport } = this.$refs; + if (!presentationViewport) return false; + + return ( + presentationViewport.scrollWidth > presentationViewport.offsetWidth || + presentationViewport.scrollHeight > presentationViewport.offsetHeight + ); + }, + }, +}; +</script> + +<template> + <div + ref="presentationViewport" + class="h-100 w-100 p-3 overflow-auto position-relative" + :style="presentationStyle" + @mousedown="onPresentationMousedown" + @mousemove="onPresentationMousemove" + @mouseup="onPresentationMouseup" + @mouseleave="onPresentationMouseup" + @touchstart="onPresentationMousedown" + @touchmove="onPresentationMousemove" + @touchend="onPresentationMouseup" + @touchcancel="onPresentationMouseup" + > + <div class="h-100 w-100 d-flex align-items-center position-relative"> + <design-image + v-if="image" + :image="image" + :name="imageName" + :scale="scale" + @resize="onImageResize" + /> + <design-overlay + v-if="overlayDimensions && overlayPosition" + :dimensions="overlayDimensions" + :position="overlayPosition" + :notes="discussionStartingNotes" + :current-comment-form="currentCommentForm" + :disable-commenting="isDraggingDesign" + :resolved-discussions-expanded="resolvedDiscussionsExpanded" + @openCommentForm="openCommentForm" + @closeCommentForm="closeCommentForm" + @moveNote="moveNote" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/design_management_new/components/design_scaler.vue b/app/assets/javascripts/design_management_new/components/design_scaler.vue new file mode 100644 index 00000000000..55dee74bef5 --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/design_scaler.vue @@ -0,0 +1,65 @@ +<script> +import { GlIcon } from '@gitlab/ui'; + +const SCALE_STEP_SIZE = 0.2; +const DEFAULT_SCALE = 1; +const MIN_SCALE = 1; +const MAX_SCALE = 2; + +export default { + components: { + GlIcon, + }, + data() { + return { + scale: DEFAULT_SCALE, + }; + }, + computed: { + disableReset() { + return this.scale <= MIN_SCALE; + }, + disableDecrease() { + return this.scale === DEFAULT_SCALE; + }, + disableIncrease() { + return this.scale >= MAX_SCALE; + }, + }, + methods: { + setScale(scale) { + if (scale < MIN_SCALE) { + return; + } + + this.scale = Math.round(scale * 100) / 100; + this.$emit('scale', this.scale); + }, + incrementScale() { + this.setScale(this.scale + SCALE_STEP_SIZE); + }, + decrementScale() { + this.setScale(this.scale - SCALE_STEP_SIZE); + }, + resetScale() { + this.setScale(DEFAULT_SCALE); + }, + }, +}; +</script> + +<template> + <div class="design-scaler btn-group" role="group"> + <button class="btn" :disabled="disableDecrease" @click="decrementScale"> + <span class="d-flex-center gl-icon s16"> + – + </span> + </button> + <button class="btn" :disabled="disableReset" @click="resetScale"> + <gl-icon name="redo" /> + </button> + <button class="btn" :disabled="disableIncrease" @click="incrementScale"> + <gl-icon name="plus" /> + </button> + </div> +</template> diff --git a/app/assets/javascripts/design_management_new/components/design_sidebar.vue b/app/assets/javascripts/design_management_new/components/design_sidebar.vue new file mode 100644 index 00000000000..333ad2557e8 --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/design_sidebar.vue @@ -0,0 +1,178 @@ +<script> +import { s__ } from '~/locale'; +import Cookies from 'js-cookie'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import { GlCollapse, GlButton, GlPopover } from '@gitlab/ui'; +import updateActiveDiscussionMutation from '../graphql/mutations/update_active_discussion.mutation.graphql'; +import { extractDiscussions, extractParticipants } from '../utils/design_management_utils'; +import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../constants'; +import DesignDiscussion from './design_notes/design_discussion.vue'; +import Participants from '~/sidebar/components/participants/participants.vue'; + +export default { + components: { + DesignDiscussion, + Participants, + GlCollapse, + GlButton, + GlPopover, + }, + props: { + design: { + type: Object, + required: true, + }, + resolvedDiscussionsExpanded: { + type: Boolean, + required: true, + }, + markdownPreviewPath: { + type: String, + required: true, + }, + }, + data() { + return { + isResolvedCommentsPopoverHidden: parseBoolean(Cookies.get(this.$options.cookieKey)), + discussionWithOpenForm: '', + }; + }, + computed: { + discussions() { + return extractDiscussions(this.design.discussions); + }, + issue() { + return { + ...this.design.issue, + webPath: this.design.issue.webPath.substr(1), + }; + }, + discussionParticipants() { + return extractParticipants(this.issue.participants); + }, + resolvedDiscussions() { + return this.discussions.filter(discussion => discussion.resolved); + }, + unresolvedDiscussions() { + return this.discussions.filter(discussion => !discussion.resolved); + }, + resolvedCommentsToggleIcon() { + return this.resolvedDiscussionsExpanded ? 'chevron-down' : 'chevron-right'; + }, + }, + methods: { + handleSidebarClick() { + this.isResolvedCommentsPopoverHidden = true; + Cookies.set(this.$options.cookieKey, 'true', { expires: 365 * 10 }); + this.updateActiveDiscussion(); + }, + updateActiveDiscussion(id) { + this.$apollo.mutate({ + mutation: updateActiveDiscussionMutation, + variables: { + id, + source: ACTIVE_DISCUSSION_SOURCE_TYPES.discussion, + }, + }); + }, + closeCommentForm() { + this.comment = ''; + this.$emit('closeCommentForm'); + }, + updateDiscussionWithOpenForm(id) { + this.discussionWithOpenForm = id; + }, + }, + resolveCommentsToggleText: s__('DesignManagement|Resolved Comments'), + cookieKey: 'hide_design_resolved_comments_popover', +}; +</script> + +<template> + <div class="image-notes" @click="handleSidebarClick"> + <h2 class="gl-font-weight-bold gl-mt-0"> + {{ issue.title }} + </h2> + <a + class="gl-text-gray-600 gl-text-decoration-none gl-mb-6 gl-display-block" + :href="issue.webUrl" + >{{ issue.webPath }}</a + > + <participants + :participants="discussionParticipants" + :show-participant-label="false" + class="gl-mb-4" + /> + <h2 + v-if="unresolvedDiscussions.length === 0" + class="new-discussion-disclaimer gl-font-base gl-m-0 gl-mb-4" + data-testid="new-discussion-disclaimer" + > + {{ s__("DesignManagement|Click the image where you'd like to start a new discussion") }} + </h2> + <design-discussion + v-for="discussion in unresolvedDiscussions" + :key="discussion.id" + :discussion="discussion" + :design-id="$route.params.id" + :noteable-id="design.id" + :markdown-preview-path="markdownPreviewPath" + :resolved-discussions-expanded="resolvedDiscussionsExpanded" + :discussion-with-open-form="discussionWithOpenForm" + data-testid="unresolved-discussion" + @createNoteError="$emit('onDesignDiscussionError', $event)" + @updateNoteError="$emit('updateNoteError', $event)" + @resolveDiscussionError="$emit('resolveDiscussionError', $event)" + @click.native.stop="updateActiveDiscussion(discussion.notes[0].id)" + @openForm="updateDiscussionWithOpenForm" + /> + <template v-if="resolvedDiscussions.length > 0"> + <gl-button + id="resolved-comments" + data-testid="resolved-comments" + :icon="resolvedCommentsToggleIcon" + variant="link" + class="link-inherit-color gl-text-black-normal gl-text-decoration-none gl-font-weight-bold gl-mb-4" + @click="$emit('toggleResolvedComments')" + >{{ $options.resolveCommentsToggleText }} ({{ resolvedDiscussions.length }}) + </gl-button> + <gl-popover + v-if="!isResolvedCommentsPopoverHidden" + :show="!isResolvedCommentsPopoverHidden" + target="resolved-comments" + container="popovercontainer" + placement="top" + :title="s__('DesignManagement|Resolved Comments')" + > + <p> + {{ + s__( + 'DesignManagement|Comments you resolve can be viewed and unresolved by going to the "Resolved Comments" section below', + ) + }} + </p> + <a href="#" rel="noopener noreferrer" target="_blank">{{ + s__('DesignManagement|Learn more about resolving comments') + }}</a> + </gl-popover> + <gl-collapse :visible="resolvedDiscussionsExpanded" class="gl-mt-3"> + <design-discussion + v-for="discussion in resolvedDiscussions" + :key="discussion.id" + :discussion="discussion" + :design-id="$route.params.id" + :noteable-id="design.id" + :markdown-preview-path="markdownPreviewPath" + :resolved-discussions-expanded="resolvedDiscussionsExpanded" + :discussion-with-open-form="discussionWithOpenForm" + data-testid="resolved-discussion" + @error="$emit('onDesignDiscussionError', $event)" + @updateNoteError="$emit('updateNoteError', $event)" + @openForm="updateDiscussionWithOpenForm" + @click.native.stop="updateActiveDiscussion(discussion.notes[0].id)" + /> + </gl-collapse> + </template> + <slot name="replyForm"></slot> + </div> +</template> diff --git a/app/assets/javascripts/design_management_new/components/image.vue b/app/assets/javascripts/design_management_new/components/image.vue new file mode 100644 index 00000000000..91b7b576e0c --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/image.vue @@ -0,0 +1,110 @@ +<script> +import { throttle } from 'lodash'; +import { GlIcon } from '@gitlab/ui'; + +export default { + components: { + GlIcon, + }, + props: { + image: { + type: String, + required: false, + default: '', + }, + name: { + type: String, + required: false, + default: '', + }, + scale: { + type: Number, + required: false, + default: 1, + }, + }, + data() { + return { + baseImageSize: null, + imageStyle: null, + imageError: false, + }; + }, + watch: { + scale(val) { + this.zoom(val); + }, + }, + beforeDestroy() { + window.removeEventListener('resize', this.resizeThrottled, false); + }, + mounted() { + this.onImgLoad(); + + this.resizeThrottled = throttle(() => { + // NOTE: if imageStyle is set, then baseImageSize + // won't change due to resize. We must still emit a + // `resize` event so that the parent can handle + // resizes appropriately (e.g. for design_overlay) + this.setBaseImageSize(); + }, 400); + window.addEventListener('resize', this.resizeThrottled, false); + }, + methods: { + onImgLoad() { + requestIdleCallback(this.setBaseImageSize, { timeout: 1000 }); + }, + onImgError() { + this.imageError = true; + }, + setBaseImageSize() { + const { contentImg } = this.$refs; + if (!contentImg || contentImg.offsetHeight === 0 || contentImg.offsetWidth === 0) return; + + this.baseImageSize = { + height: contentImg.offsetHeight, + width: contentImg.offsetWidth, + }; + this.onResize({ width: this.baseImageSize.width, height: this.baseImageSize.height }); + }, + onResize({ width, height }) { + this.$emit('resize', { width, height }); + }, + zoom(amount) { + if (amount === 1) { + this.imageStyle = null; + this.$nextTick(() => { + this.setBaseImageSize(); + }); + return; + } + const width = this.baseImageSize.width * amount; + const height = this.baseImageSize.height * amount; + + this.imageStyle = { + width: `${width}px`, + height: `${height}px`, + }; + + this.onResize({ width, height }); + }, + }, +}; +</script> + +<template> + <div class="m-auto js-design-image"> + <gl-icon v-if="imageError" class="text-secondary-100" name="media-broken" :size="48" /> + <img + v-show="!imageError" + ref="contentImg" + class="mh-100" + :src="image" + :alt="name" + :style="imageStyle" + :class="{ 'img-fluid': !imageStyle }" + @error="onImgError" + @load="onImgLoad" + /> + </div> +</template> diff --git a/app/assets/javascripts/design_management_new/components/list/item.vue b/app/assets/javascripts/design_management_new/components/list/item.vue new file mode 100644 index 00000000000..b19aef9c22d --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/list/item.vue @@ -0,0 +1,174 @@ +<script> +import { GlLoadingIcon, GlIcon, GlIntersectionObserver } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import Timeago from '~/vue_shared/components/time_ago_tooltip.vue'; +import { n__, __ } from '~/locale'; +import { DESIGN_ROUTE_NAME } from '../../router/constants'; + +export default { + components: { + GlLoadingIcon, + GlIntersectionObserver, + GlIcon, + Icon, + Timeago, + }, + props: { + id: { + type: [Number, String], + required: true, + }, + event: { + type: String, + required: true, + }, + notesCount: { + type: Number, + required: true, + }, + image: { + type: String, + required: true, + }, + filename: { + type: String, + required: true, + }, + updatedAt: { + type: String, + required: false, + default: null, + }, + isUploading: { + type: Boolean, + required: false, + default: true, + }, + imageV432x230: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + imageLoading: true, + imageError: false, + wasInView: false, + }; + }, + computed: { + icon() { + const normalizedEvent = this.event.toLowerCase(); + const icons = { + creation: { + name: 'file-addition-solid', + classes: 'text-success-500', + tooltip: __('Added in this version'), + }, + modification: { + name: 'file-modified-solid', + classes: 'text-primary-500', + tooltip: __('Modified in this version'), + }, + deletion: { + name: 'file-deletion-solid', + classes: 'text-danger-500', + tooltip: __('Deleted in this version'), + }, + }; + + return icons[normalizedEvent] ? icons[normalizedEvent] : {}; + }, + notesLabel() { + return n__('%d comment', '%d comments', this.notesCount); + }, + imageLink() { + return this.wasInView ? this.imageV432x230 || this.image : ''; + }, + showLoadingSpinner() { + return this.imageLoading || this.isUploading; + }, + showImageErrorIcon() { + return this.wasInView && this.imageError; + }, + showImage() { + return !this.showLoadingSpinner && !this.showImageErrorIcon; + }, + }, + methods: { + onImageLoad() { + this.imageLoading = false; + this.imageError = false; + }, + onImageError() { + this.imageLoading = false; + this.imageError = true; + }, + onAppear() { + // do nothing if image has previously + // been in view + if (this.wasInView) { + return; + } + + this.wasInView = true; + this.imageLoading = true; + }, + }, + DESIGN_ROUTE_NAME, +}; +</script> + +<template> + <router-link + :to="{ + name: $options.DESIGN_ROUTE_NAME, + params: { id: filename }, + query: $route.query, + }" + class="card cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new" + > + <div class="card-body p-0 d-flex-center overflow-hidden position-relative"> + <div v-if="icon.name" class="design-event position-absolute"> + <span :title="icon.tooltip" :aria-label="icon.tooltip"> + <icon :name="icon.name" :size="18" :class="icon.classes" /> + </span> + </div> + <gl-intersection-observer @appear="onAppear"> + <gl-loading-icon v-if="showLoadingSpinner" size="md" /> + <gl-icon + v-else-if="showImageErrorIcon" + name="media-broken" + class="text-secondary" + :size="32" + /> + <img + v-show="showImage" + :src="imageLink" + :alt="filename" + class="block mx-auto mw-100 mh-100 design-img" + data-qa-selector="design_image" + @load="onImageLoad" + @error="onImageError" + /> + </gl-intersection-observer> + </div> + <div class="card-footer d-flex w-100"> + <div class="d-flex flex-column str-truncated-100"> + <span class="bold str-truncated-100" data-qa-selector="design_file_name">{{ + filename + }}</span> + <span v-if="updatedAt" class="str-truncated-100"> + {{ __('Updated') }} <timeago :time="updatedAt" tooltip-placement="bottom" /> + </span> + </div> + <div v-if="notesCount" class="ml-auto d-flex align-items-center text-secondary"> + <icon name="comments" class="ml-1" /> + <span :aria-label="notesLabel" class="ml-1"> + {{ notesCount }} + </span> + </div> + </div> + </router-link> +</template> diff --git a/app/assets/javascripts/design_management_new/components/toolbar/index.vue b/app/assets/javascripts/design_management_new/components/toolbar/index.vue new file mode 100644 index 00000000000..0b51035e83e --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/toolbar/index.vue @@ -0,0 +1,124 @@ +<script> +import { GlDeprecatedButton } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import Pagination from './pagination.vue'; +import DeleteButton from '../delete_button.vue'; +import permissionsQuery from '../../graphql/queries/design_permissions.query.graphql'; +import { DESIGNS_ROUTE_NAME } from '../../router/constants'; + +export default { + components: { + Icon, + Pagination, + DeleteButton, + GlDeprecatedButton, + }, + mixins: [timeagoMixin], + props: { + id: { + type: String, + required: true, + }, + isDeleting: { + type: Boolean, + required: true, + }, + filename: { + type: String, + required: false, + default: '', + }, + updatedAt: { + type: String, + required: false, + default: null, + }, + updatedBy: { + type: Object, + required: false, + default: () => ({}), + }, + isLatestVersion: { + type: Boolean, + required: true, + }, + image: { + type: String, + required: true, + }, + }, + data() { + return { + permissions: { + createDesign: false, + }, + }; + }, + inject: { + projectPath: { + default: '', + }, + issueIid: { + default: '', + }, + }, + apollo: { + permissions: { + query: permissionsQuery, + variables() { + return { + fullPath: this.projectPath, + iid: this.issueIid, + }; + }, + update: data => data.project.issue.userPermissions, + }, + }, + computed: { + updatedText() { + return sprintf(__('Updated %{updated_at} by %{updated_by}'), { + updated_at: this.timeFormatted(this.updatedAt), + updated_by: this.updatedBy.name, + }); + }, + canDeleteDesign() { + return this.permissions.createDesign; + }, + }, + DESIGNS_ROUTE_NAME, +}; +</script> + +<template> + <header class="d-flex p-2 bg-white align-items-center js-design-header"> + <router-link + :to="{ + name: $options.DESIGNS_ROUTE_NAME, + query: $route.query, + }" + :aria-label="s__('DesignManagement|Go back to designs')" + data-testid="close-design" + class="mr-3 text-plain d-flex justify-content-center align-items-center" + > + <icon :size="18" name="close" /> + </router-link> + <div class="overflow-hidden d-flex align-items-center"> + <h2 class="m-0 str-truncated-100 gl-font-base">{{ filename }}</h2> + <small v-if="updatedAt" class="text-secondary">{{ updatedText }}</small> + </div> + <pagination :id="id" class="ml-auto flex-shrink-0" /> + <gl-deprecated-button :href="image" class="mr-2"> + <icon :size="18" name="download" /> + </gl-deprecated-button> + <delete-button + v-if="isLatestVersion && canDeleteDesign" + :is-deleting="isDeleting" + button-variant="danger" + @deleteSelectedDesigns="$emit('delete')" + > + <icon :size="18" name="remove" /> + </delete-button> + </header> +</template> diff --git a/app/assets/javascripts/design_management_new/components/toolbar/pagination.vue b/app/assets/javascripts/design_management_new/components/toolbar/pagination.vue new file mode 100644 index 00000000000..bf62a8f66a6 --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/toolbar/pagination.vue @@ -0,0 +1,83 @@ +<script> +/* global Mousetrap */ +import 'mousetrap'; +import { s__, sprintf } from '~/locale'; +import PaginationButton from './pagination_button.vue'; +import allDesignsMixin from '../../mixins/all_designs'; +import { DESIGN_ROUTE_NAME } from '../../router/constants'; + +export default { + components: { + PaginationButton, + }, + mixins: [allDesignsMixin], + props: { + id: { + type: String, + required: true, + }, + }, + computed: { + designsCount() { + return this.designs.length; + }, + currentIndex() { + return this.designs.findIndex(design => design.filename === this.id); + }, + paginationText() { + return sprintf(s__('DesignManagement|%{current_design} of %{designs_count}'), { + current_design: this.currentIndex + 1, + designs_count: this.designsCount, + }); + }, + previousDesign() { + if (!this.designsCount) return null; + + return this.designs[this.currentIndex - 1]; + }, + nextDesign() { + if (!this.designsCount) return null; + + return this.designs[this.currentIndex + 1]; + }, + }, + mounted() { + Mousetrap.bind('left', () => this.navigateToDesign(this.previousDesign)); + Mousetrap.bind('right', () => this.navigateToDesign(this.nextDesign)); + }, + beforeDestroy() { + Mousetrap.unbind(['left', 'right'], this.navigateToDesign); + }, + methods: { + navigateToDesign(design) { + if (design) { + this.$router.push({ + name: DESIGN_ROUTE_NAME, + params: { id: design.filename }, + query: this.$route.query, + }); + } + }, + }, +}; +</script> + +<template> + <div v-if="designsCount" class="d-flex align-items-center"> + {{ paginationText }} + <div class="btn-group ml-3 mr-3"> + <pagination-button + :design="previousDesign" + :title="s__('DesignManagement|Go to previous design')" + icon-name="angle-left" + class="js-previous-design" + /> + <pagination-button + :design="nextDesign" + :title="s__('DesignManagement|Go to next design')" + icon-name="angle-right" + class="js-next-design" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/design_management_new/components/toolbar/pagination_button.vue b/app/assets/javascripts/design_management_new/components/toolbar/pagination_button.vue new file mode 100644 index 00000000000..f00ecefca01 --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/toolbar/pagination_button.vue @@ -0,0 +1,48 @@ +<script> +import Icon from '~/vue_shared/components/icon.vue'; +import { DESIGN_ROUTE_NAME } from '../../router/constants'; + +export default { + components: { + Icon, + }, + props: { + design: { + type: Object, + required: false, + default: null, + }, + title: { + type: String, + required: true, + }, + iconName: { + type: String, + required: true, + }, + }, + computed: { + designLink() { + if (!this.design) return {}; + + return { + name: DESIGN_ROUTE_NAME, + params: { id: this.design.filename }, + query: this.$route.query, + }; + }, + }, +}; +</script> + +<template> + <router-link + :to="designLink" + :disabled="!design" + :class="{ disabled: !design }" + :aria-label="title" + class="btn btn-default" + > + <icon :name="iconName" /> + </router-link> +</template> diff --git a/app/assets/javascripts/design_management_new/components/upload/button.vue b/app/assets/javascripts/design_management_new/components/upload/button.vue new file mode 100644 index 00000000000..de8a38334ac --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/upload/button.vue @@ -0,0 +1,59 @@ +<script> +import { GlButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import { VALID_DESIGN_FILE_MIMETYPE } from '../../constants'; + +export default { + components: { + GlButton, + GlLoadingIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + isSaving: { + type: Boolean, + required: true, + }, + }, + methods: { + openFileUpload() { + this.$refs.fileUpload.click(); + }, + onFileUploadChange(e) { + this.$emit('upload', e.target.files); + }, + }, + VALID_DESIGN_FILE_MIMETYPE, +}; +</script> + +<template> + <div> + <gl-button + v-gl-tooltip.hover + :title=" + s__( + 'DesignManagement|Adding a design with the same filename replaces the file in a new version.', + ) + " + :disabled="isSaving" + variant="success" + size="small" + @click="openFileUpload" + > + {{ s__('DesignManagement|Upload designs') }} + <gl-loading-icon v-if="isSaving" inline class="ml-1" /> + </gl-button> + + <input + ref="fileUpload" + type="file" + name="design_file" + :accept="$options.VALID_DESIGN_FILE_MIMETYPE.mimetype" + class="hide" + multiple + @change="onFileUploadChange" + /> + </div> +</template> diff --git a/app/assets/javascripts/design_management_new/components/upload/design_dropzone.vue b/app/assets/javascripts/design_management_new/components/upload/design_dropzone.vue new file mode 100644 index 00000000000..7b829d63330 --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/upload/design_dropzone.vue @@ -0,0 +1,136 @@ +<script> +import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; +import createFlash from '~/flash'; +import uploadDesignMutation from '../../graphql/mutations/upload_design.mutation.graphql'; +import { UPLOAD_DESIGN_INVALID_FILETYPE_ERROR } from '../../utils/error_messages'; +import { isValidDesignFile } from '../../utils/design_management_utils'; +import { VALID_DATA_TRANSFER_TYPE, VALID_DESIGN_FILE_MIMETYPE } from '../../constants'; + +export default { + components: { + GlIcon, + GlLink, + GlSprintf, + }, + props: { + hasDesigns: { + type: Boolean, + required: true, + }, + }, + data() { + return { + dragCounter: 0, + isDragDataValid: false, + }; + }, + computed: { + dragging() { + return this.dragCounter !== 0; + }, + }, + methods: { + isValidUpload(files) { + return files.every(isValidDesignFile); + }, + isValidDragDataType({ dataTransfer }) { + return Boolean(dataTransfer && dataTransfer.types.some(t => t === VALID_DATA_TRANSFER_TYPE)); + }, + ondrop({ dataTransfer = {} }) { + this.dragCounter = 0; + // User already had feedback when dropzone was active, so bail here + if (!this.isDragDataValid) { + return; + } + + const { files } = dataTransfer; + if (!this.isValidUpload(Array.from(files))) { + createFlash(UPLOAD_DESIGN_INVALID_FILETYPE_ERROR); + return; + } + + this.$emit('change', files); + }, + ondragenter(e) { + this.dragCounter += 1; + this.isDragDataValid = this.isValidDragDataType(e); + }, + ondragleave() { + this.dragCounter -= 1; + }, + openFileUpload() { + this.$refs.fileUpload.click(); + }, + onDesignInputChange(e) { + this.$emit('change', e.target.files); + }, + }, + uploadDesignMutation, + VALID_DESIGN_FILE_MIMETYPE, +}; +</script> + +<template> + <div + class="w-100 position-relative" + @dragstart.prevent.stop + @dragend.prevent.stop + @dragover.prevent.stop + @dragenter.prevent.stop="ondragenter" + @dragleave.prevent.stop="ondragleave" + @drop.prevent.stop="ondrop" + > + <slot> + <button + class="card design-dropzone-card design-dropzone-border w-100 h-100 gl-align-items-center gl-justify-content-center gl-p-3" + @click="openFileUpload" + > + <div + :class="{ 'gl-flex-direction-column': hasDesigns }" + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center" + data-testid="dropzone-area" + > + <gl-icon name="upload" :size="24" :class="hasDesigns ? 'gl-mb-2' : 'gl-mr-4'" /> + <p class="gl-font-weight-bold gl-mb-0"> + <gl-sprintf :message="__('Drop or %{linkStart}upload%{linkEnd} Designs to attach')"> + <template #link="{ content }"> + <gl-link class="gl-font-weight-normal" @click.stop="openFileUpload"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </p> + </div> + </button> + + <input + ref="fileUpload" + type="file" + name="design_file" + :accept="$options.VALID_DESIGN_FILE_MIMETYPE.mimetype" + class="hide" + multiple + @change="onDesignInputChange" + /> + </slot> + <transition name="design-dropzone-fade"> + <div + v-show="dragging" + class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" + > + <div v-show="!isDragDataValid" class="mw-50 text-center"> + <h3 :class="{ 'gl-font-base gl-display-inline': !hasDesigns }">{{ __('Oh no!') }}</h3> + <span>{{ + __( + 'You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.', + ) + }}</span> + </div> + <div v-show="isDragDataValid" class="mw-50 text-center"> + <h3 :class="{ 'gl-font-base gl-display-inline': !hasDesigns }">{{ __('Incoming!') }}</h3> + <span>{{ __('Drop your designs to start your upload.') }}</span> + </div> + </div> + </transition> + </div> +</template> diff --git a/app/assets/javascripts/design_management_new/components/upload/design_version_dropdown.vue b/app/assets/javascripts/design_management_new/components/upload/design_version_dropdown.vue new file mode 100644 index 00000000000..5299d0ce09e --- /dev/null +++ b/app/assets/javascripts/design_management_new/components/upload/design_version_dropdown.vue @@ -0,0 +1,76 @@ +<script> +import { GlNewDropdown, GlNewDropdownItem } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import allVersionsMixin from '../../mixins/all_versions'; +import { findVersionId } from '../../utils/design_management_utils'; + +export default { + components: { + GlNewDropdown, + GlNewDropdownItem, + }, + mixins: [allVersionsMixin], + computed: { + queryVersion() { + return this.$route.query.version; + }, + currentVersionIdx() { + if (!this.queryVersion) return 0; + + const idx = this.allVersions.findIndex( + version => this.findVersionId(version.node.id) === this.queryVersion, + ); + + // if the currentVersionId isn't a valid version (i.e. not in allVersions) + // then return the latest version (index 0) + return idx !== -1 ? idx : 0; + }, + currentVersionId() { + if (this.queryVersion) return this.queryVersion; + + const currentVersion = this.allVersions[this.currentVersionIdx]; + return this.findVersionId(currentVersion.node.id); + }, + dropdownText() { + if (this.isLatestVersion) { + return __('Showing Latest Version'); + } + // allVersions is sorted in reverse chronological order (latest first) + const currentVersionNumber = this.allVersions.length - this.currentVersionIdx; + + return sprintf(__('Showing Version #%{versionNumber}'), { + versionNumber: currentVersionNumber, + }); + }, + }, + methods: { + findVersionId, + }, +}; +</script> + +<template> + <gl-new-dropdown :text="dropdownText" size="small" class="design-version-dropdown"> + <gl-new-dropdown-item v-for="(version, index) in allVersions" :key="version.node.id"> + <router-link + class="d-flex js-version-link" + :to="{ path: $route.path, query: { version: findVersionId(version.node.id) } }" + > + <div class="flex-grow-1 ml-2"> + <div> + <strong + >{{ __('Version') }} {{ allVersions.length - index }} + <span v-if="findVersionId(version.node.id) === latestVersionId" + >({{ __('latest') }})</span + > + </strong> + </div> + </div> + <i + v-if="findVersionId(version.node.id) === currentVersionId" + class="fa fa-check pull-right" + ></i> + </router-link> + </gl-new-dropdown-item> + </gl-new-dropdown> +</template> diff --git a/app/assets/javascripts/design_management_new/constants.js b/app/assets/javascripts/design_management_new/constants.js new file mode 100644 index 00000000000..21ff361a277 --- /dev/null +++ b/app/assets/javascripts/design_management_new/constants.js @@ -0,0 +1,16 @@ +// WARNING: replace this with something +// more sensical as per https://gitlab.com/gitlab-org/gitlab/issues/118611 +export const VALID_DESIGN_FILE_MIMETYPE = { + mimetype: 'image/*', + regex: /image\/.+/, +}; + +// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/types +export const VALID_DATA_TRANSFER_TYPE = 'Files'; + +export const ACTIVE_DISCUSSION_SOURCE_TYPES = { + pin: 'pin', + discussion: 'discussion', +}; + +export const DESIGN_DETAIL_LAYOUT_CLASSLIST = ['design-detail-layout', 'overflow-hidden', 'm-0']; diff --git a/app/assets/javascripts/design_management_new/graphql.js b/app/assets/javascripts/design_management_new/graphql.js new file mode 100644 index 00000000000..fae337aa75b --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql.js @@ -0,0 +1,45 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { uniqueId } from 'lodash'; +import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; +import createDefaultClient from '~/lib/graphql'; +import activeDiscussionQuery from './graphql/queries/active_discussion.query.graphql'; +import typeDefs from './graphql/typedefs.graphql'; + +Vue.use(VueApollo); + +const resolvers = { + Mutation: { + updateActiveDiscussion: (_, { id = null, source }, { cache }) => { + const data = cache.readQuery({ query: activeDiscussionQuery }); + data.activeDiscussion = { + __typename: 'ActiveDiscussion', + id, + source, + }; + cache.writeQuery({ query: activeDiscussionQuery, data }); + }, + }, +}; + +const defaultClient = createDefaultClient( + resolvers, + // This config is added temporarily to resolve an issue with duplicate design IDs. + // Should be removed as soon as https://gitlab.com/gitlab-org/gitlab/issues/13495 is resolved + { + cacheConfig: { + dataIdFromObject: object => { + // eslint-disable-next-line no-underscore-dangle, @gitlab/require-i18n-strings + if (object.__typename === 'Design') { + return object.id && object.image ? `${object.id}-${object.image}` : uniqueId(); + } + return defaultDataIdFromObject(object); + }, + }, + typeDefs, + }, +); + +export default new VueApollo({ + defaultClient, +}); diff --git a/app/assets/javascripts/design_management_new/graphql/fragments/design.fragment.graphql b/app/assets/javascripts/design_management_new/graphql/fragments/design.fragment.graphql new file mode 100644 index 00000000000..4b1703e41c3 --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/fragments/design.fragment.graphql @@ -0,0 +1,24 @@ +#import "./design_note.fragment.graphql" +#import "./design_list.fragment.graphql" +#import "./diff_refs.fragment.graphql" +#import "./discussion_resolved_status.fragment.graphql" + +fragment DesignItem on Design { + ...DesignListItem + fullPath + diffRefs { + ...DesignDiffRefs + } + discussions { + nodes { + id + replyId + ...ResolvedStatus + notes { + nodes { + ...DesignNote + } + } + } + } +} diff --git a/app/assets/javascripts/design_management_new/graphql/fragments/design_list.fragment.graphql b/app/assets/javascripts/design_management_new/graphql/fragments/design_list.fragment.graphql new file mode 100644 index 00000000000..bc3132f9b42 --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/fragments/design_list.fragment.graphql @@ -0,0 +1,8 @@ +fragment DesignListItem on Design { + id + event + filename + notesCount + image + imageV432x230 +} diff --git a/app/assets/javascripts/design_management_new/graphql/fragments/design_note.fragment.graphql b/app/assets/javascripts/design_management_new/graphql/fragments/design_note.fragment.graphql new file mode 100644 index 00000000000..26edd2c0be1 --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/fragments/design_note.fragment.graphql @@ -0,0 +1,29 @@ +#import "./diff_refs.fragment.graphql" +#import "~/graphql_shared/fragments/author.fragment.graphql" +#import "./note_permissions.fragment.graphql" + +fragment DesignNote on Note { + id + author { + ...Author + } + body + bodyHtml + createdAt + resolved + position { + diffRefs { + ...DesignDiffRefs + } + x + y + height + width + } + userPermissions { + ...DesignNotePermissions + } + discussion { + id + } +} diff --git a/app/assets/javascripts/design_management_new/graphql/fragments/diff_refs.fragment.graphql b/app/assets/javascripts/design_management_new/graphql/fragments/diff_refs.fragment.graphql new file mode 100644 index 00000000000..984a55814b0 --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/fragments/diff_refs.fragment.graphql @@ -0,0 +1,5 @@ +fragment DesignDiffRefs on DiffRefs { + baseSha + startSha + headSha +} diff --git a/app/assets/javascripts/design_management_new/graphql/fragments/discussion_resolved_status.fragment.graphql b/app/assets/javascripts/design_management_new/graphql/fragments/discussion_resolved_status.fragment.graphql new file mode 100644 index 00000000000..7483b508721 --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/fragments/discussion_resolved_status.fragment.graphql @@ -0,0 +1,9 @@ +fragment ResolvedStatus on Discussion { + resolvable + resolved + resolvedAt + resolvedBy { + name + webUrl + } +} diff --git a/app/assets/javascripts/design_management_new/graphql/fragments/note_permissions.fragment.graphql b/app/assets/javascripts/design_management_new/graphql/fragments/note_permissions.fragment.graphql new file mode 100644 index 00000000000..c243e39f3d3 --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/fragments/note_permissions.fragment.graphql @@ -0,0 +1,3 @@ +fragment DesignNotePermissions on NotePermissions { + adminNote +} diff --git a/app/assets/javascripts/design_management_new/graphql/fragments/version.fragment.graphql b/app/assets/javascripts/design_management_new/graphql/fragments/version.fragment.graphql new file mode 100644 index 00000000000..7eb40b12f51 --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/fragments/version.fragment.graphql @@ -0,0 +1,4 @@ +fragment VersionListItem on DesignVersion { + id + sha +} diff --git a/app/assets/javascripts/design_management_new/graphql/mutations/create_image_diff_note.mutation.graphql b/app/assets/javascripts/design_management_new/graphql/mutations/create_image_diff_note.mutation.graphql new file mode 100644 index 00000000000..c8ade328120 --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/mutations/create_image_diff_note.mutation.graphql @@ -0,0 +1,21 @@ +#import "../fragments/design_note.fragment.graphql" + +mutation createImageDiffNote($input: CreateImageDiffNoteInput!) { + createImageDiffNote(input: $input) { + note { + ...DesignNote + discussion { + id + replyId + notes { + edges { + node { + ...DesignNote + } + } + } + } + } + errors + } +} diff --git a/app/assets/javascripts/design_management_new/graphql/mutations/create_note.mutation.graphql b/app/assets/javascripts/design_management_new/graphql/mutations/create_note.mutation.graphql new file mode 100644 index 00000000000..184ee6955dc --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/mutations/create_note.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/design_note.fragment.graphql" + +mutation createNote($input: CreateNoteInput!) { + createNote(input: $input) { + note { + ...DesignNote + } + errors + } +} diff --git a/app/assets/javascripts/design_management_new/graphql/mutations/destroy_design.mutation.graphql b/app/assets/javascripts/design_management_new/graphql/mutations/destroy_design.mutation.graphql new file mode 100644 index 00000000000..0b3cf636cdb --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/mutations/destroy_design.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/version.fragment.graphql" + +mutation destroyDesign($filenames: [String!]!, $projectPath: ID!, $iid: ID!) { + designManagementDelete(input: { projectPath: $projectPath, iid: $iid, filenames: $filenames }) { + version { + ...VersionListItem + } + errors + } +} diff --git a/app/assets/javascripts/design_management_new/graphql/mutations/toggle_resolve_discussion.mutation.graphql b/app/assets/javascripts/design_management_new/graphql/mutations/toggle_resolve_discussion.mutation.graphql new file mode 100644 index 00000000000..1157fc05d5f --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/mutations/toggle_resolve_discussion.mutation.graphql @@ -0,0 +1,17 @@ +#import "../fragments/design_note.fragment.graphql" +#import "../fragments/discussion_resolved_status.fragment.graphql" + +mutation toggleResolveDiscussion($id: ID!, $resolve: Boolean!) { + discussionToggleResolve(input: { id: $id, resolve: $resolve }) { + discussion { + id + ...ResolvedStatus + notes { + nodes { + ...DesignNote + } + } + } + errors + } +} diff --git a/app/assets/javascripts/design_management_new/graphql/mutations/update_active_discussion.mutation.graphql b/app/assets/javascripts/design_management_new/graphql/mutations/update_active_discussion.mutation.graphql new file mode 100644 index 00000000000..a24b6737159 --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/mutations/update_active_discussion.mutation.graphql @@ -0,0 +1,3 @@ +mutation updateActiveDiscussion($id: String, $source: String) { + updateActiveDiscussion(id: $id, source: $source) @client +} diff --git a/app/assets/javascripts/design_management_new/graphql/mutations/update_image_diff_note.mutation.graphql b/app/assets/javascripts/design_management_new/graphql/mutations/update_image_diff_note.mutation.graphql new file mode 100644 index 00000000000..5562ca9d89f --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/mutations/update_image_diff_note.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/design_note.fragment.graphql" + +mutation updateImageDiffNote($input: UpdateImageDiffNoteInput!) { + updateImageDiffNote(input: $input) { + errors + note { + ...DesignNote + } + } +} diff --git a/app/assets/javascripts/design_management_new/graphql/mutations/update_note.mutation.graphql b/app/assets/javascripts/design_management_new/graphql/mutations/update_note.mutation.graphql new file mode 100644 index 00000000000..b995e99fb6a --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/mutations/update_note.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/design_note.fragment.graphql" + +mutation updateNote($input: UpdateNoteInput!) { + updateNote(input: $input) { + note { + ...DesignNote + } + errors + } +} diff --git a/app/assets/javascripts/design_management_new/graphql/mutations/upload_design.mutation.graphql b/app/assets/javascripts/design_management_new/graphql/mutations/upload_design.mutation.graphql new file mode 100644 index 00000000000..d694e6558a0 --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/mutations/upload_design.mutation.graphql @@ -0,0 +1,21 @@ +#import "../fragments/design.fragment.graphql" + +mutation uploadDesign($files: [Upload!]!, $projectPath: ID!, $iid: ID!) { + designManagementUpload(input: { projectPath: $projectPath, iid: $iid, files: $files }) { + designs { + ...DesignItem + versions { + edges { + node { + id + sha + } + } + } + } + skippedDesigns { + filename + } + errors + } +} diff --git a/app/assets/javascripts/design_management_new/graphql/queries/active_discussion.query.graphql b/app/assets/javascripts/design_management_new/graphql/queries/active_discussion.query.graphql new file mode 100644 index 00000000000..111023cea68 --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/queries/active_discussion.query.graphql @@ -0,0 +1,6 @@ +query activeDiscussion { + activeDiscussion @client { + id + source + } +} diff --git a/app/assets/javascripts/design_management_new/graphql/queries/design_permissions.query.graphql b/app/assets/javascripts/design_management_new/graphql/queries/design_permissions.query.graphql new file mode 100644 index 00000000000..a87b256dc95 --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/queries/design_permissions.query.graphql @@ -0,0 +1,10 @@ +query permissions($fullPath: ID!, $iid: String!) { + project(fullPath: $fullPath) { + id + issue(iid: $iid) { + userPermissions { + createDesign + } + } + } +} diff --git a/app/assets/javascripts/design_management_new/graphql/queries/get_design.query.graphql b/app/assets/javascripts/design_management_new/graphql/queries/get_design.query.graphql new file mode 100644 index 00000000000..07a9af55787 --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/queries/get_design.query.graphql @@ -0,0 +1,31 @@ +#import "../fragments/design.fragment.graphql" +#import "~/graphql_shared/fragments/author.fragment.graphql" + +query getDesign($fullPath: ID!, $iid: String!, $atVersion: ID, $filenames: [String!]) { + project(fullPath: $fullPath) { + id + issue(iid: $iid) { + designCollection { + designs(atVersion: $atVersion, filenames: $filenames) { + edges { + node { + ...DesignItem + issue { + title + webPath + webUrl + participants { + edges { + node { + ...Author + } + } + } + } + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/design_management_new/graphql/queries/get_design_list.query.graphql b/app/assets/javascripts/design_management_new/graphql/queries/get_design_list.query.graphql new file mode 100644 index 00000000000..121a50555b3 --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/queries/get_design_list.query.graphql @@ -0,0 +1,26 @@ +#import "../fragments/design_list.fragment.graphql" +#import "../fragments/version.fragment.graphql" + +query getDesignList($fullPath: ID!, $iid: String!, $atVersion: ID) { + project(fullPath: $fullPath) { + id + issue(iid: $iid) { + designCollection { + designs(atVersion: $atVersion) { + edges { + node { + ...DesignListItem + } + } + } + versions { + edges { + node { + ...VersionListItem + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/design_management_new/graphql/typedefs.graphql b/app/assets/javascripts/design_management_new/graphql/typedefs.graphql new file mode 100644 index 00000000000..fdbad4a90e0 --- /dev/null +++ b/app/assets/javascripts/design_management_new/graphql/typedefs.graphql @@ -0,0 +1,12 @@ +type ActiveDiscussion { + id: ID + source: String +} + +extend type Query { + activeDiscussion: ActiveDiscussion +} + +extend type Mutation { + updateActiveDiscussion(id: ID!, source: String!): Boolean +} diff --git a/app/assets/javascripts/design_management_new/index.js b/app/assets/javascripts/design_management_new/index.js new file mode 100644 index 00000000000..20c9cacf83f --- /dev/null +++ b/app/assets/javascripts/design_management_new/index.js @@ -0,0 +1,33 @@ +import Vue from 'vue'; +import createRouter from './router'; +import App from './components/app.vue'; +import apolloProvider from './graphql'; + +export default () => { + const el = document.querySelector('.js-design-management-new'); + const { issueIid, projectPath, issuePath } = el.dataset; + const router = createRouter(issuePath); + + apolloProvider.clients.defaultClient.cache.writeData({ + data: { + activeDiscussion: { + __typename: 'ActiveDiscussion', + id: null, + source: null, + }, + }, + }); + + return new Vue({ + el, + router, + apolloProvider, + provide: { + projectPath, + issueIid, + }, + render(createElement) { + return createElement(App); + }, + }); +}; diff --git a/app/assets/javascripts/design_management_new/mixins/all_designs.js b/app/assets/javascripts/design_management_new/mixins/all_designs.js new file mode 100644 index 00000000000..f7d6551c46c --- /dev/null +++ b/app/assets/javascripts/design_management_new/mixins/all_designs.js @@ -0,0 +1,49 @@ +import { propertyOf } from 'lodash'; +import createFlash from '~/flash'; +import { s__ } from '~/locale'; +import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql'; +import { extractNodes } from '../utils/design_management_utils'; +import allVersionsMixin from './all_versions'; +import { DESIGNS_ROUTE_NAME } from '../router/constants'; + +export default { + mixins: [allVersionsMixin], + apollo: { + designs: { + query: getDesignListQuery, + variables() { + return { + fullPath: this.projectPath, + iid: this.issueIid, + atVersion: this.designsVersion, + }; + }, + update: data => { + const designEdges = propertyOf(data)(['project', 'issue', 'designCollection', 'designs']); + if (designEdges) { + return extractNodes(designEdges); + } + return []; + }, + error() { + this.error = true; + }, + result() { + if (this.$route.query.version && !this.hasValidVersion) { + createFlash( + s__( + 'DesignManagement|Requested design version does not exist. Showing latest version instead', + ), + ); + this.$router.replace({ name: DESIGNS_ROUTE_NAME, query: { version: undefined } }); + } + }, + }, + }, + data() { + return { + designs: [], + error: false, + }; + }, +}; diff --git a/app/assets/javascripts/design_management_new/mixins/all_versions.js b/app/assets/javascripts/design_management_new/mixins/all_versions.js new file mode 100644 index 00000000000..99e2ee9561c --- /dev/null +++ b/app/assets/javascripts/design_management_new/mixins/all_versions.js @@ -0,0 +1,59 @@ +import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql'; +import { findVersionId } from '../utils/design_management_utils'; + +export default { + apollo: { + allVersions: { + query: getDesignListQuery, + variables() { + return { + fullPath: this.projectPath, + iid: this.issueIid, + atVersion: null, + }; + }, + update: data => data.project.issue.designCollection.versions.edges, + }, + }, + inject: { + projectPath: { + default: '', + }, + issueIid: { + default: '', + }, + }, + computed: { + hasValidVersion() { + return ( + this.$route.query.version && + this.allVersions && + this.allVersions.some(version => version.node.id.endsWith(this.$route.query.version)) + ); + }, + designsVersion() { + return this.hasValidVersion + ? `gid://gitlab/DesignManagement::Version/${this.$route.query.version}` + : null; + }, + latestVersionId() { + const latestVersion = this.allVersions[0]; + return latestVersion && findVersionId(latestVersion.node.id); + }, + isLatestVersion() { + if (this.allVersions.length > 0) { + return ( + !this.$route.query.version || + !this.latestVersionId || + this.$route.query.version === this.latestVersionId + ); + } + return true; + }, + }, + data() { + return { + allVersions: [], + }; + }, +}; diff --git a/app/assets/javascripts/design_management_new/pages/design/index.vue b/app/assets/javascripts/design_management_new/pages/design/index.vue new file mode 100644 index 00000000000..47f5e3a786f --- /dev/null +++ b/app/assets/javascripts/design_management_new/pages/design/index.vue @@ -0,0 +1,367 @@ +<script> +import Mousetrap from 'mousetrap'; +import { GlLoadingIcon, GlAlert } from '@gitlab/ui'; +import { ApolloMutation } from 'vue-apollo'; +import createFlash from '~/flash'; +import { fetchPolicies } from '~/lib/graphql'; +import allVersionsMixin from '../../mixins/all_versions'; +import Toolbar from '../../components/toolbar/index.vue'; +import DesignDestroyer from '../../components/design_destroyer.vue'; +import DesignScaler from '../../components/design_scaler.vue'; +import DesignPresentation from '../../components/design_presentation.vue'; +import DesignReplyForm from '../../components/design_notes/design_reply_form.vue'; +import DesignSidebar from '../../components/design_sidebar.vue'; +import getDesignQuery from '../../graphql/queries/get_design.query.graphql'; +import createImageDiffNoteMutation from '../../graphql/mutations/create_image_diff_note.mutation.graphql'; +import updateImageDiffNoteMutation from '../../graphql/mutations/update_image_diff_note.mutation.graphql'; +import updateActiveDiscussionMutation from '../../graphql/mutations/update_active_discussion.mutation.graphql'; +import { + extractDiscussions, + extractDesign, + updateImageDiffNoteOptimisticResponse, +} from '../../utils/design_management_utils'; +import { + updateStoreAfterAddImageDiffNote, + updateStoreAfterUpdateImageDiffNote, +} from '../../utils/cache_update'; +import { + ADD_DISCUSSION_COMMENT_ERROR, + ADD_IMAGE_DIFF_NOTE_ERROR, + UPDATE_IMAGE_DIFF_NOTE_ERROR, + DESIGN_NOT_FOUND_ERROR, + DESIGN_VERSION_NOT_EXIST_ERROR, + UPDATE_NOTE_ERROR, + designDeletionError, +} from '../../utils/error_messages'; +import { trackDesignDetailView } from '../../utils/tracking'; +import { DESIGNS_ROUTE_NAME } from '../../router/constants'; +import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants'; + +export default { + components: { + ApolloMutation, + DesignReplyForm, + DesignPresentation, + DesignScaler, + DesignDestroyer, + Toolbar, + GlLoadingIcon, + GlAlert, + DesignSidebar, + }, + mixins: [allVersionsMixin], + props: { + id: { + type: String, + required: true, + }, + }, + data() { + return { + design: {}, + comment: '', + annotationCoordinates: null, + errorMessage: '', + scale: 1, + resolvedDiscussionsExpanded: false, + }; + }, + apollo: { + design: { + query: getDesignQuery, + // We want to see cached design version if we have one, and fetch newer version on the background to update discussions + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + variables() { + return this.designVariables; + }, + update: data => extractDesign(data), + result(res) { + this.onDesignQueryResult(res); + }, + error() { + this.onQueryError(DESIGN_NOT_FOUND_ERROR); + }, + }, + }, + computed: { + isFirstLoading() { + // We only want to show spinner on initial design load (when opened from a deep link to design) + // If we already have cached a design, loading shouldn't be indicated to user + return this.$apollo.queries.design.loading && !this.design.filename; + }, + discussions() { + if (!this.design.discussions) { + return []; + } + return extractDiscussions(this.design.discussions); + }, + markdownPreviewPath() { + return `/${this.projectPath}/preview_markdown?target_type=Issue`; + }, + isSubmitButtonDisabled() { + return this.comment.trim().length === 0; + }, + designVariables() { + return { + fullPath: this.projectPath, + iid: this.issueIid, + filenames: [this.$route.params.id], + atVersion: this.designsVersion, + }; + }, + mutationPayload() { + const { x, y, width, height } = this.annotationCoordinates; + return { + noteableId: this.design.id, + body: this.comment, + position: { + headSha: this.design.diffRefs.headSha, + baseSha: this.design.diffRefs.baseSha, + startSha: this.design.diffRefs.startSha, + x, + y, + width, + height, + paths: { + newPath: this.design.fullPath, + }, + }, + }; + }, + isAnnotating() { + return Boolean(this.annotationCoordinates); + }, + resolvedDiscussions() { + return this.discussions.filter(discussion => discussion.resolved); + }, + }, + watch: { + resolvedDiscussions(val) { + if (!val.length) { + this.resolvedDiscussionsExpanded = false; + } + }, + }, + mounted() { + Mousetrap.bind('esc', this.closeDesign); + this.trackEvent(); + // We need to reset the active discussion when opening a new design + this.updateActiveDiscussion(); + }, + beforeDestroy() { + Mousetrap.unbind('esc', this.closeDesign); + }, + methods: { + addImageDiffNoteToStore( + store, + { + data: { createImageDiffNote }, + }, + ) { + updateStoreAfterAddImageDiffNote( + store, + createImageDiffNote, + getDesignQuery, + this.designVariables, + ); + }, + updateImageDiffNoteInStore( + store, + { + data: { updateImageDiffNote }, + }, + ) { + return updateStoreAfterUpdateImageDiffNote( + store, + updateImageDiffNote, + getDesignQuery, + this.designVariables, + ); + }, + onMoveNote({ noteId, discussionId, position }) { + const discussion = this.discussions.find(({ id }) => id === discussionId); + const note = discussion.notes.find( + ({ discussion: noteDiscussion }) => noteDiscussion.id === discussionId, + ); + + const mutationPayload = { + optimisticResponse: updateImageDiffNoteOptimisticResponse(note, { + position, + }), + variables: { + input: { + id: noteId, + position, + }, + }, + mutation: updateImageDiffNoteMutation, + update: this.updateImageDiffNoteInStore, + }; + + return this.$apollo.mutate(mutationPayload).catch(e => this.onUpdateImageDiffNoteError(e)); + }, + onDesignQueryResult({ data, loading }) { + // On the initial load with cache-and-network policy data is undefined while loading is true + // To prevent throwing an error, we don't perform any logic until loading is false + if (loading) { + return; + } + + if (!data || !extractDesign(data)) { + this.onQueryError(DESIGN_NOT_FOUND_ERROR); + } else if (this.$route.query.version && !this.hasValidVersion) { + this.onQueryError(DESIGN_VERSION_NOT_EXIST_ERROR); + } + }, + onQueryError(message) { + // because we redirect user to /designs (the issue page), + // we want to create these flashes on the issue page + createFlash(message); + this.$router.push({ name: this.$options.DESIGNS_ROUTE_NAME }); + }, + onError(message, e) { + this.errorMessage = message; + throw e; + }, + onCreateImageDiffNoteError(e) { + this.onError(ADD_IMAGE_DIFF_NOTE_ERROR, e); + }, + onUpdateNoteError(e) { + this.onError(UPDATE_NOTE_ERROR, e); + }, + onDesignDiscussionError(e) { + this.onError(ADD_DISCUSSION_COMMENT_ERROR, e); + }, + onUpdateImageDiffNoteError(e) { + this.onError(UPDATE_IMAGE_DIFF_NOTE_ERROR, e); + }, + onDesignDeleteError(e) { + this.onError(designDeletionError({ singular: true }), e); + }, + onResolveDiscussionError(e) { + this.onError(UPDATE_IMAGE_DIFF_NOTE_ERROR, e); + }, + openCommentForm(annotationCoordinates) { + this.annotationCoordinates = annotationCoordinates; + if (this.$refs.newDiscussionForm) { + this.$refs.newDiscussionForm.focusInput(); + } + }, + closeCommentForm() { + this.comment = ''; + this.annotationCoordinates = null; + }, + closeDesign() { + this.$router.push({ + name: this.$options.DESIGNS_ROUTE_NAME, + query: this.$route.query, + }); + }, + trackEvent() { + // TODO: This needs to be made aware of referers, or if it's rendered in a different context than a Issue + trackDesignDetailView( + 'issue-design-collection', + 'issue', + this.$route.query.version || this.latestVersionId, + this.isLatestVersion, + ); + }, + updateActiveDiscussion(id) { + this.$apollo.mutate({ + mutation: updateActiveDiscussionMutation, + variables: { + id, + source: ACTIVE_DISCUSSION_SOURCE_TYPES.discussion, + }, + }); + }, + toggleResolvedComments() { + this.resolvedDiscussionsExpanded = !this.resolvedDiscussionsExpanded; + }, + }, + createImageDiffNoteMutation, + DESIGNS_ROUTE_NAME, +}; +</script> + +<template> + <div + class="design-detail js-design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row" + > + <gl-loading-icon v-if="isFirstLoading" size="xl" class="align-self-center" /> + <template v-else> + <div class="d-flex overflow-hidden flex-grow-1 flex-column position-relative"> + <design-destroyer + :filenames="[design.filename]" + :project-path="projectPath" + :iid="issueIid" + @done="$router.push({ name: $options.DESIGNS_ROUTE_NAME })" + @error="onDesignDeleteError" + > + <template #default="{ mutate, loading }"> + <toolbar + :id="id" + :is-deleting="loading" + :is-latest-version="isLatestVersion" + v-bind="design" + @delete="mutate" + /> + </template> + </design-destroyer> + + <div v-if="errorMessage" class="p-3"> + <gl-alert variant="danger" @dismiss="errorMessage = null"> + {{ errorMessage }} + </gl-alert> + </div> + <design-presentation + :image="design.image" + :image-name="design.filename" + :discussions="discussions" + :is-annotating="isAnnotating" + :scale="scale" + :resolved-discussions-expanded="resolvedDiscussionsExpanded" + @openCommentForm="openCommentForm" + @closeCommentForm="closeCommentForm" + @moveNote="onMoveNote" + /> + + <div class="design-scaler-wrapper position-absolute mb-4 d-flex-center"> + <design-scaler @scale="scale = $event" /> + </div> + </div> + <design-sidebar + :design="design" + :resolved-discussions-expanded="resolvedDiscussionsExpanded" + :markdown-preview-path="markdownPreviewPath" + @onDesignDiscussionError="onDesignDiscussionError" + @onCreateImageDiffNoteError="onCreateImageDiffNoteError" + @updateNoteError="onUpdateNoteError" + @resolveDiscussionError="onResolveDiscussionError" + @toggleResolvedComments="toggleResolvedComments" + > + <template #replyForm> + <apollo-mutation + v-if="isAnnotating" + #default="{ mutate, loading }" + :mutation="$options.createImageDiffNoteMutation" + :variables="{ + input: mutationPayload, + }" + :update="addImageDiffNoteToStore" + @done="closeCommentForm" + @error="onCreateImageDiffNoteError" + > + <design-reply-form + ref="newDiscussionForm" + v-model="comment" + :is-saving="loading" + :markdown-preview-path="markdownPreviewPath" + @submitForm="mutate" + @cancelForm="closeCommentForm" + /> </apollo-mutation + ></template> + </design-sidebar> + </template> + </div> +</template> diff --git a/app/assets/javascripts/design_management_new/pages/index.vue b/app/assets/javascripts/design_management_new/pages/index.vue new file mode 100644 index 00000000000..2a100fae280 --- /dev/null +++ b/app/assets/javascripts/design_management_new/pages/index.vue @@ -0,0 +1,346 @@ +<script> +import { GlLoadingIcon, GlButton, GlAlert } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { s__, sprintf } from '~/locale'; +import UploadButton from '../components/upload/button.vue'; +import DeleteButton from '../components/delete_button.vue'; +import Design from '../components/list/item.vue'; +import DesignDestroyer from '../components/design_destroyer.vue'; +import DesignVersionDropdown from '../components/upload/design_version_dropdown.vue'; +import DesignDropzone from '../components/upload/design_dropzone.vue'; +import uploadDesignMutation from '../graphql/mutations/upload_design.mutation.graphql'; +import permissionsQuery from '../graphql/queries/design_permissions.query.graphql'; +import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql'; +import allDesignsMixin from '../mixins/all_designs'; +import { + UPLOAD_DESIGN_ERROR, + EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE, + EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE, + designUploadSkippedWarning, + designDeletionError, +} from '../utils/error_messages'; +import { updateStoreAfterUploadDesign } from '../utils/cache_update'; +import { + designUploadOptimisticResponse, + isValidDesignFile, +} from '../utils/design_management_utils'; +import { getFilename } from '~/lib/utils/file_upload'; +import { DESIGNS_ROUTE_NAME } from '../router/constants'; + +const MAXIMUM_FILE_UPLOAD_LIMIT = 10; + +export default { + components: { + GlLoadingIcon, + GlAlert, + GlButton, + UploadButton, + Design, + DesignDestroyer, + DesignVersionDropdown, + DeleteButton, + DesignDropzone, + }, + mixins: [allDesignsMixin], + apollo: { + permissions: { + query: permissionsQuery, + variables() { + return { + fullPath: this.projectPath, + iid: this.issueIid, + }; + }, + update: data => data.project.issue.userPermissions, + }, + }, + data() { + return { + permissions: { + createDesign: false, + }, + filesToBeSaved: [], + selectedDesigns: [], + }; + }, + computed: { + isLoading() { + return this.$apollo.queries.designs.loading || this.$apollo.queries.permissions.loading; + }, + isSaving() { + return this.filesToBeSaved.length > 0; + }, + canCreateDesign() { + return this.permissions.createDesign; + }, + showToolbar() { + return this.canCreateDesign && this.allVersions.length > 0; + }, + hasDesigns() { + return this.designs.length > 0; + }, + hasSelectedDesigns() { + return this.selectedDesigns.length > 0; + }, + canDeleteDesigns() { + return this.isLatestVersion && this.hasSelectedDesigns; + }, + projectQueryBody() { + return { + query: getDesignListQuery, + variables: { fullPath: this.projectPath, iid: this.issueIid, atVersion: null }, + }; + }, + selectAllButtonText() { + return this.hasSelectedDesigns + ? s__('DesignManagement|Deselect all') + : s__('DesignManagement|Select all'); + }, + isDesignListEmpty() { + return !this.isSaving && !this.hasDesigns; + }, + designDropzoneWrapperClass() { + return this.isDesignListEmpty + ? 'col-12' + : 'gl-flex-direction-column col-md-6 col-lg-3 gl-mb-3'; + }, + }, + mounted() { + this.toggleOnPasteListener(this.$route.name); + }, + methods: { + resetFilesToBeSaved() { + this.filesToBeSaved = []; + }, + /** + * Determine if a design upload is valid, given [files] + * @param {Array<File>} files + */ + isValidDesignUpload(files) { + if (!this.canCreateDesign) return false; + + if (files.length > MAXIMUM_FILE_UPLOAD_LIMIT) { + createFlash( + sprintf( + s__( + 'DesignManagement|The maximum number of designs allowed to be uploaded is %{upload_limit}. Please try again.', + ), + { + upload_limit: MAXIMUM_FILE_UPLOAD_LIMIT, + }, + ), + ); + + return false; + } + return true; + }, + onUploadDesign(files) { + // convert to Array so that we have Array methods (.map, .some, etc.) + this.filesToBeSaved = Array.from(files); + if (!this.isValidDesignUpload(this.filesToBeSaved)) return null; + + const mutationPayload = { + optimisticResponse: designUploadOptimisticResponse(this.filesToBeSaved), + variables: { + files: this.filesToBeSaved, + projectPath: this.projectPath, + iid: this.issueIid, + }, + context: { + hasUpload: true, + }, + mutation: uploadDesignMutation, + update: this.afterUploadDesign, + }; + + return this.$apollo + .mutate(mutationPayload) + .then(res => this.onUploadDesignDone(res)) + .catch(() => this.onUploadDesignError()); + }, + afterUploadDesign( + store, + { + data: { designManagementUpload }, + }, + ) { + updateStoreAfterUploadDesign(store, designManagementUpload, this.projectQueryBody); + }, + onUploadDesignDone(res) { + const skippedFiles = res?.data?.designManagementUpload?.skippedDesigns || []; + const skippedWarningMessage = designUploadSkippedWarning(this.filesToBeSaved, skippedFiles); + if (skippedWarningMessage) { + createFlash(skippedWarningMessage, 'warning'); + } + + // if this upload resulted in a new version being created, redirect user to the latest version + if (!this.isLatestVersion) { + this.$router.push({ name: DESIGNS_ROUTE_NAME }); + } + this.resetFilesToBeSaved(); + }, + onUploadDesignError() { + this.resetFilesToBeSaved(); + createFlash(UPLOAD_DESIGN_ERROR); + }, + changeSelectedDesigns(filename) { + if (this.isDesignSelected(filename)) { + this.selectedDesigns = this.selectedDesigns.filter(design => design !== filename); + } else { + this.selectedDesigns.push(filename); + } + }, + toggleDesignsSelection() { + if (this.hasSelectedDesigns) { + this.selectedDesigns = []; + } else { + this.selectedDesigns = this.designs.map(design => design.filename); + } + }, + isDesignSelected(filename) { + return this.selectedDesigns.includes(filename); + }, + isDesignToBeSaved(filename) { + return this.filesToBeSaved.some(file => file.name === filename); + }, + canSelectDesign(filename) { + return this.isLatestVersion && this.canCreateDesign && !this.isDesignToBeSaved(filename); + }, + onDesignDelete() { + this.selectedDesigns = []; + if (this.$route.query.version) this.$router.push({ name: DESIGNS_ROUTE_NAME }); + }, + onDesignDeleteError() { + const errorMessage = designDeletionError({ singular: this.selectedDesigns.length === 1 }); + createFlash(errorMessage); + }, + onExistingDesignDropzoneChange(files, existingDesignFilename) { + const filesArr = Array.from(files); + + if (filesArr.length > 1) { + createFlash(EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE); + return; + } + + if (!filesArr.some(({ name }) => existingDesignFilename === name)) { + createFlash(EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE); + return; + } + + this.onUploadDesign(files); + }, + onDesignPaste(event) { + const { clipboardData } = event; + const files = Array.from(clipboardData.files); + if (clipboardData && files.length > 0) { + if (!files.some(isValidDesignFile)) { + return; + } + event.preventDefault(); + let filename = getFilename(event); + if (!filename || filename === 'image.png') { + filename = `design_${Date.now()}.png`; + } + const newFile = new File([files[0]], filename); + this.onUploadDesign([newFile]); + } + }, + toggleOnPasteListener(route) { + if (route === DESIGNS_ROUTE_NAME) { + document.addEventListener('paste', this.onDesignPaste); + } else { + document.removeEventListener('paste', this.onDesignPaste); + } + }, + }, + beforeRouteUpdate(to, from, next) { + this.toggleOnPasteListener(to.name); + this.selectedDesigns = []; + next(); + }, + beforeRouteLeave(to, from, next) { + this.toggleOnPasteListener(to.name); + next(); + }, +}; +</script> + +<template> + <div data-testid="designs-root" class="gl-mt-5"> + <header v-if="showToolbar" class="row-content-block border-top-0 p-2 d-flex"> + <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-w-full"> + <div> + <span class="gl-font-weight-bold gl-mr-3">{{ s__('DesignManagement|Designs') }}</span> + <design-version-dropdown /> + </div> + <div v-show="hasDesigns" class="qa-selector-toolbar gl-display-flex"> + <gl-button + v-if="isLatestVersion" + variant="link" + size="small" + class="gl-mr-2 js-select-all" + @click="toggleDesignsSelection" + >{{ selectAllButtonText }} + </gl-button> + <design-destroyer + #default="{ mutate, loading }" + :filenames="selectedDesigns" + @done="onDesignDelete" + @error="onDesignDeleteError" + > + <delete-button + v-if="isLatestVersion" + :is-deleting="loading" + button-variant="danger" + button-class="gl-mr-4" + button-size="small" + :has-selected-designs="hasSelectedDesigns" + @deleteSelectedDesigns="mutate()" + > + {{ s__('DesignManagement|Delete selected') }} + <gl-loading-icon v-if="loading" inline class="ml-1" /> + </delete-button> + </design-destroyer> + <upload-button v-if="canCreateDesign" :is-saving="isSaving" @upload="onUploadDesign" /> + </div> + </div> + </header> + <div class="mt-4"> + <gl-loading-icon v-if="isLoading" size="md" /> + <gl-alert v-else-if="error" variant="danger" :dismissible="false"> + {{ __('An error occurred while loading designs. Please try again.') }} + </gl-alert> + <ol v-else class="list-unstyled row"> + <span + v-if="isDesignListEmpty && !allVersions.length" + class="gl-font-weight-bold gl-font-weight-bold gl-ml-5 gl-mb-4" + >{{ s__('DesignManagement|Designs') }}</span + > + <li :class="designDropzoneWrapperClass" data-testid="design-dropzone-wrapper"> + <design-dropzone + :class="{ 'design-list-item design-list-item-new': !isDesignListEmpty }" + :has-designs="hasDesigns" + @change="onUploadDesign" + /> + </li> + <li v-for="design in designs" :key="design.id" class="col-md-6 col-lg-3 gl-mb-3"> + <design-dropzone + :has-designs="hasDesigns" + @change="onExistingDesignDropzoneChange($event, design.filename)" + ><design v-bind="design" :is-uploading="isDesignToBeSaved(design.filename)" + /></design-dropzone> + + <input + v-if="canSelectDesign(design.filename)" + :checked="isDesignSelected(design.filename)" + type="checkbox" + class="design-checkbox" + @change="changeSelectedDesigns(design.filename)" + /> + </li> + </ol> + </div> + <router-view :key="$route.fullPath" /> + </div> +</template> diff --git a/app/assets/javascripts/design_management_new/router/constants.js b/app/assets/javascripts/design_management_new/router/constants.js new file mode 100644 index 00000000000..dd2ee8d8689 --- /dev/null +++ b/app/assets/javascripts/design_management_new/router/constants.js @@ -0,0 +1,2 @@ +export const DESIGNS_ROUTE_NAME = 'designs'; +export const DESIGN_ROUTE_NAME = 'design'; diff --git a/app/assets/javascripts/design_management_new/router/index.js b/app/assets/javascripts/design_management_new/router/index.js new file mode 100644 index 00000000000..40e2d35bc40 --- /dev/null +++ b/app/assets/javascripts/design_management_new/router/index.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import routes from './routes'; +import { DESIGN_ROUTE_NAME } from './constants'; +import { getPageLayoutElement } from '~/design_management_new/utils/design_management_utils'; +import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '../constants'; + +Vue.use(VueRouter); + +export default function createRouter(base) { + const router = new VueRouter({ + base, + mode: 'history', + routes, + }); + const pageEl = getPageLayoutElement(); + + router.beforeEach(({ name }, _, next) => { + // apply a fullscreen layout style in Design View (a.k.a design detail) + if (pageEl) { + if (name === DESIGN_ROUTE_NAME) { + pageEl.classList.add(...DESIGN_DETAIL_LAYOUT_CLASSLIST); + } else { + pageEl.classList.remove(...DESIGN_DETAIL_LAYOUT_CLASSLIST); + } + } + + next(); + }); + + return router; +} diff --git a/app/assets/javascripts/design_management_new/router/routes.js b/app/assets/javascripts/design_management_new/router/routes.js new file mode 100644 index 00000000000..d888b856611 --- /dev/null +++ b/app/assets/javascripts/design_management_new/router/routes.js @@ -0,0 +1,29 @@ +import Home from '../pages/index.vue'; +import DesignDetail from '../pages/design/index.vue'; +import { DESIGNS_ROUTE_NAME, DESIGN_ROUTE_NAME } from './constants'; + +export default [ + { + name: DESIGNS_ROUTE_NAME, + path: '/', + component: Home, + alias: '/designs', + }, + { + name: DESIGN_ROUTE_NAME, + path: '/designs/:id', + component: DesignDetail, + beforeEnter( + { + params: { id }, + }, + _, + next, + ) { + if (typeof id === 'string') { + next(); + } + }, + props: ({ params: { id } }) => ({ id }), + }, +]; diff --git a/app/assets/javascripts/design_management_new/utils/cache_update.js b/app/assets/javascripts/design_management_new/utils/cache_update.js new file mode 100644 index 00000000000..24b374b79fd --- /dev/null +++ b/app/assets/javascripts/design_management_new/utils/cache_update.js @@ -0,0 +1,276 @@ +/* eslint-disable @gitlab/require-i18n-strings */ + +import createFlash from '~/flash'; +import { extractCurrentDiscussion, extractDesign } from './design_management_utils'; +import { + ADD_IMAGE_DIFF_NOTE_ERROR, + UPDATE_IMAGE_DIFF_NOTE_ERROR, + ADD_DISCUSSION_COMMENT_ERROR, + designDeletionError, +} from './error_messages'; + +const deleteDesignsFromStore = (store, query, selectedDesigns) => { + const data = store.readQuery(query); + + const changedDesigns = data.project.issue.designCollection.designs.edges.filter( + ({ node }) => !selectedDesigns.includes(node.filename), + ); + data.project.issue.designCollection.designs.edges = [...changedDesigns]; + + store.writeQuery({ + ...query, + data, + }); +}; + +/** + * Adds a new version of designs to store + * + * @param {Object} store + * @param {Object} query + * @param {Object} version + */ +const addNewVersionToStore = (store, query, version) => { + if (!version) return; + + const data = store.readQuery(query); + const newEdge = { node: version, __typename: 'DesignVersionEdge' }; + + data.project.issue.designCollection.versions.edges = [ + newEdge, + ...data.project.issue.designCollection.versions.edges, + ]; + + store.writeQuery({ + ...query, + data, + }); +}; + +const addDiscussionCommentToStore = (store, createNote, query, queryVariables, discussionId) => { + const data = store.readQuery({ + query, + variables: queryVariables, + }); + + const design = extractDesign(data); + const currentDiscussion = extractCurrentDiscussion(design.discussions, discussionId); + currentDiscussion.notes.nodes = [...currentDiscussion.notes.nodes, createNote.note]; + + design.notesCount += 1; + if ( + !design.issue.participants.edges.some( + participant => participant.node.username === createNote.note.author.username, + ) + ) { + design.issue.participants.edges = [ + ...design.issue.participants.edges, + { + __typename: 'UserEdge', + node: { + __typename: 'User', + ...createNote.note.author, + }, + }, + ]; + } + store.writeQuery({ + query, + variables: queryVariables, + data: { + ...data, + design: { + ...design, + }, + }, + }); +}; + +const addImageDiffNoteToStore = (store, createImageDiffNote, query, variables) => { + const data = store.readQuery({ + query, + variables, + }); + const newDiscussion = { + __typename: 'Discussion', + id: createImageDiffNote.note.discussion.id, + replyId: createImageDiffNote.note.discussion.replyId, + resolvable: true, + resolved: false, + resolvedAt: null, + resolvedBy: null, + notes: { + __typename: 'NoteConnection', + nodes: [createImageDiffNote.note], + }, + }; + const design = extractDesign(data); + const notesCount = design.notesCount + 1; + design.discussions.nodes = [...design.discussions.nodes, newDiscussion]; + if ( + !design.issue.participants.edges.some( + participant => participant.node.username === createImageDiffNote.note.author.username, + ) + ) { + design.issue.participants.edges = [ + ...design.issue.participants.edges, + { + __typename: 'UserEdge', + node: { + __typename: 'User', + ...createImageDiffNote.note.author, + }, + }, + ]; + } + store.writeQuery({ + query, + variables, + data: { + ...data, + design: { + ...design, + notesCount, + }, + }, + }); +}; + +const updateImageDiffNoteInStore = (store, updateImageDiffNote, query, variables) => { + const data = store.readQuery({ + query, + variables, + }); + + const design = extractDesign(data); + const discussion = extractCurrentDiscussion( + design.discussions, + updateImageDiffNote.note.discussion.id, + ); + + discussion.notes = { + ...discussion.notes, + nodes: [updateImageDiffNote.note, ...discussion.notes.nodes.slice(1)], + }; + + store.writeQuery({ + query, + variables, + data: { + ...data, + design, + }, + }); +}; + +const addNewDesignToStore = (store, designManagementUpload, query) => { + const data = store.readQuery(query); + + const newDesigns = data.project.issue.designCollection.designs.edges.reduce((acc, design) => { + if (!acc.find(d => d.filename === design.node.filename)) { + acc.push(design.node); + } + + return acc; + }, designManagementUpload.designs); + + let newVersionNode; + const findNewVersions = designManagementUpload.designs.find(design => design.versions); + + if (findNewVersions) { + const findNewVersionsEdges = findNewVersions.versions.edges; + + if (findNewVersionsEdges && findNewVersionsEdges.length) { + newVersionNode = [findNewVersionsEdges[0]]; + } + } + + const newVersions = [ + ...(newVersionNode || []), + ...data.project.issue.designCollection.versions.edges, + ]; + + const updatedDesigns = { + __typename: 'DesignCollection', + designs: { + __typename: 'DesignConnection', + edges: newDesigns.map(design => ({ + __typename: 'DesignEdge', + node: design, + })), + }, + versions: { + __typename: 'DesignVersionConnection', + edges: newVersions, + }, + }; + + data.project.issue.designCollection = updatedDesigns; + + store.writeQuery({ + ...query, + data, + }); +}; + +const onError = (data, message) => { + createFlash(message); + throw new Error(data.errors); +}; + +export const hasErrors = ({ errors = [] }) => errors?.length; + +/** + * Updates a store after design deletion + * + * @param {Object} store + * @param {Object} data + * @param {Object} query + * @param {Array} designs + */ +export const updateStoreAfterDesignsDelete = (store, data, query, designs) => { + if (hasErrors(data)) { + onError(data, designDeletionError({ singular: designs.length === 1 })); + } else { + deleteDesignsFromStore(store, query, designs); + addNewVersionToStore(store, query, data.version); + } +}; + +export const updateStoreAfterAddDiscussionComment = ( + store, + data, + query, + queryVariables, + discussionId, +) => { + if (hasErrors(data)) { + onError(data, ADD_DISCUSSION_COMMENT_ERROR); + } else { + addDiscussionCommentToStore(store, data, query, queryVariables, discussionId); + } +}; + +export const updateStoreAfterAddImageDiffNote = (store, data, query, queryVariables) => { + if (hasErrors(data)) { + onError(data, ADD_IMAGE_DIFF_NOTE_ERROR); + } else { + addImageDiffNoteToStore(store, data, query, queryVariables); + } +}; + +export const updateStoreAfterUpdateImageDiffNote = (store, data, query, queryVariables) => { + if (hasErrors(data)) { + onError(data, UPDATE_IMAGE_DIFF_NOTE_ERROR); + } else { + updateImageDiffNoteInStore(store, data, query, queryVariables); + } +}; + +export const updateStoreAfterUploadDesign = (store, data, query) => { + if (hasErrors(data)) { + onError(data, data.errors[0]); + } else { + addNewDesignToStore(store, data, query); + } +}; diff --git a/app/assets/javascripts/design_management_new/utils/design_management_utils.js b/app/assets/javascripts/design_management_new/utils/design_management_utils.js new file mode 100644 index 00000000000..22705cf67a1 --- /dev/null +++ b/app/assets/javascripts/design_management_new/utils/design_management_utils.js @@ -0,0 +1,128 @@ +import { uniqueId } from 'lodash'; +import { VALID_DESIGN_FILE_MIMETYPE } from '../constants'; + +export const isValidDesignFile = ({ type }) => + (type.match(VALID_DESIGN_FILE_MIMETYPE.regex) || []).length > 0; + +/** + * Returns formatted array that doesn't contain + * `edges`->`node` nesting + * + * @param {Array} elements + */ + +export const extractNodes = elements => elements.edges.map(({ node }) => node); + +/** + * Returns formatted array of discussions that doesn't contain + * `edges`->`node` nesting for child notes + * + * @param {Array} discussions + */ + +export const extractDiscussions = discussions => + discussions.nodes.map((discussion, index) => ({ + ...discussion, + index: index + 1, + notes: discussion.notes.nodes, + })); + +/** + * Returns a discussion with the given id from discussions array + * + * @param {Array} discussions + */ + +export const extractCurrentDiscussion = (discussions, id) => + discussions.nodes.find(discussion => discussion.id === id); + +export const findVersionId = id => (id.match('::Version/(.+$)') || [])[1]; + +export const findNoteId = id => (id.match('DiffNote/(.+$)') || [])[1]; + +export const extractDesigns = data => data.project.issue.designCollection.designs.edges; + +export const extractDesign = data => (extractDesigns(data) || [])[0]?.node; + +/** + * Generates optimistic response for a design upload mutation + * @param {Array<File>} files + */ +export const designUploadOptimisticResponse = files => { + const designs = files.map(file => ({ + // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26 + // eslint-disable-next-line @gitlab/require-i18n-strings + __typename: 'Design', + id: -uniqueId(), + image: '', + imageV432x230: '', + filename: file.name, + fullPath: '', + notesCount: 0, + event: 'NONE', + diffRefs: { + __typename: 'DiffRefs', + baseSha: '', + startSha: '', + headSha: '', + }, + discussions: { + __typename: 'DesignDiscussion', + nodes: [], + }, + versions: { + __typename: 'DesignVersionConnection', + edges: { + __typename: 'DesignVersionEdge', + node: { + __typename: 'DesignVersion', + id: -uniqueId(), + sha: -uniqueId(), + }, + }, + }, + })); + + return { + // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26 + // eslint-disable-next-line @gitlab/require-i18n-strings + __typename: 'Mutation', + designManagementUpload: { + __typename: 'DesignManagementUploadPayload', + designs, + skippedDesigns: [], + errors: [], + }, + }; +}; + +/** + * Generates optimistic response for a design upload mutation + * @param {Array<File>} files + */ +export const updateImageDiffNoteOptimisticResponse = (note, { position }) => ({ + // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26 + // eslint-disable-next-line @gitlab/require-i18n-strings + __typename: 'Mutation', + updateImageDiffNote: { + __typename: 'UpdateImageDiffNotePayload', + note: { + ...note, + position: { + ...note.position, + ...position, + }, + }, + errors: [], + }, +}); + +const normalizeAuthor = author => ({ + ...author, + web_url: author.webUrl, + avatar_url: author.avatarUrl, +}); + +export const extractParticipants = users => users.edges.map(({ node }) => normalizeAuthor(node)); + +export const getPageLayoutElement = () => document.querySelector('.layout-page'); diff --git a/app/assets/javascripts/design_management_new/utils/error_messages.js b/app/assets/javascripts/design_management_new/utils/error_messages.js new file mode 100644 index 00000000000..7666c726c2f --- /dev/null +++ b/app/assets/javascripts/design_management_new/utils/error_messages.js @@ -0,0 +1,95 @@ +import { __, s__, n__, sprintf } from '~/locale'; + +export const ADD_DISCUSSION_COMMENT_ERROR = s__( + 'DesignManagement|Could not add a new comment. Please try again.', +); + +export const ADD_IMAGE_DIFF_NOTE_ERROR = s__( + 'DesignManagement|Could not create new discussion. Please try again.', +); + +export const UPDATE_IMAGE_DIFF_NOTE_ERROR = s__( + 'DesignManagement|Could not update discussion. Please try again.', +); + +export const UPDATE_NOTE_ERROR = s__('DesignManagement|Could not update note. Please try again.'); + +export const UPLOAD_DESIGN_ERROR = s__( + 'DesignManagement|Error uploading a new design. Please try again.', +); + +export const UPLOAD_DESIGN_INVALID_FILETYPE_ERROR = __( + 'Could not upload your designs as one or more files uploaded are not supported.', +); + +export const DESIGN_NOT_FOUND_ERROR = __('Could not find design.'); + +export const DESIGN_VERSION_NOT_EXIST_ERROR = __('Requested design version does not exist.'); + +const DESIGN_UPLOAD_SKIPPED_MESSAGE = s__('DesignManagement|Upload skipped.'); + +const ALL_DESIGNS_SKIPPED_MESSAGE = `${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${s__( + 'The designs you tried uploading did not change.', +)}`; + +export const EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE = __( + 'You can only upload one design when dropping onto an existing design.', +); + +export const EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE = __( + 'You must upload a file with the same file name when dropping onto an existing design.', +); + +const MAX_SKIPPED_FILES_LISTINGS = 5; + +const oneDesignSkippedMessage = filename => + `${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${sprintf(s__('DesignManagement|%{filename} did not change.'), { + filename, + })}`; + +/** + * Return warning message indicating that some (but not all) uploaded + * files were skipped. + * @param {Array<{ filename }>} skippedFiles + */ +const someDesignsSkippedMessage = skippedFiles => { + const designsSkippedMessage = `${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${s__( + 'Some of the designs you tried uploading did not change:', + )}`; + + const moreText = sprintf(s__(`DesignManagement|and %{moreCount} more.`), { + moreCount: skippedFiles.length - MAX_SKIPPED_FILES_LISTINGS, + }); + + return `${designsSkippedMessage} ${skippedFiles + .slice(0, MAX_SKIPPED_FILES_LISTINGS) + .map(({ filename }) => filename) + .join(', ')}${skippedFiles.length > MAX_SKIPPED_FILES_LISTINGS ? `, ${moreText}` : '.'}`; +}; + +export const designDeletionError = ({ singular = true } = {}) => { + const design = singular ? __('a design') : __('designs'); + return sprintf(s__('Could not delete %{design}. Please try again.'), { + design, + }); +}; + +/** + * Return warning message, if applicable, that one, some or all uploaded + * files were skipped. + * @param {Array<{ filename }>} uploadedDesigns + * @param {Array<{ filename }>} skippedFiles + */ +export const designUploadSkippedWarning = (uploadedDesigns, skippedFiles) => { + if (skippedFiles.length === 0) { + return null; + } + + if (skippedFiles.length === uploadedDesigns.length) { + const { filename } = skippedFiles[0]; + + return n__(oneDesignSkippedMessage(filename), ALL_DESIGNS_SKIPPED_MESSAGE, skippedFiles.length); + } + + return someDesignsSkippedMessage(skippedFiles); +}; diff --git a/app/assets/javascripts/design_management_new/utils/tracking.js b/app/assets/javascripts/design_management_new/utils/tracking.js new file mode 100644 index 00000000000..b3ecc1453a6 --- /dev/null +++ b/app/assets/javascripts/design_management_new/utils/tracking.js @@ -0,0 +1,27 @@ +import Tracking from '~/tracking'; + +// Tracking Constants +const DESIGN_TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/design_management_context/jsonschema/1-0-0'; +const DESIGN_TRACKING_PAGE_NAME = 'projects:issues:design'; +const DESIGN_TRACKING_EVENT_NAME = 'view_design'; + +// eslint-disable-next-line import/prefer-default-export +export function trackDesignDetailView( + referer = '', + owner = '', + designVersion = 1, + latestVersion = false, +) { + Tracking.event(DESIGN_TRACKING_PAGE_NAME, DESIGN_TRACKING_EVENT_NAME, { + label: DESIGN_TRACKING_EVENT_NAME, + context: { + schema: DESIGN_TRACKING_CONTEXT_SCHEMA, + data: { + 'design-version-number': designVersion, + 'design-is-current-version': latestVersion, + 'internal-object-referrer': referer, + 'design-collection-owner': owner, + }, + }, + }); +} diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 941365d9d1d..1e524882d5f 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -1,6 +1,6 @@ <script> import { mapState, mapGetters, mapActions } from 'vuex'; -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlButtonGroup, GlButton } from '@gitlab/ui'; import Mousetrap from 'mousetrap'; import { __ } from '~/locale'; import createFlash from '~/flash'; @@ -36,6 +36,8 @@ export default { TreeList, GlLoadingIcon, PanelResizer, + GlButtonGroup, + GlButton, }, mixins: [glFeatureFlagsMixin()], props: { @@ -94,6 +96,11 @@ export default { required: false, default: false, }, + viewDiffsFileByFile: { + type: Boolean, + required: false, + default: false, + }, }, data() { const treeWidth = @@ -120,9 +127,18 @@ export default { emailPatchPath: state => state.diffs.emailPatchPath, retrievingBatches: state => state.diffs.retrievingBatches, }), - ...mapState('diffs', ['showTreeList', 'isLoading', 'startVersion']), + ...mapState('diffs', ['showTreeList', 'isLoading', 'startVersion', 'currentDiffFileId']), ...mapGetters('diffs', ['isParallelView', 'currentDiffIndex']), ...mapGetters(['isNotesFetched', 'getNoteableData']), + diffs() { + if (!this.viewDiffsFileByFile) { + return this.diffFiles; + } + + return this.diffFiles.filter((file, i) => { + return file.file_hash === this.currentDiffFileId || (i === 0 && !this.currentDiffFileId); + }); + }, canCurrentUserFork() { return this.currentUser.can_fork === true && this.currentUser.can_create_merge_request; }, @@ -183,16 +199,22 @@ export default { dismissEndpoint: this.dismissEndpoint, showSuggestPopover: this.showSuggestPopover, useSingleDiffStyle: this.glFeatures.singleMrDiffView, + viewDiffsFileByFile: this.viewDiffsFileByFile, }); if (this.shouldShow) { this.fetchData(); } - const id = window && window.location && window.location.hash; + const id = window?.location?.hash; - if (id) { - this.setHighlightedRow(id.slice(1)); + if (id && id.indexOf('#note') !== 0) { + this.setHighlightedRow( + id + .split('diff-content') + .pop() + .slice(1), + ); } }, created() { @@ -236,6 +258,7 @@ export default { 'cacheTreeListWidth', 'scrollToFile', 'toggleShowTreeList', + 'navigateToDiffFileIndex', ]), refetchDiffData() { this.fetchData(false); @@ -398,7 +421,7 @@ export default { class="files d-flex" > <div - v-show="showTreeList" + v-if="showTreeList" :style="{ width: `${treeWidth}px` }" class="diff-tree-list js-diff-tree-list mr-3" > @@ -422,12 +445,31 @@ export default { <div v-if="isBatchLoading" class="loading"><gl-loading-icon size="lg" /></div> <template v-else-if="renderDiffFiles"> <diff-file - v-for="file in diffFiles" + v-for="file in diffs" :key="file.newPath" :file="file" :help-page-path="helpPagePath" :can-current-user-fork="canCurrentUserFork" + :view-diffs-file-by-file="viewDiffsFileByFile" /> + <div v-if="viewDiffsFileByFile" class="d-flex gl-justify-content-center"> + <gl-button-group> + <gl-button + :disabled="currentDiffIndex === 0" + data-testid="singleFilePrevious" + @click="navigateToDiffFileIndex(currentDiffIndex - 1)" + > + {{ __('Prev') }} + </gl-button> + <gl-button + :disabled="currentDiffIndex === diffFiles.length - 1" + data-testid="singleFileNext" + @click="navigateToDiffFileIndex(currentDiffIndex + 1)" + > + {{ __('Next') }} + </gl-button> + </gl-button-group> + </div> </template> <no-changes v-else :changes-empty-state-illustration="changesEmptyStateIllustration" /> </div> diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index 54852b113ae..00d36c0b978 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -30,6 +30,10 @@ export default { required: false, default: '', }, + viewDiffsFileByFile: { + type: Boolean, + required: true, + }, }, data() { return { @@ -154,6 +158,7 @@ export default { :collapsible="true" :expanded="!isCollapsed" :add-merge-request-buttons="true" + :view-diffs-file-by-file="viewDiffsFileByFile" class="js-file-title file-title" @toggleFile="handleToggle" @showForkMessage="showForkMessage" diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index 61bbf13aa53..5727fbaaf68 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -2,7 +2,6 @@ import { escape } from 'lodash'; import { mapActions, mapGetters } from 'vuex'; import { GlDeprecatedButton, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui'; -import { polyfillSticky } from '~/lib/utils/sticky'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import Icon from '~/vue_shared/components/icon.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; @@ -55,6 +54,11 @@ export default { type: Boolean, required: true, }, + viewDiffsFileByFile: { + type: Boolean, + required: false, + default: false, + }, }, computed: { ...mapGetters('diffs', ['diffHasExpandedDiscussions', 'diffHasDiscussions']), @@ -124,9 +128,6 @@ export default { return s__('MRDiff|Show full file'); }, }, - mounted() { - polyfillSticky(this.$refs.header); - }, methods: { ...mapActions('diffs', [ 'toggleFileDiscussions', @@ -167,22 +168,17 @@ export default { :name="collapseIcon" :size="16" aria-hidden="true" - class="diff-toggle-caret append-right-5" + class="diff-toggle-caret gl-mr-2" @click.stop="handleToggleFile" /> <a - v-once ref="titleWrapper" - class="append-right-4" + :v-once="!viewDiffsFileByFile" + class="gl-mr-2" :href="titleLink" @click="handleFileNameClick" > - <file-icon - :file-name="filePath" - :size="18" - aria-hidden="true" - css-classes="append-right-5" - /> + <file-icon :file-name="filePath" :size="18" aria-hidden="true" css-classes="gl-mr-2" /> <span v-if="isFileRenamed"> <strong v-gl-tooltip @@ -218,7 +214,7 @@ export default { {{ diffFile.a_mode }} → {{ diffFile.b_mode }} </small> - <span v-if="isUsingLfs" class="label label-lfs append-right-5"> {{ __('LFS') }} </span> + <span v-if="isUsingLfs" class="label label-lfs gl-mr-2"> {{ __('LFS') }} </span> </div> <div diff --git a/app/assets/javascripts/diffs/components/diff_file_row.vue b/app/assets/javascripts/diffs/components/diff_file_row.vue index c8ba8d6040e..43b669625f4 100644 --- a/app/assets/javascripts/diffs/components/diff_file_row.vue +++ b/app/assets/javascripts/diffs/components/diff_file_row.vue @@ -23,6 +23,11 @@ export default { type: Boolean, required: true, }, + currentDiffFileId: { + type: String, + required: false, + default: null, + }, }, computed: { showFileRowStats() { @@ -33,7 +38,13 @@ export default { </script> <template> - <file-row :file="file" v-bind="$attrs" v-on="$listeners"> + <file-row + :file="file" + v-bind="$attrs" + :class="{ 'is-active': currentDiffFileId === file.fileHash }" + class="diff-file-row" + v-on="$listeners" + > <file-row-stats v-if="showFileRowStats" :file="file" class="mr-1" /> <changed-file-icon :file="file" :size="16" :show-tooltip="true" /> </file-row> diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue index 74305ee69bc..d2f49bd0020 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -8,7 +8,10 @@ import MultilineCommentForm from '../../notes/components/multiline_comment_form. import autosave from '../../notes/mixins/autosave'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import { DIFF_NOTE_TYPE } from '../constants'; -import { commentLineOptions } from '../../notes/components/multiline_comment_utils'; +import { + commentLineOptions, + formatLineRange, +} from '../../notes/components/multiline_comment_utils'; export default { components: { @@ -44,8 +47,10 @@ export default { data() { return { commentLineStart: { - lineCode: this.line.line_code, + line_code: this.line.line_code, type: this.line.type, + old_line: this.line.old_line, + new_line: this.line.new_line, }, }; }, @@ -74,19 +79,26 @@ export default { diffViewType: this.diffViewType, diffFile: this.diffFile, linePosition: this.linePosition, - lineRange: { - start_line_code: this.commentLineStart.lineCode, - start_line_type: this.commentLineStart.type, - end_line_code: this.line.line_code, - end_line_type: this.line.type, - }, + lineRange: formatLineRange(this.commentLineStart, this.line), }; }, diffFile() { return this.getDiffFileByHash(this.diffFileHash); }, commentLineOptions() { - return commentLineOptions(this.diffFile.highlighted_diff_lines, this.line.line_code); + const combineSides = (acc, { left, right }) => { + // ignore null values match lines + if (left && left.type !== 'match') acc.push(left); + // if the line_codes are identically, return to avoid duplicates + if (left?.line_code === right?.line_code) return acc; + if (right && right.type !== 'match') acc.push(right); + return acc; + }; + const side = this.line.type === 'new' ? 'right' : 'left'; + const lines = this.diffFile.highlighted_diff_lines.length + ? this.diffFile.highlighted_diff_lines + : this.diffFile.parallel_diff_lines.reduce(combineSides, []); + return commentLineOptions(lines, this.line, this.line.line_code, side); }, }, mounted() { @@ -136,10 +148,7 @@ export default { <template> <div class="content discussion-form discussion-form-container discussion-notes"> - <div - v-if="glFeatures.multilineComments" - class="gl-mb-3 gl-text-gray-700 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-pb-3" - > + <div v-if="glFeatures.multilineComments" class="gl-mb-3 gl-text-gray-700 gl-pb-3"> <multiline-comment-form v-model="commentLineStart" :line="line" diff --git a/app/assets/javascripts/diffs/components/diff_table_cell.vue b/app/assets/javascripts/diffs/components/diff_table_cell.vue index 514d26862a3..198113e330a 100644 --- a/app/assets/javascripts/diffs/components/diff_table_cell.vue +++ b/app/assets/javascripts/diffs/components/diff_table_cell.vue @@ -4,15 +4,13 @@ import { GlIcon } from '@gitlab/ui'; import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils'; import DiffGutterAvatars from './diff_gutter_avatars.vue'; import { - MATCH_LINE_TYPE, CONTEXT_LINE_TYPE, LINE_POSITION_RIGHT, EMPTY_CELL_TYPE, - OLD_LINE_TYPE, OLD_NO_NEW_LINE_TYPE, + OLD_LINE_TYPE, NEW_NO_NEW_LINE_TYPE, LINE_HOVER_CLASS_NAME, - LINE_UNFOLD_CLASS_NAME, } from '../constants'; export default { @@ -29,10 +27,6 @@ export default { type: String, required: true, }, - contextLinesPath: { - type: String, - required: true, - }, isHighlighted: { type: Boolean, required: true, @@ -52,11 +46,6 @@ export default { required: false, default: '', }, - isContentLine: { - type: Boolean, - required: false, - default: false, - }, isBottom: { type: Boolean, required: false, @@ -68,6 +57,11 @@ export default { default: false, }, }, + data() { + return { + isCommentButtonRendered: false, + }; + }, computed: { ...mapGetters(['isLoggedIn']), lineCode() { @@ -81,13 +75,7 @@ export default { return `#${this.line.line_code || ''}`; }, shouldShowCommentButton() { - return ( - this.isHover && - !this.isMatchLine && - !this.isContextLine && - !this.isMetaLine && - !this.hasDiscussions - ); + return this.isHover && !this.isContextLine && !this.isMetaLine && !this.hasDiscussions; }, hasDiscussions() { return this.line.discussions && this.line.discussions.length > 0; @@ -99,6 +87,10 @@ export default { return this.showCommentButton && this.hasDiscussions; }, shouldRenderCommentButton() { + if (!this.isCommentButtonRendered) { + return false; + } + if (this.isLoggedIn && this.showCommentButton) { const isDiffHead = parseBoolean(getParameterByName('diff_head')); return !isDiffHead || gon.features?.mergeRefHeadComments; @@ -106,9 +98,6 @@ export default { return false; }, - isMatchLine() { - return this.line.type === MATCH_LINE_TYPE; - }, isContextLine() { return this.line.type === CONTEXT_LINE_TYPE; }, @@ -126,13 +115,8 @@ export default { type, { hll: this.isHighlighted, - [LINE_UNFOLD_CLASS_NAME]: this.isMatchLine, [LINE_HOVER_CLASS_NAME]: - this.isLoggedIn && - this.isHover && - !this.isMatchLine && - !this.isContextLine && - !this.isMetaLine, + this.isLoggedIn && this.isHover && !this.isContextLine && !this.isMetaLine, }, ]; }, @@ -140,6 +124,17 @@ export default { return this.lineType === OLD_LINE_TYPE ? this.line.old_line : this.line.new_line; }, }, + mounted() { + this.unwatchShouldShowCommentButton = this.$watch('shouldShowCommentButton', newVal => { + if (newVal) { + this.isCommentButtonRendered = true; + this.unwatchShouldShowCommentButton(); + } + }); + }, + beforeDestroy() { + this.unwatchShouldShowCommentButton(); + }, methods: { ...mapActions('diffs', ['showCommentForm', 'setHighlightedRow', 'toggleLineDiscussions']), handleCommentButton() { @@ -151,34 +146,32 @@ export default { <template> <td ref="td" :class="classNameMap"> - <div> - <button - v-if="shouldRenderCommentButton" - v-show="shouldShowCommentButton" - ref="addDiffNoteButton" - type="button" - class="add-diff-note js-add-diff-note-button qa-diff-comment" - title="Add a comment to this line" - @click="handleCommentButton" - > - <gl-icon :size="12" name="comment" /> - </button> - <a - v-if="lineNumber" - ref="lineNumberRef" - :data-linenumber="lineNumber" - :href="lineHref" - @click="setHighlightedRow(lineCode)" - > - </a> - <diff-gutter-avatars - v-if="shouldShowAvatarsOnGutter" - :discussions="line.discussions" - :discussions-expanded="line.discussionsExpanded" - @toggleLineDiscussions=" - toggleLineDiscussions({ lineCode, fileHash, expanded: !line.discussionsExpanded }) - " - /> - </div> + <button + v-if="shouldRenderCommentButton" + v-show="shouldShowCommentButton" + ref="addDiffNoteButton" + type="button" + class="add-diff-note js-add-diff-note-button qa-diff-comment" + title="Add a comment to this line" + @click="handleCommentButton" + > + <gl-icon :size="12" name="comment" /> + </button> + <a + v-if="lineNumber" + ref="lineNumberRef" + :data-linenumber="lineNumber" + :href="lineHref" + @click="setHighlightedRow(lineCode)" + > + </a> + <diff-gutter-avatars + v-if="shouldShowAvatarsOnGutter" + :discussions="line.discussions" + :discussions-expanded="line.discussionsExpanded" + @toggleLineDiscussions=" + toggleLineDiscussions({ lineCode, fileHash, expanded: !line.discussionsExpanded }) + " + /> </td> </template> diff --git a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue index bd99fcb71b8..168e8c6c14e 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue @@ -28,10 +28,6 @@ export default { type: String, required: true, }, - contextLinesPath: { - type: String, - required: true, - }, line: { type: Object, required: true, @@ -41,6 +37,11 @@ export default { required: false, default: false, }, + isCommented: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -51,7 +52,10 @@ export default { ...mapGetters('diffs', ['fileLineCoverage']), ...mapState({ isHighlighted(state) { - return this.line.line_code !== null && this.line.line_code === state.diffs.highlightedRow; + if (this.isCommented) return true; + + const lineCode = this.line.line_code; + return lineCode ? lineCode === state.diffs.highlightedRow : false; }, }), isContextLine() { @@ -106,7 +110,6 @@ export default { > <diff-table-cell :file-hash="fileHash" - :context-lines-path="contextLinesPath" :line="line" :line-type="oldLineType" :is-bottom="isBottom" @@ -117,7 +120,6 @@ export default { /> <diff-table-cell :file-hash="fileHash" - :context-lines-path="contextLinesPath" :line="line" :line-type="newLineType" :is-bottom="isBottom" diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue index ad72016f03b..e82d06ee385 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_view.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue @@ -1,10 +1,11 @@ <script> -import { mapGetters } from 'vuex'; +import { mapGetters, mapState } from 'vuex'; import draftCommentsMixin from '~/diffs/mixins/draft_comments'; import InlineDraftCommentRow from '~/batch_comments/components/inline_draft_comment_row.vue'; import inlineDiffTableRow from './inline_diff_table_row.vue'; import inlineDiffCommentRow from './inline_diff_comment_row.vue'; import inlineDiffExpansionRow from './inline_diff_expansion_row.vue'; +import { getCommentedLines } from '~/notes/components/multiline_comment_utils'; export default { components: { @@ -31,9 +32,19 @@ export default { }, computed: { ...mapGetters('diffs', ['commitId']), + ...mapState({ + selectedCommentPosition: ({ notes }) => notes.selectedCommentPosition, + selectedCommentPositionHover: ({ notes }) => notes.selectedCommentPositionHover, + }), diffLinesLength() { return this.diffLines.length; }, + commentedLines() { + return getCommentedLines( + this.selectedCommentPosition || this.selectedCommentPositionHover, + this.diffLines, + ); + }, }, userColorScheme: window.gon.user_color_scheme, }; @@ -65,9 +76,9 @@ export default { :key="`${line.line_code || index}`" :file-hash="diffFile.file_hash" :file-path="diffFile.file_path" - :context-lines-path="diffFile.context_lines_path" :line="line" :is-bottom="index + 1 === diffLinesLength" + :is-commented="index >= commentedLines.startLine && index <= commentedLines.endLine" /> <inline-diff-comment-row :key="`icr-${line.line_code || index}`" diff --git a/app/assets/javascripts/diffs/components/no_changes.vue b/app/assets/javascripts/diffs/components/no_changes.vue index 94c2695a945..93afa978862 100644 --- a/app/assets/javascripts/diffs/components/no_changes.vue +++ b/app/assets/javascripts/diffs/components/no_changes.vue @@ -1,12 +1,12 @@ <script> import { mapGetters } from 'vuex'; import { escape } from 'lodash'; -import { GlDeprecatedButton } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; export default { components: { - GlDeprecatedButton, + GlButton, }, props: { changesEmptyStateIllustration: { @@ -43,9 +43,9 @@ export default { <div class="text-content text-center"> <span v-html="emptyStateText"></span> <div class="text-center"> - <gl-deprecated-button :href="getNoteableData.new_blob_path" variant="success">{{ + <gl-button :href="getNoteableData.new_blob_path" variant="success" category="primary">{{ __('Create commit') - }}</gl-deprecated-button> + }}</gl-button> </div> </div> </div> diff --git a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue index 83d803f42b1..ccb32a2a745 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue @@ -31,10 +31,6 @@ export default { type: String, required: true, }, - contextLinesPath: { - type: String, - required: true, - }, line: { type: Object, required: true, @@ -44,6 +40,11 @@ export default { required: false, default: false, }, + isCommented: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -55,6 +56,8 @@ export default { ...mapGetters('diffs', ['fileLineCoverage']), ...mapState({ isHighlighted(state) { + if (this.isCommented) return true; + const lineCode = (this.line.left && this.line.left.line_code) || (this.line.right && this.line.right.line_code); @@ -144,7 +147,6 @@ export default { <template v-if="line.left && !isMatchLineLeft"> <diff-table-cell :file-hash="fileHash" - :context-lines-path="contextLinesPath" :line="line.left" :line-type="oldLineType" :is-bottom="isBottom" @@ -172,7 +174,6 @@ export default { <template v-if="line.right && !isMatchLineRight"> <diff-table-cell :file-hash="fileHash" - :context-lines-path="contextLinesPath" :line="line.right" :line-type="newLineType" :is-bottom="isBottom" diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue index b5fcc50ce26..46a691ad22d 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_view.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue @@ -1,10 +1,11 @@ <script> -import { mapGetters } from 'vuex'; +import { mapGetters, mapState } from 'vuex'; import draftCommentsMixin from '~/diffs/mixins/draft_comments'; import ParallelDraftCommentRow from '~/batch_comments/components/parallel_draft_comment_row.vue'; import parallelDiffTableRow from './parallel_diff_table_row.vue'; import parallelDiffCommentRow from './parallel_diff_comment_row.vue'; import parallelDiffExpansionRow from './parallel_diff_expansion_row.vue'; +import { getCommentedLines } from '~/notes/components/multiline_comment_utils'; export default { components: { @@ -31,9 +32,19 @@ export default { }, computed: { ...mapGetters('diffs', ['commitId']), + ...mapState({ + selectedCommentPosition: ({ notes }) => notes.selectedCommentPosition, + selectedCommentPositionHover: ({ notes }) => notes.selectedCommentPositionHover, + }), diffLinesLength() { return this.diffLines.length; }, + commentedLines() { + return getCommentedLines( + this.selectedCommentPosition || this.selectedCommentPositionHover, + this.diffLines, + ); + }, }, userColorScheme: window.gon.user_color_scheme, }; @@ -67,9 +78,9 @@ export default { :key="line.line_code" :file-hash="diffFile.file_hash" :file-path="diffFile.file_path" - :context-lines-path="diffFile.context_lines_path" :line="line" :is-bottom="index + 1 === diffLinesLength" + :is-commented="index >= commentedLines.startLine && index <= commentedLines.endLine" /> <parallel-diff-comment-row :key="`dcr-${line.line_code || index}`" diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue index 52611f3c82a..38fbd8e61d4 100644 --- a/app/assets/javascripts/diffs/components/tree_list.vue +++ b/app/assets/javascripts/diffs/components/tree_list.vue @@ -26,7 +26,7 @@ export default { }; }, computed: { - ...mapState('diffs', ['tree', 'renderTreeList']), + ...mapState('diffs', ['tree', 'renderTreeList', 'currentDiffFileId']), ...mapGetters('diffs', ['allBlobs']), filteredTreeList() { const search = this.search.toLowerCase().trim(); @@ -96,6 +96,7 @@ export default { :level="0" :hide-file-stats="hideFileStats" :file-row-component="$options.DiffFileRow" + :current-diff-file-id="currentDiffFileId" @toggleTreeOpen="toggleTreeOpen" @clickFile="scrollToFile" /> diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js index 9269dacd582..e3dd882f3dc 100644 --- a/app/assets/javascripts/diffs/constants.js +++ b/app/assets/javascripts/diffs/constants.js @@ -1,3 +1,7 @@ +// The backend actually uses "hide_whitespace" while the frontend +// uses "show whitspace" so these values are opposite what you might expect +export const NO_SHOW_WHITESPACE = '1'; +export const SHOW_WHITESPACE = '0'; export const INLINE_DIFF_VIEW_TYPE = 'inline'; export const PARALLEL_DIFF_VIEW_TYPE = 'parallel'; export const MATCH_LINE_TYPE = 'match'; @@ -20,6 +24,7 @@ export const LINE_SIDE_LEFT = 'left-side'; export const LINE_SIDE_RIGHT = 'right-side'; export const DIFF_VIEW_COOKIE_NAME = 'diff_view'; +export const DIFF_WHITESPACE_COOKIE_NAME = 'diff_whitespace'; export const LINE_HOVER_CLASS_NAME = 'is-over'; export const LINE_UNFOLD_CLASS_NAME = 'unfold js-unfold'; export const CONTEXT_LINE_CLASS_NAME = 'diff-expanded'; @@ -35,7 +40,6 @@ export const MR_TREE_SHOW_KEY = 'mr_tree_show'; export const TREE_TYPE = 'tree'; export const TREE_LIST_STORAGE_KEY = 'mr_diff_tree_list'; -export const WHITESPACE_STORAGE_KEY = 'mr_show_whitespace'; export const TREE_LIST_WIDTH_STORAGE_KEY = 'mr_tree_list_width'; export const INITIAL_TREE_WIDTH = 320; diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index ce48e36bfd7..76ff67ab861 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -1,11 +1,11 @@ import Vue from 'vue'; import { mapActions, mapState, mapGetters } from 'vuex'; import { parseBoolean } from '~/lib/utils/common_utils'; -import { getParameterValues } from '~/lib/utils/url_utility'; import FindFile from '~/vue_shared/components/file_finder/index.vue'; import eventHub from '../notes/event_hub'; import diffsApp from './components/app.vue'; -import { TREE_LIST_STORAGE_KEY } from './constants'; +import { TREE_LIST_STORAGE_KEY, DIFF_WHITESPACE_COOKIE_NAME } from './constants'; +import Cookies from 'js-cookie'; export default function initDiffsApp(store) { const fileFinderEl = document.getElementById('js-diff-file-finder'); @@ -78,6 +78,7 @@ export default function initDiffsApp(store) { dismissEndpoint: dataset.dismissEndpoint, showSuggestPopover: parseBoolean(dataset.showSuggestPopover), showWhitespaceDefault: parseBoolean(dataset.showWhitespaceDefault), + viewDiffsFileByFile: parseBoolean(dataset.fileByFileDefault), }; }, computed: { @@ -86,15 +87,16 @@ export default function initDiffsApp(store) { }), }, created() { - let hideWhitespace = getParameterValues('w')[0]; const treeListStored = localStorage.getItem(TREE_LIST_STORAGE_KEY); const renderTreeList = treeListStored !== null ? parseBoolean(treeListStored) : true; this.setRenderTreeList(renderTreeList); - if (!hideWhitespace) { - hideWhitespace = this.showWhitespaceDefault ? '0' : '1'; + + // Set whitespace default as per user preferences unless cookie is already set + if (!Cookies.get(DIFF_WHITESPACE_COOKIE_NAME)) { + const hideWhitespace = this.showWhitespaceDefault ? '0' : '1'; + this.setShowWhitespace({ showWhitespace: hideWhitespace !== '1' }); } - this.setShowWhitespace({ showWhitespace: hideWhitespace !== '1' }); }, methods: { ...mapActions('diffs', ['setRenderTreeList', 'setShowWhitespace']), @@ -114,7 +116,7 @@ export default function initDiffsApp(store) { isFluidLayout: this.isFluidLayout, dismissEndpoint: this.dismissEndpoint, showSuggestPopover: this.showSuggestPopover, - showWhitespaceDefault: this.showWhitespaceDefault, + viewDiffsFileByFile: this.viewDiffsFileByFile, }, }); }, diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index a8d348e1836..d469ed8ee82 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -25,7 +25,6 @@ import { DIFF_VIEW_COOKIE_NAME, MR_TREE_SHOW_KEY, TREE_LIST_STORAGE_KEY, - WHITESPACE_STORAGE_KEY, TREE_LIST_WIDTH_STORAGE_KEY, OLD_LINE_KEY, NEW_LINE_KEY, @@ -38,6 +37,9 @@ import { INLINE_DIFF_LINES_KEY, PARALLEL_DIFF_LINES_KEY, DIFFS_PER_PAGE, + DIFF_WHITESPACE_COOKIE_NAME, + SHOW_WHITESPACE, + NO_SHOW_WHITESPACE, } from '../constants'; import { diffViewerModes } from '~/ide/constants'; @@ -103,7 +105,9 @@ export const fetchDiffFiles = ({ state, commit }) => { .catch(() => worker.terminate()); }; -export const fetchDiffFilesBatch = ({ commit, state }) => { +export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { + const id = window?.location?.hash; + const isNoteLink = id.indexOf('#note') === 0; const urlParams = { per_page: DIFFS_PER_PAGE, w: state.showWhitespace ? '0' : '1', @@ -123,16 +127,36 @@ export const fetchDiffFilesBatch = ({ commit, state }) => { commit(types.SET_DIFF_DATA_BATCH, { diff_files }); commit(types.SET_BATCH_LOADING, false); + if (!isNoteLink && !state.currentDiffFileId) { + commit(types.UPDATE_CURRENT_DIFF_FILE_ID, diff_files[0].file_hash); + } + + if (isNoteLink) { + dispatch('setCurrentDiffFileIdFromNote', id.split('_').pop()); + } + if (!pagination.next_page) { commit(types.SET_RETRIEVING_BATCHES, false); + + // We need to check that the currentDiffFileId points to a file that exists + if ( + state.currentDiffFileId && + !state.diffFiles.some(f => f.file_hash === state.currentDiffFileId) && + !isNoteLink + ) { + commit(types.UPDATE_CURRENT_DIFF_FILE_ID, state.diffFiles[0].file_hash); + } + if (gon.features?.codeNavigation) { // eslint-disable-next-line promise/catch-or-return,promise/no-nesting import('~/code_navigation').then(m => m.default({ - blobs: state.diffFiles.map(f => ({ - path: f.new_path, - codeNavigationPath: f.code_navigation_path, - })), + blobs: state.diffFiles + .filter(f => f.code_navigation_path) + .map(f => ({ + path: f.new_path, + codeNavigationPath: f.code_navigation_path, + })), definitionPathPrefix: state.definitionPathPrefix, }), ); @@ -211,9 +235,11 @@ export const setHighlightedRow = ({ commit }, lineCode) => { // This is adding line discussions to the actual lines in the diff tree // once for parallel and once for inline mode export const assignDiscussionsToDiff = ( - { commit, state, rootState }, + { commit, state, rootState, dispatch }, discussions = rootState.notes.discussions, ) => { + const id = window?.location?.hash; + const isNoteLink = id.indexOf('#note') === 0; const diffPositionByLineCode = getDiffPositionByLineCode( state.diffFiles, state.useSingleDiffStyle, @@ -230,6 +256,10 @@ export const assignDiscussionsToDiff = ( }); }); + if (isNoteLink) { + dispatch('setCurrentDiffFileIdFromNote', id.split('_').pop()); + } + Vue.nextTick(() => { eventHub.$emit('scrollToDiscussion'); }); @@ -448,6 +478,8 @@ export const toggleTreeOpen = ({ commit }, path) => { }; export const scrollToFile = ({ state, commit }, path) => { + if (!state.treeEntries[path]) return; + const { fileHash } = state.treeEntries[path]; document.location.hash = fileHash; @@ -484,11 +516,12 @@ export const setRenderTreeList = ({ commit }, renderTreeList) => { export const setShowWhitespace = ({ commit }, { showWhitespace, pushState = false }) => { commit(types.SET_SHOW_WHITESPACE, showWhitespace); + const w = showWhitespace ? SHOW_WHITESPACE : NO_SHOW_WHITESPACE; - localStorage.setItem(WHITESPACE_STORAGE_KEY, showWhitespace); + Cookies.set(DIFF_WHITESPACE_COOKIE_NAME, w); if (pushState) { - historyPushState(mergeUrlParams({ w: showWhitespace ? '0' : '1' }, window.location.href)); + historyPushState(mergeUrlParams({ w }, window.location.href)); } eventHub.$emit('refetchDiffData'); @@ -710,5 +743,22 @@ export function moveToNeighboringCommit({ dispatch, state }, { direction }) { } } +export const setCurrentDiffFileIdFromNote = ({ commit, rootGetters }, noteId) => { + const note = rootGetters.notesById[noteId]; + + if (!note) return; + + const fileHash = rootGetters.getDiscussion(note.discussion_id).diff_file.file_hash; + + commit(types.UPDATE_CURRENT_DIFF_FILE_ID, fileHash); +}; + +export const navigateToDiffFileIndex = ({ commit, state }, index) => { + const fileHash = state.diffFiles[index].file_hash; + document.location.hash = fileHash; + + commit(types.UPDATE_CURRENT_DIFF_FILE_ID, fileHash); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js index 87938ababed..1f165dd4971 100644 --- a/app/assets/javascripts/diffs/store/modules/diff_state.js +++ b/app/assets/javascripts/diffs/store/modules/diff_state.js @@ -1,10 +1,17 @@ import Cookies from 'js-cookie'; import { getParameterValues } from '~/lib/utils/url_utility'; -import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME } from '../../constants'; +import { + INLINE_DIFF_VIEW_TYPE, + DIFF_VIEW_COOKIE_NAME, + DIFF_WHITESPACE_COOKIE_NAME, +} from '../../constants'; +import { getDefaultWhitespace } from '../utils'; const viewTypeFromQueryString = getParameterValues('view')[0]; const viewTypeFromCookie = Cookies.get(DIFF_VIEW_COOKIE_NAME); const defaultViewType = INLINE_DIFF_VIEW_TYPE; +const whiteSpaceFromQueryString = getParameterValues('w')[0]; +const whiteSpaceFromCookie = Cookies.get(DIFF_WHITESPACE_COOKIE_NAME); export default () => ({ isLoading: true, @@ -29,7 +36,7 @@ export default () => ({ commentForms: [], highlightedRow: null, renderTreeList: true, - showWhitespace: true, + showWhitespace: getDefaultWhitespace(whiteSpaceFromQueryString, whiteSpaceFromCookie), fileFinderVisible: false, dismissEndpoint: '', showSuggestPopover: true, diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index d261be1b550..bc85dd0a1d4 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -15,6 +15,8 @@ import { TREE_TYPE, INLINE_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE, + SHOW_WHITESPACE, + NO_SHOW_WHITESPACE, } from '../constants'; export function findDiffFile(files, match, matchKey = 'file_hash') { @@ -701,3 +703,10 @@ export const allDiscussionWrappersExpanded = diff => { return discussionsExpanded; }; + +export const getDefaultWhitespace = (queryString, cookie) => { + // Querystring should override stored cookie value + if (queryString) return queryString === SHOW_WHITESPACE; + if (cookie === NO_SHOW_WHITESPACE) return false; + return true; +}; diff --git a/app/assets/javascripts/editor/editor_lite.js b/app/assets/javascripts/editor/editor_lite.js index 020ed6dc867..551ffbabaef 100644 --- a/app/assets/javascripts/editor/editor_lite.js +++ b/app/assets/javascripts/editor/editor_lite.js @@ -1,4 +1,4 @@ -import { editor as monacoEditor, languages as monacoLanguages, Uri } from 'monaco-editor'; +import { editor as monacoEditor, languages as monacoLanguages, Position, Uri } from 'monaco-editor'; import { DEFAULT_THEME, themes } from '~/ide/lib/themes'; import languages from '~/ide/lib/languages'; import { defaultEditorOptions } from '~/ide/lib/editor_options'; @@ -70,6 +70,27 @@ export default class Editor { } getValue() { - return this.model.getValue(); + return this.instance.getValue(); + } + + setValue(val) { + this.instance.setValue(val); + } + + focus() { + this.instance.focus(); + } + + navigateFileStart() { + this.instance.setPosition(new Position(1, 1)); + } + + updateOptions(options = {}) { + this.instance.updateOptions(options); + } + + use(exts = []) { + const extensions = Array.isArray(exts) ? exts : [exts]; + Object.assign(this, ...extensions); } } diff --git a/app/assets/javascripts/editor/editor_markdown_ext.js b/app/assets/javascripts/editor/editor_markdown_ext.js new file mode 100644 index 00000000000..9d09663e643 --- /dev/null +++ b/app/assets/javascripts/editor/editor_markdown_ext.js @@ -0,0 +1,99 @@ +export default { + getSelectedText(selection = this.getSelection()) { + const { startLineNumber, endLineNumber, startColumn, endColumn } = selection; + const valArray = this.instance.getValue().split('\n'); + let text = ''; + if (startLineNumber === endLineNumber) { + text = valArray[startLineNumber - 1].slice(startColumn - 1, endColumn - 1); + } else { + const startLineText = valArray[startLineNumber - 1].slice(startColumn - 1); + const endLineText = valArray[endLineNumber - 1].slice(0, endColumn - 1); + + for (let i = startLineNumber, k = endLineNumber - 1; i < k; i += 1) { + text += `${valArray[i]}`; + if (i !== k - 1) text += `\n`; + } + text = text + ? [startLineText, text, endLineText].join('\n') + : [startLineText, endLineText].join('\n'); + } + return text; + }, + + getSelection() { + return this.instance.getSelection(); + }, + + replaceSelectedText(text, select = undefined) { + const forceMoveMarkers = !select; + this.instance.executeEdits('', [{ range: this.getSelection(), text, forceMoveMarkers }]); + }, + + moveCursor(dx = 0, dy = 0) { + const pos = this.instance.getPosition(); + pos.column += dx; + pos.lineNumber += dy; + this.instance.setPosition(pos); + }, + + /** + * Adjust existing selection to select text within the original selection. + * - If `selectedText` is not supplied, we fetch selected text with + * + * ALGORITHM: + * + * MULTI-LINE SELECTION + * 1. Find line that contains `toSelect` text. + * 2. Using the index of this line and the position of `toSelect` text in it, + * construct: + * * newStartLineNumber + * * newStartColumn + * + * SINGLE-LINE SELECTION + * 1. Use `startLineNumber` from the current selection as `newStartLineNumber` + * 2. Find the position of `toSelect` text in it to get `newStartColumn` + * + * 3. `newEndLineNumber` — Since this method is supposed to be used with + * markdown decorators that are pretty short, the `newEndLineNumber` is + * suggested to be assumed the same as the startLine. + * 4. `newEndColumn` — pretty obvious + * 5. Adjust the start and end positions of the current selection + * 6. Re-set selection on the instance + * + * @param {string} toSelect - New text to select within current selection. + * @param {string} selectedText - Currently selected text. It's just a + * shortcut: If it's not supplied, we fetch selected text from the instance + */ + selectWithinSelection(toSelect, selectedText) { + const currentSelection = this.getSelection(); + if (currentSelection.isEmpty() || !toSelect) { + return; + } + const text = selectedText || this.getSelectedText(currentSelection); + let lineShift; + let newStartLineNumber; + let newStartColumn; + + const textLines = text.split('\n'); + + if (textLines.length > 1) { + // Multi-line selection + lineShift = textLines.findIndex(line => line.indexOf(toSelect) !== -1); + newStartLineNumber = currentSelection.startLineNumber + lineShift; + newStartColumn = textLines[lineShift].indexOf(toSelect) + 1; + } else { + // Single-line selection + newStartLineNumber = currentSelection.startLineNumber; + newStartColumn = currentSelection.startColumn + text.indexOf(toSelect); + } + + const newEndLineNumber = newStartLineNumber; + const newEndColumn = newStartColumn + toSelect.length; + + const newSelection = currentSelection + .setStartPosition(newStartLineNumber, newStartColumn) + .setEndPosition(newEndLineNumber, newEndColumn); + + this.instance.setSelection(newSelection); + }, +}; diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js index 27dff8cf9aa..4567c807c40 100644 --- a/app/assets/javascripts/emoji/index.js +++ b/app/assets/javascripts/emoji/index.js @@ -1,13 +1,63 @@ import { uniq } from 'lodash'; -import emojiMap from 'emojis/digests.json'; import emojiAliases from 'emojis/aliases.json'; +import axios from '../lib/utils/axios_utils'; -export const validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)]; +import AccessorUtilities from '../lib/utils/accessor'; + +let emojiMap = null; +let emojiPromise = null; +let validEmojiNames = null; + +export const EMOJI_VERSION = '1'; + +const isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); + +export function initEmojiMap() { + emojiPromise = + emojiPromise || + new Promise((resolve, reject) => { + if (emojiMap) { + resolve(emojiMap); + } else if ( + isLocalStorageAvailable && + window.localStorage.getItem('gl-emoji-map-version') === EMOJI_VERSION && + window.localStorage.getItem('gl-emoji-map') + ) { + emojiMap = JSON.parse(window.localStorage.getItem('gl-emoji-map')); + validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)]; + resolve(emojiMap); + } else { + // We load the JSON file direct from the server + // because it can't be loaded from a CDN due to + // cross domain problems with JSON + axios + .get(`${gon.relative_url_root || ''}/-/emojis/${EMOJI_VERSION}/emojis.json`) + .then(({ data }) => { + emojiMap = data; + validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)]; + resolve(emojiMap); + if (isLocalStorageAvailable) { + window.localStorage.setItem('gl-emoji-map-version', EMOJI_VERSION); + window.localStorage.setItem('gl-emoji-map', JSON.stringify(emojiMap)); + } + }) + .catch(err => { + reject(err); + }); + } + }); + + return emojiPromise; +} export function normalizeEmojiName(name) { return Object.prototype.hasOwnProperty.call(emojiAliases, name) ? emojiAliases[name] : name; } +export function getValidEmojiNames() { + return validEmojiNames; +} + export function isEmojiNameValid(name) { return validEmojiNames.indexOf(name) >= 0; } @@ -36,8 +86,8 @@ export function getEmojiCategoryMap() { }; Object.keys(emojiMap).forEach(name => { const emoji = emojiMap[name]; - if (emojiCategoryMap[emoji.category]) { - emojiCategoryMap[emoji.category].push(name); + if (emojiCategoryMap[emoji.c]) { + emojiCategoryMap[emoji.c].push(name); } }); } @@ -58,8 +108,9 @@ export function getEmojiInfo(query) { } export function emojiFallbackImageSrc(inputName) { - const { name, digest } = getEmojiInfo(inputName); - return `${gon.asset_host || ''}${gon.relative_url_root || ''}/assets/emoji/${name}-${digest}.png`; + const { name } = getEmojiInfo(inputName); + return `${gon.asset_host || ''}${gon.relative_url_root || + ''}/-/emojis/${EMOJI_VERSION}/${name}.png`; } export function emojiImageTag(name, src) { @@ -67,36 +118,17 @@ export function emojiImageTag(name, src) { } export function glEmojiTag(inputName, options) { - const opts = { sprite: false, forceFallback: false, ...options }; - const { name, ...emojiInfo } = getEmojiInfo(inputName); - - const fallbackImageSrc = emojiFallbackImageSrc(name); + const opts = { sprite: false, ...options }; + const name = normalizeEmojiName(inputName); const fallbackSpriteClass = `emoji-${name}`; - const classList = []; - if (opts.forceFallback && opts.sprite) { - classList.push('emoji-icon'); - classList.push(fallbackSpriteClass); - } - const classAttribute = classList.length > 0 ? `class="${classList.join(' ')}"` : ''; const fallbackSpriteAttribute = opts.sprite ? `data-fallback-sprite-class="${fallbackSpriteClass}"` : ''; - let contents = emojiInfo.moji; - if (opts.forceFallback && !opts.sprite) { - contents = emojiImageTag(name, fallbackImageSrc); - } return ` <gl-emoji - ${classAttribute} - data-name="${name}" - data-fallback-src="${fallbackImageSrc}" ${fallbackSpriteAttribute} - data-unicode-version="${emojiInfo.unicodeVersion}" - title="${emojiInfo.description}" - > - ${contents} - </gl-emoji> + data-name="${name}"></gl-emoji> `; } diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue index 899d7ec8521..4c6d233c4d2 100644 --- a/app/assets/javascripts/environments/components/container.vue +++ b/app/assets/javascripts/environments/components/container.vue @@ -67,12 +67,7 @@ export default { <template> <div class="environments-container"> - <gl-loading-icon - v-if="isLoading" - size="md" - class="prepend-top-default" - label="Loading environments" - /> + <gl-loading-icon v-if="isLoading" size="md" class="gl-mt-3" label="Loading environments" /> <slot name="emptyState"></slot> diff --git a/app/assets/javascripts/environments/components/empty_state.vue b/app/assets/javascripts/environments/components/empty_state.vue index ca2ac4c3c53..977da12e8a9 100644 --- a/app/assets/javascripts/environments/components/empty_state.vue +++ b/app/assets/javascripts/environments/components/empty_state.vue @@ -2,14 +2,6 @@ export default { name: 'EnvironmentsEmptyState', props: { - newPath: { - type: String, - required: true, - }, - canCreateEnvironment: { - type: Boolean, - required: true, - }, helpPath: { type: String, required: true, @@ -28,18 +20,8 @@ export default { s__(`Environments|Environments are places where code gets deployed, such as staging or production.`) }} - <a :href="helpPath"> {{ s__('Environments|Read more about environments') }} </a> + <a :href="helpPath"> {{ s__('Environments|More information') }} </a> </p> - - <div class="text-center"> - <a - v-if="canCreateEnvironment" - :href="newPath" - class="btn btn-success js-new-environment-button" - > - {{ s__('Environments|New environment') }} - </a> - </div> </div> </div> </template> diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index b8bcca814cd..d26bd14a937 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -159,11 +159,7 @@ export default { @onChangePage="onChangePage" > <template v-if="!isLoading && state.environments.length === 0" #emptyState> - <empty-state - :new-path="newEnvironmentPath" - :help-path="helpPagePath" - :can-create-environment="canCreateEnvironment" - /> + <empty-state :help-path="helpPagePath" /> </template> </container> </div> diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index 380e16c7b71..ab1818e61fa 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -188,7 +188,7 @@ export default { <template v-if="shouldRenderFolderContent(model)"> <div v-if="model.isLoadingFolderContent" :key="`loading-item-${i}`"> - <gl-loading-icon size="md" class="prepend-top-16" /> + <gl-loading-icon size="md" class="gl-mt-5" /> </div> <template v-else> diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js index 73dc8c02485..f2b464464e9 100644 --- a/app/assets/javascripts/environments/mixins/environments_mixin.js +++ b/app/assets/javascripts/environments/mixins/environments_mixin.js @@ -145,7 +145,7 @@ export default { deleteEnvironment(environment) { const endpoint = environment.delete_path; - const mountedToShow = environment.mounted_to_show; + const { onSingleEnvironmentPage } = environment; const errorMessage = s__( 'Environments|An error occurred while deleting the environment. Check if the environment stopped; if not, stop it and try again.', ); @@ -153,7 +153,7 @@ export default { this.service .deleteAction(endpoint) .then(() => { - if (!mountedToShow) { + if (!onSingleEnvironmentPage) { // Reload as a first solution to bust the ETag cache window.location.reload(); return; diff --git a/app/assets/javascripts/environments/mount_show.js b/app/assets/javascripts/environments/mount_show.js index 1929ed080a1..d0b68b0c14f 100644 --- a/app/assets/javascripts/environments/mount_show.js +++ b/app/assets/javascripts/environments/mount_show.js @@ -15,7 +15,7 @@ export default () => { data() { const environment = JSON.parse(JSON.stringify(container.dataset)); environment.delete_path = environment.deletePath; - environment.mounted_to_show = true; + environment.onSingleEnvironmentPage = true; return { environment, diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue index 1e8f5a26125..52444d2c493 100644 --- a/app/assets/javascripts/error_tracking/components/error_details.vue +++ b/app/assets/javascripts/error_tracking/components/error_details.vue @@ -2,7 +2,7 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import createFlash from '~/flash'; import { - GlDeprecatedButton, + GlButton, GlFormInput, GlLink, GlLoadingIcon, @@ -33,7 +33,7 @@ const SENTRY_TIMEOUT = 10000; export default { components: { - GlDeprecatedButton, + GlButton, GlFormInput, GlLink, GlLoadingIcon, @@ -106,6 +106,7 @@ export default { errorPollTimeout: 0, issueCreationInProgress: false, isAlertVisible: false, + isStacktraceEmptyAlertVisible: true, closedIssueId: null, }; }, @@ -119,10 +120,10 @@ export default { ]), ...mapGetters('details', ['stacktrace']), firstReleaseLink() { - return `${this.error.externalBaseUrl}/releases/${this.error.firstReleaseShortVersion}`; + return `${this.error.externalBaseUrl}/releases/${this.error.firstReleaseVersion}`; }, lastReleaseLink() { - return `${this.error.externalBaseUrl}/releases/${this.error.lastReleaseShortVersion}`; + return `${this.error.externalBaseUrl}/releases/${this.error.lastReleaseVersion}`; }, showStacktrace() { return Boolean(this.stacktrace?.length); @@ -167,6 +168,9 @@ export default { resolveBtnLabel() { return this.errorStatus !== errorStatus.RESOLVED ? __('Resolve') : __('Unresolve'); }, + showEmptyStacktraceAlert() { + return !this.loadingStacktrace && !this.showStacktrace && this.isStacktraceEmptyAlertVisible; + }, }, watch: { error(val) { @@ -254,6 +258,10 @@ export default { </gl-sprintf> </gl-alert> + <gl-alert v-if="showEmptyStacktraceAlert" @dismiss="isStacktraceEmptyAlertVisible = false"> + {{ __('No stack trace for this error') }} + </gl-alert> + <div class="error-details-header d-flex py-2 justify-content-between"> <div v-if="!loadingStacktrace && stacktrace" @@ -271,22 +279,24 @@ export default { </div> <div class="error-details-actions"> <div class="d-inline-flex bv-d-sm-down-none"> - <gl-deprecated-button + <gl-button :loading="updatingIgnoreStatus" data-testid="update-ignore-status-btn" @click="onIgnoreStatusUpdate" > {{ ignoreBtnLabel }} - </gl-deprecated-button> - <gl-deprecated-button - class="btn-outline-info ml-2" + </gl-button> + <gl-button + class="ml-2" + category="secondary" + variant="info" :loading="updatingResolveStatus" data-testid="update-resolve-status-btn" @click="onResolveStatusUpdate" > {{ resolveBtnLabel }} - </gl-deprecated-button> - <gl-deprecated-button + </gl-button> + <gl-button v-if="error.gitlabIssuePath" class="ml-2" data-testid="view_issue_button" @@ -294,7 +304,7 @@ export default { variant="success" > {{ __('View issue') }} - </gl-deprecated-button> + </gl-button> <form ref="sentryIssueForm" :action="projectIssuesPath" @@ -309,15 +319,16 @@ export default { name="issue[sentry_issue_attributes][sentry_issue_identifier]" /> <gl-form-input :value="csrfToken" class="hidden" name="authenticity_token" /> - <gl-deprecated-button + <gl-button v-if="!error.gitlabIssuePath" - class="btn-success" + category="primary" + variant="success" :loading="issueCreationInProgress" data-qa-selector="create_issue_button" @click="createIssue" > {{ __('Create issue') }} - </gl-deprecated-button> + </gl-button> </form> </div> <gl-dropdown @@ -389,18 +400,18 @@ export default { <icon name="external-link" class="ml-1 flex-shrink-0" /> </gl-link> </li> - <li v-if="error.firstReleaseShortVersion"> + <li v-if="error.firstReleaseVersion"> <strong class="bold">{{ __('First seen') }}:</strong> <time-ago-tooltip :time="error.firstSeen" /> <gl-link :href="firstReleaseLink" target="_blank"> - <span>{{ __('Release') }}: {{ error.firstReleaseShortVersion.substr(0, 10) }}</span> + <span>{{ __('Release') }}: {{ error.firstReleaseVersion }}</span> </gl-link> </li> - <li v-if="error.lastReleaseShortVersion"> + <li v-if="error.lastReleaseVersion"> <strong class="bold">{{ __('Last seen') }}:</strong> <time-ago-tooltip :time="error.lastSeen" /> <gl-link :href="lastReleaseLink" target="_blank"> - <span>{{ __('Release') }}: {{ error.lastReleaseShortVersion.substr(0, 10) }}</span> + <span>{{ __('Release') }}: {{ error.lastReleaseVersion }}</span> </gl-link> </li> <li> diff --git a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue index d806c6934a3..c22f34b5a8d 100644 --- a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue +++ b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue @@ -80,14 +80,9 @@ export default { <div ref="header" class="file-title file-title-flex-parent"> <div class="file-header-content d-flex align-content-center"> <div v-if="hasCode" class="d-inline-block cursor-pointer" @click="toggle()"> - <icon :name="collapseIcon" :size="16" aria-hidden="true" class="append-right-5" /> + <icon :name="collapseIcon" :size="16" aria-hidden="true" class="gl-mr-2" /> </div> - <file-icon - :file-name="filePath" - :size="18" - aria-hidden="true" - css-classes="append-right-5" - /> + <file-icon :file-name="filePath" :size="18" aria-hidden="true" css-classes="gl-mr-2" /> <strong v-gl-tooltip :title="filePath" diff --git a/app/assets/javascripts/error_tracking/queries/details.query.graphql b/app/assets/javascripts/error_tracking/queries/details.query.graphql index fa579c94257..593cbf2ae52 100644 --- a/app/assets/javascripts/error_tracking/queries/details.query.graphql +++ b/app/assets/javascripts/error_tracking/queries/details.query.graphql @@ -1,29 +1,29 @@ query errorDetails($fullPath: ID!, $errorId: ID!) { - project(fullPath: $fullPath) { - sentryErrors { - detailedError(id: $errorId) { - id - sentryId - title - userCount - count - status - firstSeen - lastSeen - message - culprit - tags { - level - logger - } - externalUrl - externalBaseUrl - firstReleaseShortVersion - lastReleaseShortVersion - gitlabCommit - gitlabCommitPath - gitlabIssuePath - } + project(fullPath: $fullPath) { + sentryErrors { + detailedError(id: $errorId) { + id + sentryId + title + userCount + count + status + firstSeen + lastSeen + message + culprit + tags { + level + logger } + externalUrl + externalBaseUrl + firstReleaseVersion + lastReleaseVersion + gitlabCommit + gitlabCommitPath + gitlabIssuePath + } } + } } diff --git a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue index 0be42519092..0de67a8bcc7 100644 --- a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue +++ b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue @@ -59,14 +59,14 @@ export default { </div> <div class="col-4 col-md-3 gl-pl-0"> <loading-button - class="js-error-tracking-connect prepend-left-5 d-inline-flex" + class="js-error-tracking-connect gl-ml-2 d-inline-flex" :label="isLoadingProjects ? __('Connecting') : __('Connect')" :loading="isLoadingProjects" @click="fetchProjects" /> <icon v-show="connectSuccessful" - class="js-error-tracking-connect-success prepend-left-5 text-success align-middle" + class="js-error-tracking-connect-success gl-ml-2 text-success align-middle" :aria-label="__('Projects Successfully Retrieved')" name="check-circle" /> diff --git a/app/assets/javascripts/filtered_search/constants.js b/app/assets/javascripts/filtered_search/constants.js index 7e7a2588951..0b9fe969da1 100644 --- a/app/assets/javascripts/filtered_search/constants.js +++ b/app/assets/javascripts/filtered_search/constants.js @@ -9,3 +9,5 @@ export const FILTER_TYPE = { none: 'none', any: 'any', }; + +export const MAX_HISTORY_SIZE = 5; diff --git a/app/assets/javascripts/filtered_search/dropdown_emoji.js b/app/assets/javascripts/filtered_search/dropdown_emoji.js index dad188f6f98..adeea0ed5f6 100644 --- a/app/assets/javascripts/filtered_search/dropdown_emoji.js +++ b/app/assets/javascripts/filtered_search/dropdown_emoji.js @@ -10,7 +10,7 @@ export default class DropdownEmoji extends FilteredSearchDropdown { super(options); this.config = { Ajax: { - endpoint: `${gon.relative_url_root || ''}/autocomplete/award_emojis`, + endpoint: `${gon.relative_url_root || ''}/-/autocomplete/award_emojis`, method: 'setData', loadingTemplate: this.loadingTemplate, onError() { diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js b/app/assets/javascripts/filtered_search/dropdown_user.js index a65c0012b4d..0fb1828fc98 100644 --- a/app/assets/javascripts/filtered_search/dropdown_user.js +++ b/app/assets/javascripts/filtered_search/dropdown_user.js @@ -5,7 +5,7 @@ export default class DropdownUser extends DropdownAjaxFilter { constructor(options = {}) { super({ ...options, - endpoint: '/autocomplete/users.json', + endpoint: '/-/autocomplete/users.json', symbol: '@', }); } diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 55a0e91b0f3..108cc8d3a78 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -32,6 +32,7 @@ export default class FilteredSearchManager { filteredSearchTokenKeys = IssuableFilteredSearchTokenKeys, stateFiltersSelector = '.issues-state-filters', placeholder = __('Search or filter results...'), + anchor = null, }) { this.isGroup = isGroup; this.isGroupAncestor = isGroupAncestor; @@ -47,6 +48,7 @@ export default class FilteredSearchManager { this.filteredSearchTokenKeys = filteredSearchTokenKeys; this.stateFiltersSelector = stateFiltersSelector; this.placeholder = placeholder; + this.anchor = anchor; const { multipleAssignees } = this.filteredSearchInput.dataset; if (multipleAssignees && this.filteredSearchTokenKeys.enableMultipleAssignees) { @@ -779,7 +781,11 @@ export default class FilteredSearchManager { paths.push(`search=${sanitized}`); } - const parameterizedUrl = `?scope=all&utf8=%E2%9C%93&${paths.join('&')}`; + let parameterizedUrl = `?scope=all&utf8=%E2%9C%93&${paths.join('&')}`; + + if (this.anchor) { + parameterizedUrl += `#${this.anchor}`; + } if (this.updateObject) { this.updateObject(parameterizedUrl); diff --git a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js index 963e8fe5df5..be0fb5cac13 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js +++ b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js @@ -6,7 +6,7 @@ export default class FilteredSearchTokenizer { // Values that start with a double quote must end in a double quote (same for single) const tokenRegex = new RegExp( - `(${allowedKeys.join('|')}):(=|!=)?([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`, + `(${allowedKeys.join('|')}):(=|!=)?([~%@&]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`, 'g', ); const tokens = []; @@ -15,17 +15,19 @@ export default class FilteredSearchTokenizer { const searchToken = input .replace(tokenRegex, (match, key, operator, symbol, v1, v2, v3) => { + const prefixedTokens = ['~', '%', '@', '&']; + const comparisonTokens = ['!=', '=']; let tokenValue = v1 || v2 || v3; let tokenSymbol = symbol; let tokenIndex = ''; let tokenOperator = operator; - if (tokenValue === '~' || tokenValue === '%' || tokenValue === '@') { + if (prefixedTokens.includes(tokenValue)) { tokenSymbol = tokenValue; tokenValue = ''; } - if (tokenValue === '!=' || tokenValue === '=') { + if (comparisonTokens.includes(tokenValue)) { tokenOperator = tokenValue; tokenValue = ''; } diff --git a/app/assets/javascripts/filtered_search/stores/recent_searches_store.js b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js index cdbc9ec84bd..423f123f71c 100644 --- a/app/assets/javascripts/filtered_search/stores/recent_searches_store.js +++ b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js @@ -1,4 +1,6 @@ -import { uniq } from 'lodash'; +import { uniqWith, isEqual } from 'lodash'; + +import { MAX_HISTORY_SIZE } from '../constants'; class RecentSearchesStore { constructor(initialState = {}, allowedKeys) { @@ -17,8 +19,12 @@ class RecentSearchesStore { } setRecentSearches(searches = []) { - const trimmedSearches = searches.map(search => search.trim()); - this.state.recentSearches = uniq(trimmedSearches).slice(0, 5); + const trimmedSearches = searches.map(search => + typeof search === 'string' ? search.trim() : search, + ); + + // Do object equality check to remove duplicates. + this.state.recentSearches = uniqWith(trimmedSearches, isEqual).slice(0, MAX_HISTORY_SIZE); return this.state.recentSearches; } } diff --git a/app/assets/javascripts/filtered_search/visual_token_value.js b/app/assets/javascripts/filtered_search/visual_token_value.js index 02caf0851af..b1e6c4142e9 100644 --- a/app/assets/javascripts/filtered_search/visual_token_value.js +++ b/app/assets/javascripts/filtered_search/visual_token_value.js @@ -7,6 +7,7 @@ import DropdownUtils from '~/filtered_search/dropdown_utils'; import Flash from '~/flash'; import UsersCache from '~/lib/utils/users_cache'; import { __ } from '~/locale'; +import * as Emoji from '~/emoji'; export default class VisualTokenValue { constructor(tokenValue, tokenType, tokenOperator) { @@ -137,18 +138,13 @@ export default class VisualTokenValue { const element = tokenValueElement; const value = this.tokenValue; - return ( - import(/* webpackChunkName: 'emoji' */ '../emoji') - .then(Emoji => { - if (!Emoji.isEmojiNameValid(value)) { - return; - } + return Emoji.initEmojiMap().then(() => { + if (!Emoji.isEmojiNameValid(value)) { + return; + } - container.dataset.originalValue = value; - element.innerHTML = Emoji.glEmojiTag(value); - }) - // ignore error and leave emoji name in the search bar - .catch(() => {}) - ); + container.dataset.originalValue = value; + element.innerHTML = Emoji.glEmojiTag(value); + }); } } diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index f3ce30c942f..36c586ddfd2 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -5,6 +5,7 @@ import SidebarMediator from '~/sidebar/sidebar_mediator'; import glRegexp from './lib/utils/regexp'; import AjaxCache from './lib/utils/ajax_cache'; import { spriteIcon } from './lib/utils/common_utils'; +import * as Emoji from '~/emoji'; function sanitize(str) { return str.replace(/<(?:.|\n)*?>/gm, ''); @@ -29,7 +30,7 @@ export function membersBeforeSave(members) { const imgAvatar = `<img src="${member.avatar_url}" alt="${member.username}" class="avatar ${rectAvatarClass} avatar-inline center s26"/>`; const txtAvatar = `<div class="avatar ${rectAvatarClass} center avatar-inline s26">${autoCompleteAvatar}</div>`; const avatarIcon = member.mentionsDisabled - ? spriteIcon('notifications-off', 's16 vertical-align-middle prepend-left-5') + ? spriteIcon('notifications-off', 's16 vertical-align-middle gl-ml-2') : ''; return { @@ -88,7 +89,6 @@ class GfmAutoComplete { if (this.enableMap.labels) this.setupLabels($input); if (this.enableMap.snippets) this.setupSnippets($input); - // We don't instantiate the quick actions autocomplete for note and issue/MR edit forms $input.filter('[data-supports-quick-actions="true"]').atwho({ at: '/', alias: 'commands', @@ -109,8 +109,10 @@ class GfmAutoComplete { tpl += ' <small class="params"><%- params.join(" ") %></small>'; } if (value.warning && value.icon && value.icon === 'confidential') { - tpl += - '<small class="description"><em><i class="fa fa-eye-slash" aria-hidden="true"/><%- warning %></em></small>'; + tpl += `<small class="description gl-display-flex gl-align-items-center">${spriteIcon( + 'eye-slash', + 's16 gl-mr-2', + )}<em><%- warning %></em></small>`; } else if (value.warning) { tpl += '<small class="description"><em><%- warning %></em></small>'; } else if (value.description !== '') { @@ -587,14 +589,12 @@ class GfmAutoComplete { if (this.cachedData[at]) { this.loadData($input, at, this.cachedData[at]); } else if (GfmAutoComplete.atTypeMap[at] === 'emojis') { - import(/* webpackChunkName: 'emoji' */ './emoji') - .then(({ validEmojiNames, glEmojiTag }) => { - this.loadData($input, at, validEmojiNames); - GfmAutoComplete.glEmojiTag = glEmojiTag; + Emoji.initEmojiMap() + .then(() => { + this.loadData($input, at, Emoji.getValidEmojiNames()); + GfmAutoComplete.glEmojiTag = Emoji.glEmojiTag; }) - .catch(() => { - this.isLoadingData[at] = false; - }); + .catch(() => {}); } else if (dataSource) { AjaxCache.retrieve(dataSource, true) .then(data => { diff --git a/app/assets/javascripts/gl_field_error.js b/app/assets/javascripts/gl_field_error.js index 04301c9ce12..ac4c8d28ee4 100644 --- a/app/assets/javascripts/gl_field_error.js +++ b/app/assets/javascripts/gl_field_error.js @@ -114,7 +114,7 @@ export default class GlFieldError { this.state.empty = currentValue === ''; this.state.submitted = true; this.renderValidity(); - this.form.focusOnFirstInvalid.apply(this.form); + this.form.focusInvalid.apply(this.form); // For UX, wait til after first invalid submission to check each keyup this.inputElement diff --git a/app/assets/javascripts/gl_field_errors.js b/app/assets/javascripts/gl_field_errors.js index c4fd719c8d0..ad79483d5ec 100644 --- a/app/assets/javascripts/gl_field_errors.js +++ b/app/assets/javascripts/gl_field_errors.js @@ -52,10 +52,23 @@ export default class GlFieldErrors { }); } - focusOnFirstInvalid() { - const firstInvalid = this.state.inputs.filter( - input => !input.inputDomElement.validity.valid, - )[0]; - firstInvalid.inputElement.focus(); + get invalidInputs() { + return this.state.inputs.filter( + ({ + inputDomElement: { + validity: { valid }, + }, + }) => !valid, + ); + } + + get focusedInvalidInput() { + return this.invalidInputs.find(({ inputElement }) => inputElement.is(':focus')); + } + + focusInvalid() { + if (this.focusedInvalidInput) return; + + this.invalidInputs[0].inputElement.focus(); } } diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index 0b7735a7db9..0a1e5490237 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -3,19 +3,22 @@ import autosize from 'autosize'; import GfmAutoComplete, { defaultAutocompleteConfig } from 'ee_else_ce/gfm_auto_complete'; import dropzoneInput from './dropzone_input'; import { addMarkdownListeners, removeMarkdownListeners } from './lib/utils/text_markdown'; +import { disableButtonIfEmptyField } from '~/lib/utils/common_utils'; export default class GLForm { constructor(form, enableGFM = {}) { this.form = form; this.textarea = this.form.find('textarea.js-gfm-input'); this.enableGFM = { ...defaultAutocompleteConfig, ...enableGFM }; + // Disable autocomplete for keywords which do not have dataSources available const dataSources = (gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources) || {}; Object.keys(this.enableGFM).forEach(item => { - if (item !== 'emojis') { - this.enableGFM[item] = Boolean(dataSources[item]); + if (item !== 'emojis' && !dataSources[item]) { + this.enableGFM[item] = false; } }); + // Before we start, we should clean up any previous data for this form this.destroy(); // Set up the form @@ -43,7 +46,7 @@ export default class GLForm { this.form.find('.div-dropzone').remove(); this.form.addClass('gfm-form'); // remove notify commit author checkbox for non-commit notes - gl.utils.disableButtonIfEmptyField( + disableButtonIfEmptyField( this.form.find('.js-note-text'), this.form.find('.js-comment-button, .js-note-new-discussion'), ); @@ -104,4 +107,8 @@ export default class GLForm { .removeClass('is-focused'); }); } + + get supportsQuickActions() { + return Boolean(this.textarea.data('supports-quick-actions')); + } } diff --git a/app/assets/javascripts/graphql_shared/fragments/user.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/user.fragment.graphql new file mode 100644 index 00000000000..096bb77ee49 --- /dev/null +++ b/app/assets/javascripts/graphql_shared/fragments/user.fragment.graphql @@ -0,0 +1,7 @@ +fragment User on User { + id + avatarUrl + name + username + webUrl +} diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index 6b9748bb725..be90ba12678 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -104,7 +104,7 @@ export default { :class="{ 'project-row-contents': !isGroup }" class="group-row-contents d-flex align-items-center py-2 pr-3" > - <div class="folder-toggle-wrap append-right-4 d-flex align-items-center"> + <div class="folder-toggle-wrap gl-mr-2 d-flex align-items-center"> <item-caret :is-group-open="group.isOpen" /> <item-type-icon :item-type="group.type" :is-group-open="group.isOpen" /> </div> @@ -140,7 +140,7 @@ export default { <item-stats-value :icon-name="visibilityIcon" :title="visibilityTooltip" - css-class="item-visibility d-inline-flex align-items-center gl-mt-3 append-right-4 text-secondary" + css-class="item-visibility d-inline-flex align-items-center gl-mt-3 gl-mr-2 text-secondary" /> <span v-if="group.permission" class="user-access-role gl-mt-3"> {{ group.permission }} diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue index c7acc21378b..c7713cbfafc 100644 --- a/app/assets/javascripts/groups/components/groups.vue +++ b/app/assets/javascripts/groups/components/groups.vue @@ -49,7 +49,7 @@ export default { <pagination-links :change="change" :page-info="pageInfo" - class="d-flex justify-content-center prepend-top-default" + class="d-flex justify-content-center gl-mt-3" /> </template> </div> diff --git a/app/assets/javascripts/groups/components/item_caret.vue b/app/assets/javascripts/groups/components/item_caret.vue index 18ea4819878..cd3e3de4cb4 100644 --- a/app/assets/javascripts/groups/components/item_caret.vue +++ b/app/assets/javascripts/groups/components/item_caret.vue @@ -21,5 +21,5 @@ export default { </script> <template> - <span class="folder-caret append-right-4"> <icon :size="10" :name="iconClass" /> </span> + <span class="folder-caret gl-mr-2"> <icon :size="10" :name="iconClass" /> </span> </template> diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js index d151cecf5be..3f9163e924d 100644 --- a/app/assets/javascripts/header.js +++ b/app/assets/javascripts/header.js @@ -16,10 +16,11 @@ import Tracking from '~/tracking'; */ export default function initTodoToggle() { $(document).on('todo:toggle', (e, count) => { + const updatedCount = count || e?.detail?.count || 0; const $todoPendingCount = $('.todos-count'); - $todoPendingCount.text(highCountTrim(count)); - $todoPendingCount.toggleClass('hidden', count === 0); + $todoPendingCount.text(highCountTrim(updatedCount)); + $todoPendingCount.toggleClass('hidden', updatedCount === 0); }); } diff --git a/app/assets/javascripts/helpers/event_hub_factory.js b/app/assets/javascripts/helpers/event_hub_factory.js index 4d7f7550a94..a9c301e3a93 100644 --- a/app/assets/javascripts/helpers/event_hub_factory.js +++ b/app/assets/javascripts/helpers/event_hub_factory.js @@ -1,20 +1,101 @@ -import mitt from 'mitt'; +/** + * An event hub with a Vue instance like API + * + * NOTE: There's an [issue open][4] to eventually remove this when some + * coupling in our codebase has been fixed. + * + * NOTE: This is a derivative work from [mitt][1] v1.2.0 which is licensed by + * [MIT License][2] © [Jason Miller][3] + * + * [1]: https://github.com/developit/mitt + * [2]: https://opensource.org/licenses/MIT + * [3]: https://jasonformat.com/ + * [4]: https://gitlab.com/gitlab-org/gitlab/-/issues/223864 + */ +class EventHub { + constructor() { + this.$_all = new Map(); + } -export default () => { - const emitter = mitt(); + dispose() { + this.$_all.clear(); + } + + /** + * Register an event handler for the given type. + * + * @param {string|symbol} type Type of event to listen for + * @param {Function} handler Function to call in response to given event + */ + $on(type, handler) { + const handlers = this.$_all.get(type); + const added = handlers && handlers.push(handler); + + if (!added) { + this.$_all.set(type, [handler]); + } + } + + /** + * Remove an event handler or all handlers for the given type. + * + * @param {string|symbol} type Type of event to unregister `handler` + * @param {Function} handler Handler function to remove + */ + $off(type, handler) { + const handlers = this.$_all.get(type) || []; - emitter.once = (event, handler) => { - const wrappedHandler = evt => { - handler(evt); - emitter.off(event, wrappedHandler); + const newHandlers = handler ? handlers.filter(x => x !== handler) : []; + + if (newHandlers.length) { + this.$_all.set(type, newHandlers); + } else { + this.$_all.delete(type); + } + } + + /** + * Add an event listener to type but only trigger it once + * + * @param {string|symbol} type Type of event to listen for + * @param {Function} handler Handler function to call in response to event + */ + $once(type, handler) { + const wrapHandler = (...args) => { + this.$off(type, wrapHandler); + handler(...args); }; - emitter.on(event, wrappedHandler); - }; + this.$on(type, wrapHandler); + } - emitter.$on = emitter.on; - emitter.$once = emitter.once; - emitter.$off = emitter.off; - emitter.$emit = emitter.emit; + /** + * Invoke all handlers for the given type. + * + * @param {string|symbol} type The event type to invoke + * @param {Any} [evt] Any value passed to each handler + */ + $emit(type, ...args) { + const handlers = this.$_all.get(type) || []; - return emitter; + handlers.forEach(handler => { + handler(...args); + }); + } +} + +/** + * Return a Vue like event hub + * + * - $on + * - $off + * - $once + * - $emit + * + * Please note, this was once implemented with `mitt`, but since then has been reverted + * because of some API issues. https://gitlab.com/gitlab-org/gitlab/-/merge_requests/35074 + * + * We'd like to shy away from using a full fledged Vue instance from this in the future. + */ +export default () => { + return new EventHub(); }; diff --git a/app/assets/javascripts/helpers/monitor_helper.js b/app/assets/javascripts/helpers/monitor_helper.js index 94a0d38f05f..5f85ee58779 100644 --- a/app/assets/javascripts/helpers/monitor_helper.js +++ b/app/assets/javascripts/helpers/monitor_helper.js @@ -65,18 +65,10 @@ const getSeriesLabel = (queryLabel, metricAttributes) => { */ // eslint-disable-next-line import/prefer-default-export export const makeDataSeries = (queryResults, defaultConfig) => - queryResults - .map(result => { - // NaN values may disrupt avg., max. & min. calculations in the legend, filter them out - const data = result.values.filter(([, value]) => !Number.isNaN(value)); - if (!data.length) { - return null; - } - const series = { data }; - return { - ...defaultConfig, - ...series, - name: getSeriesLabel(defaultConfig.name, result.metric), - }; - }) - .filter(series => series !== null); + queryResults.map(result => { + return { + ...defaultConfig, + data: result.values, + name: getSeriesLabel(defaultConfig.name, result.metric), + }; + }); diff --git a/app/assets/javascripts/ide/components/branches/item.vue b/app/assets/javascripts/ide/components/branches/item.vue index e7f4cd796b5..49744d573da 100644 --- a/app/assets/javascripts/ide/components/branches/item.vue +++ b/app/assets/javascripts/ide/components/branches/item.vue @@ -33,7 +33,7 @@ export default { <template> <a :href="branchHref" class="btn-link d-flex align-items-center"> - <span class="d-flex append-right-default ide-search-list-current-icon"> + <span class="d-flex gl-mr-3 ide-search-list-current-icon"> <icon v-if="isActive" :size="18" name="mobile-issue-close" /> </span> <span> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue index 6c563776533..407e4c57cd8 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue @@ -70,7 +70,7 @@ export default { </script> <template> - <div class="append-bottom-15 ide-commit-options"> + <div class="gl-mb-5 ide-commit-options"> <radio-group :value="$options.commitToCurrentBranch" :disabled="!canPushToBranch" diff --git a/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue b/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue index a13ca0cd138..3ffbcbf99e8 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue @@ -12,7 +12,7 @@ export default { <div v-if="!lastCommitMsg" class="multi-file-commit-panel-section ide-commit-empty-state"> <div class="ide-commit-empty-state-container"> <div class="svg-content svg-80"><img :src="noChangesStateSvgPath" /></div> - <div class="append-right-default prepend-left-default"> + <div class="gl-mr-3 gl-ml-3"> <div class="text-content text-center"> <h4>{{ __('No changes') }}</h4> <p>{{ __('Edit files in the editor and commit changes here') }}</p> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue index b6fc567f8cc..03304337839 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue @@ -75,7 +75,7 @@ export default { :title="titleTooltip" data-container="body" data-placement="left" - class="append-bottom-15" + class="gl-mb-5" > <icon v-once :name="iconName" :size="18" /> </div> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue index 6b0aa5b2b2b..b37c7280a30 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue @@ -83,7 +83,7 @@ export default { <ul class="nav-links"> <li> {{ __('Commit Message') }} - <span v-popover="$options.popoverOptions" class="form-text text-muted prepend-left-10"> + <span v-popover="$options.popoverOptions" class="form-text text-muted gl-ml-3"> <icon name="question" /> </span> </li> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue b/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue index 0812599c25c..cdf49866982 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue @@ -44,7 +44,7 @@ export default { data-qa-selector="start_new_mr_checkbox" @change="toggleShouldCreateMR" /> - <span class="prepend-left-10 ide-option-label"> + <span class="gl-ml-3 ide-option-label"> {{ __('Start a new merge request') }} </span> </label> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue index a9591805261..aed7b792902 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue @@ -66,7 +66,7 @@ export default { name="commit-action" @change="updateCommitAction($event.target.value)" /> - <span class="prepend-left-10"> + <span class="gl-ml-3"> <span v-if="label" class="ide-option-label"> {{ label }} </span> <slot v-else></slot> </span> </label> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue b/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue index 137f8bb18c7..327b0b8172f 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue @@ -13,7 +13,7 @@ export default { <div class="svg-content svg-80"> <img :src="committedStateSvgPath" :alt="s__('IDE|Successful commit')" /> </div> - <div class="append-right-default prepend-left-default"> + <div class="gl-mr-3 gl-ml-3"> <div class="text-content text-center"> <h4>{{ __('All changes are committed') }}</h4> <p v-html="lastCommitMsg"></p> diff --git a/app/assets/javascripts/ide/components/file_row_extra.vue b/app/assets/javascripts/ide/components/file_row_extra.vue index 51509cd5fe6..f7cf7a5b251 100644 --- a/app/assets/javascripts/ide/components/file_row_extra.vue +++ b/app/assets/javascripts/ide/components/file_row_extra.vue @@ -76,7 +76,7 @@ export default { data-container="body" data-placement="right" name="file-modified" - class="prepend-left-5 ide-file-modified" + class="gl-ml-2 ide-file-modified" /> </span> <changed-file-icon diff --git a/app/assets/javascripts/ide/components/file_templates/bar.vue b/app/assets/javascripts/ide/components/file_templates/bar.vue index d459e3b43d3..b6a57d1b6e6 100644 --- a/app/assets/javascripts/ide/components/file_templates/bar.vue +++ b/app/assets/javascripts/ide/components/file_templates/bar.vue @@ -48,7 +48,7 @@ export default { <template> <div class="d-flex align-items-center ide-file-templates qa-file-templates-bar"> - <strong class="append-right-default"> {{ __('File templates') }} </strong> + <strong class="gl-mr-3"> {{ __('File templates') }} </strong> <dropdown :data="templateTypes" :label="selectedTemplateType.name || __('Choose a type...')" diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index e9f84eb8648..55b3eaf9737 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -1,6 +1,6 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; -import { GlDeprecatedButton, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import { modalTypes } from '../constants'; import FindFile from '~/vue_shared/components/file_finder/index.vue'; @@ -24,7 +24,7 @@ export default { FindFile, ErrorMessage, CommitEditorHeader, - GlDeprecatedButton, + GlButton, GlLoadingIcon, RightPane, }, @@ -121,15 +121,16 @@ export default { ) }} </p> - <gl-deprecated-button + <gl-button variant="success" + category="primary" :title="__('New file')" :aria-label="__('New file')" data-qa-selector="first_file_button" @click="createNewFile()" > {{ __('New file') }} - </gl-deprecated-button> + </gl-button> </template> <gl-loading-icon v-else-if="!currentTree || currentTree.loading" size="md" /> <p v-else> diff --git a/app/assets/javascripts/ide/components/ide_review.vue b/app/assets/javascripts/ide/components/ide_review.vue index 62dbfea2088..95348711e1d 100644 --- a/app/assets/javascripts/ide/components/ide_review.vue +++ b/app/assets/javascripts/ide/components/ide_review.vue @@ -53,7 +53,7 @@ export default { @click="updateViewer" /> </div> - <div class="prepend-top-5 ide-review-sub-header"> + <div class="gl-mt-2 ide-review-sub-header"> <template v-if="showLatestChangesText"> {{ __('Latest changes') }} </template> diff --git a/app/assets/javascripts/ide/components/ide_status_list.vue b/app/assets/javascripts/ide/components/ide_status_list.vue index 92d25709bd5..1354fdc3d98 100644 --- a/app/assets/javascripts/ide/components/ide_status_list.vue +++ b/app/assets/javascripts/ide/components/ide_status_list.vue @@ -1,12 +1,17 @@ <script> import { mapGetters } from 'vuex'; +import { GlLink, GlTooltipDirective } from '@gitlab/ui'; import TerminalSyncStatusSafe from './terminal_sync/terminal_sync_status_safe.vue'; import { getFileEOL } from '../utils'; export default { components: { + GlLink, TerminalSyncStatusSafe, }, + directives: { + GlTooltip: GlTooltipDirective, + }, computed: { ...mapGetters(['activeFile']), activeFileEOL() { @@ -19,12 +24,14 @@ export default { <template> <div class="ide-status-list d-flex"> <template v-if="activeFile"> - <div class="ide-status-file">{{ activeFile.name }}</div> - <div class="ide-status-file">{{ activeFileEOL }}</div> - <div v-if="!activeFile.binary" class="ide-status-file"> - {{ activeFile.editorRow }}:{{ activeFile.editorColumn }} + <div> + <gl-link v-gl-tooltip.hover :href="activeFile.permalink" :title="__('Open in file view')"> + {{ activeFile.name }} + </gl-link> </div> - <div class="ide-status-file">{{ activeFile.fileLanguage }}</div> + <div>{{ activeFileEOL }}</div> + <div v-if="!activeFile.binary">{{ activeFile.editorRow }}:{{ activeFile.editorColumn }}</div> + <div>{{ activeFile.fileLanguage }}</div> </template> <terminal-sync-status-safe /> </div> diff --git a/app/assets/javascripts/ide/components/jobs/item.vue b/app/assets/javascripts/ide/components/jobs/item.vue index be8bf77bba0..db3630bc1d1 100644 --- a/app/assets/javascripts/ide/components/jobs/item.vue +++ b/app/assets/javascripts/ide/components/jobs/item.vue @@ -26,7 +26,7 @@ export default { <template> <div class="ide-job-item"> - <job-description :job="job" class="append-right-default" /> + <job-description :job="job" class="gl-mr-3" /> <div class="ml-auto align-self-center"> <button v-if="job.started" type="button" class="btn btn-default btn-sm" @click="clickViewLog"> {{ __('View log') }} diff --git a/app/assets/javascripts/ide/components/jobs/list.vue b/app/assets/javascripts/ide/components/jobs/list.vue index b97b7289886..4e0912f3f44 100644 --- a/app/assets/javascripts/ide/components/jobs/list.vue +++ b/app/assets/javascripts/ide/components/jobs/list.vue @@ -26,7 +26,7 @@ export default { <template> <div> - <gl-loading-icon v-if="loading && !stages.length" size="lg" class="prepend-top-default" /> + <gl-loading-icon v-if="loading && !stages.length" size="lg" class="gl-mt-3" /> <template v-else> <stage v-for="stage in stages" diff --git a/app/assets/javascripts/ide/components/jobs/stage.vue b/app/assets/javascripts/ide/components/jobs/stage.vue index 169a948c2da..75441e8c1c8 100644 --- a/app/assets/javascripts/ide/components/jobs/stage.vue +++ b/app/assets/javascripts/ide/components/jobs/stage.vue @@ -56,7 +56,7 @@ export default { </script> <template> - <div class="ide-stage card prepend-top-default"> + <div class="ide-stage card gl-mt-3"> <div ref="cardHeader" :class="{ diff --git a/app/assets/javascripts/ide/components/merge_requests/item.vue b/app/assets/javascripts/ide/components/merge_requests/item.vue index 3f060392686..8b7b8d5a91c 100644 --- a/app/assets/javascripts/ide/components/merge_requests/item.vue +++ b/app/assets/javascripts/ide/components/merge_requests/item.vue @@ -40,7 +40,7 @@ export default { <template> <a :href="mergeRequestHref" class="btn-link d-flex align-items-center"> - <span class="d-flex append-right-default ide-search-list-current-icon"> + <span class="d-flex gl-mr-3 ide-search-list-current-icon"> <icon v-if="isActive" :size="18" name="mobile-issue-close" /> </span> <span> diff --git a/app/assets/javascripts/ide/components/merge_requests/list.vue b/app/assets/javascripts/ide/components/merge_requests/list.vue index bf2a33be653..af45d88b84a 100644 --- a/app/assets/javascripts/ide/components/merge_requests/list.vue +++ b/app/assets/javascripts/ide/components/merge_requests/list.vue @@ -102,7 +102,7 @@ export default { class="btn-link d-flex align-items-center" @click.stop="setSearchType(searchType)" > - <span class="d-flex append-right-default ide-search-list-current-icon"> + <span class="d-flex gl-mr-3 ide-search-list-current-icon"> <icon :size="18" name="search" /> </span> <span>{{ searchType.label }}</span> diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue index 2798ede5341..b656e35f150 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/index.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue @@ -64,6 +64,7 @@ export default { :aria-label="__('Create new file or directory')" type="button" class="rounded border-0 d-flex ide-entry-dropdown-toggle" + data-qa-selector="dropdown_button" @click.stop="openDropdown()" > <icon name="ellipsis_v" /> <icon name="chevron-down" /> @@ -97,6 +98,7 @@ export default { class="d-flex" icon="pencil" icon-classes="mr-2" + data-qa-selector="rename_move_button" @click="createNewItem($options.modalTypes.rename)" /> </li> diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index 586d6867ab4..fe0167942b8 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -154,10 +154,7 @@ export default { data-qa-selector="file_name_field" :placeholder="placeholder" /> - <ul - v-if="isCreatingNewFile" - class="file-templates prepend-top-default list-inline qa-template-list" - > + <ul v-if="isCreatingNewFile" class="file-templates gl-mt-3 list-inline qa-template-list"> <li v-for="(template, index) in templateTypes" :key="index" class="list-inline-item"> <button type="button" diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue index 6958a5d2526..6038e92f254 100644 --- a/app/assets/javascripts/ide/components/pipelines/list.vue +++ b/app/assets/javascripts/ide/components/pipelines/list.vue @@ -7,7 +7,7 @@ import Icon from '../../../vue_shared/components/icon.vue'; import CiIcon from '../../../vue_shared/components/ci_icon.vue'; import Tabs from '../../../vue_shared/components/tabs/tabs'; import Tab from '../../../vue_shared/components/tabs/tab.vue'; -import EmptyState from '../../../pipelines/components/empty_state.vue'; +import EmptyState from '../../../pipelines/components/pipelines_list/empty_state.vue'; import JobsList from '../jobs/list.vue'; import IDEServices from '~/ide/services'; @@ -59,7 +59,7 @@ export default { <template> <div class="ide-pipeline"> - <gl-loading-icon v-if="showLoadingIcon" size="lg" class="prepend-top-default" /> + <gl-loading-icon v-if="showLoadingIcon" size="lg" class="gl-mt-3" /> <template v-else-if="hasLoadedPipeline"> <header v-if="latestPipeline" class="ide-tree-header ide-pipeline-header"> <ci-icon :status="latestPipeline.details.status" :size="24" class="d-flex" /> diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index a7646083428..ac445a1d9f1 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -185,7 +185,6 @@ export default { 'setFileLanguage', 'setEditorPosition', 'setFileViewMode', - 'updateViewer', 'removePendingTab', 'triggerFilesChange', 'addTempImage', @@ -241,7 +240,7 @@ export default { }); }, setupEditor() { - if (!this.file || !this.editor.instance) return; + if (!this.file || !this.editor.instance || this.file.loading) return; const head = this.getStagedFile(this.file.path); diff --git a/app/assets/javascripts/ide/components/terminal/empty_state.vue b/app/assets/javascripts/ide/components/terminal/empty_state.vue index 9841f1ece48..5dd12e62820 100644 --- a/app/assets/javascripts/ide/components/terminal/empty_state.vue +++ b/app/assets/javascripts/ide/components/terminal/empty_state.vue @@ -43,7 +43,7 @@ export default { <div class="text-center p-3"> <div v-if="illustrationPath" class="svg-content svg-130"><img :src="illustrationPath" /></div> <h4>{{ __('Web Terminal') }}</h4> - <gl-loading-icon v-if="isLoading" size="lg" class="prepend-top-default" /> + <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3" /> <template v-else> <p>{{ __('Run tests against your code live using the Web Terminal') }}</p> <p> diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js index 4dfc27117c0..6e90968f008 100644 --- a/app/assets/javascripts/ide/lib/editor.js +++ b/app/assets/javascripts/ide/lib/editor.js @@ -8,9 +8,10 @@ import ModelManager from './common/model_manager'; import { editorOptions, defaultEditorOptions, defaultDiffEditorOptions } from './editor_options'; import { themes } from './themes'; import languages from './languages'; +import schemas from './schemas'; import keymap from './keymap.json'; import { clearDomElement } from '~/editor/utils'; -import { registerLanguages } from '../utils'; +import { registerLanguages, registerSchemas } from '../utils'; function setupThemes() { themes.forEach(theme => { @@ -45,6 +46,10 @@ export default class Editor { setupThemes(); registerLanguages(...languages); + if (gon.features?.schemaLinting) { + registerSchemas(...schemas); + } + this.debouncedUpdate = debounce(() => { this.updateDimensions(); }, 200); diff --git a/app/assets/javascripts/ide/lib/schemas/index.js b/app/assets/javascripts/ide/lib/schemas/index.js new file mode 100644 index 00000000000..38a2f81921b --- /dev/null +++ b/app/assets/javascripts/ide/lib/schemas/index.js @@ -0,0 +1,4 @@ +import json from './json'; +import yaml from './yaml'; + +export default [json, yaml]; diff --git a/app/assets/javascripts/ide/lib/schemas/json/index.js b/app/assets/javascripts/ide/lib/schemas/json/index.js new file mode 100644 index 00000000000..900d5442bec --- /dev/null +++ b/app/assets/javascripts/ide/lib/schemas/json/index.js @@ -0,0 +1,8 @@ +export default { + language: 'json', + options: { + validate: true, + enableSchemaRequest: true, + schemas: [], + }, +}; diff --git a/app/assets/javascripts/ide/lib/schemas/yaml/gitlab_ci.js b/app/assets/javascripts/ide/lib/schemas/yaml/gitlab_ci.js new file mode 100644 index 00000000000..af20744abb3 --- /dev/null +++ b/app/assets/javascripts/ide/lib/schemas/yaml/gitlab_ci.js @@ -0,0 +1,4 @@ +export default { + uri: 'https://json.schemastore.org/gitlab-ci', + fileMatch: ['*.gitlab-ci.yml'], +}; diff --git a/app/assets/javascripts/ide/lib/schemas/yaml/index.js b/app/assets/javascripts/ide/lib/schemas/yaml/index.js new file mode 100644 index 00000000000..e3fc406df4b --- /dev/null +++ b/app/assets/javascripts/ide/lib/schemas/yaml/index.js @@ -0,0 +1,12 @@ +import gitlabCi from './gitlab_ci'; + +export default { + language: 'yaml', + options: { + validate: true, + enableSchemaRequest: true, + hover: true, + completion: true, + schemas: [gitlabCi], + }, +}; diff --git a/app/assets/javascripts/ide/queries/getUserPermissions.query.graphql b/app/assets/javascripts/ide/queries/getUserPermissions.query.graphql index 2c9013ffa9c..f0b50793226 100644 --- a/app/assets/javascripts/ide/queries/getUserPermissions.query.graphql +++ b/app/assets/javascripts/ide/queries/getUserPermissions.query.graphql @@ -1,8 +1,8 @@ query getUserPermissions($projectPath: ID!) { project(fullPath: $projectPath) { userPermissions { - createMergeRequestIn, - readMergeRequest, + createMergeRequestIn + readMergeRequest pushCode } } diff --git a/app/assets/javascripts/ide/services/gql.js b/app/assets/javascripts/ide/services/gql.js index 8a7f27328ba..211cc78bd99 100644 --- a/app/assets/javascripts/ide/services/gql.js +++ b/app/assets/javascripts/ide/services/gql.js @@ -1,8 +1,21 @@ +import { memoize } from 'lodash'; import createGqClient, { fetchPolicies } from '~/lib/graphql'; -export default createGqClient( - {}, - { - fetchPolicy: fetchPolicies.NO_CACHE, - }, +/** + * Returns a memoized client + * + * We defer creating the client so that importing this module does not cause any side-effects. + * Creating the client immediately caused issues with miragejs where the gql client uses the + * real fetch() instead of the shimmed one. + */ +const getClient = memoize(() => + createGqClient( + {}, + { + fetchPolicy: fetchPolicies.NO_CACHE, + }, + ), ); + +// eslint-disable-next-line import/prefer-default-export +export const query = (...args) => getClient().query(...args); diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js index 1767d961259..ae4a1ba3db5 100644 --- a/app/assets/javascripts/ide/services/index.js +++ b/app/assets/javascripts/ide/services/index.js @@ -2,17 +2,15 @@ import axios from '~/lib/utils/axios_utils'; import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility'; import Api from '~/api'; import getUserPermissions from '../queries/getUserPermissions.query.graphql'; -import gqClient from './gql'; +import { query } from './gql'; const fetchApiProjectData = projectPath => Api.project(projectPath).then(({ data }) => data); const fetchGqlProjectData = projectPath => - gqClient - .query({ - query: getUserPermissions, - variables: { projectPath }, - }) - .then(({ data }) => data.project); + query({ + query: getUserPermissions, + variables: { projectPath }, + }).then(({ data }) => data.project); export default { getFileData(endpoint) { diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index 47f9337a288..c0cb924e749 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -65,7 +65,7 @@ export const getFileData = ( if (file.raw || (file.tempFile && !file.prevPath && !fileDeletedAndReadded)) return Promise.resolve(); - commit(types.TOGGLE_LOADING, { entry: file }); + commit(types.TOGGLE_LOADING, { entry: file, forceValue: true }); const url = joinPaths( gon.relative_url_root || '/', @@ -79,15 +79,15 @@ export const getFileData = ( return service .getFileData(url) .then(({ data }) => { - setPageTitleForFile(state, file); - if (data) commit(types.SET_FILE_DATA, { data, file }); if (openFile) commit(types.TOGGLE_FILE_OPEN, path); - if (makeFileActive) dispatch('setFileActive', path); - commit(types.TOGGLE_LOADING, { entry: file }); + + if (makeFileActive) { + setPageTitleForFile(state, file); + dispatch('setFileActive', path); + } }) .catch(() => { - commit(types.TOGGLE_LOADING, { entry: file }); dispatch('setErrorMessage', { text: __('An error occurred while loading the file.'), action: payload => @@ -95,6 +95,9 @@ export const getFileData = ( actionText: __('Please try again'), actionPayload: { path, makeFileActive }, }); + }) + .finally(() => { + commit(types.TOGGLE_LOADING, { entry: file, forceValue: false }); }); }; @@ -106,45 +109,41 @@ export const getRawFileData = ({ state, commit, dispatch, getters }, { path }) = const file = state.entries[path]; const stagedFile = state.stagedFiles.find(f => f.path === path); - return new Promise((resolve, reject) => { - const fileDeletedAndReadded = getters.isFileDeletedAndReadded(path); - service - .getRawFileData(fileDeletedAndReadded ? stagedFile : file) - .then(raw => { - if (!(file.tempFile && !file.prevPath && !fileDeletedAndReadded)) - commit(types.SET_FILE_RAW_DATA, { file, raw, fileDeletedAndReadded }); - - if (file.mrChange && file.mrChange.new_file === false) { - const baseSha = - (getters.currentMergeRequest && getters.currentMergeRequest.baseCommitSha) || ''; - - service - .getBaseRawFileData(file, baseSha) - .then(baseRaw => { - commit(types.SET_FILE_BASE_RAW_DATA, { - file, - baseRaw, - }); - resolve(raw); - }) - .catch(e => { - reject(e); - }); - } else { - resolve(raw); - } - }) - .catch(() => { - dispatch('setErrorMessage', { - text: __('An error occurred while loading the file content.'), - action: payload => - dispatch('getRawFileData', payload).then(() => dispatch('setErrorMessage', null)), - actionText: __('Please try again'), - actionPayload: { path }, + const fileDeletedAndReadded = getters.isFileDeletedAndReadded(path); + commit(types.TOGGLE_LOADING, { entry: file, forceValue: true }); + return service + .getRawFileData(fileDeletedAndReadded ? stagedFile : file) + .then(raw => { + if (!(file.tempFile && !file.prevPath && !fileDeletedAndReadded)) + commit(types.SET_FILE_RAW_DATA, { file, raw, fileDeletedAndReadded }); + + if (file.mrChange && file.mrChange.new_file === false) { + const baseSha = + (getters.currentMergeRequest && getters.currentMergeRequest.baseCommitSha) || ''; + + return service.getBaseRawFileData(file, baseSha).then(baseRaw => { + commit(types.SET_FILE_BASE_RAW_DATA, { + file, + baseRaw, + }); + return raw; }); - reject(); + } + return raw; + }) + .catch(e => { + dispatch('setErrorMessage', { + text: __('An error occurred while loading the file content.'), + action: payload => + dispatch('getRawFileData', payload).then(() => dispatch('setErrorMessage', null)), + actionText: __('Please try again'), + actionPayload: { path }, }); - }); + throw e; + }) + .finally(() => { + commit(types.TOGGLE_LOADING, { entry: file, forceValue: false }); + }); }; export const changeFileContent = ({ commit, state, getters }, { path, content }) => { diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js index fcaf060ef09..3fdfdc5422b 100644 --- a/app/assets/javascripts/ide/stores/actions/merge_request.js +++ b/app/assets/javascripts/ide/stores/actions/merge_request.js @@ -16,6 +16,7 @@ export const getMergeRequestsForBranch = ( .getProjectMergeRequests(`${projectId}`, { source_branch: branchId, source_project_id: state.projects[projectId].id, + state: 'opened', order_by: 'created_at', per_page: 1, }) diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index d94adc3760f..ae119c2b1fd 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -1,6 +1,5 @@ export const SET_INITIAL_DATA = 'SET_INITIAL_DATA'; export const TOGGLE_LOADING = 'TOGGLE_LOADING'; -export const SET_LAST_COMMIT_DATA = 'SET_LAST_COMMIT_DATA'; export const SET_LAST_COMMIT_MSG = 'SET_LAST_COMMIT_MSG'; export const SET_RESIZING_STATUS = 'SET_RESIZING_STATUS'; export const SET_EMPTY_STATE_SVGS = 'SET_EMPTY_STATE_SVGS'; diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index e827aacac13..c64839e5019 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -34,15 +34,6 @@ export default { panelResizing: resizing, }); }, - [types.SET_LAST_COMMIT_DATA](state, { entry, lastCommit }) { - Object.assign(entry.lastCommit, { - id: lastCommit.commit.id, - url: lastCommit.commit_path, - message: lastCommit.commit.message, - author: lastCommit.commit.author_name, - updatedAt: lastCommit.commit.authored_date, - }); - }, [types.SET_LAST_COMMIT_MSG](state, lastCommitMsg) { Object.assign(state, { lastCommitMsg, diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index 1c5fe9fe9a5..f074e6880d0 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -25,13 +25,6 @@ export const dataStructure = () => ({ changed: false, staged: false, lastCommitSha: '', - lastCommit: { - id: '', - url: '', - message: '', - updatedAt: '', - author: '', - }, rawPath: '', binary: false, raw: '', diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js index c28a2bd9f1d..9ec7b2c06ce 100644 --- a/app/assets/javascripts/ide/utils.js +++ b/app/assets/javascripts/ide/utils.js @@ -66,7 +66,7 @@ export const trimPathComponents = path => .join('/'); export function registerLanguages(def, ...defs) { - if (defs.length) defs.forEach(lang => registerLanguages(lang)); + defs.forEach(lang => registerLanguages(lang)); const languageId = def.id; @@ -75,6 +75,19 @@ export function registerLanguages(def, ...defs) { languages.setLanguageConfiguration(languageId, def.conf); } +export function registerSchemas({ language, options }, ...schemas) { + schemas.forEach(schema => registerSchemas(schema)); + + const defaults = { + json: languages.json.jsonDefaults, + yaml: languages.yaml.yamlDefaults, + }; + + if (defaults[language]) { + defaults[language].setDiagnosticsOptions(options); + } +} + export const otherSide = side => (side === SIDE_RIGHT ? SIDE_LEFT : SIDE_RIGHT); export function trimTrailingWhitespace(content) { diff --git a/app/assets/javascripts/import_projects/components/bitbucket_status_table.vue b/app/assets/javascripts/import_projects/components/bitbucket_status_table.vue index 1a9974db727..f673a0e42dc 100644 --- a/app/assets/javascripts/import_projects/components/bitbucket_status_table.vue +++ b/app/assets/javascripts/import_projects/components/bitbucket_status_table.vue @@ -28,7 +28,7 @@ export default { }; </script> <template> - <import-projects-table provider-title="providerTitle"> + <import-projects-table :provider-title="providerTitle"> <template #actions> <slot name="actions"></slot> </template> diff --git a/app/assets/javascripts/import_projects/store/actions.js b/app/assets/javascripts/import_projects/store/actions.js index 2422a1ed2e4..8d8d33f5972 100644 --- a/app/assets/javascripts/import_projects/store/actions.js +++ b/app/assets/javascripts/import_projects/store/actions.js @@ -70,8 +70,19 @@ export const fetchImport = ({ state, commit }, { newName, targetNamespace, repo repoId: repo.id, }), ) - .catch(() => { - createFlash(s__('ImportProjects|Importing the project failed')); + .catch(e => { + const serverErrorMessage = e?.response?.data?.errors; + const flashMessage = serverErrorMessage + ? sprintf( + s__('ImportProjects|Importing the project failed: %{reason}'), + { + reason: serverErrorMessage, + }, + false, + ) + : s__('ImportProjects|Importing the project failed'); + + createFlash(flashMessage); commit(types.RECEIVE_IMPORT_ERROR, repo.id); }); diff --git a/app/assets/javascripts/importer_status.js b/app/assets/javascripts/importer_status.js index d6b519f7eac..f44c5c3d289 100644 --- a/app/assets/javascripts/importer_status.js +++ b/app/assets/javascripts/importer_status.js @@ -35,8 +35,8 @@ class ImporterStatus { const $tr = $btn.closest('tr'); const $targetField = $tr.find('.import-target'); const $namespaceInput = $targetField.find('.js-select-namespace option:selected'); - const id = $tr.attr('id').replace('repo_', ''); const repoData = $tr.data(); + const id = repoData.id || $tr.attr('id').replace('repo_', ''); let targetNamespace; let newName; @@ -63,7 +63,7 @@ class ImporterStatus { return axios .post(this.importUrl, attributes) .then(({ data }) => { - const job = $(`tr#repo_${id}`); + const job = $tr; job.attr('id', `project_${data.id}`); job.find('.import-target').html(`<a href="${data.full_path}">${data.full_path}</a>`); @@ -86,7 +86,7 @@ class ImporterStatus { .catch(error => { let details = error; - const $statusField = $(`#repo_${this.id} .job-status`); + const $statusField = $tr.find('.job-status'); $statusField.text(__('Failed')); if (error.response && error.response.data && error.response.data.errors) { diff --git a/app/assets/javascripts/incidents_settings/components/alerts_form.vue b/app/assets/javascripts/incidents_settings/components/alerts_form.vue new file mode 100644 index 00000000000..a394f404ee1 --- /dev/null +++ b/app/assets/javascripts/incidents_settings/components/alerts_form.vue @@ -0,0 +1,139 @@ +<script> +import { + GlButton, + GlSprintf, + GlLink, + GlIcon, + GlFormGroup, + GlFormCheckbox, + GlNewDropdown, + GlNewDropdownItem, +} from '@gitlab/ui'; +import { + I18N_ALERT_SETTINGS_FORM, + NO_ISSUE_TEMPLATE_SELECTED, + TAKING_INCIDENT_ACTION_DOCS_LINK, + ISSUE_TEMPLATES_DOCS_LINK, +} from '../constants'; + +export default { + components: { + GlButton, + GlSprintf, + GlLink, + GlFormGroup, + GlIcon, + GlFormCheckbox, + GlNewDropdown, + GlNewDropdownItem, + }, + inject: ['service', 'alertSettings'], + data() { + return { + templates: [NO_ISSUE_TEMPLATE_SELECTED, ...this.alertSettings.templates], + createIssueEnabled: this.alertSettings.createIssue, + issueTemplate: this.alertSettings.issueTemplateKey, + sendEmailEnabled: this.alertSettings.sendEmail, + loading: false, + }; + }, + i18n: I18N_ALERT_SETTINGS_FORM, + TAKING_INCIDENT_ACTION_DOCS_LINK, + ISSUE_TEMPLATES_DOCS_LINK, + computed: { + issueTemplateHeader() { + return this.issueTemplate || NO_ISSUE_TEMPLATE_SELECTED.name; + }, + formData() { + return { + create_issue: this.createIssueEnabled, + issue_template_key: this.issueTemplate, + send_email: this.sendEmailEnabled, + }; + }, + }, + methods: { + selectIssueTemplate(templateKey) { + this.issueTemplate = templateKey; + }, + isTemplateSelected(templateKey) { + return templateKey === this.issueTemplate; + }, + updateAlertsIntegrationSettings() { + this.loading = true; + + this.service.updateSettings(this.formData).catch(() => { + this.loading = false; + }); + }, + }, +}; +</script> + +<template> + <div> + <p> + <gl-sprintf :message="$options.i18n.introText"> + <template #docsLink> + <gl-link :href="$options.TAKING_INCIDENT_ACTION_DOCS_LINK" target="_blank"> + <span>{{ $options.i18n.introLinkText }}</span> + </gl-link> + </template> + </gl-sprintf> + </p> + <form ref="settingsForm" @submit.prevent="updateAlertsIntegrationSettings"> + <gl-form-group class="gl-pl-0"> + <gl-form-checkbox v-model="createIssueEnabled" data-qa-selector="create_issue_checkbox"> + <span>{{ $options.i18n.createIssue.label }}</span> + </gl-form-checkbox> + </gl-form-group> + + <gl-form-group + label-size="sm" + label-for="alert-integration-settings-issue-template" + class="col-8 col-md-9 gl-px-6" + > + <label class="gl-display-inline-flex" for="alert-integration-settings-issue-template"> + {{ $options.i18n.issueTemplate.label }} + <gl-link :href="$options.ISSUE_TEMPLATES_DOCS_LINK" target="_blank"> + <gl-icon name="question" :size="12" /> + </gl-link> + </label> + <gl-new-dropdown + id="alert-integration-settings-issue-template" + data-qa-selector="incident_templates_dropdown" + :text="issueTemplateHeader" + :block="true" + > + <gl-new-dropdown-item + v-for="template in templates" + :key="template.key" + data-qa-selector="incident_templates_item" + :is-check-item="true" + :is-checked="isTemplateSelected(template.key)" + @click="selectIssueTemplate(template.key)" + > + {{ template.name }} + </gl-new-dropdown-item> + </gl-new-dropdown> + </gl-form-group> + + <gl-form-group class="gl-pl-0 gl-mb-5"> + <gl-form-checkbox v-model="sendEmailEnabled"> + <span>{{ $options.i18n.sendEmail.label }}</span> + </gl-form-checkbox> + </gl-form-group> + + <gl-button + ref="submitBtn" + data-qa-selector="save_changes_button" + :disabled="loading" + variant="success" + type="submit" + class="js-no-auto-disable" + > + {{ $options.i18n.saveBtnLabel }} + </gl-button> + </form> + </div> +</template> diff --git a/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue b/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue new file mode 100644 index 00000000000..0623c275c5a --- /dev/null +++ b/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue @@ -0,0 +1,61 @@ +<script> +import { GlButton, GlTabs, GlTab } from '@gitlab/ui'; +import AlertsSettingsForm from './alerts_form.vue'; +import PagerDutySettingsForm from './pagerduty_form.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { INTEGRATION_TABS_CONFIG, I18N_INTEGRATION_TABS } from '../constants'; + +export default { + components: { + GlButton, + GlTabs, + GlTab, + AlertsSettingsForm, + PagerDutySettingsForm, + }, + mixins: [glFeatureFlagMixin()], + tabs: INTEGRATION_TABS_CONFIG, + i18n: I18N_INTEGRATION_TABS, + methods: { + isFeatureFlagEnabled(tab) { + if (tab.featureFlag) { + return this.glFeatures[tab.featureFlag]; + } + return true; + }, + }, +}; +</script> + +<template> + <section + id="incident-management-settings" + data-qa-selector="incidents_settings_content" + class="settings no-animate qa-incident-management-settings" + > + <div class="settings-header"> + <h3 ref="sectionHeader" class="h4"> + {{ $options.i18n.headerText }} + </h3> + <gl-button ref="toggleBtn" class="js-settings-toggle">{{ + $options.i18n.expandBtnLabel + }}</gl-button> + <p ref="sectionSubHeader"> + {{ $options.i18n.subHeaderText }} + </p> + </div> + + <div class="settings-content"> + <gl-tabs> + <gl-tab + v-for="(tab, index) in $options.tabs" + v-if="tab.active && isFeatureFlagEnabled(tab)" + :key="`${tab.title}_${index}`" + :title="tab.title" + > + <component :is="tab.component" class="gl-pt-3" :data-testid="`${tab.component}-tab`" /> + </gl-tab> + </gl-tabs> + </div> + </section> +</template> diff --git a/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue b/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue new file mode 100644 index 00000000000..027848db6e9 --- /dev/null +++ b/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue @@ -0,0 +1,183 @@ +<script> +import { + GlAlert, + GlButton, + GlSprintf, + GlLink, + GlIcon, + GlFormGroup, + GlFormInputGroup, + GlToggle, + GlModal, + GlModalDirective, +} from '@gitlab/ui'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { I18N_PAGERDUTY_SETTINGS_FORM, CONFIGURE_PAGERDUTY_WEBHOOK_DOCS_LINK } from '../constants'; +import { isEqual } from 'lodash'; + +export default { + components: { + GlAlert, + GlButton, + GlSprintf, + GlLink, + GlIcon, + GlFormGroup, + GlFormInputGroup, + GlToggle, + GlModal, + ClipboardButton, + }, + directives: { + 'gl-modal': GlModalDirective, + }, + inject: ['service', 'pagerDutySettings'], + data() { + return { + active: this.pagerDutySettings.active, + webhookUrl: this.pagerDutySettings.webhookUrl, + loading: false, + resettingWebhook: false, + webhookUpdateFailed: false, + showAlert: false, + }; + }, + i18n: I18N_PAGERDUTY_SETTINGS_FORM, + CONFIGURE_PAGERDUTY_WEBHOOK_DOCS_LINK, + computed: { + formData() { + return { + pagerduty_active: this.active, + }; + }, + isFormUpdated() { + return isEqual(this.pagerDutySettings, { + active: this.active, + webhookUrl: this.webhookUrl, + }); + }, + isSaveDisabled() { + return this.isFormUpdated || this.loading || this.resettingWebhook; + }, + webhookUpdateAlertMsg() { + return this.webhookUpdateFailed + ? this.$options.i18n.webhookUrl.updateErrMsg + : this.$options.i18n.webhookUrl.updateSuccessMsg; + }, + webhookUpdateAlertVariant() { + return this.webhookUpdateFailed ? 'danger' : 'success'; + }, + }, + methods: { + updatePagerDutyIntegrationSettings() { + this.loading = true; + + this.service.updateSettings(this.formData).catch(() => { + this.loading = false; + }); + }, + resetWebhookUrl() { + this.resettingWebhook = true; + + this.service + .resetWebhookUrl() + .then(({ data: { pagerduty_webhook_url: url } }) => { + this.webhookUrl = url; + this.showAlert = true; + this.webhookUpdateFailed = false; + }) + .catch(() => { + this.showAlert = true; + this.webhookUpdateFailed = true; + }) + .finally(() => { + this.resettingWebhook = false; + }); + }, + }, +}; +</script> + +<template> + <div> + <gl-alert + v-if="showAlert" + class="gl-mb-3" + :variant="webhookUpdateAlertVariant" + @dismiss="showAlert = false" + > + {{ webhookUpdateAlertMsg }} + </gl-alert> + + <p>{{ $options.i18n.introText }}</p> + <form ref="settingsForm" @submit.prevent="updatePagerDutyIntegrationSettings"> + <gl-form-group class="col-8 col-md-9 gl-p-0"> + <gl-toggle + id="active" + v-model="active" + :is-loading="loading" + :label="$options.i18n.activeToggle.label" + /> + </gl-form-group> + + <gl-form-group + class="col-8 col-md-9 gl-p-0" + :label="$options.i18n.webhookUrl.label" + label-for="url" + label-class="label-bold" + > + <gl-form-input-group id="url" data-testid="webhook-url" readonly :value="webhookUrl"> + <template #append> + <clipboard-button + :text="webhookUrl" + :title="$options.i18n.webhookUrl.copyToClipboard" + /> + </template> + </gl-form-input-group> + + <div class="gl-text-gray-400 gl-pt-2"> + <gl-sprintf :message="$options.i18n.webhookUrl.helpText"> + <template #docsLink> + <gl-link + :href="$options.CONFIGURE_PAGERDUTY_WEBHOOK_DOCS_LINK" + target="_blank" + class="gl-display-inline-flex" + > + <span>{{ $options.i18n.webhookUrl.helpDocsLink }}</span> + <gl-icon name="external-link" /> + </gl-link> + </template> + </gl-sprintf> + </div> + <gl-button + v-gl-modal.resetWebhookModal + class="gl-mt-3" + :disabled="loading" + :loading="resettingWebhook" + data-testid="webhook-reset-btn" + > + {{ $options.i18n.webhookUrl.resetWebhookUrl }} + </gl-button> + <gl-modal + modal-id="resetWebhookModal" + :title="$options.i18n.webhookUrl.resetWebhookUrl" + :ok-title="$options.i18n.webhookUrl.resetWebhookUrl" + ok-variant="danger" + @ok="resetWebhookUrl" + > + {{ $options.i18n.webhookUrl.restKeyInfo }} + </gl-modal> + </gl-form-group> + + <gl-button + ref="submitBtn" + :disabled="isSaveDisabled" + variant="success" + type="submit" + class="js-no-auto-disable" + > + {{ $options.i18n.saveBtnLabel }} + </gl-button> + </form> + </div> +</template> diff --git a/app/assets/javascripts/incidents_settings/constants.js b/app/assets/javascripts/incidents_settings/constants.js new file mode 100644 index 00000000000..b443c237f0f --- /dev/null +++ b/app/assets/javascripts/incidents_settings/constants.js @@ -0,0 +1,83 @@ +import { __, s__ } from '~/locale'; + +/* Integration tabs constants */ +export const INTEGRATION_TABS_CONFIG = [ + { + title: s__('IncidentSettings|Alert integration'), + component: 'AlertsSettingsForm', + active: true, + }, + { + title: s__('IncidentSettings|PagerDuty integration'), + component: 'PagerDutySettingsForm', + active: true, + featureFlag: 'pagerdutyWebhook', + }, + { + title: s__('IncidentSettings|Grafana integration'), + component: '', + active: false, + }, +]; + +export const I18N_INTEGRATION_TABS = { + headerText: s__('IncidentSettings|Incidents'), + expandBtnLabel: __('Expand'), + subHeaderText: s__( + 'IncidentSettings|Set up integrations with external tools to help better manage incidents.', + ), +}; + +/* Alerts integration settings constants */ + +export const I18N_ALERT_SETTINGS_FORM = { + saveBtnLabel: __('Save changes'), + introText: __('Action to take when receiving an alert. %{docsLink}'), + introLinkText: __('More information.'), + createIssue: { + label: __('Create an issue. Issues are created for each alert triggered.'), + }, + issueTemplate: { + label: __('Issue template (optional)'), + }, + sendEmail: { + label: __('Send a separate email notification to Developers.'), + }, +}; + +export const NO_ISSUE_TEMPLATE_SELECTED = { key: '', name: __('No template selected') }; +export const TAKING_INCIDENT_ACTION_DOCS_LINK = + '/help/user/project/integrations/prometheus#taking-action-on-incidents-ultimate'; +export const ISSUE_TEMPLATES_DOCS_LINK = + '/help/user/project/description_templates#creating-issue-templates'; + +/* PagerDuty integration settings constants */ + +export const I18N_PAGERDUTY_SETTINGS_FORM = { + introText: s__( + 'PagerDutySettings|Setting up a webhook with PagerDuty will automatically create a GitLab issue for each PagerDuty incident.', + ), + activeToggle: { + label: s__('PagerDutySettings|Active'), + }, + webhookUrl: { + label: s__('PagerDutySettings|Webhook URL'), + helpText: s__( + 'PagerDutySettings|Create a GitLab issue for each PagerDuty incident by %{docsLink}', + ), + helpDocsLink: s__('PagerDutySettings|configuring a webhook in PagerDuty'), + resetWebhookUrl: s__('PagerDutySettings|Reset webhook URL'), + copyToClipboard: __('Copy'), + updateErrMsg: s__('PagerDutySettings|Failed to update Webhook URL'), + updateSuccessMsg: s__('PagerDutySettings|Webhook URL update was successful'), + restKeyInfo: s__( + "PagerDutySettings|Resetting the webhook URL for this project will require updating this integration's settings in PagerDuty.", + ), + }, + saveBtnLabel: __('Save changes'), +}; + +export const CONFIGURE_PAGERDUTY_WEBHOOK_DOCS_LINK = 'https://support.pagerduty.com/docs/webhooks'; + +/* common constants */ +export const ERROR_MSG = __('There was an error saving your changes.'); diff --git a/app/assets/javascripts/incidents_settings/incidents_settings_service.js b/app/assets/javascripts/incidents_settings/incidents_settings_service.js new file mode 100644 index 00000000000..bd4f5bb8820 --- /dev/null +++ b/app/assets/javascripts/incidents_settings/incidents_settings_service.js @@ -0,0 +1,32 @@ +import axios from '~/lib/utils/axios_utils'; +import { refreshCurrentPage } from '~/lib/utils/url_utility'; +import createFlash from '~/flash'; +import { ERROR_MSG } from './constants'; + +export default class IncidentsSettingsService { + constructor(settingsEndpoint, webhookUpdateEndpoint) { + this.settingsEndpoint = settingsEndpoint; + this.webhookUpdateEndpoint = webhookUpdateEndpoint; + } + + updateSettings(data) { + return axios + .patch(this.settingsEndpoint, { + project: { + incident_management_setting_attributes: data, + }, + }) + .then(() => { + refreshCurrentPage(); + }) + .catch(({ response }) => { + const message = response?.data?.message || ''; + + createFlash(`${ERROR_MSG} ${message}`, 'alert'); + }); + } + + resetWebhookUrl() { + return axios.post(this.webhookUpdateEndpoint); + } +} diff --git a/app/assets/javascripts/incidents_settings/index.js b/app/assets/javascripts/incidents_settings/index.js new file mode 100644 index 00000000000..80e7d07feca --- /dev/null +++ b/app/assets/javascripts/incidents_settings/index.js @@ -0,0 +1,46 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import SettingsTabs from './components/incidents_settings_tabs.vue'; +import IncidentsSettingsService from './incidents_settings_service'; + +export default () => { + const el = document.querySelector('.js-incidents-settings'); + + if (!el) { + return null; + } + + const { + dataset: { + operationsSettingsEndpoint, + templates, + createIssue, + issueTemplateKey, + sendEmail, + pagerdutyActive, + pagerdutyWebhookUrl, + pagerdutyResetKeyPath, + }, + } = el; + + const service = new IncidentsSettingsService(operationsSettingsEndpoint, pagerdutyResetKeyPath); + return new Vue({ + el, + provide: { + service, + alertSettings: { + templates: JSON.parse(templates), + createIssue: parseBoolean(createIssue), + issueTemplateKey, + sendEmail: parseBoolean(sendEmail), + }, + pagerDutySettings: { + active: parseBoolean(pagerdutyActive), + webhookUrl: pagerdutyWebhookUrl, + }, + }, + render(createElement) { + return createElement(SettingsTabs); + }, + }); +}; diff --git a/app/assets/javascripts/integrations/edit/components/active_toggle.vue b/app/assets/javascripts/integrations/edit/components/active_toggle.vue index dc89e139320..a3087c8958e 100644 --- a/app/assets/javascripts/integrations/edit/components/active_toggle.vue +++ b/app/assets/javascripts/integrations/edit/components/active_toggle.vue @@ -1,4 +1,5 @@ <script> +import { mapGetters } from 'vuex'; import eventHub from '../event_hub'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { GlFormGroup, GlToggle } from '@gitlab/ui'; @@ -21,6 +22,9 @@ export default { activated: this.initialActivated, }; }, + computed: { + ...mapGetters(['isInheriting']), + }, mounted() { // Initialize view this.$nextTick(() => { @@ -42,6 +46,7 @@ export default { v-model="activated" name="service[active]" class="gl-display-block gl-line-height-0" + :disabled="isInheriting" @change="onToggle" /> </gl-form-group> @@ -50,7 +55,12 @@ export default { <div class="form-group row" role="group"> <label for="service[active]" class="col-form-label col-sm-2">{{ __('Active') }}</label> <div class="col-sm-10 pt-1"> - <gl-toggle v-model="activated" name="service[active]" @change="onToggle" /> + <gl-toggle + v-model="activated" + name="service[active]" + :disabled="isInheriting" + @change="onToggle" + /> </div> </div> </div> diff --git a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue index 29318d6aaa8..6053d11e6da 100644 --- a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue +++ b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue @@ -1,4 +1,5 @@ <script> +import { mapGetters } from 'vuex'; import eventHub from '../event_hub'; import { capitalize, lowerCase, isEmpty } from 'lodash'; import { __, sprintf } from '~/locale'; @@ -59,6 +60,7 @@ export default { }; }, computed: { + ...mapGetters(['isInheriting']), isCheckbox() { return this.type === 'checkbox'; }, @@ -106,10 +108,12 @@ export default { return { id: this.fieldId, name: this.fieldName, + state: this.valid, + readonly: this.isInheriting, }; }, valid() { - return !this.required || !isEmpty(this.model) || !this.validated; + return !this.required || !isEmpty(this.model) || this.isNonEmptyPassword || !this.validated; }, }, created() { @@ -135,15 +139,21 @@ export default { :label-for="fieldId" :invalid-feedback="__('This field is required.')" :state="valid" - :description="help" > + <template #description> + <span v-html="help"></span> + </template> + <template v-if="isCheckbox"> - <input :name="fieldName" type="hidden" value="false" /> - <gl-form-checkbox v-model="model" v-bind="sharedProps"> + <input :name="fieldName" type="hidden" :value="model || false" /> + <gl-form-checkbox :id="fieldId" v-model="model" :disabled="isInheriting"> {{ humanizedTitle }} </gl-form-checkbox> </template> - <gl-form-select v-else-if="isSelect" v-model="model" v-bind="sharedProps" :options="options" /> + <template v-else-if="isSelect"> + <input type="hidden" :name="fieldName" :value="model" /> + <gl-form-select :id="fieldId" v-model="model" :options="options" :disabled="isInheriting" /> + </template> <gl-form-textarea v-else-if="isTextarea" v-model="model" @@ -159,6 +169,7 @@ export default { autocomplete="new-password" :placeholder="placeholder" :required="passwordRequired" + :data-qa-selector="`${fieldId}_field`" /> <gl-form-input v-else @@ -167,6 +178,7 @@ export default { :type="type" :placeholder="placeholder" :required="required" + :data-qa-selector="`${fieldId}_field`" /> </gl-form-group> </template> diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue index ef7a4d44b20..5088664c3bd 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue @@ -1,58 +1,74 @@ <script> +import { mapState, mapActions, mapGetters } from 'vuex'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; + +import OverrideDropdown from './override_dropdown.vue'; import ActiveToggle from './active_toggle.vue'; import JiraTriggerFields from './jira_trigger_fields.vue'; +import JiraIssuesFields from './jira_issues_fields.vue'; import TriggerFields from './trigger_fields.vue'; import DynamicField from './dynamic_field.vue'; export default { name: 'IntegrationForm', components: { + OverrideDropdown, ActiveToggle, JiraTriggerFields, + JiraIssuesFields, TriggerFields, DynamicField, }, - props: { - activeToggleProps: { - type: Object, - required: true, - }, - showActive: { - type: Boolean, - required: true, - }, - triggerFieldsProps: { - type: Object, - required: true, - }, - triggerEvents: { - type: Array, - required: false, - default: () => [], - }, - fields: { - type: Array, - required: false, - default: () => [], - }, - type: { - type: String, - required: true, - }, - }, + mixins: [glFeatureFlagsMixin()], computed: { + ...mapGetters(['currentKey', 'propsSource']), + ...mapState(['adminState', 'override']), isJira() { - return this.type === 'jira'; + return this.propsSource.type === 'jira'; }, + showJiraIssuesFields() { + return this.isJira && this.glFeatures.jiraIssuesIntegration; + }, + }, + methods: { + ...mapActions(['setOverride']), }, }; </script> <template> <div> - <active-toggle v-if="showActive" v-bind="activeToggleProps" /> - <jira-trigger-fields v-if="isJira" v-bind="triggerFieldsProps" /> - <trigger-fields v-else-if="triggerEvents.length" :events="triggerEvents" :type="type" /> - <dynamic-field v-for="field in fields" :key="field.name" v-bind="field" /> + <override-dropdown + v-if="adminState !== null" + :inherit-from-id="adminState.id" + :override="override" + @change="setOverride" + /> + <active-toggle + v-if="propsSource.showActive" + :key="`${currentKey}-active-toggle`" + v-bind="propsSource.activeToggleProps" + /> + <jira-trigger-fields + v-if="isJira" + :key="`${currentKey}-jira-trigger-fields`" + v-bind="propsSource.triggerFieldsProps" + /> + <trigger-fields + v-else-if="propsSource.triggerEvents.length" + :key="`${currentKey}-trigger-fields`" + :events="propsSource.triggerEvents" + :type="propsSource.type" + /> + <dynamic-field + v-for="field in propsSource.fields" + :key="`${currentKey}-${field.name}`" + v-bind="field" + /> + <jira-issues-fields + v-if="showJiraIssuesFields" + :key="`${currentKey}-jira-issues-fields`" + v-bind="propsSource.jiraIssuesProps" + /> </div> </template> diff --git a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue new file mode 100644 index 00000000000..5444cd5a712 --- /dev/null +++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue @@ -0,0 +1,151 @@ +<script> +import eventHub from '../event_hub'; +import { + GlFormGroup, + GlFormCheckbox, + GlFormInput, + GlSprintf, + GlLink, + GlButton, + GlCard, +} from '@gitlab/ui'; + +export default { + name: 'JiraIssuesFields', + components: { + GlFormGroup, + GlFormCheckbox, + GlFormInput, + GlSprintf, + GlLink, + GlButton, + GlCard, + }, + props: { + showJiraIssuesIntegration: { + type: Boolean, + required: false, + default: false, + }, + initialEnableJiraIssues: { + type: Boolean, + required: false, + default: null, + }, + initialProjectKey: { + type: String, + required: false, + default: null, + }, + upgradePlanPath: { + type: String, + required: false, + default: null, + }, + editProjectPath: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + enableJiraIssues: this.initialEnableJiraIssues, + projectKey: this.initialProjectKey, + validated: false, + }; + }, + computed: { + validProjectKey() { + return !this.enableJiraIssues || Boolean(this.projectKey) || !this.validated; + }, + }, + created() { + eventHub.$on('validateForm', this.validateForm); + }, + beforeDestroy() { + eventHub.$off('validateForm', this.validateForm); + }, + methods: { + validateForm() { + this.validated = true; + }, + }, +}; +</script> + +<template> + <div> + <gl-form-group + :label="s__('JiraService|View Jira issues in GitLab')" + label-for="jira-issue-settings" + > + <div id="jira-issue-settings"> + <p> + {{ + s__( + 'JiraService|Work on Jira issues without leaving GitLab. Adds a Jira menu to access your list of Jira issues and view any issue as read-only.', + ) + }} + </p> + <template v-if="showJiraIssuesIntegration"> + <input name="service[issues_enabled]" type="hidden" :value="enableJiraIssues || false" /> + <gl-form-checkbox v-model="enableJiraIssues"> + {{ s__('JiraService|Enable Jira issues') }} + <template #help> + {{ + s__( + 'JiraService|Warning: All GitLab users that have access to this GitLab project will be able to view all issues from the Jira project specified below.', + ) + }} + </template> + </gl-form-checkbox> + </template> + <gl-card v-else class="gl-mt-7"> + <strong>{{ __('This is a Premium feature') }}</strong> + <p>{{ __('Upgrade your plan to enable this feature of the Jira Integration.') }}</p> + <gl-button + v-if="upgradePlanPath" + category="primary" + variant="info" + :href="upgradePlanPath" + target="_blank" + > + {{ __('Upgrade your plan') }} + </gl-button> + </gl-card> + </div> + </gl-form-group> + <template v-if="showJiraIssuesIntegration"> + <gl-form-group + :label="s__('JiraService|Jira project key')" + label-for="service_project_key" + :invalid-feedback="__('This field is required.')" + :state="validProjectKey" + > + <gl-form-input + id="service_project_key" + v-model="projectKey" + name="service[project_key]" + :placeholder="s__('JiraService|e.g. AB')" + :required="enableJiraIssues" + :state="validProjectKey" + :disabled="!enableJiraIssues" + /> + </gl-form-group> + <p> + <gl-sprintf + :message=" + s__( + 'JiraService|Displaying Jira issues while leaving the GitLab issue functionality enabled might be confusing. Consider %{linkStart}disabling GitLab issues%{linkEnd} if they won’t otherwise be used.', + ) + " + > + <template #link="{ content }"> + <gl-link :href="editProjectPath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + </template> + </div> +</template> diff --git a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue index 64e5789764f..1d3354c6651 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue @@ -1,5 +1,6 @@ <script> import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { mapGetters } from 'vuex'; import { s__ } from '~/locale'; import { GlFormGroup, GlFormCheckbox, GlFormRadio } from '@gitlab/ui'; @@ -55,6 +56,7 @@ export default { }; }, computed: { + ...mapGetters(['isInheriting']), showEnableComments() { return this.triggerCommit || this.triggerMergeRequest; }, @@ -73,13 +75,17 @@ export default { ) " > - <input name="service[commit_events]" type="hidden" value="false" /> - <gl-form-checkbox v-model="triggerCommit" name="service[commit_events]"> + <input name="service[commit_events]" type="hidden" :value="triggerCommit || false" /> + <gl-form-checkbox v-model="triggerCommit" :disabled="isInheriting"> {{ __('Commit') }} </gl-form-checkbox> - <input name="service[merge_requests_events]" type="hidden" value="false" /> - <gl-form-checkbox v-model="triggerMergeRequest" name="service[merge_requests_events]"> + <input + name="service[merge_requests_events]" + type="hidden" + :value="triggerMergeRequest || false" + /> + <gl-form-checkbox v-model="triggerMergeRequest" :disabled="isInheriting"> {{ __('Merge request') }} </gl-form-checkbox> </gl-form-group> @@ -89,8 +95,12 @@ export default { :label="s__('Integrations|Comment settings:')" data-testid="comment-settings" > - <input name="service[comment_on_event_enabled]" type="hidden" value="false" /> - <gl-form-checkbox v-model="enableComments" name="service[comment_on_event_enabled]"> + <input + name="service[comment_on_event_enabled]" + type="hidden" + :value="enableComments || false" + /> + <gl-form-checkbox v-model="enableComments" :disabled="isInheriting"> {{ s__('Integrations|Enable comments') }} </gl-form-checkbox> </gl-form-group> @@ -100,12 +110,18 @@ export default { :label="s__('Integrations|Comment detail:')" data-testid="comment-detail" > + <input + v-if="isInheriting" + name="service[comment_detail]" + type="hidden" + :value="commentDetail" + /> <gl-form-radio v-for="commentDetailOption in commentDetailOptions" :key="commentDetailOption.value" v-model="commentDetail" :value="commentDetailOption.value" - name="service[comment_detail]" + :disabled="isInheriting" > {{ commentDetailOption.label }} <template #help> @@ -126,13 +142,17 @@ export default { }} </label> - <input name="service[commit_events]" type="hidden" value="false" /> - <gl-form-checkbox v-model="triggerCommit" name="service[commit_events]"> + <input name="service[commit_events]" type="hidden" :value="triggerCommit || false" /> + <gl-form-checkbox v-model="triggerCommit" :disabled="isInheriting"> {{ __('Commit') }} </gl-form-checkbox> - <input name="service[merge_requests_events]" type="hidden" value="false" /> - <gl-form-checkbox v-model="triggerMergeRequest" name="service[merge_requests_events]"> + <input + name="service[merge_requests_events]" + type="hidden" + :value="triggerMergeRequest || false" + /> + <gl-form-checkbox v-model="triggerMergeRequest" :disabled="isInheriting"> {{ __('Merge request') }} </gl-form-checkbox> @@ -144,8 +164,12 @@ export default { <label> {{ s__('Integrations|Comment settings:') }} </label> - <input name="service[comment_on_event_enabled]" type="hidden" value="false" /> - <gl-form-checkbox v-model="enableComments" name="service[comment_on_event_enabled]"> + <input + name="service[comment_on_event_enabled]" + type="hidden" + :value="enableComments || false" + /> + <gl-form-checkbox v-model="enableComments" :disabled="isInheriting"> {{ s__('Integrations|Enable comments') }} </gl-form-checkbox> @@ -153,12 +177,18 @@ export default { <label> {{ s__('Integrations|Comment detail:') }} </label> + <input + v-if="isInheriting" + name="service[comment_detail]" + type="hidden" + :value="commentDetail" + /> <gl-form-radio v-for="commentDetailOption in commentDetailOptions" :key="commentDetailOption.value" v-model="commentDetail" :value="commentDetailOption.value" - name="service[comment_detail]" + :disabled="isInheriting" > {{ commentDetailOption.label }} <template #help> diff --git a/app/assets/javascripts/integrations/edit/components/override_dropdown.vue b/app/assets/javascripts/integrations/edit/components/override_dropdown.vue new file mode 100644 index 00000000000..0ae2f267434 --- /dev/null +++ b/app/assets/javascripts/integrations/edit/components/override_dropdown.vue @@ -0,0 +1,63 @@ +<script> +import { s__ } from '~/locale'; +import { GlNewDropdown, GlNewDropdownItem } from '@gitlab/ui'; + +const dropdownOptions = [ + { + value: false, + text: s__('Integrations|Use instance level settings'), + }, + { + value: true, + text: s__('Integrations|Use custom settings'), + }, +]; + +export default { + dropdownOptions, + name: 'OverrideDropdown', + components: { + GlNewDropdown, + GlNewDropdownItem, + }, + props: { + inheritFromId: { + type: Number, + required: true, + }, + override: { + type: Boolean, + required: true, + }, + }, + data() { + return { + selected: dropdownOptions.find(x => x.value === this.override), + }; + }, + methods: { + onClick(option) { + this.selected = option; + this.$emit('change', option.value); + }, + }, +}; +</script> + +<template> + <div + class="gl-display-flex gl-justify-content-space-between gl-align-items-baseline gl-py-4 gl-mt-5 gl-mb-6 gl-border-t-1 gl-border-t-solid gl-border-b-1 gl-border-b-solid gl-border-gray-100" + > + <span>{{ s__('Integrations|This integration has multiple settings available.') }}</span> + <input name="service[inherit_from_id]" :value="override ? '' : inheritFromId" type="hidden" /> + <gl-new-dropdown :text="selected.text"> + <gl-new-dropdown-item + v-for="option in $options.dropdownOptions" + :key="option.value" + @click="onClick(option)" + > + {{ option.text }} + </gl-new-dropdown-item> + </gl-new-dropdown> + </div> +</template> diff --git a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue index 531490ae40c..bb1e0d9d360 100644 --- a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue @@ -1,4 +1,5 @@ <script> +import { mapGetters } from 'vuex'; import { startCase } from 'lodash'; import { __ } from '~/locale'; import { GlFormGroup, GlFormCheckbox, GlFormInput } from '@gitlab/ui'; @@ -32,6 +33,7 @@ export default { }, }, computed: { + ...mapGetters(['isInheriting']), placeholder() { return placeholderForType[this.type]; }, @@ -57,8 +59,8 @@ export default { > <div id="trigger-fields" class="gl-pt-3"> <gl-form-group v-for="event in events" :key="event.title" :description="event.description"> - <input :name="checkboxName(event.name)" type="hidden" value="false" /> - <gl-form-checkbox v-model="event.value" :name="checkboxName(event.name)"> + <input :name="checkboxName(event.name)" type="hidden" :value="event.value || false" /> + <gl-form-checkbox v-model="event.value" :disabled="isInheriting"> {{ startCase(event.title) }} </gl-form-checkbox> <gl-form-input @@ -66,6 +68,7 @@ export default { v-model="event.field.value" :name="fieldName(event.field.name)" :placeholder="placeholder" + :readonly="isInheriting" /> </gl-form-group> </div> diff --git a/app/assets/javascripts/integrations/edit/index.js b/app/assets/javascripts/integrations/edit/index.js index 21b5ca17951..ea5463832ce 100644 --- a/app/assets/javascripts/integrations/edit/index.js +++ b/app/assets/javascripts/integrations/edit/index.js @@ -1,49 +1,86 @@ import Vue from 'vue'; +import { createStore } from './store'; import { parseBoolean } from '~/lib/utils/common_utils'; import IntegrationForm from './components/integration_form.vue'; -export default el => { - if (!el) { - return null; - } - - function parseBooleanInData(data) { - const result = {}; - Object.entries(data).forEach(([key, value]) => { - result[key] = parseBoolean(value); - }); - return result; - } +function parseBooleanInData(data) { + const result = {}; + Object.entries(data).forEach(([key, value]) => { + result[key] = parseBoolean(value); + }); + return result; +} - const { type, commentDetail, triggerEvents, fields, ...booleanAttributes } = el.dataset; +function parseDatasetToProps(data) { + const { + id, + type, + commentDetail, + projectKey, + upgradePlanPath, + editProjectPath, + triggerEvents, + fields, + inheritFromId, + ...booleanAttributes + } = data; const { showActive, activated, commitEvents, mergeRequestEvents, enableComments, + showJiraIssuesIntegration, + enableJiraIssues, } = parseBooleanInData(booleanAttributes); + return { + activeToggleProps: { + initialActivated: activated, + }, + showActive, + type, + triggerFieldsProps: { + initialTriggerCommit: commitEvents, + initialTriggerMergeRequest: mergeRequestEvents, + initialEnableComments: enableComments, + initialCommentDetail: commentDetail, + }, + jiraIssuesProps: { + showJiraIssuesIntegration, + initialEnableJiraIssues: enableJiraIssues, + initialProjectKey: projectKey, + upgradePlanPath, + editProjectPath, + }, + triggerEvents: JSON.parse(triggerEvents), + fields: JSON.parse(fields), + inheritFromId: parseInt(inheritFromId, 10), + id: parseInt(id, 10), + }; +} + +export default (el, adminEl) => { + if (!el) { + return null; + } + + const props = parseDatasetToProps(el.dataset); + + const initialState = { + adminState: null, + customState: props, + }; + + if (adminEl) { + initialState.adminState = Object.freeze(parseDatasetToProps(adminEl.dataset)); + } + return new Vue({ el, + store: createStore(initialState), render(createElement) { - return createElement(IntegrationForm, { - props: { - activeToggleProps: { - initialActivated: activated, - }, - showActive, - type, - triggerFieldsProps: { - initialTriggerCommit: commitEvents, - initialTriggerMergeRequest: mergeRequestEvents, - initialEnableComments: enableComments, - initialCommentDetail: commentDetail, - }, - triggerEvents: JSON.parse(triggerEvents), - fields: JSON.parse(fields), - }, - }); + return createElement(IntegrationForm); }, }); }; diff --git a/app/assets/javascripts/integrations/edit/store/actions.js b/app/assets/javascripts/integrations/edit/store/actions.js new file mode 100644 index 00000000000..3decdaab55d --- /dev/null +++ b/app/assets/javascripts/integrations/edit/store/actions.js @@ -0,0 +1,4 @@ +import * as types from './mutation_types'; + +// eslint-disable-next-line import/prefer-default-export +export const setOverride = ({ commit }, override) => commit(types.SET_OVERRIDE, override); diff --git a/app/assets/javascripts/integrations/edit/store/getters.js b/app/assets/javascripts/integrations/edit/store/getters.js new file mode 100644 index 00000000000..b68bd668980 --- /dev/null +++ b/app/assets/javascripts/integrations/edit/store/getters.js @@ -0,0 +1,6 @@ +export const isInheriting = state => (state.adminState === null ? false : !state.override); + +export const propsSource = (state, getters) => + getters.isInheriting ? state.adminState : state.customState; + +export const currentKey = (state, getters) => (getters.isInheriting ? 'admin' : 'custom'); diff --git a/app/assets/javascripts/integrations/edit/store/index.js b/app/assets/javascripts/integrations/edit/store/index.js new file mode 100644 index 00000000000..eea5e48780d --- /dev/null +++ b/app/assets/javascripts/integrations/edit/store/index.js @@ -0,0 +1,17 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; +import createState from './state'; + +Vue.use(Vuex); + +// eslint-disable-next-line import/prefer-default-export +export const createStore = (initialState = {}) => + new Vuex.Store({ + actions, + getters, + mutations, + state: createState(initialState), + }); diff --git a/app/assets/javascripts/integrations/edit/store/mutation_types.js b/app/assets/javascripts/integrations/edit/store/mutation_types.js new file mode 100644 index 00000000000..274afe3fb49 --- /dev/null +++ b/app/assets/javascripts/integrations/edit/store/mutation_types.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export +export const SET_OVERRIDE = 'SET_OVERRIDE'; diff --git a/app/assets/javascripts/integrations/edit/store/mutations.js b/app/assets/javascripts/integrations/edit/store/mutations.js new file mode 100644 index 00000000000..8757d415197 --- /dev/null +++ b/app/assets/javascripts/integrations/edit/store/mutations.js @@ -0,0 +1,7 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_OVERRIDE](state, override) { + state.override = override; + }, +}; diff --git a/app/assets/javascripts/integrations/edit/store/state.js b/app/assets/javascripts/integrations/edit/store/state.js new file mode 100644 index 00000000000..95c1a2be500 --- /dev/null +++ b/app/assets/javascripts/integrations/edit/store/state.js @@ -0,0 +1,9 @@ +export default ({ adminState = null, customState = {} } = {}) => { + const override = adminState !== null ? adminState.id !== customState.inheritFromId : false; + + return { + override, + adminState, + customState, + }; +}; diff --git a/app/assets/javascripts/integrations/integration_settings_form.js b/app/assets/javascripts/integrations/integration_settings_form.js index 8844cbebe85..837409a91ca 100644 --- a/app/assets/javascripts/integrations/integration_settings_form.js +++ b/app/assets/javascripts/integrations/integration_settings_form.js @@ -22,7 +22,10 @@ export default class IntegrationSettingsForm { init() { // Init Vue component - initForm(document.querySelector('.js-vue-integration-settings')); + initForm( + document.querySelector('.js-vue-integration-settings'), + document.querySelector('.js-vue-admin-integration-settings'), + ); eventHub.$on('toggle', active => { this.formActive = active; this.handleServiceToggle(); diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js index 01ea3eee16e..d968e9e5235 100644 --- a/app/assets/javascripts/issuable_bulk_update_actions.js +++ b/app/assets/javascripts/issuable_bulk_update_actions.js @@ -1,7 +1,5 @@ -/* eslint-disable consistent-return, func-names, array-callback-return */ - import $ from 'jquery'; -import { intersection } from 'lodash'; +import { difference, intersection, union } from 'lodash'; import axios from './lib/utils/axios_utils'; import Flash from './flash'; import { __ } from './locale'; @@ -36,43 +34,6 @@ export default { return new Flash(__('Issue update failed')); }, - getSelectedIssues() { - return this.issues.has('.selected-issuable:checked'); - }, - - getLabelsFromSelection() { - const labels = []; - this.getSelectedIssues().map(function() { - const labelsData = $(this).data('labels'); - if (labelsData) { - return labelsData.map(labelId => { - if (labels.indexOf(labelId) === -1) { - return labels.push(labelId); - } - }); - } - }); - return labels; - }, - - /** - * Will return only labels that were marked previously and the user has unmarked - * @return {Array} Label IDs - */ - - getUnmarkedIndeterminedLabels() { - const result = []; - const labelsToKeep = this.$labelDropdown.data('indeterminate'); - - this.getLabelsFromSelection().forEach(id => { - if (labelsToKeep.indexOf(id) === -1) { - result.push(id); - } - }); - - return result; - }, - /** * Simple form serialization, it will return just what we need * Returns key/value pairs from form data @@ -86,40 +47,44 @@ export default { milestone_id: this.form.find('input[name="update[milestone_id]"]').val(), issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(), subscription_event: this.form.find('input[name="update[subscription_event]"]').val(), + health_status: this.form.find('input[name="update[health_status]"]').val(), + epic_id: this.form.find('input[name="update[epic_id]"]').val(), add_label_ids: [], remove_label_ids: [], }, }; if (this.willUpdateLabels) { - formData.update.add_label_ids = this.$labelDropdown.data('marked'); - formData.update.remove_label_ids = this.$labelDropdown.data('unmarked'); + formData.update.add_label_ids = this.$labelDropdown.data('user-checked'); + formData.update.remove_label_ids = this.$labelDropdown.data('user-unchecked'); } return formData; }, setOriginalDropdownData() { const $labelSelect = $('.bulk-update .js-label-select'); - const dirtyLabelIds = $labelSelect.data('marked') || []; - const chosenLabelIds = [...this.getOriginalMarkedIds(), ...dirtyLabelIds]; - - $labelSelect.data('common', this.getOriginalCommonIds()); - $labelSelect.data('marked', chosenLabelIds); - $labelSelect.data('indeterminate', this.getOriginalIndeterminateIds()); + const userCheckedIds = $labelSelect.data('user-checked') || []; + const userUncheckedIds = $labelSelect.data('user-unchecked') || []; + + // Common labels plus user checked labels minus user unchecked labels + const checkedIdsToShow = difference( + union(this.getOriginalCommonIds(), userCheckedIds), + userUncheckedIds, + ); + + // Indeterminate labels minus user checked labels minus user unchecked labels + const indeterminateIdsToShow = difference( + this.getOriginalIndeterminateIds(), + userCheckedIds, + userUncheckedIds, + ); + + $labelSelect.data('marked', checkedIdsToShow); + $labelSelect.data('indeterminate', indeterminateIdsToShow); }, // From issuable's initial bulk selection getOriginalCommonIds() { const labelIds = []; - - this.getElement('.selected-issuable:checked').each((i, el) => { - labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels')); - }); - return intersection.apply(this, labelIds); - }, - - // From issuable's initial bulk selection - getOriginalMarkedIds() { - const labelIds = []; this.getElement('.selected-issuable:checked').each((i, el) => { labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels')); }); diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar.js index 50562688c53..85c2a370ff3 100644 --- a/app/assets/javascripts/issuable_bulk_update_sidebar.js +++ b/app/assets/javascripts/issuable_bulk_update_sidebar.js @@ -63,6 +63,22 @@ export default class IssuableBulkUpdateSidebar { new MilestoneSelect(); issueStatusSelect(); subscriptionSelect(); + + if (IS_EE) { + import('ee/vue_shared/components/sidebar/health_status_select/health_status_bundle') + .then(({ default: HealthStatusSelect }) => { + HealthStatusSelect(); + }) + .catch(() => {}); + } + + if (IS_EE) { + import('ee/vue_shared/components/sidebar/epics_select/epics_select_bundle') + .then(({ default: EpicSelect }) => { + EpicSelect(); + }) + .catch(() => {}); + } } setupBulkUpdateActions() { diff --git a/app/assets/javascripts/issuable_suggestions/components/app.vue b/app/assets/javascripts/issuable_suggestions/components/app.vue index 67d10b797fb..810ca7ac1bd 100644 --- a/app/assets/javascripts/issuable_suggestions/components/app.vue +++ b/app/assets/javascripts/issuable_suggestions/components/app.vue @@ -84,7 +84,7 @@ export default { v-for="(suggestion, index) in issues" :key="suggestion.id" :class="{ - 'append-bottom-default': index !== issues.length - 1, + 'gl-mb-3': index !== issues.length - 1, }" > <suggestion :suggestion="suggestion" /> diff --git a/app/assets/javascripts/issuable_suggestions/components/item.vue b/app/assets/javascripts/issuable_suggestions/components/item.vue index 51904c64085..dfadb9d2b24 100644 --- a/app/assets/javascripts/issuable_suggestions/components/item.vue +++ b/app/assets/javascripts/issuable_suggestions/components/item.vue @@ -75,7 +75,11 @@ export default { name="eye-slash" class="suggestion-help-hover mr-1 suggestion-confidential" /> - <gl-link :href="suggestion.webUrl" target="_blank" class="suggestion bold str-truncated-100"> + <gl-link + :href="suggestion.webUrl" + target="_blank" + class="suggestion bold str-truncated-100 gl-text-gray-900!" + > {{ suggestion.title }} </gl-link> </div> diff --git a/app/assets/javascripts/issuables_list/components/issuable.vue b/app/assets/javascripts/issuables_list/components/issuable.vue index 947c7518289..b7f4292a126 100644 --- a/app/assets/javascripts/issuables_list/components/issuable.vue +++ b/app/assets/javascripts/issuables_list/components/issuable.vue @@ -3,8 +3,11 @@ * This is tightly coupled to projects/issues/_issue.html.haml, * any changes done to the haml need to be reflected here. */ + +// TODO: need to move this component to graphql - https://gitlab.com/gitlab-org/gitlab/-/issues/221246 import { escape, isNumber } from 'lodash'; -import { GlLink, GlTooltipDirective as GlTooltip, GlSprintf } from '@gitlab/ui'; +import { GlLink, GlTooltipDirective as GlTooltip, GlSprintf, GlLabel, GlIcon } from '@gitlab/ui'; +import jiraLogo from '@gitlab/svgs/dist/illustrations/logos/jira.svg'; import { dateInWords, formatDate, @@ -16,22 +19,26 @@ import { import { sprintf, __ } from '~/locale'; import initUserPopovers from '~/user_popovers'; import { mergeUrlParams } from '~/lib/utils/url_utility'; -import Icon from '~/vue_shared/components/icon.vue'; import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; +import { isScopedLabel } from '~/lib/utils/common_utils'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { i18n: { openedAgo: __('opened %{timeAgoString} by %{user}'), + openedAgoJira: __('opened %{timeAgoString} by %{user} in Jira'), }, components: { - Icon, IssueAssignees, GlLink, + GlLabel, + GlIcon, GlSprintf, }, directives: { GlTooltip, }, + mixins: [glFeatureFlagsMixin()], props: { issuable: { type: Object, @@ -55,14 +62,19 @@ export default { }, }, }, + data() { + return { + jiraLogo, + }; + }, computed: { milestoneLink() { const { title } = this.issuable.milestone; return this.issuableLink({ milestone_title: title }); }, - hasLabels() { - return Boolean(this.issuable.labels && this.issuable.labels.length); + scopedLabelsAvailable() { + return this.glFeatures.scopedLabels; }, hasWeight() { return isNumber(this.issuable.weight); @@ -82,6 +94,12 @@ export default { isClosed() { return this.issuable.state === 'closed'; }, + isJiraIssue() { + return this.issuable.external_tracker === 'jira'; + }, + linkTarget() { + return this.isJiraIssue ? '_blank' : null; + }, issueCreatedToday() { return getDayDifference(new Date(this.issuable.created_at), new Date()) < 1; }, @@ -147,14 +165,14 @@ export default { value: this.issuable.upvotes, title: __('Upvotes'), class: 'js-upvotes', - faicon: 'fa-thumbs-up', + icon: 'thumb-up', }, { key: 'downvotes', value: this.issuable.downvotes, title: __('Downvotes'), class: 'js-downvotes', - faicon: 'fa-thumbs-down', + icon: 'thumb-down', }, ]; }, @@ -165,16 +183,17 @@ export default { initUserPopovers([this.$refs.openedAgoByContainer.$el]); }, methods: { - labelStyle(label) { - return { - backgroundColor: label.color, - color: label.text_color, - }; - }, issuableLink(params) { return mergeUrlParams(params, this.baseUrl); }, + isScoped({ name }) { + return isScopedLabel({ title: name }) && this.scopedLabelsAvailable; + }, labelHref({ name }) { + if (this.isJiraIssue) { + return this.issuableLink({ 'labels[]': name }); + } + return this.issuableLink({ 'label_name[]': name }); }, onSelect(ev) { @@ -214,14 +233,23 @@ export default { <div class="flex-grow-1"> <div class="title"> <span class="issue-title-text"> - <i + <gl-icon v-if="issuable.confidential" v-gl-tooltip - class="fa fa-eye-slash" + name="eye-slash" + class="gl-vertical-align-text-bottom" + :size="16" :title="$options.confidentialTooltipText" :aria-label="$options.confidentialTooltipText" - ></i> - <gl-link :href="issuable.web_url">{{ issuable.title }}</gl-link> + /> + <gl-link :href="issuable.web_url" :target="linkTarget" data-testid="issuable-title"> + {{ issuable.title }} + <gl-icon + v-if="isJiraIssue" + name="external-link" + class="gl-vertical-align-text-bottom" + /> + </gl-link> </span> <span v-if="issuable.has_tasks" class="ml-1 task-status d-none d-sm-inline-block"> {{ issuable.task_status }} @@ -229,11 +257,21 @@ export default { </div> <div class="issuable-info"> - <span class="js-ref-path">{{ referencePath }}</span> + <span class="js-ref-path"> + <span + v-if="isJiraIssue" + class="svg-container jira-logo-container" + data-testid="jira-logo" + v-html="jiraLogo" + ></span> + {{ referencePath }} + </span> <span data-testid="openedByMessage" class="d-none d-sm-inline-block mr-1"> · - <gl-sprintf :message="$options.i18n.openedAgo"> + <gl-sprintf + :message="isJiraIssue ? $options.i18n.openedAgoJira : $options.i18n.openedAgo" + > <template #timeAgoString> <span>{{ issuableCreatedAt }}</span> </template> @@ -242,6 +280,7 @@ export default { ref="openedAgoByContainer" v-bind="popoverDataAttrs" :href="issuableAuthor.web_url" + :target="linkTarget" > {{ issuableAuthor.name }} </gl-link> @@ -271,30 +310,29 @@ export default { {{ dueDateWords }} </span> - <span v-if="hasLabels" class="js-labels"> - <gl-link - v-for="label in issuable.labels" - :key="label.id" - class="label-link mr-1" - :href="labelHref(label)" - > - <span - v-gl-tooltip - class="badge color-label" - :style="labelStyle(label)" - :title="label.description" - >{{ label.name }}</span - > - </gl-link> - </span> + <gl-label + v-for="label in issuable.labels" + :key="label.id" + data-qa-selector="issuable-label" + :target="labelHref(label)" + :background-color="label.color" + :description="label.description" + :color="label.text_color" + :title="label.name" + :scoped="isScoped(label)" + size="sm" + class="mr-1" + >{{ label.name }}</gl-label + > <span v-if="hasWeight" v-gl-tooltip :title="__('Weight')" class="d-none d-sm-inline-block js-weight" + data-testid="weight" > - <icon name="weight" class="align-text-bottom" /> + <gl-icon name="weight" class="align-text-bottom" /> {{ issuable.weight }} </span> </div> @@ -303,7 +341,8 @@ export default { <!-- Issuable meta --> <div class="flex-shrink-0 d-flex flex-column align-items-end justify-content-center"> <div class="controls d-flex"> - <span v-if="isClosed" class="issuable-status">{{ __('CLOSED') }}</span> + <span v-if="isJiraIssue" data-testid="issuable-status">{{ issuable.status }}</span> + <span v-else-if="isClosed" class="issuable-status">{{ __('CLOSED') }}</span> <issue-assignees :assignees="issuable.assignees" @@ -318,23 +357,23 @@ export default { v-if="meta.value" :key="meta.key" v-gl-tooltip - :class="['d-none d-sm-inline-block ml-2', meta.class]" + :class="['d-none d-sm-inline-block ml-2 vertical-align-middle', meta.class]" :title="meta.title" > - <icon v-if="meta.icon" :name="meta.icon" /> - <i v-else :class="['fa', meta.faicon]"></i> + <gl-icon v-if="meta.icon" :name="meta.icon" /> {{ meta.value }} </span> </template> <gl-link + v-if="!isJiraIssue" v-gl-tooltip class="ml-2 js-notes" :href="`${issuable.web_url}#notes`" :title="__('Comments')" :class="{ 'no-comments': hasNoComments }" > - <i class="fa fa-comments"></i> + <gl-icon name="comments" class="gl-vertical-align-text-bottom" /> {{ userNotesCount }} </gl-link> </div> diff --git a/app/assets/javascripts/issuables_list/components/issuable_list_root_app.vue b/app/assets/javascripts/issuables_list/components/issuable_list_root_app.vue index 49a89d15c35..cc90d23eda7 100644 --- a/app/assets/javascripts/issuables_list/components/issuable_list_root_app.vue +++ b/app/assets/javascripts/issuables_list/components/issuable_list_root_app.vue @@ -1,10 +1,13 @@ <script> import { GlAlert, GlLabel } from '@gitlab/ui'; +import { last } from 'lodash'; +import { n__ } from '~/locale'; import getIssuesListDetailsQuery from '../queries/get_issues_list_details.query.graphql'; import { calculateJiraImportLabel, - isFinished, isInProgress, + setFinishedAlertHideMap, + shouldShowFinishedAlert, } from '~/jira_import/utils/jira_import_utils'; export default { @@ -33,8 +36,6 @@ export default { }, data() { return { - isFinishedAlertShowing: true, - isInProgressAlertShowing: true, jiraImport: {}, }; }, @@ -46,36 +47,42 @@ export default { fullPath: this.projectPath, }; }, - update: ({ project }) => ({ - isInProgress: isInProgress(project.jiraImportStatus), - isFinished: isFinished(project.jiraImportStatus), - label: calculateJiraImportLabel( + update: ({ project }) => { + const label = calculateJiraImportLabel( project.jiraImports.nodes, project.issues.nodes.flatMap(({ labels }) => labels.nodes), - ), - }), + ); + return { + importedIssuesCount: last(project.jiraImports.nodes)?.importedIssuesCount, + label, + shouldShowFinishedAlert: shouldShowFinishedAlert(label.title, project.jiraImportStatus), + shouldShowInProgressAlert: isInProgress(project.jiraImportStatus), + }; + }, skip() { return !this.isJiraConfigured || !this.canEdit; }, }, }, computed: { + finishedMessage() { + return n__( + '%d issue successfully imported with the label', + '%d issues successfully imported with the label', + this.jiraImport.importedIssuesCount, + ); + }, labelTarget() { return `${this.issuesPath}?label_name[]=${encodeURIComponent(this.jiraImport.label.title)}`; }, - shouldShowFinishedAlert() { - return this.isFinishedAlertShowing && this.jiraImport.isFinished; - }, - shouldShowInProgressAlert() { - return this.isInProgressAlertShowing && this.jiraImport.isInProgress; - }, }, methods: { hideFinishedAlert() { - this.isFinishedAlertShowing = false; + setFinishedAlertHideMap(this.jiraImport.label.title); + this.jiraImport.shouldShowFinishedAlert = false; }, hideInProgressAlert() { - this.isInProgressAlertShowing = false; + this.jiraImport.shouldShowInProgressAlert = false; }, }, }; @@ -83,11 +90,16 @@ export default { <template> <div class="issuable-list-root"> - <gl-alert v-if="shouldShowInProgressAlert" @dismiss="hideInProgressAlert"> + <gl-alert v-if="jiraImport.shouldShowInProgressAlert" @dismiss="hideInProgressAlert"> {{ __('Import in progress. Refresh page to see newly added issues.') }} </gl-alert> - <gl-alert v-if="shouldShowFinishedAlert" variant="success" @dismiss="hideFinishedAlert"> - {{ __('Issues successfully imported with the label') }} + + <gl-alert + v-if="jiraImport.shouldShowFinishedAlert" + variant="success" + @dismiss="hideFinishedAlert" + > + {{ finishedMessage }} <gl-label :background-color="jiraImport.label.color" scoped diff --git a/app/assets/javascripts/issuables_list/components/issuables_list_app.vue b/app/assets/javascripts/issuables_list/components/issuables_list_app.vue index 1c395fd9795..21aeb2ca143 100644 --- a/app/assets/javascripts/issuables_list/components/issuables_list_app.vue +++ b/app/assets/javascripts/issuables_list/components/issuables_list_app.vue @@ -12,8 +12,10 @@ import { import { __ } from '~/locale'; import initManualOrdering from '~/manual_ordering'; import Issuable from './issuable.vue'; +import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import { sortOrderMap, + availableSortOptionsJira, RELATIVE_POSITION, PAGE_SIZE, PAGE_SIZE_MANUAL, @@ -29,6 +31,7 @@ export default { GlPagination, GlSkeletonLoading, Issuable, + FilteredSearchBar, }, props: { canBulkEdit: { @@ -50,14 +53,25 @@ export default { type: String, required: true, }, + projectPath: { + type: String, + required: false, + default: '', + }, sortKey: { type: String, required: false, default: '', }, + type: { + type: String, + required: false, + default: '', + }, }, data() { return { + availableSortOptionsJira, filters: {}, isBulkEditing: false, issuables: [], @@ -118,6 +132,45 @@ export default { baseUrl() { return window.location.href.replace(/(\?.*)?(#.*)?$/, ''); }, + paginationNext() { + return this.page + 1; + }, + paginationPrev() { + return this.page - 1; + }, + paginationProps() { + const paginationProps = { value: this.page }; + + if (this.totalItems) { + return { + ...paginationProps, + perPage: this.itemsPerPage, + totalItems: this.totalItems, + }; + } + + return { + ...paginationProps, + prevPage: this.paginationPrev, + nextPage: this.paginationNext, + }; + }, + isJira() { + return this.type === 'jira'; + }, + initialFilterValue() { + const value = []; + const { search } = this.getQueryObject(); + + if (search) { + value.push(search); + } + return value; + }, + initialSortBy() { + const { sort } = this.getQueryObject(); + return sort || 'created_desc'; + }, }, watch: { selection() { @@ -222,9 +275,13 @@ export default { const { label_name: labels, milestone_title: milestoneTitle, + 'not[label_name]': excludedLabels, + 'not[milestone_title]': excludedMilestone, ...filters } = this.getQueryObject(); + // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/227880 + if (milestoneTitle) { filters.milestone = milestoneTitle; } @@ -235,58 +292,104 @@ export default { filters.state = 'opened'; } + if (excludedLabels) { + filters['not[labels]'] = excludedLabels; + } + + if (excludedMilestone) { + filters['not[milestone]'] = excludedMilestone; + } + Object.assign(filters, sortOrderMap[this.sortKey]); this.filters = filters; }, + refetchIssuables() { + const ignored = ['utf8']; + const params = omit(this.filters, ignored); + + historyPushState(setUrlParams(params, window.location.href, true, true)); + this.fetchIssuables(); + }, + handleFilter(filters) { + let search = null; + + filters.forEach(filter => { + if (typeof filter === 'string') { + search = filter; + } + }); + + this.filters.search = search; + this.page = 1; + + this.refetchIssuables(); + }, + handleSort(sort) { + this.filters.sort = sort; + this.page = 1; + + this.refetchIssuables(); + }, }, }; </script> <template> - <ul v-if="loading" class="content-list"> - <li v-for="n in $options.LOADING_LIST_ITEMS_LENGTH" :key="n" class="issue gl-px-5! gl-py-5!"> - <gl-skeleton-loading /> - </li> - </ul> - <div v-else-if="issuables.length"> - <div v-if="isBulkEditing" class="issue px-3 py-3 border-bottom border-light"> - <input type="checkbox" :checked="allIssuablesSelected" class="mr-2" @click="onSelectAll" /> - <strong>{{ __('Select all') }}</strong> - </div> - <ul - class="content-list issuable-list issues-list" - :class="{ 'manual-ordering': isManualOrdering }" - > - <issuable - v-for="issuable in issuables" - :key="issuable.id" - class="pr-3" - :class="{ 'user-can-drag': isManualOrdering }" - :issuable="issuable" - :is-bulk-editing="isBulkEditing" - :selected="isSelected(issuable.id)" - :base-url="baseUrl" - @select="onSelectIssuable" - /> + <div> + <filtered-search-bar + v-if="isJira" + :namespace="projectPath" + :search-input-placeholder="__('Search Jira issues')" + :tokens="[]" + :sort-options="availableSortOptionsJira" + :initial-filter-value="initialFilterValue" + :initial-sort-by="initialSortBy" + class="row-content-block" + @onFilter="handleFilter" + @onSort="handleSort" + /> + <ul v-if="loading" class="content-list"> + <li v-for="n in $options.LOADING_LIST_ITEMS_LENGTH" :key="n" class="issue gl-px-5! gl-py-5!"> + <gl-skeleton-loading /> + </li> </ul> - <div class="mt-3"> - <gl-pagination - v-if="totalItems" - :value="page" - :per-page="itemsPerPage" - :total-items="totalItems" - class="justify-content-center" - @input="onPaginate" - /> + <div v-else-if="issuables.length"> + <div v-if="isBulkEditing" class="issue px-3 py-3 border-bottom border-light"> + <input type="checkbox" :checked="allIssuablesSelected" class="mr-2" @click="onSelectAll" /> + <strong>{{ __('Select all') }}</strong> + </div> + <ul + class="content-list issuable-list issues-list" + :class="{ 'manual-ordering': isManualOrdering }" + > + <issuable + v-for="issuable in issuables" + :key="issuable.id" + class="pr-3" + :class="{ 'user-can-drag': isManualOrdering }" + :issuable="issuable" + :is-bulk-editing="isBulkEditing" + :selected="isSelected(issuable.id)" + :base-url="baseUrl" + @select="onSelectIssuable" + /> + </ul> + <div class="mt-3"> + <gl-pagination + v-bind="paginationProps" + class="gl-justify-content-center" + @input="onPaginate" + /> + </div> </div> + <gl-empty-state + v-else + :title="emptyState.title" + :description="emptyState.description" + :svg-path="emptySvgPath" + :primary-button-link="emptyState.primaryLink" + :primary-button-text="emptyState.primaryText" + /> </div> - <gl-empty-state - v-else - :title="emptyState.title" - :description="emptyState.description" - :svg-path="emptySvgPath" - :primary-button-link="emptyState.primaryLink" - :primary-button-text="emptyState.primaryText" - /> </template> diff --git a/app/assets/javascripts/issuables_list/constants.js b/app/assets/javascripts/issuables_list/constants.js index 71b9c52c703..f008ba1bf4a 100644 --- a/app/assets/javascripts/issuables_list/constants.js +++ b/app/assets/javascripts/issuables_list/constants.js @@ -1,3 +1,5 @@ +import { __ } from '~/locale'; + // Maps sort order as it appears in the URL query to API `order_by` and `sort` params. const PRIORITY = 'priority'; const ASC = 'asc'; @@ -31,3 +33,24 @@ export const sortOrderMap = { weight_desc: { order_by: WEIGHT, sort: DESC }, weight: { order_by: WEIGHT, sort: ASC }, }; + +export const availableSortOptionsJira = [ + { + id: 1, + title: __('Created date'), + sortDirection: { + descending: 'created_desc', + ascending: 'created_asc', + }, + }, + { + id: 2, + title: __('Last updated'), + sortDirection: { + descending: 'updated_desc', + ascending: 'updated_asc', + }, + }, +]; + +export const JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY = 'jira-import-success-alert-hide-map'; diff --git a/app/assets/javascripts/issuables_list/index.js b/app/assets/javascripts/issuables_list/index.js index 6bfb885a8af..40252c10d5f 100644 --- a/app/assets/javascripts/issuables_list/index.js +++ b/app/assets/javascripts/issuables_list/index.js @@ -36,7 +36,7 @@ function mountIssuableListRootApp() { } function mountIssuablesListApp() { - if (!gon.features?.vueIssuablesList) { + if (!gon.features?.vueIssuablesList && !gon.features?.jiraIssuesIntegration) { return; } diff --git a/app/assets/javascripts/issuables_list/queries/get_issues_list_details.query.graphql b/app/assets/javascripts/issuables_list/queries/get_issues_list_details.query.graphql index b62b9b2af60..8f9b888d19b 100644 --- a/app/assets/javascripts/issuables_list/queries/get_issues_list_details.query.graphql +++ b/app/assets/javascripts/issuables_list/queries/get_issues_list_details.query.graphql @@ -1,5 +1,3 @@ -#import "~/jira_import/queries/jira_import.fragment.graphql" - query($fullPath: ID!) { project(fullPath: $fullPath) { issues { @@ -15,7 +13,8 @@ query($fullPath: ID!) { jiraImportStatus jiraImports { nodes { - ...JiraImport + importedIssuesCount + jiraProjectKey } } } diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index 252e8e92f5e..a01faeb1c8d 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -11,7 +11,7 @@ import { __ } from './locale'; export default class Issue { constructor() { - if ($('a.btn-close').length) this.initIssueBtnEventListeners(); + if ($('.btn-close, .btn-reopen').length) this.initIssueBtnEventListeners(); if ($('.js-close-blocked-issue-warning').length) this.initIssueWarningBtnEventListener(); @@ -32,8 +32,8 @@ export default class Issue { Issue.initRelatedBranches(); } - this.closeButtons = $('a.btn-close'); - this.reopenButtons = $('a.btn-reopen'); + this.closeButtons = $('.btn-close'); + this.reopenButtons = $('.btn-reopen'); this.initCloseReopenReport(); @@ -103,7 +103,7 @@ export default class Issue { // NOTE: data attribute seems unnecessary but is actually necessary return $('.js-issuable-buttons[data-action="close-reopen"]').on( 'click', - 'a.btn-close, a.btn-reopen, a.btn-close-anyway', + '.btn-close, .btn-reopen, .btn-close-anyway', e => { e.preventDefault(); e.stopImmediatePropagation(); @@ -120,7 +120,7 @@ export default class Issue { } else { this.disableCloseReopenButton($button); - const url = $button.attr('href'); + const url = $button.data('endpoint'); return axios .put(url) diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index 09acfd1cfae..bcf5dc2aaaf 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -420,7 +420,7 @@ export default { <transition name="issuable-header-slide"> <div v-if="shouldShowStickyHeader" - class="issue-sticky-header gl-fixed gl-z-index-2 gl-bg-white gl-border-1 gl-border-b-solid gl-border-b-gray-200 gl-py-3" + class="issue-sticky-header gl-fixed gl-z-index-3 gl-bg-white gl-border-1 gl-border-b-solid gl-border-b-gray-100 gl-py-3" data-testid="issue-sticky-header" > <div diff --git a/app/assets/javascripts/issue_show/components/edit_actions.vue b/app/assets/javascripts/issue_show/components/edit_actions.vue index 588ae655de4..4ee44e50d2f 100644 --- a/app/assets/javascripts/issue_show/components/edit_actions.vue +++ b/app/assets/javascripts/issue_show/components/edit_actions.vue @@ -63,7 +63,7 @@ export default { </script> <template> - <div class="prepend-top-default append-bottom-default clearfix"> + <div class="gl-mt-3 gl-mb-3 clearfix"> <button :class="{ disabled: formState.updateLoading || !isSubmitEnabled }" :disabled="formState.updateLoading || !isSubmitEnabled" @@ -81,7 +81,7 @@ export default { v-if="shouldShowDeleteButton" :class="{ disabled: deleteLoading }" :disabled="deleteLoading" - class="btn btn-danger float-right append-right-default qa-delete-button" + class="btn btn-danger float-right gl-mr-3 qa-delete-button" type="button" @click="deleteIssuable" > diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue index 35165c9b481..0de0060615b 100644 --- a/app/assets/javascripts/issue_show/components/fields/description.vue +++ b/app/assets/javascripts/issue_show/components/fields/description.vue @@ -55,7 +55,7 @@ export default { class="note-textarea js-gfm-input js-autosize markdown-area qa-description-textarea" dir="auto" - data-supports-quick-actions="false" + data-supports-quick-actions="true" :aria-label="__('Description')" :placeholder="__('Write a comment or drag your files here…')" @keydown.meta.enter="updateIssuable" diff --git a/app/assets/javascripts/issue_show/components/issuable_header_warnings.vue b/app/assets/javascripts/issue_show/components/issuable_header_warnings.vue new file mode 100644 index 00000000000..b6816be9eb8 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/issuable_header_warnings.vue @@ -0,0 +1,28 @@ +<script> +import { mapState } from 'vuex'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { + Icon, + }, + computed: { + ...mapState({ + confidential: ({ noteableData }) => noteableData.confidential, + dicussionLocked: ({ noteableData }) => noteableData.discussion_locked, + }), + }, +}; +</script> + +<template> + <div class="gl-display-inline-block"> + <div v-if="confidential" class="issuable-warning-icon inline"> + <icon class="icon" name="eye-slash" data-testid="confidential" /> + </div> + + <div v-if="dicussionLocked" class="issuable-warning-icon inline"> + <icon class="icon" name="lock" data-testid="locked" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/issue_show/components/pinned_links.vue b/app/assets/javascripts/issue_show/components/pinned_links.vue index 4b50acceb62..a877aa2ac96 100644 --- a/app/assets/javascripts/issue_show/components/pinned_links.vue +++ b/app/assets/javascripts/issue_show/components/pinned_links.vue @@ -1,11 +1,10 @@ <script> -import { GlLink } from '@gitlab/ui'; -import Icon from '~/vue_shared/components/icon.vue'; +import { GlButton } from '@gitlab/ui'; +import { STATUS_PAGE_PUBLISHED, JOIN_ZOOM_MEETING } from '../constants'; export default { components: { - Icon, - GlLink, + GlButton, }, props: { zoomMeetingUrl: { @@ -19,32 +18,46 @@ export default { default: '', }, }, + computed: { + pinnedLinks() { + return [ + { + id: 'publishedIncidentUrl', + url: this.publishedIncidentUrl, + text: STATUS_PAGE_PUBLISHED, + icon: 'tanuki', + }, + { + id: 'zoomMeetingUrl', + url: this.zoomMeetingUrl, + text: JOIN_ZOOM_MEETING, + icon: 'brand-zoom', + }, + ]; + }, + }, + methods: { + needsPaddingClass(i) { + return i < this.pinnedLinks.length - 1; + }, + }, }; </script> <template> <div class="border-bottom gl-mb-6 gl-display-flex gl-justify-content-start"> - <div v-if="publishedIncidentUrl" class="gl-pr-3"> - <gl-link - :href="publishedIncidentUrl" - target="_blank" - class="btn btn-inverted btn-secondary btn-sm text-dark mb-3" - data-testid="publishedIncidentUrl" - > - <icon name="tanuki" :size="14" /> - <strong class="vertical-align-top">{{ __('Published on status page') }}</strong> - </gl-link> - </div> - <div v-if="zoomMeetingUrl"> - <gl-link - :href="zoomMeetingUrl" - target="_blank" - class="btn btn-inverted btn-secondary btn-sm text-dark mb-3" - data-testid="zoomMeetingUrl" - > - <icon name="brand-zoom" :size="14" /> - <strong class="vertical-align-top">{{ __('Join Zoom meeting') }}</strong> - </gl-link> - </div> + <template v-for="(link, i) in pinnedLinks"> + <div v-if="link.url" :key="link.id" :class="{ 'gl-pr-3': needsPaddingClass(i) }"> + <gl-button + :href="link.url" + target="_blank" + :icon="link.icon" + size="small" + class="gl-font-weight-bold gl-mb-5" + :data-testid="link.id" + >{{ link.text }}</gl-button + > + </div> + </template> </div> </template> diff --git a/app/assets/javascripts/issue_show/constants.js b/app/assets/javascripts/issue_show/constants.js index d73cc8cf007..6bc6ed2b372 100644 --- a/app/assets/javascripts/issue_show/constants.js +++ b/app/assets/javascripts/issue_show/constants.js @@ -15,3 +15,6 @@ export const IssuableType = { Epic: 'epic', MergeRequest: 'merge_request', }; + +export const STATUS_PAGE_PUBLISHED = __('Published on status page'); +export const JOIN_ZOOM_MEETING = __('Join Zoom meeting'); diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js index e170d338408..fe4ff133145 100644 --- a/app/assets/javascripts/issue_show/index.js +++ b/app/assets/javascripts/issue_show/index.js @@ -1,6 +1,8 @@ import Vue from 'vue'; import issuableApp from './components/app.vue'; +import IssuableHeaderWarnings from './components/issuable_header_warnings.vue'; import { parseIssuableData } from './utils/parse_data'; +import { store } from '~/notes/stores'; export default function initIssueableApp() { return new Vue({ @@ -15,3 +17,13 @@ export default function initIssueableApp() { }, }); } + +export function issuableHeaderWarnings() { + return new Vue({ + el: document.getElementById('js-issuable-header-warnings'), + store, + render(createElement) { + return createElement(IssuableHeaderWarnings); + }, + }); +} diff --git a/app/assets/javascripts/jira_import/components/jira_import_app.vue b/app/assets/javascripts/jira_import/components/jira_import_app.vue index ef0fc4716dd..6222bd28c9d 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_app.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_app.vue @@ -3,6 +3,7 @@ import { GlAlert, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; import { last } from 'lodash'; import { __ } from '~/locale'; import getJiraImportDetailsQuery from '../queries/get_jira_import_details.query.graphql'; +import getJiraUserMappingMutation from '../queries/get_jira_user_mapping.mutation.graphql'; import initiateJiraImportMutation from '../queries/initiate_jira_import.mutation.graphql'; import { addInProgressImportToStore } from '../utils/cache_update'; import { isInProgress, extractJiraProjectsOptions } from '../utils/jira_import_utils'; @@ -37,6 +38,10 @@ export default { type: String, required: true, }, + projectId: { + type: String, + required: true, + }, projectPath: { type: String, required: true, @@ -48,10 +53,12 @@ export default { }, data() { return { + isSubmitting: false, jiraImportDetails: {}, + selectedProject: undefined, + userMappings: [], errorMessage: '', showAlert: false, - selectedProject: undefined, }; }, apollo: { @@ -89,15 +96,43 @@ export default { : 'jira-import::KEY-1'; }, }, + mounted() { + if (this.isJiraConfigured) { + this.$apollo + .mutate({ + mutation: getJiraUserMappingMutation, + variables: { + input: { + projectPath: this.projectPath, + startAt: 1, + }, + }, + }) + .then(({ data }) => { + if (data.jiraImportUsers.errors.length) { + this.setAlertMessage(data.jiraImportUsers.errors.join('. ')); + } else { + this.userMappings = data.jiraImportUsers.jiraUsers; + } + }) + .catch(() => this.setAlertMessage(__('There was an error retrieving the Jira users.'))); + } + }, methods: { initiateJiraImport(project) { + this.isSubmitting = true; + this.$apollo .mutate({ mutation: initiateJiraImportMutation, variables: { input: { - projectPath: this.projectPath, jiraProjectKey: project, + projectPath: this.projectPath, + usersMapping: this.userMappings.map(({ gitlabId, jiraAccountId }) => ({ + gitlabId, + jiraAccountId, + })), }, }, update: (store, { data }) => @@ -110,7 +145,21 @@ export default { this.selectedProject = undefined; } }) - .catch(() => this.setAlertMessage(__('There was an error importing the Jira project.'))); + .catch(() => this.setAlertMessage(__('There was an error importing the Jira project.'))) + .finally(() => { + this.isSubmitting = false; + }); + }, + updateMapping(jiraAccountId, gitlabId, gitlabUsername) { + this.userMappings = this.userMappings.map(userMapping => + userMapping.jiraAccountId === jiraAccountId + ? { + ...userMapping, + gitlabId, + gitlabUsername, + } + : userMapping, + ); }, setAlertMessage(message) { this.errorMessage = message; @@ -155,9 +204,13 @@ export default { v-else v-model="selectedProject" :import-label="importLabel" + :is-submitting="isSubmitting" :issues-path="issuesPath" :jira-projects="jiraImportDetails.projects" + :project-id="projectId" + :user-mappings="userMappings" @initiateJiraImport="initiateJiraImport" + @updateMapping="updateMapping" /> </div> </template> diff --git a/app/assets/javascripts/jira_import/components/jira_import_form.vue b/app/assets/javascripts/jira_import/components/jira_import_form.vue index c2fe7b29c28..24bfb49a7d1 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_form.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_form.vue @@ -1,22 +1,61 @@ <script> -import { GlAvatar, GlButton, GlFormGroup, GlFormSelect, GlLabel } from '@gitlab/ui'; +import { + GlButton, + GlNewDropdown, + GlNewDropdownItem, + GlNewDropdownText, + GlFormGroup, + GlFormSelect, + GlIcon, + GlLabel, + GlLoadingIcon, + GlSearchBoxByType, + GlTable, +} from '@gitlab/ui'; +import { debounce } from 'lodash'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; export default { name: 'JiraImportForm', components: { - GlAvatar, GlButton, + GlNewDropdown, + GlNewDropdownItem, + GlNewDropdownText, GlFormGroup, GlFormSelect, + GlIcon, GlLabel, + GlLoadingIcon, + GlSearchBoxByType, + GlTable, }, - currentUserAvatarUrl: gon.current_user_avatar_url, currentUsername: gon.current_username, + dropdownLabel: __('The GitLab user to which the Jira user %{jiraDisplayName} will be mapped'), + tableConfig: [ + { + key: 'jiraDisplayName', + label: __('Jira display name'), + }, + { + key: 'arrow', + label: '', + }, + { + key: 'gitlabUsername', + label: __('GitLab username'), + }, + ], props: { importLabel: { type: String, required: true, }, + isSubmitting: { + type: Boolean, + required: true, + }, issuesPath: { type: String, required: true, @@ -25,6 +64,14 @@ export default { type: Array, required: true, }, + projectId: { + type: String, + required: true, + }, + userMappings: { + type: Array, + required: true, + }, value: { type: String, required: false, @@ -33,10 +80,53 @@ export default { }, data() { return { + isFetching: false, + searchTerm: '', selectState: null, + users: [], }; }, + computed: { + shouldShowNoMatchesFoundText() { + return !this.isFetching && this.users.length === 0; + }, + }, + watch: { + searchTerm: debounce(function debouncedUserSearch() { + this.searchUsers(); + }, 500), + }, + mounted() { + this.searchUsers() + .then(data => { + this.initialUsers = data; + }) + .catch(() => {}); + }, methods: { + searchUsers() { + const params = { + active: true, + project_id: this.projectId, + search: this.searchTerm, + }; + + this.isFetching = true; + + return axios + .get('/-/autocomplete/users.json', { params }) + .then(({ data }) => { + this.users = data; + return data; + }) + .finally(() => { + this.isFetching = false; + }); + }, + resetDropdown() { + this.searchTerm = ''; + this.users = this.initialUsers; + }, initiateJiraImport(event) { event.preventDefault(); if (this.value) { @@ -70,6 +160,7 @@ export default { > <gl-form-select id="jira-project-select" + data-qa-selector="jira_project_dropdown" class="mb-2" :options="jiraProjects" :state="selectState" @@ -79,7 +170,7 @@ export default { </gl-form-group> <gl-form-group - class="row align-items-center" + class="row gl-align-items-center gl-mb-6" :label="__('Issue label')" label-cols-sm="2" label-for="jira-project-label" @@ -93,50 +184,65 @@ export default { /> </gl-form-group> - <hr /> + <h4 class="gl-mb-4">{{ __('Jira-GitLab user mapping template') }}</h4> - <p class="offset-md-1"> + <p> {{ __( - "For each Jira issue successfully imported, we'll create a new GitLab issue with the following data:", + `Jira users have been matched with similar GitLab users. + This can be overwritten by selecting a GitLab user from the dropdown in the "GitLab + username" column. + If it wasn't possible to match a Jira user with a GitLab user, the dropdown defaults to + the user conducting the import.`, ) }} </p> - <gl-form-group - class="row align-items-center mb-1" - :label="__('Title')" - label-cols-sm="2" - label-for="jira-project-title" - > - <p id="jira-project-title" class="mb-2">{{ __('jira.issue.summary') }}</p> - </gl-form-group> - <gl-form-group - class="row align-items-center mb-1" - :label="__('Reporter')" - label-cols-sm="2" - label-for="jira-project-reporter" - > - <gl-avatar - id="jira-project-reporter" - class="mb-2" - :src="$options.currentUserAvatarUrl" - :size="24" - :aria-label="$options.currentUsername" - /> - </gl-form-group> - <gl-form-group - class="row align-items-center mb-1" - :label="__('Description')" - label-cols-sm="2" - label-for="jira-project-description" - > - <p id="jira-project-description" class="mb-2">{{ __('jira.issue.description.content') }}</p> - </gl-form-group> + <gl-table :fields="$options.tableConfig" :items="userMappings" fixed> + <template #cell(arrow)> + <gl-icon name="arrow-right" :aria-label="__('Will be mapped to')" /> + </template> + <template #cell(gitlabUsername)="data"> + <gl-new-dropdown + :text="data.value || $options.currentUsername" + class="w-100" + :aria-label=" + sprintf($options.dropdownLabel, { jiraDisplayName: data.item.jiraDisplayName }) + " + @hide="resetDropdown" + > + <gl-search-box-by-type v-model.trim="searchTerm" class="m-2" /> + + <div v-if="isFetching" class="gl-text-center"> + <gl-loading-icon /> + </div> + + <gl-new-dropdown-item + v-for="user in users" + v-else + :key="user.id" + @click="$emit('updateMapping', data.item.jiraAccountId, user.id, user.username)" + > + {{ user.username }} ({{ user.name }}) + </gl-new-dropdown-item> + + <gl-new-dropdown-text v-show="shouldShowNoMatchesFoundText" class="text-secondary"> + {{ __('No matches found') }} + </gl-new-dropdown-text> + </gl-new-dropdown> + </template> + </gl-table> <div class="footer-block row-content-block d-flex justify-content-between"> - <gl-button type="submit" category="primary" variant="success" class="js-no-auto-disable"> - {{ __('Next') }} + <gl-button + type="submit" + category="primary" + variant="success" + class="js-no-auto-disable" + :loading="isSubmitting" + data-qa-selector="jira_issues_import_button" + > + {{ __('Continue') }} </gl-button> <gl-button :href="issuesPath">{{ __('Cancel') }}</gl-button> </div> diff --git a/app/assets/javascripts/jira_import/index.js b/app/assets/javascripts/jira_import/index.js index 924cc7e6864..695a237bf50 100644 --- a/app/assets/javascripts/jira_import/index.js +++ b/app/assets/javascripts/jira_import/index.js @@ -28,6 +28,7 @@ export default function mountJiraImportApp() { isJiraConfigured: parseBoolean(el.dataset.isJiraConfigured), issuesPath: el.dataset.issuesPath, jiraIntegrationPath: el.dataset.jiraIntegrationPath, + projectId: el.dataset.projectId, projectPath: el.dataset.projectPath, setupIllustration: el.dataset.setupIllustration, }, diff --git a/app/assets/javascripts/jira_import/queries/get_jira_user_mapping.mutation.graphql b/app/assets/javascripts/jira_import/queries/get_jira_user_mapping.mutation.graphql new file mode 100644 index 00000000000..1f7c52eec58 --- /dev/null +++ b/app/assets/javascripts/jira_import/queries/get_jira_user_mapping.mutation.graphql @@ -0,0 +1,11 @@ +mutation($input: JiraImportUsersInput!) { + jiraImportUsers(input: $input) { + jiraUsers { + jiraAccountId + jiraDisplayName + jiraEmail + gitlabId + } + errors + } +} diff --git a/app/assets/javascripts/jira_import/utils/jira_import_utils.js b/app/assets/javascripts/jira_import/utils/jira_import_utils.js index e82a3f44a29..a1186b087e1 100644 --- a/app/assets/javascripts/jira_import/utils/jira_import_utils.js +++ b/app/assets/javascripts/jira_import/utils/jira_import_utils.js @@ -1,4 +1,5 @@ import { last } from 'lodash'; +import { JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY } from '~/issuables_list/constants'; export const IMPORT_STATE = { FAILED: 'failed', @@ -68,3 +69,36 @@ export const calculateJiraImportLabel = (jiraImports, labels) => { title, }; }; + +/** + * Calculates whether the Jira import success alert should be shown. + * + * @param {string} labelTitle - Jira import label, for checking localStorage + * @param {string} importStatus - Jira import status + * @returns {boolean} - A boolean indicating whether to show the success alert + */ +export const shouldShowFinishedAlert = (labelTitle, importStatus) => { + const finishedAlertHideMap = + JSON.parse(localStorage.getItem(JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY)) || {}; + + const shouldHide = finishedAlertHideMap[labelTitle]; + + return !shouldHide && isFinished(importStatus); +}; + +/** + * Updates the localStorage map to permanently hide the Jira import success alert + * + * @param {string} labelTitle - Jira import label, for checking localStorage + */ +export const setFinishedAlertHideMap = labelTitle => { + const finishedAlertHideMap = + JSON.parse(localStorage.getItem(JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY)) || {}; + + finishedAlertHideMap[labelTitle] = true; + + localStorage.setItem( + JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY, + JSON.stringify(finishedAlertHideMap), + ); +}; diff --git a/app/assets/javascripts/jobs/components/commit_block.vue b/app/assets/javascripts/jobs/components/commit_block.vue index 72a5ff01672..c4f180f200c 100644 --- a/app/assets/javascripts/jobs/components/commit_block.vue +++ b/app/assets/javascripts/jobs/components/commit_block.vue @@ -32,7 +32,7 @@ export default { block: !isLastBlock, }" > - <p class="append-bottom-5"> + <p class="gl-mb-2"> <span class="font-weight-bold">{{ __('Commit') }}</span> <gl-link :href="commit.commit_path" class="js-commit-sha commit-sha link-commit"> diff --git a/app/assets/javascripts/jobs/components/environments_block.vue b/app/assets/javascripts/jobs/components/environments_block.vue index c34a3488dbd..c78738221f1 100644 --- a/app/assets/javascripts/jobs/components/environments_block.vue +++ b/app/assets/javascripts/jobs/components/environments_block.vue @@ -274,7 +274,7 @@ export default { }; </script> <template> - <div class="prepend-top-default append-bottom-default js-environment-container"> + <div class="gl-mt-3 gl-mb-3 js-environment-container"> <div class="environment-information"> <ci-icon :status="iconStatus" /> <p class="inline gl-mb-0" v-html="environment"></p> diff --git a/app/assets/javascripts/jobs/components/erased_block.vue b/app/assets/javascripts/jobs/components/erased_block.vue index fc5e022f44a..a6d1b41c275 100644 --- a/app/assets/javascripts/jobs/components/erased_block.vue +++ b/app/assets/javascripts/jobs/components/erased_block.vue @@ -27,7 +27,7 @@ export default { }; </script> <template> - <div class="prepend-top-default js-build-erased"> + <div class="gl-mt-3 js-build-erased"> <div class="erased alert alert-warning"> <template v-if="isErasedByUser"> {{ s__('Job|Job has been erased by') }} diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue index 0783d1157be..f43a058b5f8 100644 --- a/app/assets/javascripts/jobs/components/job_app.vue +++ b/app/assets/javascripts/jobs/components/job_app.vue @@ -17,7 +17,7 @@ import UnmetPrerequisitesBlock from './unmet_prerequisites_block.vue'; import Sidebar from './sidebar.vue'; import { sprintf } from '~/locale'; import delayedJobMixin from '../mixins/delayed_job_mixin'; -import { isNewJobLogActive } from '../store/utils'; +import Log from './log/log.vue'; export default { name: 'JobPageApp', @@ -28,7 +28,7 @@ export default { EnvironmentsBlock, ErasedBlock, Icon, - Log: () => (isNewJobLogActive() ? import('./log/log.vue') : import('./job_log.vue')), + Log, LogTopBar, StuckBlock, UnmetPrerequisitesBlock, @@ -270,7 +270,7 @@ export default { <div v-if="job.archived" ref="sticky" - class="js-archived-job prepend-top-default archived-job" + class="js-archived-job gl-mt-3 archived-job" :class="{ 'sticky-top border-bottom-0': hasTrace }" > <icon name="lock" class="align-text-bottom" /> @@ -280,7 +280,7 @@ export default { <div v-if="hasTrace" class="build-trace-container position-relative" - :class="{ 'prepend-top-default': !job.archived }" + :class="{ 'gl-mt-3': !job.archived }" > <log-top-bar :class="{ diff --git a/app/assets/javascripts/jobs/components/job_log.vue b/app/assets/javascripts/jobs/components/job_log.vue deleted file mode 100644 index 20888c0af42..00000000000 --- a/app/assets/javascripts/jobs/components/job_log.vue +++ /dev/null @@ -1,59 +0,0 @@ -<script> -import { mapState, mapActions } from 'vuex'; - -export default { - name: 'JobLog', - props: { - trace: { - type: String, - required: true, - }, - isComplete: { - type: Boolean, - required: true, - }, - }, - computed: { - ...mapState(['isScrolledToBottomBeforeReceivingTrace']), - }, - updated() { - this.$nextTick(() => { - this.handleScrollDown(); - }); - }, - mounted() { - this.$nextTick(() => { - this.handleScrollDown(); - }); - }, - methods: { - ...mapActions(['scrollBottom']), - /** - * The job log is sent in HTML, which means we need to use `v-html` to render it - * Using the updated hook with $nextTick is not enough to wait for the DOM to be updated - * in this case because it runs before `v-html` has finished running, since there's no - * Vue binding. - * In order to scroll the page down after `v-html` has finished, we need to use setTimeout - */ - handleScrollDown() { - if (this.isScrolledToBottomBeforeReceivingTrace) { - setTimeout(() => { - this.scrollBottom(); - }, 0); - } - }, - }, -}; -</script> -<template> - <pre class="js-build-trace build-trace qa-build-trace"> - <code class="bash" v-html="trace"> - </code> - - <div v-if="!isComplete" class="js-log-animation build-loader-animation"> - <div class="dot"></div> - <div class="dot"></div> - <div class="dot"></div> - </div> - </pre> -</template> diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue index bcec83a7aee..a68174d8e1d 100644 --- a/app/assets/javascripts/jobs/components/job_log_controllers.vue +++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue @@ -77,7 +77,7 @@ export default { <gl-link v-if="rawPath" :href="rawPath" - class="js-raw-link text-plain text-underline prepend-left-5" + class="js-raw-link text-plain text-underline gl-ml-2" >{{ s__('Job|Complete Raw') }}</gl-link > </template> diff --git a/app/assets/javascripts/jobs/components/log/collapsible_section.vue b/app/assets/javascripts/jobs/components/log/collapsible_section.vue index 0c7b78a3da7..55cdfb691f4 100644 --- a/app/assets/javascripts/jobs/components/log/collapsible_section.vue +++ b/app/assets/javascripts/jobs/components/log/collapsible_section.vue @@ -3,7 +3,7 @@ import LogLine from './line.vue'; import LogLineHeader from './line_header.vue'; export default { - name: 'CollpasibleLogSection', + name: 'CollapsibleLogSection', components: { LogLine, LogLineHeader, diff --git a/app/assets/javascripts/jobs/components/log/line.vue b/app/assets/javascripts/jobs/components/log/line.vue index 33ee84bd4ee..48f669ae8ed 100644 --- a/app/assets/javascripts/jobs/components/log/line.vue +++ b/app/assets/javascripts/jobs/components/log/line.vue @@ -2,9 +2,7 @@ import LineNumber from './line_number.vue'; export default { - components: { - LineNumber, - }, + functional: true, props: { line: { type: Object, @@ -15,18 +13,28 @@ export default { required: true, }, }, + render(h, { props }) { + const { line, path } = props; + + const chars = line.content.map(content => { + return h( + 'span', + { + class: ['ws-pre-wrap', content.style], + }, + content.text, + ); + }); + + return h('div', { class: 'js-line log-line' }, [ + h(LineNumber, { + props: { + lineNumber: line.lineNumber, + path, + }, + }), + ...chars, + ]); + }, }; </script> - -<template> - <div class="js-line log-line"> - <line-number :line-number="line.lineNumber" :path="path" /> - <span - v-for="(content, i) in line.content" - :key="i" - :class="content.style" - class="ws-pre-wrap" - >{{ content.text }}</span - > - </div> -</template> diff --git a/app/assets/javascripts/jobs/components/log/line_number.vue b/app/assets/javascripts/jobs/components/log/line_number.vue index ae96c32874b..7ca9154d2fe 100644 --- a/app/assets/javascripts/jobs/components/log/line_number.vue +++ b/app/assets/javascripts/jobs/components/log/line_number.vue @@ -1,10 +1,6 @@ <script> -import { GlLink } from '@gitlab/ui'; - export default { - components: { - GlLink, - }, + functional: true, props: { lineNumber: { type: Number, @@ -15,41 +11,24 @@ export default { required: true, }, }, - computed: { - /** - * Builds the url for each line number - * - * @returns {String} - */ - buildLineNumber() { - return `${this.path}#${this.lineNumberId}`; - }, - /** - * Array indexes start with 0, so we add 1 - * to create the line number - * - * @returns {Number} the line number - */ - parsedLineNumber() { - return this.lineNumber + 1; - }, + render(h, { props }) { + const { lineNumber, path } = props; - /** - * Creates the anchor for each link - * - * @returns {String} - */ - lineNumberId() { - return `L${this.parsedLineNumber}`; - }, + const parsedLineNumber = lineNumber + 1; + const lineId = `L${parsedLineNumber}`; + const lineHref = `${path}#${lineId}`; + + return h( + 'a', + { + class: 'gl-link d-inline-block text-right line-number flex-shrink-0', + attrs: { + id: lineId, + href: lineHref, + }, + }, + parsedLineNumber, + ); }, }; </script> -<template> - <gl-link - :id="lineNumberId" - class="d-inline-block text-right line-number flex-shrink-0" - :href="buildLineNumber" - >{{ parsedLineNumber }}</gl-link - > -</template> diff --git a/app/assets/javascripts/jobs/components/log/log.vue b/app/assets/javascripts/jobs/components/log/log.vue index f0bdbde0602..0134e5dafe8 100644 --- a/app/assets/javascripts/jobs/components/log/log.vue +++ b/app/assets/javascripts/jobs/components/log/log.vue @@ -1,11 +1,11 @@ <script> import { mapState, mapActions } from 'vuex'; -import CollpasibleLogSection from './collapsible_section.vue'; +import CollapsibleLogSection from './collapsible_section.vue'; import LogLine from './line.vue'; export default { components: { - CollpasibleLogSection, + CollapsibleLogSection, LogLine, }, computed: { @@ -51,7 +51,7 @@ export default { <template> <code class="job-log d-block" data-qa-selector="job_log_content"> <template v-for="(section, index) in trace"> - <collpasible-log-section + <collapsible-log-section v-if="section.isHeader" :key="`collapsible-${index}`" :section="section" diff --git a/app/assets/javascripts/jobs/components/manual_variables_form.vue b/app/assets/javascripts/jobs/components/manual_variables_form.vue index d4aab5c7faf..d83c598dd48 100644 --- a/app/assets/javascripts/jobs/components/manual_variables_form.vue +++ b/app/assets/javascripts/jobs/components/manual_variables_form.vue @@ -112,7 +112,7 @@ export default { <div v-for="variable in variables" :key="variable.id" class="gl-responsive-table-row"> <div class="table-section section-50"> <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Key') }}</div> - <div class="table-mobile-content append-right-10"> + <div class="table-mobile-content gl-mr-3"> <input :ref="`${$options.inputTypes.key}-${variable.id}`" v-model="variable.key" @@ -124,7 +124,7 @@ export default { <div class="table-section section-50"> <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Value') }}</div> - <div class="table-mobile-content append-right-10"> + <div class="table-mobile-content gl-mr-3"> <input :ref="`${$options.inputTypes.value}-${variable.id}`" v-model="variable.secret_value" @@ -149,7 +149,7 @@ export default { <div class="gl-responsive-table-row"> <div class="table-section section-50"> <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Key') }}</div> - <div class="table-mobile-content append-right-10"> + <div class="table-mobile-content gl-mr-3"> <input ref="inputKey" v-model="key" @@ -161,7 +161,7 @@ export default { <div class="table-section section-50"> <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Value') }}</div> - <div class="table-mobile-content append-right-10"> + <div class="table-mobile-content gl-mr-3"> <input ref="inputSecretValue" v-model="secretValue" @@ -172,7 +172,7 @@ export default { </div> </div> </div> - <div class="d-flex prepend-top-default justify-content-center"> + <div class="d-flex gl-mt-3 justify-content-center"> <p class="text-muted" v-html="helpText"></p> </div> <div class="d-flex justify-content-center"> diff --git a/app/assets/javascripts/jobs/components/stuck_block.vue b/app/assets/javascripts/jobs/components/stuck_block.vue index da01269a50c..b69e6f9686f 100644 --- a/app/assets/javascripts/jobs/components/stuck_block.vue +++ b/app/assets/javascripts/jobs/components/stuck_block.vue @@ -31,7 +31,7 @@ export default { s__(`This job is stuck because you don't have any active runners online or available with any of these tags assigned to them:`) }} - <span v-for="(tag, index) in tags" :key="index" class="badge badge-primary append-right-4"> + <span v-for="(tag, index) in tags" :key="index" class="badge badge-primary gl-mr-2"> {{ tag }} </span> </p> diff --git a/app/assets/javascripts/jobs/components/trigger_block.vue b/app/assets/javascripts/jobs/components/trigger_block.vue index 1a076249fe7..f55429ecdae 100644 --- a/app/assets/javascripts/jobs/components/trigger_block.vue +++ b/app/assets/javascripts/jobs/components/trigger_block.vue @@ -46,7 +46,7 @@ export default { <p v-if="trigger.short_token" class="js-short-token" - :class="{ 'append-bottom-5': hasVariables, 'gl-mb-0': !hasVariables }" + :class="{ 'gl-mb-2': hasVariables, 'gl-mb-0': !hasVariables }" > <span class="font-weight-bold">{{ __('Trigger token:') }}</span> {{ trigger.short_token }} </p> diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/jobs/store/actions.js index 0ce8dfe4442..4bd8d6f58a6 100644 --- a/app/assets/javascripts/jobs/store/actions.js +++ b/app/assets/javascripts/jobs/store/actions.js @@ -195,7 +195,7 @@ export const receiveTraceError = ({ dispatch }) => { flash(__('An error occurred while fetching the job log.')); }; /** - * When the user clicks a collpasible line in the job + * When the user clicks a collapsible line in the job * log, we commit a mutation to update the state * * @param {Object} section diff --git a/app/assets/javascripts/jobs/store/mutations.js b/app/assets/javascripts/jobs/store/mutations.js index 6193d8d34ab..924b811d0d6 100644 --- a/app/assets/javascripts/jobs/store/mutations.js +++ b/app/assets/javascripts/jobs/store/mutations.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import * as types from './mutation_types'; -import { logLinesParser, updateIncrementalTrace, isNewJobLogActive } from './utils'; +import { logLinesParser, updateIncrementalTrace } from './utils'; export default { [types.SET_JOB_ENDPOINT](state, endpoint) { @@ -25,22 +25,16 @@ export default { } if (log.append) { - if (isNewJobLogActive()) { - state.trace = log.lines ? updateIncrementalTrace(log.lines, state.trace) : state.trace; - } else { - state.trace += log.html; - } + state.trace = log.lines ? updateIncrementalTrace(log.lines, state.trace) : state.trace; + state.traceSize += log.size; } else { // When the job still does not have a trace // the trace response will not have a defined // html or size. We keep the old value otherwise these // will be set to `null` - if (isNewJobLogActive()) { - state.trace = log.lines ? logLinesParser(log.lines) : state.trace; - } else { - state.trace = log.html || state.trace; - } + state.trace = log.lines ? logLinesParser(log.lines) : state.trace; + state.traceSize = log.size || state.traceSize; } diff --git a/app/assets/javascripts/jobs/store/state.js b/app/assets/javascripts/jobs/store/state.js index d76828ad19b..2fe945b2985 100644 --- a/app/assets/javascripts/jobs/store/state.js +++ b/app/assets/javascripts/jobs/store/state.js @@ -1,5 +1,3 @@ -import { isNewJobLogActive } from './utils'; - export default () => ({ jobEndpoint: null, traceEndpoint: null, @@ -18,7 +16,7 @@ export default () => ({ // Used to check if we should keep the automatic scroll isScrolledToBottomBeforeReceivingTrace: true, - trace: isNewJobLogActive() ? [] : '', + trace: [], isTraceComplete: false, traceSize: 0, isTraceSizeVisible: false, diff --git a/app/assets/javascripts/jobs/store/utils.js b/app/assets/javascripts/jobs/store/utils.js index 0b28c52a78f..8d6e5aac566 100644 --- a/app/assets/javascripts/jobs/store/utils.js +++ b/app/assets/javascripts/jobs/store/utils.js @@ -11,7 +11,7 @@ export const parseLine = (line = {}, lineNumber) => ({ /** * When a line has `section_header` set to true, we create a new * structure to allow to nest the lines that belong to the - * collpasible section + * collapsible section * * @param Object line * @param Number lineNumber @@ -91,7 +91,7 @@ export const getIncrementalLineNumber = acc => { * Parses the job log content into a structure usable by the template * * For collaspible lines (section_header = true): - * - creates a new array to hold the lines that are collpasible, + * - creates a new array to hold the lines that are collapsible, * - adds a isClosed property to handle toggle * - adds a isHeader property to handle template logic * - adds the section_duration @@ -177,5 +177,3 @@ export const updateIncrementalTrace = (newLog = [], oldParsed = []) => { return logLinesParser(newLog, parsedLog); }; - -export const isNewJobLogActive = () => gon && gon.features && gon.features.jobLogJson; diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 65d8866fcc3..63c4ad3c410 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -3,7 +3,7 @@ /* global ListLabel */ import $ from 'jquery'; -import { isEqual, escape, sortBy, template } from 'lodash'; +import { difference, isEqual, escape, sortBy, template } from 'lodash'; import { sprintf, s__, __ } from './locale'; import axios from './lib/utils/axios_utils'; import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; @@ -497,7 +497,7 @@ export default class LabelsSelect { const scopedLabelTemplate = template( [ - '<span class="gl-label gl-label-scoped" style="color: <%= escapeStr(label.color) %>;">', + '<span class="gl-label gl-label-scoped" style="color: <%= escapeStr(label.color) %>; --label-inset-border: inset 0 0 0 2px <%= escapeStr(label.color) %>;">', linkOpenTag, spanOpenTag, '<%- label.title.slice(0, label.title.lastIndexOf("::")) %>', @@ -526,9 +526,7 @@ export default class LabelsSelect { [ '<% labels.forEach(function(label){ %>', '<% if (isScopedLabel(label) && enableScopedLabels) { %>', - '<span class="d-inline-block position-relative scoped-label-wrapper">', '<%= scopedLabelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, rightLabelTextColor, tooltipTitleTemplate, escapeStr, linkAttrs: \'data-html="true"\' }) %>', - '</span>', '<% } else { %>', '<%= labelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, tooltipTitleTemplate, escapeStr, linkAttrs: "" }) %>', '<% } %>', @@ -562,45 +560,20 @@ export default class LabelsSelect { IssuableBulkUpdateActions.willUpdateLabels = true; } // eslint-disable-next-line class-methods-use-this - setDropdownData($dropdown, isMarking, value) { - const markedIds = $dropdown.data('marked') || []; - const unmarkedIds = $dropdown.data('unmarked') || []; - const indeterminateIds = $dropdown.data('indeterminate') || []; - - if (isMarking) { - markedIds.push(value); + setDropdownData($dropdown, isChecking, labelId) { + let userCheckedIds = $dropdown.data('user-checked') || []; + let userUncheckedIds = $dropdown.data('user-unchecked') || []; - let i = indeterminateIds.indexOf(value); - if (i > -1) { - indeterminateIds.splice(i, 1); - } - - i = unmarkedIds.indexOf(value); - if (i > -1) { - unmarkedIds.splice(i, 1); - } + if (isChecking) { + userCheckedIds = userCheckedIds.concat(labelId); + userUncheckedIds = difference(userUncheckedIds, [labelId]); } else { - // If marked item (not common) is unmarked - const i = markedIds.indexOf(value); - if (i > -1) { - markedIds.splice(i, 1); - } - - // If an indeterminate item is being unmarked - if (IssuableBulkUpdateActions.getOriginalIndeterminateIds().indexOf(value) > -1) { - unmarkedIds.push(value); - } - - // If a marked item is being unmarked - // (a marked item could also be a label that is present in all selection) - if (IssuableBulkUpdateActions.getOriginalCommonIds().indexOf(value) > -1) { - unmarkedIds.push(value); - } + userUncheckedIds = userUncheckedIds.concat(labelId); + userCheckedIds = difference(userCheckedIds, [labelId]); } - $dropdown.data('marked', markedIds); - $dropdown.data('unmarked', unmarkedIds); - $dropdown.data('indeterminate', indeterminateIds); + $dropdown.data('user-checked', userCheckedIds); + $dropdown.data('user-unchecked', userUncheckedIds); } // eslint-disable-next-line class-methods-use-this setOriginalDropdownData($container, $dropdown) { diff --git a/app/assets/javascripts/lazy_loader.js b/app/assets/javascripts/lazy_loader.js index 75542267f37..aa7fe087678 100644 --- a/app/assets/javascripts/lazy_loader.js +++ b/app/assets/javascripts/lazy_loader.js @@ -14,6 +14,10 @@ export default class LazyLoader { scrollContainer.addEventListener('load', () => this.register()); } + static supportsNativeLazyLoading() { + return 'loading' in HTMLImageElement.prototype; + } + static supportsIntersectionObserver() { return Boolean(window.IntersectionObserver); } @@ -23,7 +27,9 @@ export default class LazyLoader { () => { const lazyImages = [].slice.call(document.querySelectorAll('.lazy')); - if (LazyLoader.supportsIntersectionObserver()) { + if (LazyLoader.supportsNativeLazyLoading()) { + lazyImages.forEach(img => LazyLoader.loadImage(img)); + } else if (LazyLoader.supportsIntersectionObserver()) { if (this.intersectionObserver) { lazyImages.forEach(img => this.intersectionObserver.observe(img)); } @@ -72,11 +78,14 @@ export default class LazyLoader { } register() { - if (LazyLoader.supportsIntersectionObserver()) { - this.startIntersectionObserver(); - } else { - this.startLegacyObserver(); + if (!LazyLoader.supportsNativeLazyLoading()) { + if (LazyLoader.supportsIntersectionObserver()) { + this.startIntersectionObserver(); + } else { + this.startLegacyObserver(); + } } + this.startContentObserver(); this.searchLazyImages(); } @@ -148,16 +157,12 @@ export default class LazyLoader { static loadImage(img) { if (img.getAttribute('data-src')) { + img.setAttribute('loading', 'lazy'); let imgUrl = img.getAttribute('data-src'); // Only adding width + height for avatars for now if (imgUrl.indexOf('/avatar/') > -1 && imgUrl.indexOf('?') === -1) { - let targetWidth = null; - if (img.getAttribute('width')) { - targetWidth = img.getAttribute('width'); - } else { - targetWidth = img.width; - } - if (targetWidth) imgUrl += `?width=${targetWidth}`; + const targetWidth = img.getAttribute('width') || img.width; + imgUrl += `?width=${targetWidth}`; } img.setAttribute('src', imgUrl); img.removeAttribute('data-src'); diff --git a/app/assets/javascripts/lib/utils/axios_startup_calls.js b/app/assets/javascripts/lib/utils/axios_startup_calls.js new file mode 100644 index 00000000000..cb2e8a76c08 --- /dev/null +++ b/app/assets/javascripts/lib/utils/axios_startup_calls.js @@ -0,0 +1,52 @@ +import { isEmpty } from 'lodash'; +import { mergeUrlParams } from './url_utility'; + +// We should probably not couple this utility to `gon.gitlab_url` +// Also, this would replace occurrences that aren't at the beginning of the string +const removeGitLabUrl = url => url.replace(gon.gitlab_url, ''); + +const getFullUrl = req => { + const url = removeGitLabUrl(req.url); + return mergeUrlParams(req.params || {}, url); +}; + +const setupAxiosStartupCalls = axios => { + const { startup_calls: startupCalls } = window.gl || {}; + + if (!startupCalls || isEmpty(startupCalls)) { + return; + } + + // TODO: To save performance of future axios calls, we can + // remove this interceptor once the "startupCalls" have been loaded + axios.interceptors.request.use(req => { + const fullUrl = getFullUrl(req); + + const existing = startupCalls[fullUrl]; + + if (existing) { + // eslint-disable-next-line no-param-reassign + req.adapter = () => + existing.fetchCall.then(res => { + const fetchHeaders = {}; + res.headers.forEach((val, key) => { + fetchHeaders[key] = val; + }); + + // eslint-disable-next-line promise/no-nesting + return res.json().then(data => ({ + data, + status: res.status, + statusText: res.statusText, + headers: fetchHeaders, + config: req, + request: req, + })); + }); + } + + return req; + }); +}; + +export default setupAxiosStartupCalls; diff --git a/app/assets/javascripts/lib/utils/axios_utils.js b/app/assets/javascripts/lib/utils/axios_utils.js index 4eec5bffc66..9d517f45caa 100644 --- a/app/assets/javascripts/lib/utils/axios_utils.js +++ b/app/assets/javascripts/lib/utils/axios_utils.js @@ -1,6 +1,7 @@ import axios from 'axios'; import csrf from './csrf'; import suppressAjaxErrorsDuringNavigation from './suppress_ajax_errors_during_navigation'; +import setupAxiosStartupCalls from './axios_startup_calls'; axios.defaults.headers.common[csrf.headerKey] = csrf.token; // Used by Rails to check if it is a valid XHR request @@ -14,6 +15,8 @@ axios.interceptors.request.use(config => { return config; }); +setupAxiosStartupCalls(axios); + // Remove the global counter axios.interceptors.response.use( response => { diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index a60748215ab..8bf9a281151 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -53,16 +53,6 @@ export const getCspNonceValue = () => { return metaTag && metaTag.content; }; -export const ajaxGet = url => - axios - .get(url, { - params: { format: 'js' }, - responseType: 'text', - }) - .then(({ data }) => { - $.globalEval(data, { nonce: getCspNonceValue() }); - }); - export const rstrip = val => { if (val) { return val.replace(/\s+$/, ''); @@ -105,6 +95,7 @@ export const handleLocationHash = () => { const topPadding = 8; const diffFileHeader = document.querySelector('.js-file-title'); const versionMenusContainer = document.querySelector('.mr-version-menus-container'); + const fixedIssuableTitle = document.querySelector('.issue-sticky-header'); let adjustment = 0; if (fixedNav) adjustment -= fixedNav.offsetHeight; @@ -133,6 +124,10 @@ export const handleLocationHash = () => { adjustment -= versionMenusContainer.offsetHeight; } + if (isInIssuePage()) { + adjustment -= fixedIssuableTitle.offsetHeight; + } + if (isInMRPage()) { adjustment -= topPadding; } @@ -370,34 +365,6 @@ export const insertText = (target, text) => { target.dispatchEvent(event); }; -export const nodeMatchesSelector = (node, selector) => { - const matches = - Element.prototype.matches || - Element.prototype.matchesSelector || - Element.prototype.mozMatchesSelector || - Element.prototype.msMatchesSelector || - Element.prototype.oMatchesSelector || - Element.prototype.webkitMatchesSelector; - - if (matches) { - return matches.call(node, selector); - } - - // IE11 doesn't support `node.matches(selector)` - - let { parentNode } = node; - - if (!parentNode) { - parentNode = document.createElement('div'); - // eslint-disable-next-line no-param-reassign - node = node.cloneNode(true); - parentNode.appendChild(node); - } - - const matchingNodes = parentNode.querySelectorAll(selector); - return Array.prototype.indexOf.call(matchingNodes, node) !== -1; -}; - /** this will take in the headers from an API response and normalize them this way we don't run into production issues when nginx gives us lowercased header keys @@ -413,24 +380,6 @@ export const normalizeHeaders = headers => { }; /** - this will take in the getAllResponseHeaders result and normalize them - this way we don't run into production issues when nginx gives us lowercased header keys -*/ -export const normalizeCRLFHeaders = headers => { - const headersObject = {}; - const headersArray = headers.split('\n'); - - headersArray.forEach(header => { - const keyValue = header.split(': '); - - // eslint-disable-next-line prefer-destructuring - headersObject[keyValue[0]] = keyValue[1]; - }); - - return normalizeHeaders(headersObject); -}; - -/** * Parses pagination object string values into numbers. * * @param {Object} paginationInformation @@ -638,13 +587,6 @@ export const setFaviconOverlay = overlayPath => { ); }; -export const setFavicon = faviconPath => { - const faviconEl = document.getElementById('favicon'); - if (faviconEl && faviconPath) { - faviconEl.setAttribute('href', faviconPath); - } -}; - export const resetFavicon = () => { const faviconEl = document.getElementById('favicon'); @@ -883,35 +825,6 @@ export const searchBy = (query = '', searchSpace = {}) => { */ export const isScopedLabel = ({ title = '' }) => title.indexOf('::') !== -1; -window.gl = window.gl || {}; -window.gl.utils = { - ...(window.gl.utils || {}), - getPagePath, - isInGroupsPage, - isInProjectPage, - getProjectSlug, - getGroupSlug, - isInIssuePage, - ajaxGet, - rstrip, - updateTooltipTitle, - disableButtonIfEmptyField, - handleLocationHash, - isInViewport, - parseUrl, - parseUrlPathname, - getUrlParamsArray, - isMetaKey, - isMetaClick, - scrollToElement, - getParameterByName, - getSelectedFragment, - insertText, - nodeMatchesSelector, - spriteIcon, - imagePath, -}; - // Methods to set and get Cookie export const setCookie = (name, value) => Cookies.set(name, value, { expires: 365 }); diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js index eb6c9bf7eb6..993d51370ec 100644 --- a/app/assets/javascripts/lib/utils/constants.js +++ b/app/assets/javascripts/lib/utils/constants.js @@ -1,5 +1,7 @@ export const BYTES_IN_KIB = 1024; export const HIDDEN_CLASS = 'hidden'; +export const TRUNCATE_WIDTH_DEFAULT_WIDTH = 80; +export const TRUNCATE_WIDTH_DEFAULT_FONT_SIZE = 12; export const DATETIME_RANGE_TYPES = { fixed: 'fixed', diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 6b69d2febe0..6e02fc1eb91 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -89,13 +89,15 @@ export const getDayName = date => * @example * dateFormat('2017-12-05','mmm d, yyyy h:MMtt Z' ) -> "Dec 5, 2017 12:00am GMT+0000" * @param {date} datetime + * @param {String} format + * @param {Boolean} UTC convert local time to UTC * @returns {String} */ -export const formatDate = (datetime, format = 'mmm d, yyyy h:MMtt Z') => { +export const formatDate = (datetime, format = 'mmm d, yyyy h:MMtt Z', utc = false) => { if (isString(datetime) && datetime.match(/\d+-\d+\d+ /)) { throw new Error(__('Invalid date')); } - return dateFormat(datetime, format); + return dateFormat(datetime, format, utc); }; /** @@ -425,7 +427,6 @@ export const dayInQuarter = (date, quarter) => { window.gl = window.gl || {}; window.gl.utils = { ...(window.gl.utils || {}), - getTimeago, localTimeAgo, }; diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js index 8fa235f8afb..d9b0e8c4476 100644 --- a/app/assets/javascripts/lib/utils/dom_utils.js +++ b/app/assets/javascripts/lib/utils/dom_utils.js @@ -1,3 +1,4 @@ +import { has } from 'lodash'; import { isInIssuePage, isInMRPage, isInEpicPage } from './common_utils'; export const addClassIfElementExists = (element, className) => { @@ -25,3 +26,24 @@ export const toggleContainerClasses = (containerEl, classList) => { }); } }; + +/** + * Return a object mapping element dataset names to booleans. + * + * This is useful for data- attributes whose presense represent + * a truthiness, no matter the value of the attribute. The absense of the + * attribute represents falsiness. + * + * This can be useful when Rails-provided boolean-like values are passed + * directly to the HAML template, rather than cast to a string. + * + * @param {HTMLElement} element - The DOM element to inspect + * @param {string[]} names - The dataset (i.e., camelCase) names to inspect + * @returns {Object.<string, boolean>} + */ +export const parseBooleanDataAttributes = ({ dataset }, names) => + names.reduce((acc, name) => { + acc[name] = has(dataset, name); + + return acc; + }, {}); diff --git a/app/assets/javascripts/lib/utils/grammar.js b/app/assets/javascripts/lib/utils/grammar.js index 18f9e2ed846..b1f38429369 100644 --- a/app/assets/javascripts/lib/utils/grammar.js +++ b/app/assets/javascripts/lib/utils/grammar.js @@ -20,18 +20,22 @@ export const toNounSeriesText = items => { if (items.length === 0) { return ''; } else if (items.length === 1) { - return items[0]; + return sprintf(s__(`nounSeries|%{item}`), { item: items[0] }, false); } else if (items.length === 2) { - return sprintf(s__('nounSeries|%{firstItem} and %{lastItem}'), { - firstItem: items[0], - lastItem: items[1], - }); + return sprintf( + s__('nounSeries|%{firstItem} and %{lastItem}'), + { + firstItem: items[0], + lastItem: items[1], + }, + false, + ); } return items.reduce((item, nextItem, idx) => idx === items.length - 1 - ? sprintf(s__('nounSeries|%{item}, and %{lastItem}'), { item, lastItem: nextItem }) - : sprintf(s__('nounSeries|%{item}, %{nextItem}'), { item, nextItem }), + ? sprintf(s__('nounSeries|%{item}, and %{lastItem}'), { item, lastItem: nextItem }, false) + : sprintf(s__('nounSeries|%{item}, %{nextItem}'), { item, nextItem }, false), ); }; diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index 0dfc144c363..4d25ee9e4bd 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -27,9 +27,28 @@ function lineAfter(text, textarea) { .split('\n')[0]; } +function convertMonacoSelectionToAceFormat(sel) { + return { + start: { + row: sel.startLineNumber, + column: sel.startColumn, + }, + end: { + row: sel.endLineNumber, + column: sel.endColumn, + }, + }; +} + +function getEditorSelectionRange(editor) { + return window.gon.features?.monacoBlobs + ? convertMonacoSelectionToAceFormat(editor.getSelection()) + : editor.getSelectionRange(); +} + function editorBlockTagText(text, blockTag, selected, editor) { const lines = text.split('\n'); - const selectionRange = editor.getSelectionRange(); + const selectionRange = getEditorSelectionRange(editor); const shouldRemoveBlock = lines[selectionRange.start.row - 1] === blockTag && lines[selectionRange.end.row + 1] === blockTag; @@ -90,8 +109,12 @@ function moveCursor({ const endPosition = startPosition + select.length; return textArea.setSelectionRange(startPosition, endPosition); } else if (editor) { - editor.navigateLeft(tag.length - tag.indexOf(select)); - editor.getSelection().selectAWord(); + if (window.gon.features?.monacoBlobs) { + editor.selectWithinSelection(select, tag); + } else { + editor.navigateLeft(tag.length - tag.indexOf(select)); + editor.getSelection().selectAWord(); + } return; } } @@ -115,7 +138,11 @@ function moveCursor({ } } else if (editor && editorSelectionStart.row === editorSelectionEnd.row) { if (positionBetweenTags) { - editor.navigateLeft(tag.length); + if (window.gon.features?.monacoBlobs) { + editor.moveCursor(tag.length * -1); + } else { + editor.navigateLeft(tag.length); + } } } } @@ -140,7 +167,7 @@ export function insertMarkdownText({ let textToInsert; if (editor) { - const selectionRange = editor.getSelectionRange(); + const selectionRange = getEditorSelectionRange(editor); editorSelectionStart = selectionRange.start; editorSelectionEnd = selectionRange.end; @@ -237,7 +264,11 @@ export function insertMarkdownText({ } if (editor) { - editor.insert(textToInsert); + if (window.gon.features?.monacoBlobs) { + editor.replaceSelectedText(textToInsert, select); + } else { + editor.insert(textToInsert); + } } else { insertText(textArea, textToInsert); } diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index be3fe1ed620..e2953ce330c 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -1,4 +1,9 @@ -import { isString } from 'lodash'; +import { isString, memoize } from 'lodash'; + +import { + TRUNCATE_WIDTH_DEFAULT_WIDTH, + TRUNCATE_WIDTH_DEFAULT_FONT_SIZE, +} from '~/lib/utils/constants'; /** * Adds a , to a string composed by numbers, at every 3 chars. @@ -73,7 +78,79 @@ export const slugifyWithUnderscore = str => slugify(str, '_'); * @param {Number} maxLength * @returns {String} */ -export const truncate = (string, maxLength) => `${string.substr(0, maxLength - 3)}...`; +export const truncate = (string, maxLength) => { + if (string.length - 1 > maxLength) { + return `${string.substr(0, maxLength - 1)}…`; + } + + return string; +}; + +/** + * This function calculates the average char width. It does so by placing a string in the DOM and measuring the width. + * NOTE: This will cause a reflow and should be used sparsely! + * The default fontFamily is 'sans-serif' and 12px in ECharts, so that is the default basis for calculating the average with. + * https://echarts.apache.org/en/option.html#xAxis.nameTextStyle.fontFamily + * https://echarts.apache.org/en/option.html#xAxis.nameTextStyle.fontSize + * @param {Object} options + * @param {Number} options.fontSize style to size the text for measurement + * @param {String} options.fontFamily style of font family to measure the text with + * @param {String} options.chars string of chars to use as a basis for calculating average width + * @return {Number} + */ +const getAverageCharWidth = memoize(function getAverageCharWidth(options = {}) { + const { + fontSize = 12, + fontFamily = 'sans-serif', + // eslint-disable-next-line @gitlab/require-i18n-strings + chars = ' ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789', + } = options; + const div = document.createElement('div'); + + div.style.fontFamily = fontFamily; + div.style.fontSize = `${fontSize}px`; + // Place outside of view + div.style.position = 'absolute'; + div.style.left = -1000; + div.style.top = -1000; + + div.innerHTML = chars; + + document.body.appendChild(div); + const width = div.clientWidth; + document.body.removeChild(div); + + return width / chars.length / fontSize; +}); + +/** + * This function returns a truncated version of `string` if its estimated rendered width is longer than `maxWidth`, + * otherwise it will return the original `string` + * Inspired by https://bl.ocks.org/tophtucker/62f93a4658387bb61e4510c37e2e97cf + * @param {String} string text to truncate + * @param {Object} options + * @param {Number} options.maxWidth largest rendered width the text may have + * @param {Number} options.fontSize size of the font used to render the text + * @return {String} either the original string or a truncated version + */ +export const truncateWidth = (string, options = {}) => { + const { + maxWidth = TRUNCATE_WIDTH_DEFAULT_WIDTH, + fontSize = TRUNCATE_WIDTH_DEFAULT_FONT_SIZE, + } = options; + const { truncateIndex } = string.split('').reduce( + (memo, char, index) => { + let newIndex = index; + if (memo.width > maxWidth) { + newIndex = memo.truncateIndex; + } + return { width: memo.width + getAverageCharWidth() * fontSize, truncateIndex: newIndex }; + }, + { width: 0, truncateIndex: 0 }, + ); + + return truncate(string, truncateIndex); +}; /** * Truncate SHA to 8 characters diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index 0472b8cf51f..c6c34b831ee 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -344,9 +344,15 @@ export function objectToQuery(obj) { * @param {Object} params The query params to be set/updated * @param {String} url The url to be operated on * @param {Boolean} clearParams Indicates whether existing query params should be removed or not + * @param {Boolean} railsArraySyntax When enabled, changes the array syntax from `keys=` to `keys[]=` according to Rails conventions * @returns {String} A copy of the original with the updated query params */ -export const setUrlParams = (params, url = window.location.href, clearParams = false) => { +export const setUrlParams = ( + params, + url = window.location.href, + clearParams = false, + railsArraySyntax = false, +) => { const urlObj = new URL(url); const queryString = urlObj.search; const searchParams = clearParams ? new URLSearchParams('') : new URLSearchParams(queryString); @@ -355,11 +361,12 @@ export const setUrlParams = (params, url = window.location.href, clearParams = f if (params[key] === null || params[key] === undefined) { searchParams.delete(key); } else if (Array.isArray(params[key])) { + const keyName = railsArraySyntax ? `${key}[]` : key; params[key].forEach((val, idx) => { if (idx === 0) { - searchParams.set(key, val); + searchParams.set(keyName, val); } else { - searchParams.append(key, val); + searchParams.append(keyName, val); } }); } else { diff --git a/app/assets/javascripts/logs/components/environment_logs.vue b/app/assets/javascripts/logs/components/environment_logs.vue index 01a4cbd41f6..f37f48aa431 100644 --- a/app/assets/javascripts/logs/components/environment_logs.vue +++ b/app/assets/javascripts/logs/components/environment_logs.vue @@ -8,6 +8,7 @@ import { GlDropdown, GlDropdownHeader, GlDropdownItem, + GlDropdownDivider, GlInfiniteScroll, } from '@gitlab/ui'; @@ -27,6 +28,7 @@ export default { GlDropdown, GlDropdownHeader, GlDropdownItem, + GlDropdownDivider, GlInfiniteScroll, LogSimpleFilters, LogAdvancedFilters, @@ -55,6 +57,10 @@ export default { type: String, required: true, }, + clustersPath: { + type: String, + required: true, + }, }, data() { return { @@ -63,7 +69,7 @@ export default { }; }, computed: { - ...mapState('environmentLogs', ['environments', 'timeRange', 'logs', 'pods']), + ...mapState('environmentLogs', ['environments', 'timeRange', 'logs', 'pods', 'managedApps']), ...mapGetters('environmentLogs', ['trace', 'showAdvancedFilters']), showLoader() { @@ -85,12 +91,15 @@ export default { }); this.fetchEnvironments(this.environmentsPath); + this.fetchManagedApps(this.clustersPath); }, methods: { ...mapActions('environmentLogs', [ 'setInitData', 'showEnvironment', + 'showManagedApp', 'fetchEnvironments', + 'fetchManagedApps', 'refreshPodLogs', 'fetchMoreLogsPrepend', 'dismissRequestEnvironmentsError', @@ -101,6 +110,9 @@ export default { isCurrentEnvironment(envName) { return envName === this.environments.current; }, + isCurrentManagedApp(appName) { + return appName === this.managedApps.current; + }, topReached() { if (!this.logs.isLoading) { this.fetchMoreLogsPrepend(); @@ -164,12 +176,12 @@ export default { <div class="flex-grow-0"> <gl-dropdown id="environments-dropdown" - :text="environments.current" + :text="environments.current || managedApps.current" :disabled="environments.isLoading" class="mb-2 gl-h-32 pr-2 d-flex d-md-block js-environments-dropdown" > - <gl-dropdown-header class="text-center"> - {{ s__('Environments|Select environment') }} + <gl-dropdown-header class="gl-text-center"> + {{ s__('Environments|Environments') }} </gl-dropdown-header> <gl-dropdown-item v-for="env in environments.options" @@ -181,7 +193,24 @@ export default { :class="{ invisible: !isCurrentEnvironment(env.name) }" name="status_success_borderless" /> - <div class="flex-grow-1">{{ env.name }}</div> + <div class="gl-flex-grow-1">{{ env.name }}</div> + </div> + </gl-dropdown-item> + <gl-dropdown-divider /> + <gl-dropdown-header class="gl-text-center"> + {{ s__('Environments|Managed apps') }} + </gl-dropdown-header> + <gl-dropdown-item + v-for="app in managedApps.options" + :key="app.id" + @click="showManagedApp(app.name)" + > + <div class="gl-display-flex"> + <gl-icon + :class="{ invisible: !isCurrentManagedApp(app.name) }" + name="status_success_borderless" + /> + <div class="gl-flex-grow-1">{{ app.name }}</div> </div> </gl-dropdown-item> </gl-dropdown> @@ -202,7 +231,7 @@ export default { <log-control-buttons ref="scrollButtons" - class="flex-grow-0 pr-2 mb-2 controllers" + class="flex-grow-0 pr-2 mb-2 controllers gl-display-inline-flex" :scroll-down-button-disabled="scrollDownButtonDisabled" @refresh="refreshPodLogs()" @scrollDown="scrollDown" diff --git a/app/assets/javascripts/logs/components/log_control_buttons.vue b/app/assets/javascripts/logs/components/log_control_buttons.vue index 3f5de4c22e0..e44b5394fa1 100644 --- a/app/assets/javascripts/logs/components/log_control_buttons.vue +++ b/app/assets/javascripts/logs/components/log_control_buttons.vue @@ -1,11 +1,9 @@ <script> -import { GlDeprecatedButton, GlTooltipDirective } from '@gitlab/ui'; -import Icon from '~/vue_shared/components/icon.vue'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; export default { components: { - Icon, - GlDeprecatedButton, + GlButton, }, directives: { GlTooltip: GlTooltipDirective, @@ -51,14 +49,16 @@ export default { :title="__('Scroll to top')" aria-labelledby="scroll-to-top" > - <gl-deprecated-button + <gl-button id="scroll-to-top" - class="btn-blank js-scroll-to-top" + class="js-scroll-to-top gl-mr-2 btn-blank" :aria-label="__('Scroll to top')" :disabled="scrollUpButtonDisabled" + icon="scroll_up" + category="primary" + variant="default" @click="handleScrollUp()" - ><icon name="scroll_up" - /></gl-deprecated-button> + /> </div> <div v-if="scrollDownAvailable" @@ -68,25 +68,28 @@ export default { :title="__('Scroll to bottom')" aria-labelledby="scroll-to-bottom" > - <gl-deprecated-button + <gl-button id="scroll-to-bottom" - class="btn-blank js-scroll-to-bottom" + class="js-scroll-to-bottom gl-mr-2 btn-blank" :aria-label="__('Scroll to bottom')" :v-if="scrollDownAvailable" :disabled="scrollDownButtonDisabled" + icon="scroll_down" + category="primary" + variant="default" @click="handleScrollDown()" - ><icon name="scroll_down" - /></gl-deprecated-button> + /> </div> - <gl-deprecated-button + <gl-button id="refresh-log" v-gl-tooltip - class="ml-1 px-2 js-refresh-log" + class="js-refresh-log" :title="__('Refresh')" :aria-label="__('Refresh')" + icon="retry" + category="primary" + variant="default" @click="handleRefreshClick" - > - <icon name="retry" /> - </gl-deprecated-button> + /> </div> </template> diff --git a/app/assets/javascripts/logs/constants.js b/app/assets/javascripts/logs/constants.js index f83d369c6b8..0cdef53df34 100644 --- a/app/assets/javascripts/logs/constants.js +++ b/app/assets/javascripts/logs/constants.js @@ -8,4 +8,10 @@ export const tracking = { TIME_RANGE_SET: 'time_range_set', ENVIRONMENT_SELECTED: 'environment_selected', REFRESH_POD_LOGS: 'refresh_pod_logs', + MANAGED_APP_SELECTED: 'managed_app_selected', +}; + +export const logExplorerOptions = { + environments: 'environments', + managedApps: 'managedApps', }; diff --git a/app/assets/javascripts/logs/stores/actions.js b/app/assets/javascripts/logs/stores/actions.js index d828e8f8a3e..0edd825b6e9 100644 --- a/app/assets/javascripts/logs/stores/actions.js +++ b/app/assets/javascripts/logs/stores/actions.js @@ -2,7 +2,7 @@ import { backOff } from '~/lib/utils/common_utils'; import httpStatusCodes from '~/lib/utils/http_status'; import axios from '~/lib/utils/axios_utils'; import { convertToFixedRange } from '~/lib/utils/datetime_range'; -import { TOKEN_TYPE_POD_NAME, tracking } from '../constants'; +import { TOKEN_TYPE_POD_NAME, tracking, logExplorerOptions } from '../constants'; import trackLogs from '../logs_tracking_helper'; import * as types from './mutation_types'; @@ -25,9 +25,15 @@ const requestUntilData = (url, params) => const requestLogsUntilData = ({ commit, state }) => { const params = {}; - const { logs_api_path } = state.environments.options.find( - ({ name }) => name === state.environments.current, - ); + const type = state.environments.current + ? logExplorerOptions.environments + : logExplorerOptions.managedApps; + const selectedObj = state[type].options.find(({ name }) => name === state[type].current); + + const path = + type === logExplorerOptions.environments + ? selectedObj.logs_api_path + : selectedObj.gitlab_managed_apps_logs_path; if (state.pods.current) { params.pod_name = state.pods.current; @@ -48,7 +54,7 @@ const requestLogsUntilData = ({ commit, state }) => { params.cursor = state.logs.cursor; } - return requestUntilData(logs_api_path, params); + return requestUntilData(path, params); }; /** @@ -100,6 +106,11 @@ export const showEnvironment = ({ dispatch, commit }, environmentName) => { dispatch('fetchLogs', tracking.ENVIRONMENT_SELECTED); }; +export const showManagedApp = ({ dispatch, commit }, managedApp) => { + commit(types.SET_MANAGED_APP, managedApp); + dispatch('fetchLogs', tracking.MANAGED_APP_SELECTED); +}; + export const refreshPodLogs = ({ dispatch, commit }) => { commit(types.REFRESH_POD_LOGS); dispatch('fetchLogs', tracking.REFRESH_POD_LOGS); @@ -124,6 +135,23 @@ export const fetchEnvironments = ({ commit, dispatch }, environmentsPath) => { }); }; +/** + * Fetch managed apps data + * @param {Object} store + * @param {String} clustersPath + */ + +export const fetchManagedApps = ({ commit }, clustersPath) => { + return axios + .get(clustersPath) + .then(({ data }) => { + commit(types.RECEIVE_MANAGED_APPS_DATA_SUCCESS, data.clusters); + }) + .catch(() => { + commit(types.RECEIVE_MANAGED_APPS_DATA_ERROR); + }); +}; + export const fetchLogs = ({ commit, state }, trackingLabel) => { commit(types.REQUEST_LOGS_DATA); diff --git a/app/assets/javascripts/logs/stores/mutation_types.js b/app/assets/javascripts/logs/stores/mutation_types.js index 9010ec51817..eaa4b13f8bd 100644 --- a/app/assets/javascripts/logs/stores/mutation_types.js +++ b/app/assets/javascripts/logs/stores/mutation_types.js @@ -1,5 +1,6 @@ export const SET_PROJECT_ENVIRONMENT = 'SET_PROJECT_ENVIRONMENT'; export const SET_SEARCH = 'SET_SEARCH'; +export const SET_MANAGED_APP = 'SET_MANAGED_APP'; export const SET_TIME_RANGE = 'SET_TIME_RANGE'; export const SHOW_TIME_RANGE_INVALID_WARNING = 'SHOW_TIME_RANGE_INVALID_WARNING'; @@ -12,6 +13,9 @@ export const RECEIVE_ENVIRONMENTS_DATA_SUCCESS = 'RECEIVE_ENVIRONMENTS_DATA_SUCC export const RECEIVE_ENVIRONMENTS_DATA_ERROR = 'RECEIVE_ENVIRONMENTS_DATA_ERROR'; export const HIDE_REQUEST_ENVIRONMENTS_ERROR = 'HIDE_REQUEST_ENVIRONMENTS_ERROR'; +export const RECEIVE_MANAGED_APPS_DATA_SUCCESS = 'RECEIVE_MANAGED_APPS_DATA_SUCCESS'; +export const RECEIVE_MANAGED_APPS_DATA_ERROR = 'RECEIVE_MANAGED_APPS_DATA_ERROR'; + export const REQUEST_LOGS_DATA = 'REQUEST_LOGS_DATA'; export const RECEIVE_LOGS_DATA_SUCCESS = 'RECEIVE_LOGS_DATA_SUCCESS'; export const RECEIVE_LOGS_DATA_ERROR = 'RECEIVE_LOGS_DATA_ERROR'; diff --git a/app/assets/javascripts/logs/stores/mutations.js b/app/assets/javascripts/logs/stores/mutations.js index 5e1c794c3a9..be22204d88d 100644 --- a/app/assets/javascripts/logs/stores/mutations.js +++ b/app/assets/javascripts/logs/stores/mutations.js @@ -32,6 +32,9 @@ export default { // Clear current pod options state.pods.current = null; state.pods.options = []; + + // Clear current managedApps options + state.managedApps.current = null; }, [types.REQUEST_ENVIRONMENTS_DATA](state) { state.environments.options = []; @@ -107,4 +110,24 @@ export default { [types.RECEIVE_PODS_DATA_ERROR](state) { state.pods.options = []; }, + // Managed apps data + [types.RECEIVE_MANAGED_APPS_DATA_SUCCESS](state, apps) { + state.managedApps.options = apps; + state.managedApps.isLoading = false; + }, + [types.RECEIVE_MANAGED_APPS_DATA_ERROR](state) { + state.managedApps.options = []; + state.managedApps.isLoading = false; + state.managedApps.fetchError = true; + }, + [types.SET_MANAGED_APP](state, managedApp) { + state.managedApps.current = managedApp; + + // Clear current pod options + state.pods.current = null; + state.pods.options = []; + + // Clear current environment options + state.environments.current = null; + }, }; diff --git a/app/assets/javascripts/logs/stores/state.js b/app/assets/javascripts/logs/stores/state.js index 11185c9ccf1..fbe6589dd84 100644 --- a/app/assets/javascripts/logs/stores/state.js +++ b/app/assets/javascripts/logs/stores/state.js @@ -31,6 +31,16 @@ export default () => ({ }, /** + * Managed apps list information + */ + managedApps: { + options: [], + isLoading: false, + current: null, + fetchError: false, + }, + + /** * Logs including trace */ logs: { diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 5f5fd790f67..3f85295a5ed 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -19,6 +19,7 @@ import { getLocationHash, visitUrl } from './lib/utils/url_utility'; // everything else import loadAwardsHandler from './awards_handler'; +import applyGitLabUIConfig from '@gitlab/ui/dist/config'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import Flash, { removeFlashClickListener } from './flash'; import './gl_dropdown'; @@ -32,7 +33,7 @@ import initFrequentItemDropdowns from './frequent_items'; import initBreadcrumbs from './breadcrumb'; import initUsagePingConsent from './usage_ping_consent'; import initPerformanceBar from './performance_bar'; -import initGlobalSearchInput from './global_search_input'; +import initSearchAutocomplete from './search_autocomplete'; import GlFieldErrors from './gl_field_errors'; import initUserPopovers from './user_popovers'; import initBroadcastNotifications from './broadcast_notification'; @@ -42,6 +43,8 @@ import { __ } from './locale'; import 'ee_else_ce/main_ee'; +applyGitLabUIConfig(); + // expose jQuery as global (TODO: remove these) window.jQuery = jQuery; window.$ = jQuery; @@ -62,12 +65,12 @@ function disableJQueryAnimations() { } // Disable jQuery animations -if (gon && gon.disable_animations) { +if (gon?.disable_animations) { disableJQueryAnimations(); } // inject test utilities if necessary -if (process.env.NODE_ENV !== 'production' && gon && gon.test_env) { +if (process.env.NODE_ENV !== 'production' && gon?.test_env) { disableJQueryAnimations(); import(/* webpackMode: "eager" */ './test_utils/'); // eslint-disable-line no-unused-expressions } @@ -110,7 +113,7 @@ function deferredInitialisation() { initFrequentItemDropdowns(); initPersistentUserCallouts(); - if (document.querySelector('.search')) initGlobalSearchInput(); + if (document.querySelector('.search')) initSearchAutocomplete(); addSelectOnFocusBehaviour('.js-select-on-focus'); @@ -132,27 +135,6 @@ function deferredInitialisation() { .fadeOut(); }); - // Initialize select2 selects - if ($('select.select2').length) { - import(/* webpackChunkName: 'select2' */ 'select2/select2') - .then(() => { - $('select.select2').select2({ - width: 'resolve', - minimumResultsForSearch: 10, - dropdownAutoWidth: true, - }); - - // Close select2 on escape - $('.js-select2').on('select2-close', () => { - setTimeout(() => { - $('.select2-container-active').removeClass('select2-container-active'); - $(':focus').blur(); - }, 1); - }); - }) - .catch(() => {}); - } - const glTooltipDelay = localStorage.getItem('gl-tooltip-delay'); const delay = glTooltipDelay ? JSON.parse(glTooltipDelay) : 0; @@ -179,9 +161,7 @@ function deferredInitialisation() { document.addEventListener('DOMContentLoaded', () => { const $body = $('body'); const $document = $(document); - const $window = $(window); - const $sidebarGutterToggle = $('.js-sidebar-toggle'); - let bootstrapBreakpoint = bp.getBreakpointSize(); + const bootstrapBreakpoint = bp.getBreakpointSize(); if (document.querySelector('#js-peek')) initPerformanceBar({ container: '#js-peek' }); @@ -199,6 +179,15 @@ document.addEventListener('DOMContentLoaded', () => { } }); + /** + * TODO: Apparently we are collapsing the right sidebar on certain screensizes per default + * except on issue board pages. Why can't we do it with CSS? + * + * Proposal: Expose a global sidebar API, which we could import wherever we are manipulating + * the visibility of the sidebar. + * + * Quick fix: Get rid of jQuery for this implementation + */ const isBoardsPage = /(projects|groups):boards:show/.test(document.body.dataset.page); if (!isBoardsPage && (bootstrapBreakpoint === 'sm' || bootstrapBreakpoint === 'xs')) { const $rightSidebar = $('aside.right-sidebar'); @@ -225,14 +214,12 @@ document.addEventListener('DOMContentLoaded', () => { localTimeAgo($('abbr.timeago, .js-timeago'), true); - // Form submitter - $('.trigger-submit').on('change', function triggerSubmitCallback() { - $(this) - .parents('form') - .submit(); - }); - - // Disable form buttons while a form is submitting + /** + * This disables form buttons while a form is submitting + * We do not difinitively know all of the places where this is used + * + * TODO: Defer execution, migrate to behaviors, and add sentry logging + */ $body.on('ajax:complete, ajax:beforeSend, submit', 'form', function ajaxCompleteCallback(e) { const $buttons = $('[type="submit"], .js-disable-on-submit', this).not('.js-no-auto-disable'); switch (e.type) { @@ -259,7 +246,11 @@ document.addEventListener('DOMContentLoaded', () => { $('.header-content').toggleClass('menu-expanded'); }); - // Commit show suppressed diff + /** + * Show suppressed commit diff + * + * TODO: Move to commit diff pages + */ $document.on('click', '.diff-content .js-show-suppressed-diff', function showDiffCallback() { const $container = $(this).parent(); $container.next('table').show(); @@ -290,39 +281,6 @@ document.addEventListener('DOMContentLoaded', () => { $(document).trigger('toggle.comments'); }); - $document.on('breakpoint:change', (e, breakpoint) => { - const breakpointSizes = ['md', 'sm', 'xs']; - if (breakpointSizes.includes(breakpoint)) { - const $gutterIcon = $sidebarGutterToggle.find('i'); - if ($gutterIcon.hasClass('fa-angle-double-right')) { - $sidebarGutterToggle.trigger('click'); - } - - const sidebarGutterVueToggleEl = document.querySelector('.js-sidebar-vue-toggle'); - - // Sidebar has an icon which corresponds to collapsing the sidebar - // only then trigger the click. - if (sidebarGutterVueToggleEl) { - const collapseIcon = sidebarGutterVueToggleEl.querySelector('i.fa-angle-double-right'); - - if (collapseIcon) { - collapseIcon.click(); - } - } - } - }); - - function fitSidebarForSize() { - const oldBootstrapBreakpoint = bootstrapBreakpoint; - bootstrapBreakpoint = bp.getBreakpointSize(); - - if (bootstrapBreakpoint !== oldBootstrapBreakpoint) { - $document.trigger('breakpoint:change', [bootstrapBreakpoint]); - } - } - - $window.on('resize.app', fitSidebarForSize); - $('form.filter-form').on('submit', function filterFormSubmitCallback(event) { const link = document.createElement('a'); link.href = this.action; diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js index d719fd8748d..f220e9e0192 100644 --- a/app/assets/javascripts/members.js +++ b/app/assets/javascripts/members.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { disableButtonIfEmptyField } from '~/lib/utils/common_utils'; export default class Members { constructor() { @@ -13,7 +14,7 @@ export default class Members { $('.js-edit-member-form') .off('ajax:success') .on('ajax:success', this.formSuccess.bind(this)); - gl.utils.disableButtonIfEmptyField('#user_ids', 'input[name=commit]', 'change'); + disableButtonIfEmptyField('#user_ids', 'input[name=commit]', 'change'); } dropdownClicked(options) { diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 6c794c1d324..a90e4e32d34 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -1,6 +1,7 @@ /* eslint-disable func-names, no-underscore-dangle, consistent-return */ import $ from 'jquery'; +import axios from './lib/utils/axios_utils'; import { __ } from '~/locale'; import createFlash from '~/flash'; import TaskList from './task_list'; @@ -65,9 +66,17 @@ MergeRequest.prototype.showAllCommits = function() { MergeRequest.prototype.initMRBtnListeners = function() { const _this = this; - return $('a.btn-close, a.btn-reopen').on('click', function(e) { + return $('.btn-close, .btn-reopen').on('click', function(e) { const $this = $(this); const shouldSubmit = $this.hasClass('btn-comment'); + if ($this.hasClass('js-btn-issue-action')) { + const url = $this.data('endpoint'); + return axios + .put(url) + .then(() => window.location.reload()) + .catch(() => createFlash(__('Something went wrong.'))); + } + if (shouldSubmit && $this.data('submitted')) { return; } diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 6c63ab7cf95..e9b7b56a160 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -226,6 +226,8 @@ export default class MergeRequestTabs { this.resetViewContainer(); this.destroyPipelinesView(); } + + $('.detail-page-description').renderGFM(); } else if (action === this.currentAction) { // ContentTop is used to handle anything at the top of the page before the main content const mainContentContainer = document.querySelector('.content-wrapper'); diff --git a/app/assets/javascripts/monitoring/components/charts/anomaly.vue b/app/assets/javascripts/monitoring/components/charts/anomaly.vue index 34da5885c97..ac401c6e381 100644 --- a/app/assets/javascripts/monitoring/components/charts/anomaly.vue +++ b/app/assets/javascripts/monitoring/components/charts/anomaly.vue @@ -218,7 +218,7 @@ export default { <gl-chart-series-label :color="content.color"> {{ content.name }} </gl-chart-series-label> - <div class="prepend-left-32"> + <div class="gl-ml-7"> {{ yValueFormatted(seriesIndex, content.dataIndex) }} </div> </div> diff --git a/app/assets/javascripts/monitoring/components/charts/heatmap.vue b/app/assets/javascripts/monitoring/components/charts/heatmap.vue index f6f266dacf3..ddb44f7b1be 100644 --- a/app/assets/javascripts/monitoring/components/charts/heatmap.vue +++ b/app/assets/javascripts/monitoring/components/charts/heatmap.vue @@ -48,7 +48,10 @@ export default { return this.result.values.map(val => { const [yLabel] = val; - return formatDate(new Date(yLabel), { format: formats.shortTime, timezone: this.timezone }); + return formatDate(new Date(yLabel), { + format: formats.shortTime, + timezone: this.timezone, + }); }); }, result() { diff --git a/app/assets/javascripts/monitoring/components/charts/options.js b/app/assets/javascripts/monitoring/components/charts/options.js index f7822e69b1d..42252dd5897 100644 --- a/app/assets/javascripts/monitoring/components/charts/options.js +++ b/app/assets/javascripts/monitoring/components/charts/options.js @@ -17,7 +17,9 @@ const defaultTooltipFormat = defaultFormat; const defaultTooltipPrecision = 3; // Give enough space for y-axis with units and name. -const chartGridLeft = 75; +const chartGridLeft = 63; // larger gap than gitlab-ui's default to fit formatted numbers +const chartGridRight = 10; // half of the scroll-handle icon for data zoom +const yAxisNameGap = chartGridLeft - 12; // offset the axis label line-height // Axis options @@ -62,7 +64,7 @@ export const getYAxisOptions = ({ precision = defaultYAxisPrecision, } = {}) => { return { - nameGap: 63, // larger gap than gitlab-ui's default to fit with formatted numbers + nameGap: yAxisNameGap, scale: true, boundaryGap: yAxisBoundaryGap, @@ -74,11 +76,14 @@ export const getYAxisOptions = ({ }; }; -export const getTimeAxisOptions = ({ timezone = timezones.LOCAL } = {}) => ({ +export const getTimeAxisOptions = ({ + timezone = timezones.LOCAL, + format = formats.shortDateTime, +} = {}) => ({ name: __('Time'), type: axisTypes.time, axisLabel: { - formatter: date => formatDate(date, { format: formats.shortTime, timezone }), + formatter: date => formatDate(date, { format, timezone }), }, axisPointer: { snap: false, @@ -90,7 +95,10 @@ export const getTimeAxisOptions = ({ timezone = timezones.LOCAL } = {}) => ({ /** * Grid with enough room to display chart. */ -export const getChartGrid = ({ left = chartGridLeft } = {}) => ({ left }); +export const getChartGrid = ({ left = chartGridLeft, right = chartGridRight } = {}) => ({ + left, + right, +}); // Tooltip options diff --git a/app/assets/javascripts/monitoring/components/charts/single_stat.vue b/app/assets/javascripts/monitoring/components/charts/single_stat.vue index eee5eaa5eca..106c76a97dc 100644 --- a/app/assets/javascripts/monitoring/components/charts/single_stat.vue +++ b/app/assets/javascripts/monitoring/components/charts/single_stat.vue @@ -1,9 +1,11 @@ <script> import { GlSingleStat } from '@gitlab/ui/dist/charts'; +import { __ } from '~/locale'; import { SUPPORTED_FORMATS, getFormatter } from '~/lib/utils/unit_format'; import { graphDataValidatorForValues } from '../../utils'; const defaultPrecision = 2; +const emptyStateMsg = __('No data to display'); export default { components: { @@ -21,6 +23,9 @@ export default { queryInfo() { return this.graphData.metrics[0]; }, + queryMetric() { + return this.queryInfo.result[0]?.metric; + }, queryResult() { return this.queryInfo.result[0]?.value[1]; }, @@ -33,6 +38,12 @@ export default { statValue() { let formatter; + // if field is present the metric value is not displayed. Hence + // the early exit without formatting. + if (this.graphData?.field) { + return this.queryMetric?.[this.graphData.field] ?? emptyStateMsg; + } + if (this.graphData?.maxValue) { formatter = getFormatter(SUPPORTED_FORMATS.percent); return formatter(this.queryResult / Number(this.graphData.maxValue), defaultPrecision); diff --git a/app/assets/javascripts/monitoring/components/charts/stacked_column.vue b/app/assets/javascripts/monitoring/components/charts/stacked_column.vue index ac31d107e63..9bcd4419a14 100644 --- a/app/assets/javascripts/monitoring/components/charts/stacked_column.vue +++ b/app/assets/javascripts/monitoring/components/charts/stacked_column.vue @@ -6,7 +6,7 @@ import { chartHeight, legendLayoutTypes } from '../../constants'; import { s__ } from '~/locale'; import { graphDataValidatorForValues } from '../../utils'; import { getTimeAxisOptions, axisTypes } from './options'; -import { timezones } from '../../format_date'; +import { formats, timezones } from '../../format_date'; export default { components: { @@ -97,7 +97,7 @@ export default { chartOptions() { return { xAxis: { - ...getTimeAxisOptions({ timezone: this.timezone }), + ...getTimeAxisOptions({ timezone: this.timezone, format: formats.shortTime }), type: this.xAxisType, }, dataZoom: [this.dataZoomConfig], diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue index 28af2d8ba77..f2add429a80 100644 --- a/app/assets/javascripts/monitoring/components/charts/time_series.vue +++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue @@ -415,7 +415,7 @@ export default { <gl-chart-series-label :color="isMultiSeries ? content.color : ''"> {{ content.name }} </gl-chart-series-label> - <div class="prepend-left-32"> + <div class="gl-ml-7"> {{ content.value }} </div> </div> diff --git a/app/assets/javascripts/monitoring/components/create_dashboard_modal.vue b/app/assets/javascripts/monitoring/components/create_dashboard_modal.vue new file mode 100644 index 00000000000..74799002b17 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/create_dashboard_modal.vue @@ -0,0 +1,66 @@ +<script> +import { GlButton, GlModal, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { isSafeURL } from '~/lib/utils/url_utility'; + +export default { + components: { GlButton, GlModal, GlSprintf }, + props: { + modalId: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + validator: isSafeURL, + }, + addDashboardDocumentationPath: { + type: String, + required: true, + }, + }, + methods: { + cancelHandler() { + this.$refs.modal.hide(); + }, + }, + i18n: { + titleText: s__('Metrics|Create your dashboard configuration file'), + mainText: s__( + 'Metrics|To create a new dashboard, add a new YAML file to %{codeStart}.gitlab/dashboards%{codeEnd} at the root of this project.', + ), + }, +}; +</script> + +<template> + <gl-modal ref="modal" :modal-id="modalId" :title="$options.i18n.titleText"> + <p> + <gl-sprintf :message="$options.i18n.mainText"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + <template #modal-footer> + <gl-button category="secondary" @click="cancelHandler">{{ s__('Metrics|Cancel') }}</gl-button> + <gl-button + category="secondary" + variant="info" + target="_blank" + :href="addDashboardDocumentationPath" + data-testid="create-dashboard-modal-docs-button" + > + {{ s__('Metrics|View documentation') }} + </gl-button> + <gl-button + variant="success" + data-testid="create-dashboard-modal-repo-button" + :href="projectPath" + > + {{ s__('Metrics|Open repository') }} + </gl-button> + </template> + </gl-modal> +</template> diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index f54319d283e..bde62275797 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -1,6 +1,7 @@ <script> import { mapActions, mapState, mapGetters } from 'vuex'; import VueDraggable from 'vuedraggable'; +import Mousetrap from 'mousetrap'; import { GlIcon, GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; import DashboardHeader from './dashboard_header.vue'; import DashboardPanel from './dashboard_panel.vue'; @@ -24,7 +25,7 @@ import { expandedPanelPayloadFromUrl, convertVariablesForURL, } from '../utils'; -import { metricStates } from '../constants'; +import { metricStates, keyboardShortcutKeys } from '../constants'; import { defaultTimeRange } from '~/vue_shared/constants'; export default { @@ -71,6 +72,10 @@ export default { type: String, required: true, }, + addDashboardDocumentationPath: { + type: String, + required: true, + }, settingsPath: { type: String, required: true, @@ -149,21 +154,25 @@ export default { selectedTimeRange: timeRangeFromUrl() || defaultTimeRange, isRearrangingPanels: false, originalDocumentTitle: document.title, + hoveredPanel: '', }; }, computed: { ...mapState('monitoringDashboard', [ 'dashboard', 'emptyState', - 'showEmptyState', 'expandedPanel', 'variables', 'links', 'currentDashboard', + 'hasDashboardValidationWarnings', ]), ...mapGetters('monitoringDashboard', ['selectedDashboard', 'getMetricStates']), + shouldShowEmptyState() { + return Boolean(this.emptyState); + }, shouldShowVariablesSection() { - return Object.keys(this.variables).length > 0; + return Boolean(this.variables.length); }, shouldShowLinksSection() { return Object.keys(this.links).length > 0; @@ -197,12 +206,29 @@ export default { selectedDashboard(dashboard) { this.prependToDocumentTitle(dashboard?.display_name); }, + hasDashboardValidationWarnings(hasWarnings) { + /** + * This watcher is set for future SPA behaviour of the dashboard + */ + if (hasWarnings) { + createFlash( + s__( + 'Metrics|Your dashboard schema is invalid. Edit the dashboard to correct the YAML schema.', + ), + 'warning', + ); + } + }, }, created() { window.addEventListener('keyup', this.onKeyup); + + Mousetrap.bind(Object.values(keyboardShortcutKeys), this.runShortcut); }, destroyed() { window.removeEventListener('keyup', this.onKeyup); + + Mousetrap.unbind(Object.values(keyboardShortcutKeys)); }, mounted() { if (!this.hasMetrics) { @@ -254,6 +280,14 @@ export default { return null; }, /** + * Return true if the entire group is loading. + * @param {String} groupKey - Identifier for group + * @returns {boolean} + */ + isGroupLoading(groupKey) { + return this.groupSingleEmptyState(groupKey) === metricStates.LOADING; + }, + /** * A group should be not collapsed if any metric is loaded (OK) * * @param {String} groupKey - Identifier for group @@ -302,6 +336,66 @@ export default { // As a fallback, switch to default time range instead this.selectedTimeRange = defaultTimeRange; }, + isPanelHalfWidth(panelIndex, totalPanels) { + /** + * A single panel on a row should take the full width of its parent. + * All others should have half the width their parent. + */ + const isNumberOfPanelsEven = totalPanels % 2 === 0; + const isLastPanel = panelIndex === totalPanels - 1; + + return isNumberOfPanelsEven || !isLastPanel; + }, + /** + * TODO: Investigate this to utilize the eventBus from Vue + * The intentation behind this cleanup is to allow for better tests + * as well as use the correct eventBus facilities that are compatible + * with Vue 3 + * https://gitlab.com/gitlab-org/gitlab/-/issues/225583 + */ + // + runShortcut(e) { + const panel = this.$refs[this.hoveredPanel]; + + if (!panel) return; + + const [panelInstance] = panel; + let actionToRun = ''; + + switch (e.key) { + case keyboardShortcutKeys.EXPAND: + actionToRun = 'onExpandFromKeyboardShortcut'; + break; + + case keyboardShortcutKeys.VISIT_LOGS: + actionToRun = 'visitLogsPageFromKeyboardShortcut'; + break; + + case keyboardShortcutKeys.SHOW_ALERT: + actionToRun = 'showAlertModalFromKeyboardShortcut'; + break; + + case keyboardShortcutKeys.DOWNLOAD_CSV: + actionToRun = 'downloadCsvFromKeyboardShortcut'; + break; + + case keyboardShortcutKeys.CHART_COPY: + actionToRun = 'copyChartLinkFromKeyboardShotcut'; + break; + + default: + actionToRun = 'onExpandFromKeyboardShortcut'; + break; + } + + panelInstance[actionToRun](); + }, + setHoveredPanel(groupKey, graphIndex) { + this.hoveredPanel = `dashboard-panel-${groupKey}-${graphIndex}`; + }, + clearHoveredPanel() { + this.hoveredPanel = ''; + }, }, i18n: { goBackLabel: s__('Metrics|Go back (Esc)'), @@ -315,6 +409,7 @@ export default { v-if="showHeader" ref="prometheusGraphsHeader" class="prometheus-graphs-header d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 border-bottom bg-gray-light" + :add-dashboard-documentation-path="addDashboardDocumentationPath" :default-branch="defaultBranch" :rearrange-panels-available="rearrangePanelsAvailable" :custom-metrics-available="customMetricsAvailable" @@ -327,9 +422,9 @@ export default { @dateTimePickerInvalid="onDateTimePickerInvalid" @setRearrangingPanels="onSetRearrangingPanels" /> - <variables-section v-if="shouldShowVariablesSection && !showEmptyState" /> - <links-section v-if="shouldShowLinksSection && !showEmptyState" /> - <div v-if="!showEmptyState"> + <template v-if="!shouldShowEmptyState"> + <variables-section v-if="shouldShowVariablesSection" /> + <links-section v-if="shouldShowLinksSection" /> <dashboard-panel v-show="expandedPanel.panel" ref="expandedPanel" @@ -364,6 +459,7 @@ export default { :key="`${groupData.group}.${groupData.priority}`" :name="groupData.group" :show-panels="showPanels" + :is-loading="isGroupLoading(groupData.key)" :collapse-group="collapseGroup(groupData.key)" > <vue-draggable @@ -377,8 +473,14 @@ export default { <div v-for="(graphData, graphIndex) in groupData.panels" :key="`dashboard-panel-${graphIndex}`" - class="col-12 col-lg-6 px-2 mb-2 draggable" - :class="{ 'draggable-enabled': isRearrangingPanels }" + data-testid="dashboard-panel-layout-wrapper" + class="col-12 px-2 mb-2 draggable" + :class="{ + 'draggable-enabled': isRearrangingPanels, + 'col-lg-6': isPanelHalfWidth(graphIndex, groupData.panels.length), + }" + @mouseover="setHoveredPanel(groupData.key, graphIndex)" + @mouseout="clearHoveredPanel" > <div class="position-relative draggable-panel js-draggable-panel"> <div @@ -392,6 +494,7 @@ export default { </div> <dashboard-panel + :ref="`dashboard-panel-${groupData.key}-${graphIndex}`" :settings-path="settingsPath" :clipboard-text="generatePanelUrl(groupData.group, graphData)" :graph-data="graphData" @@ -414,7 +517,7 @@ export default { </div> </graph-group> </div> - </div> + </template> <empty-state v-else :selected-state="emptyState" diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue index 16a21ae0d3c..fe6ca3a2a07 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_header.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_header.vue @@ -2,12 +2,16 @@ import { debounce } from 'lodash'; import { mapActions, mapState, mapGetters } from 'vuex'; import { + GlButton, GlIcon, GlDeprecatedButton, GlDropdown, GlDropdownItem, GlDropdownHeader, GlDropdownDivider, + GlNewDropdown, + GlNewDropdownDivider, + GlNewDropdownItem, GlModal, GlLoadingIcon, GlSearchBoxByType, @@ -22,6 +26,9 @@ import Icon from '~/vue_shared/components/icon.vue'; import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue'; import DashboardsDropdown from './dashboards_dropdown.vue'; +import RefreshButton from './refresh_button.vue'; +import CreateDashboardModal from './create_dashboard_modal.vue'; +import DuplicateDashboardModal from './duplicate_dashboard_modal.vue'; import TrackEventDirective from '~/vue_shared/directives/track_event'; import { getAddMetricTrackingOptions, timeRangeToUrl } from '../utils'; @@ -31,6 +38,7 @@ import { timezones } from '../format_date'; export default { components: { Icon, + GlButton, GlIcon, GlDeprecatedButton, GlDropdown, @@ -38,12 +46,18 @@ export default { GlDropdownItem, GlDropdownHeader, GlDropdownDivider, + GlNewDropdown, + GlNewDropdownDivider, + GlNewDropdownItem, GlSearchBoxByType, GlModal, CustomMetricsFormFields, DateTimePicker, DashboardsDropdown, + RefreshButton, + DuplicateDashboardModal, + CreateDashboardModal, }, directives: { GlModal: GlModalDirective, @@ -93,6 +107,10 @@ export default { type: Object, required: true, }, + addDashboardDocumentationPath: { + type: String, + required: true, + }, }, data() { return { @@ -101,20 +119,30 @@ export default { }, computed: { ...mapState('monitoringDashboard', [ + 'emptyState', 'environmentsLoading', 'currentEnvironmentName', 'isUpdatingStarredValue', - 'showEmptyState', 'dashboardTimezone', + 'projectPath', + 'canAccessOperationsSettings', + 'operationsSettingsPath', + 'currentDashboard', ]), ...mapGetters('monitoringDashboard', ['selectedDashboard', 'filteredEnvironments']), + isOutOfTheBoxDashboard() { + return this.selectedDashboard?.out_of_the_box_dashboard; + }, + shouldShowEmptyState() { + return Boolean(this.emptyState); + }, shouldShowEnvironmentsDropdownNoMatchedMsg() { return !this.environmentsLoading && this.filteredEnvironments.length === 0; }, addingMetricsAvailable() { return ( this.customMetricsAvailable && - !this.showEmptyState && + !this.shouldShowEmptyState && // Custom metrics only avaialble on system dashboards because // they are stored in the database. This can be improved. See: // https://gitlab.com/gitlab-org/gitlab/-/issues/28241 @@ -122,23 +150,29 @@ export default { ); }, showRearrangePanelsBtn() { - return !this.showEmptyState && this.rearrangePanelsAvailable; + return !this.shouldShowEmptyState && this.rearrangePanelsAvailable; }, displayUtc() { return this.dashboardTimezone === timezones.UTC; }, + shouldShowActionsMenu() { + return Boolean(this.projectPath); + }, + shouldShowSettingsButton() { + return this.canAccessOperationsSettings && this.operationsSettingsPath; + }, }, methods: { - ...mapActions('monitoringDashboard', [ - 'filterEnvironments', - 'fetchDashboardData', - 'toggleStarredValue', - ]), + ...mapActions('monitoringDashboard', ['filterEnvironments', 'toggleStarredValue']), selectDashboard(dashboard) { - const params = { - dashboard: dashboard.path, - }; - redirectTo(mergeUrlParams(params, window.location.href)); + // Once the sidebar See metrics link is updated to the new URL, + // this sort of hardcoding will not be necessary. + // https://gitlab.com/gitlab-org/gitlab/-/issues/229277 + const baseURL = `${this.projectPath}/-/metrics`; + const dashboardPath = encodeURIComponent( + dashboard.out_of_the_box_dashboard ? dashboard.path : dashboard.display_name, + ); + redirectTo(`${baseURL}/${dashboardPath}`); }, debouncedEnvironmentsSearch: debounce(function environmentsSearchOnInput(searchTerm) { this.filterEnvironments(searchTerm); @@ -149,9 +183,6 @@ export default { onDateTimePickerInvalid() { this.$emit('dateTimePickerInvalid'); }, - refreshDashboard() { - this.fetchDashboardData(); - }, toggleRearrangingPanels() { this.$emit('setRearrangingPanels', !this.isRearrangingPanels); @@ -166,14 +197,27 @@ export default { submitCustomMetricsForm() { this.$refs.customMetricsForm.submit(); }, + getEnvironmentPath(environment) { + // Once the sidebar See metrics link is updated to the new URL, + // this sort of hardcoding will not be necessary. + // https://gitlab.com/gitlab-org/gitlab/-/issues/229277 + const baseURL = `${this.projectPath}/-/metrics`; + const dashboardPath = encodeURIComponent(this.currentDashboard || ''); + // The environment_metrics_spec.rb requires the URL to not have + // slashes. Hence, this additional check. + const url = dashboardPath ? `${baseURL}/${dashboardPath}` : baseURL; + return mergeUrlParams({ environment }, url); + }, }, - addMetric: { - title: s__('Metrics|Add metric'), - modalId: 'add-metric', + modalIds: { + addMetric: 'addMetric', + createDashboard: 'createDashboard', + duplicateDashboard: 'duplicateDashboard', }, i18n: { starDashboard: s__('Metrics|Star dashboard'), unstarDashboard: s__('Metrics|Unstar dashboard'), + addMetric: s__('Metrics|Add metric'), }, timeRanges, }; @@ -181,17 +225,20 @@ export default { <template> <div ref="prometheusGraphsHeader"> - <div class="mb-2 pr-2 d-flex d-sm-block"> + <div class="mb-2 mr-2 d-flex d-sm-block"> <dashboards-dropdown id="monitor-dashboards-dropdown" data-qa-selector="dashboards_filter_dropdown" class="flex-grow-1" toggle-class="dropdown-menu-toggle" :default-branch="defaultBranch" + :modal-id="$options.modalIds.duplicateDashboard" @selectDashboard="selectDashboard" /> </div> + <span aria-hidden="true" class="gl-pl-3 border-left gl-mb-3 d-none d-sm-block"></span> + <div class="mb-2 pr-2 d-flex d-sm-block"> <gl-dropdown id="monitor-environments-dropdown" @@ -223,7 +270,7 @@ export default { :key="environment.id" :active="environment.name === currentEnvironmentName" active-class="is-active" - :href="environment.metrics_path" + :href="getEnvironmentPath(environment.id)" >{{ environment.name }}</gl-dropdown-item > </div> @@ -252,16 +299,7 @@ export default { </div> <div class="mb-2 pr-2 d-flex d-sm-block"> - <gl-deprecated-button - ref="refreshDashboardBtn" - v-gl-tooltip - class="flex-grow-1" - variant="default" - :title="s__('Metrics|Refresh dashboard')" - @click="refreshDashboard" - > - <icon name="retry" /> - </gl-deprecated-button> + <refresh-button /> </div> <div class="flex-grow-1"></div> @@ -304,17 +342,17 @@ export default { <div v-if="addingMetricsAvailable" class="mb-2 mr-2 d-flex d-sm-block"> <gl-deprecated-button ref="addMetricBtn" - v-gl-modal="$options.addMetric.modalId" + v-gl-modal="$options.modalIds.addMetric" variant="outline-success" data-qa-selector="add_metric_button" class="flex-grow-1" > - {{ $options.addMetric.title }} + {{ $options.i18n.addMetric }} </gl-deprecated-button> <gl-modal ref="addMetricModal" - :modal-id="$options.addMetric.modalId" - :title="$options.addMetric.title" + :modal-id="$options.modalIds.addMetric" + :title="$options.i18n.addMetric" > <form ref="customMetricsForm" :action="customMetricsPath" method="post"> <custom-metrics-form-fields @@ -353,7 +391,10 @@ export default { </gl-deprecated-button> </div> - <div v-if="externalDashboardUrl.length" class="mb-2 mr-2 d-flex d-sm-block"> + <div + v-if="externalDashboardUrl && externalDashboardUrl.length" + class="mb-2 mr-2 d-flex d-sm-block" + > <gl-deprecated-button class="flex-grow-1 js-external-dashboard-link" variant="primary" @@ -364,6 +405,63 @@ export default { {{ __('View full dashboard') }} <icon name="external-link" /> </gl-deprecated-button> </div> + + <!-- This separator should be displayed only if at least one of the action menu or settings button are displayed --> + <span + v-if="shouldShowActionsMenu || shouldShowSettingsButton" + aria-hidden="true" + class="gl-pl-3 border-left gl-mb-3 d-none d-sm-block" + ></span> + + <div v-if="shouldShowActionsMenu" class="gl-mb-3 gl-mr-3 d-flex d-sm-block"> + <gl-new-dropdown + v-gl-tooltip + right + class="gl-flex-grow-1" + data-testid="actions-menu" + :title="s__('Metrics|Create dashboard')" + :icon="'plus-square'" + > + <gl-new-dropdown-item + v-gl-modal="$options.modalIds.createDashboard" + data-testid="action-create-dashboard" + >{{ s__('Metrics|Create new dashboard') }}</gl-new-dropdown-item + > + + <create-dashboard-modal + data-testid="create-dashboard-modal" + :add-dashboard-documentation-path="addDashboardDocumentationPath" + :modal-id="$options.modalIds.createDashboard" + :project-path="projectPath" + /> + + <template v-if="isOutOfTheBoxDashboard"> + <gl-new-dropdown-divider /> + <gl-new-dropdown-item + ref="duplicateDashboardItem" + v-gl-modal="$options.modalIds.duplicateDashboard" + data-testid="action-duplicate-dashboard" + > + {{ s__('Metrics|Duplicate current dashboard') }} + </gl-new-dropdown-item> + </template> + </gl-new-dropdown> + </div> + + <div v-if="shouldShowSettingsButton" class="mb-2 mr-2 d-flex d-sm-block"> + <gl-button + v-gl-tooltip + data-testid="metrics-settings-button" + icon="settings" + :href="operationsSettingsPath" + :title="s__('Metrics|Metrics Settings')" + /> + </div> </div> + <duplicate-dashboard-modal + :default-branch="defaultBranch" + :modal-id="$options.modalIds.duplicateDashboard" + @dashboardDuplicated="selectDashboard" + /> </div> </template> diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel.vue b/app/assets/javascripts/monitoring/components/dashboard_panel.vue index 9545a211bbd..3e3c8408de3 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_panel.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_panel.vue @@ -2,6 +2,7 @@ import { mapState } from 'vuex'; import { pickBy } from 'lodash'; import invalidUrl from '~/lib/utils/invalid_url'; +import { relativePathToAbsolute, getBaseURL, visitUrl, isSafeURL } from '~/lib/utils/url_utility'; import { GlResizeObserverDirective, GlIcon, @@ -29,7 +30,6 @@ import MonitorStackedColumnChart from './charts/stacked_column.vue'; import TrackEventDirective from '~/vue_shared/directives/track_event'; import AlertWidget from './alert_widget.vue'; import { timeRangeToUrl, downloadCSVOptions, generateLinkToChartOptions } from '../utils'; -import { isSafeURL } from '~/lib/utils/url_utility'; const events = { timeRangeZoom: 'timerangezoom', @@ -132,7 +132,8 @@ export default { return this.graphData?.title || ''; }, graphDataHasResult() { - return this.graphData?.metrics?.[0]?.result?.length > 0; + const metrics = this.graphData?.metrics || []; + return metrics.some(({ result }) => result?.length > 0); }, graphDataIsLoading() { const metrics = this.graphData?.metrics || []; @@ -207,7 +208,17 @@ export default { return MonitorTimeSeriesChart; }, isContextualMenuShown() { - return Boolean(this.graphDataHasResult && !this.basicChartComponent); + if (!this.graphDataHasResult) { + return false; + } + // Only a few charts have a contextual menu, support + // for more chart types planned at: + // https://gitlab.com/groups/gitlab-org/-/epics/3573 + return ( + this.isPanelType(panelTypes.AREA_CHART) || + this.isPanelType(panelTypes.LINE_CHART) || + this.isPanelType(panelTypes.SINGLE_STAT) + ); }, editCustomMetricLink() { if (this.graphData.metrics.length > 1) { @@ -223,13 +234,19 @@ export default { return metrics.some(({ metricId }) => this.metricsSavedToDb.includes(metricId)); }, alertWidgetAvailable() { + const supportsAlerts = + this.isPanelType(panelTypes.AREA_CHART) || this.isPanelType(panelTypes.LINE_CHART); return ( + supportsAlerts && this.prometheusAlertsAvailable && this.alertsEndpoint && this.graphData && this.hasMetricsInDb ); }, + alertModalId() { + return `alert-modal-${this.graphData.id}`; + }, }, mounted() { this.refreshTitleTooltip(); @@ -268,6 +285,11 @@ export default { onExpand() { this.$emit(events.expand); }, + onExpandFromKeyboardShortcut() { + if (this.isContextualMenuShown) { + this.onExpand(); + } + }, setAlerts(alertPath, alertAttributes) { if (alertAttributes) { this.$set(this.allAlerts, alertPath, alertAttributes); @@ -278,18 +300,45 @@ export default { safeUrl(url) { return isSafeURL(url) ? url : '#'; }, + showAlertModal() { + this.$root.$emit('bv::show::modal', this.alertModalId); + }, + showAlertModalFromKeyboardShortcut() { + if (this.isContextualMenuShown) { + this.showAlertModal(); + } + }, + visitLogsPage() { + if (this.logsPathWithTimeRange) { + visitUrl(relativePathToAbsolute(this.logsPathWithTimeRange, getBaseURL())); + } + }, + visitLogsPageFromKeyboardShortcut() { + if (this.isContextualMenuShown) { + this.visitLogsPage(); + } + }, + downloadCsvFromKeyboardShortcut() { + if (this.csvText && this.isContextualMenuShown) { + this.$refs.downloadCsvLink.$el.firstChild.click(); + } + }, + copyChartLinkFromKeyboardShotcut() { + if (this.clipboardText && this.isContextualMenuShown) { + this.$refs.copyChartLink.$el.firstChild.click(); + } + }, }, panelTypes, }; </script> <template> <div v-gl-resize-observer="onResize" class="prometheus-graph"> - <div class="d-flex align-items-center mr-3"> + <div class="d-flex align-items-center"> <slot name="topLeft"></slot> <h5 ref="graphTitle" class="prometheus-graph-title gl-font-lg font-weight-bold text-truncate gl-mr-3" - tabindex="0" > {{ title }} </h5> @@ -299,7 +348,7 @@ export default { <alert-widget v-if="isContextualMenuShown && alertWidgetAvailable" class="mx-1" - :modal-id="`alert-modal-${graphData.id}`" + :modal-id="alertModalId" :alerts-endpoint="alertsEndpoint" :relevant-queries="graphData.metrics" :alerts-to-manage="getGraphAlerts(graphData.metrics)" @@ -314,7 +363,7 @@ export default { ref="contextualMenu" data-qa-selector="prometheus_graph_widgets" > - <div class="d-flex align-items-center"> + <div data-testid="dropdown-wrapper" class="d-flex align-items-center"> <gl-dropdown v-gl-tooltip toggle-class="shadow-none border-0" @@ -369,13 +418,13 @@ export default { </gl-dropdown-item> <gl-dropdown-item v-if="alertWidgetAvailable" - v-gl-modal="`alert-modal-${graphData.id}`" + v-gl-modal="alertModalId" data-qa-selector="alert_widget_menu_item" > {{ __('Alerts') }} </gl-dropdown-item> - <template v-if="graphData.links.length"> + <template v-if="graphData.links && graphData.links.length"> <gl-dropdown-divider /> <gl-dropdown-item v-for="(link, index) in graphData.links" diff --git a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue index 8b86890715f..574f48a72fe 100644 --- a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue +++ b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue @@ -1,19 +1,14 @@ <script> import { mapState, mapActions, mapGetters } from 'vuex'; import { - GlAlert, GlIcon, GlDropdown, GlDropdownItem, GlDropdownHeader, GlDropdownDivider, GlSearchBoxByType, - GlModal, - GlLoadingIcon, GlModalDirective, } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import DuplicateDashboardForm from './duplicate_dashboard_form.vue'; const events = { selectDashboard: 'selectDashboard', @@ -21,16 +16,12 @@ const events = { export default { components: { - GlAlert, GlIcon, GlDropdown, GlDropdownItem, GlDropdownHeader, GlDropdownDivider, GlSearchBoxByType, - GlModal, - GlLoadingIcon, - DuplicateDashboardForm, }, directives: { GlModal: GlModalDirective, @@ -40,20 +31,21 @@ export default { type: String, required: true, }, + modalId: { + type: String, + required: true, + }, }, data() { return { - alert: null, - loading: false, - form: {}, searchTerm: '', }; }, computed: { ...mapState('monitoringDashboard', ['allDashboards']), ...mapGetters('monitoringDashboard', ['selectedDashboard']), - isSystemDashboard() { - return this.selectedDashboard?.system_dashboard; + isOutOfTheBoxDashboard() { + return this.selectedDashboard?.out_of_the_box_dashboard; }, selectedDashboardText() { return this.selectedDashboard?.display_name; @@ -76,10 +68,6 @@ export default { nonStarredDashboards() { return this.filteredDashboards.filter(({ starred }) => !starred); }, - - okButtonText() { - return this.loading ? s__('Metrics|Duplicating...') : s__('Metrics|Duplicate'); - }, }, methods: { ...mapActions('monitoringDashboard', ['duplicateSystemDashboard']), @@ -89,37 +77,6 @@ export default { selectDashboard(dashboard) { this.$emit(events.selectDashboard, dashboard); }, - ok(bvModalEvt) { - // Prevent modal from hiding in case submit fails - bvModalEvt.preventDefault(); - - this.loading = true; - this.alert = null; - this.duplicateSystemDashboard(this.form) - .then(createdDashboard => { - this.loading = false; - this.alert = null; - - // Trigger hide modal as submit is successful - this.$refs.duplicateDashboardModal.hide(); - - // Dashboards in the default branch become available immediately. - // Not so in other branches, so we refresh the current dashboard - const dashboard = - this.form.branch === this.defaultBranch ? createdDashboard : this.selectedDashboard; - this.$emit(events.selectDashboard, dashboard); - }) - .catch(error => { - this.loading = false; - this.alert = error; - }); - }, - hide() { - this.alert = null; - }, - formChange(form) { - this.form = form; - }, }, }; </script> @@ -178,32 +135,14 @@ export default { {{ __('No matching results') }} </div> - <template v-if="isSystemDashboard"> + <!-- + This Duplicate Dashboard item will be removed from the dashboards dropdown + in https://gitlab.com/gitlab-org/gitlab/-/issues/223223 + --> + <template v-if="isOutOfTheBoxDashboard"> <gl-dropdown-divider /> - <gl-modal - ref="duplicateDashboardModal" - modal-id="duplicateDashboardModal" - :title="s__('Metrics|Duplicate dashboard')" - ok-variant="success" - @ok="ok" - @hide="hide" - > - <gl-alert v-if="alert" class="mb-3" variant="danger" @dismiss="alert = null"> - {{ alert }} - </gl-alert> - <duplicate-dashboard-form - :dashboard="selectedDashboard" - :default-branch="defaultBranch" - @change="formChange" - /> - <template #modal-ok> - <gl-loading-icon v-if="loading" inline color="light" /> - {{ okButtonText }} - </template> - </gl-modal> - - <gl-dropdown-item ref="duplicateDashboardItem" v-gl-modal="'duplicateDashboardModal'"> + <gl-dropdown-item v-gl-modal="modalId" data-testid="duplicateDashboardItem"> {{ s__('Metrics|Duplicate dashboard') }} </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue b/app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue new file mode 100644 index 00000000000..e64afc01fd9 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue @@ -0,0 +1,95 @@ +<script> +import { mapActions, mapGetters } from 'vuex'; +import { GlAlert, GlLoadingIcon, GlModal } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import DuplicateDashboardForm from './duplicate_dashboard_form.vue'; + +const events = { + dashboardDuplicated: 'dashboardDuplicated', +}; + +export default { + components: { GlAlert, GlLoadingIcon, GlModal, DuplicateDashboardForm }, + props: { + defaultBranch: { + type: String, + required: true, + }, + modalId: { + type: String, + required: true, + }, + }, + data() { + return { + alert: null, + loading: false, + form: {}, + }; + }, + computed: { + ...mapGetters('monitoringDashboard', ['selectedDashboard']), + okButtonText() { + return this.loading ? s__('Metrics|Duplicating...') : s__('Metrics|Duplicate'); + }, + }, + methods: { + ...mapActions('monitoringDashboard', ['duplicateSystemDashboard']), + ok(bvModalEvt) { + // Prevent modal from hiding in case submit fails + bvModalEvt.preventDefault(); + + this.loading = true; + this.alert = null; + this.duplicateSystemDashboard(this.form) + .then(createdDashboard => { + this.loading = false; + this.alert = null; + + // Trigger hide modal as submit is successful + this.$refs.duplicateDashboardModal.hide(); + + // Dashboards in the default branch become available immediately. + // Not so in other branches, so we refresh the current dashboard + const dashboard = + this.form.branch === this.defaultBranch ? createdDashboard : this.selectedDashboard; + this.$emit(events.dashboardDuplicated, dashboard); + }) + .catch(error => { + this.loading = false; + this.alert = error; + }); + }, + hide() { + this.alert = null; + }, + formChange(form) { + this.form = form; + }, + }, +}; +</script> + +<template> + <gl-modal + ref="duplicateDashboardModal" + :modal-id="modalId" + :title="s__('Metrics|Duplicate dashboard')" + ok-variant="success" + @ok="ok" + @hide="hide" + > + <gl-alert v-if="alert" class="mb-3" variant="danger" @dismiss="alert = null"> + {{ alert }} + </gl-alert> + <duplicate-dashboard-form + :dashboard="selectedDashboard" + :default-branch="defaultBranch" + @change="formChange" + /> + <template #modal-ok> + <gl-loading-icon v-if="loading" inline color="light" /> + {{ okButtonText }} + </template> + </gl-modal> +</template> diff --git a/app/assets/javascripts/monitoring/components/empty_state.vue b/app/assets/javascripts/monitoring/components/empty_state.vue index d3157b731b2..5e7c9b5d906 100644 --- a/app/assets/javascripts/monitoring/components/empty_state.vue +++ b/app/assets/javascripts/monitoring/components/empty_state.vue @@ -1,12 +1,19 @@ <script> -import { GlEmptyState } from '@gitlab/ui'; +import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui'; import { __ } from '~/locale'; +import { dashboardEmptyStates } from '../constants'; export default { components: { + GlLoadingIcon, GlEmptyState, }, props: { + selectedState: { + type: String, + required: true, + validator: state => Object.values(dashboardEmptyStates).includes(state), + }, documentationPath: { type: String, required: true, @@ -21,10 +28,6 @@ export default { required: false, default: '', }, - selectedState: { - type: String, - required: true, - }, emptyGettingStartedSvgPath: { type: String, required: true, @@ -53,52 +56,49 @@ export default { }, data() { return { + /** + * Possible empty states. + * Keys in each state must match GlEmptyState props + */ states: { - gettingStarted: { - svgUrl: this.emptyGettingStartedSvgPath, + [dashboardEmptyStates.GETTING_STARTED]: { + svgPath: this.emptyGettingStartedSvgPath, title: __('Get started with performance monitoring'), description: __(`Stay updated about the performance and health of your environment by configuring Prometheus to monitor your deployments.`), - buttonText: __('Install on clusters'), - buttonPath: this.clustersPath, + primaryButtonText: __('Install on clusters'), + primaryButtonLink: this.clustersPath, secondaryButtonText: __('Configure existing installation'), - secondaryButtonPath: this.settingsPath, + secondaryButtonLink: this.settingsPath, }, - loading: { - svgUrl: this.emptyLoadingSvgPath, - title: __('Waiting for performance data'), - description: __(`Creating graphs uses the data from the Prometheus server. - If this takes a long time, ensure that data is available.`), - buttonText: __('View documentation'), - buttonPath: this.documentationPath, - secondaryButtonText: '', - secondaryButtonPath: '', - }, - noData: { - svgUrl: this.emptyNoDataSvgPath, + [dashboardEmptyStates.NO_DATA]: { + svgPath: this.emptyNoDataSvgPath, title: __('No data found'), description: __(`You are connected to the Prometheus server, but there is currently no data to display.`), - buttonText: __('Configure Prometheus'), - buttonPath: this.settingsPath, + primaryButtonText: __('Configure Prometheus'), + primaryButtonLink: this.settingsPath, secondaryButtonText: '', - secondaryButtonPath: '', + secondaryButtonLink: '', }, - unableToConnect: { - svgUrl: this.emptyUnableToConnectSvgPath, + [dashboardEmptyStates.UNABLE_TO_CONNECT]: { + svgPath: this.emptyUnableToConnectSvgPath, title: __('Unable to connect to Prometheus server'), description: __( 'Ensure connectivity is available from the GitLab server to the Prometheus server', ), - buttonText: __('View documentation'), - buttonPath: this.documentationPath, + primaryButtonText: __('View documentation'), + primaryButtonLink: this.documentationPath, secondaryButtonText: __('Configure Prometheus'), - secondaryButtonPath: this.settingsPath, + secondaryButtonLink: this.settingsPath, }, }, }; }, computed: { + isLoading() { + return this.selectedState === dashboardEmptyStates.LOADING; + }, currentState() { return this.states[this.selectedState]; }, @@ -107,14 +107,8 @@ export default { </script> <template> - <gl-empty-state - :title="currentState.title" - :description="currentState.description" - :primary-button-text="currentState.buttonText" - :primary-button-link="currentState.buttonPath" - :secondary-button-text="currentState.secondaryButtonText" - :secondary-button-link="currentState.secondaryButtonPath" - :svg-path="currentState.svgUrl" - :compact="compact" - /> + <div> + <gl-loading-icon v-if="isLoading" size="xl" class="gl-my-9" /> + <gl-empty-state v-if="currentState" v-bind="currentState" :compact="compact" /> + </div> </template> diff --git a/app/assets/javascripts/monitoring/components/graph_group.vue b/app/assets/javascripts/monitoring/components/graph_group.vue index 08fcfa3bc56..ecb8ef4a0d0 100644 --- a/app/assets/javascripts/monitoring/components/graph_group.vue +++ b/app/assets/javascripts/monitoring/components/graph_group.vue @@ -1,9 +1,10 @@ <script> -import Icon from '~/vue_shared/components/icon.vue'; +import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; export default { components: { - Icon, + GlLoadingIcon, + GlIcon, }, props: { name: { @@ -15,6 +16,11 @@ export default { required: false, default: true, }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, /** * Initial value of collapse on mount. */ @@ -52,18 +58,21 @@ export default { </script> <template> - <div v-if="showPanels" ref="graph-group" class="card prometheus-panel" tabindex="0"> + <div v-if="showPanels" ref="graph-group" class="card prometheus-panel"> <div class="card-header d-flex align-items-center"> <h4 class="flex-grow-1">{{ name }}</h4> + <gl-loading-icon v-if="isLoading" name="loading" /> <a data-testid="group-toggle-button" + :aria-label="__('Toggle collapse')" + :icon="caretIcon" role="button" - class="js-graph-group-toggle gl-text-gray-900" + class="js-graph-group-toggle gl-display-flex gl-ml-2 gl-text-gray-900" tabindex="0" @click="collapse" @keyup.enter="collapse" > - <icon :size="16" :aria-label="__('Toggle collapse')" :name="caretIcon" /> + <gl-icon :name="caretIcon" /> </a> </div> <div diff --git a/app/assets/javascripts/monitoring/components/refresh_button.vue b/app/assets/javascripts/monitoring/components/refresh_button.vue new file mode 100644 index 00000000000..5481806c3e0 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/refresh_button.vue @@ -0,0 +1,163 @@ +<script> +import { n__, __ } from '~/locale'; +import { mapActions } from 'vuex'; + +import { + GlButtonGroup, + GlButton, + GlNewDropdown, + GlNewDropdownItem, + GlNewDropdownDivider, + GlTooltipDirective, +} from '@gitlab/ui'; + +const makeInterval = (length = 0, unit = 's') => { + const shortLabel = `${length}${unit}`; + switch (unit) { + case 'd': + return { + interval: length * 24 * 60 * 60 * 1000, + shortLabel, + label: n__('%d day', '%d days', length), + }; + case 'h': + return { + interval: length * 60 * 60 * 1000, + shortLabel, + label: n__('%d hour', '%d hours', length), + }; + case 'm': + return { + interval: length * 60 * 1000, + shortLabel, + label: n__('%d minute', '%d minutes', length), + }; + case 's': + default: + return { + interval: length * 1000, + shortLabel, + label: n__('%d second', '%d seconds', length), + }; + } +}; + +export default { + components: { + GlButtonGroup, + GlButton, + GlNewDropdown, + GlNewDropdownItem, + GlNewDropdownDivider, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + data() { + return { + refreshInterval: null, + timeoutId: null, + }; + }, + computed: { + dropdownText() { + return this.refreshInterval?.shortLabel ?? __('Off'); + }, + }, + watch: { + refreshInterval() { + if (this.refreshInterval !== null) { + this.startAutoRefresh(); + } else { + this.stopAutoRefresh(); + } + }, + }, + destroyed() { + this.stopAutoRefresh(); + }, + methods: { + ...mapActions('monitoringDashboard', ['fetchDashboardData']), + + refresh() { + this.fetchDashboardData(); + }, + startAutoRefresh() { + const schedule = () => { + if (this.refreshInterval) { + this.timeoutId = setTimeout(this.startAutoRefresh, this.refreshInterval.interval); + } + }; + + this.stopAutoRefresh(); + if (document.hidden) { + // Inactive tab? Skip fetch and schedule again + schedule(); + } else { + // Active tab! Fetch data and then schedule when settled + // eslint-disable-next-line promise/catch-or-return + this.fetchDashboardData().finally(schedule); + } + }, + stopAutoRefresh() { + clearTimeout(this.timeoutId); + this.timeoutId = null; + }, + + setRefreshInterval(option) { + this.refreshInterval = option; + }, + removeRefreshInterval() { + this.refreshInterval = null; + }, + isChecked(option) { + if (this.refreshInterval) { + return option.interval === this.refreshInterval.interval; + } + return false; + }, + }, + + refreshIntervals: [ + makeInterval(5), + makeInterval(10), + makeInterval(30), + makeInterval(5, 'm'), + makeInterval(30, 'm'), + makeInterval(1, 'h'), + makeInterval(2, 'h'), + makeInterval(12, 'h'), + makeInterval(1, 'd'), + ], +}; +</script> + +<template> + <gl-button-group> + <gl-button + v-gl-tooltip + class="gl-flex-grow-1" + variant="default" + :title="s__('Metrics|Refresh dashboard')" + icon="retry" + @click="refresh" + /> + <gl-new-dropdown v-gl-tooltip :title="s__('Metrics|Set refresh rate')" :text="dropdownText"> + <gl-new-dropdown-item + :is-check-item="true" + :is-checked="refreshInterval === null" + @click="removeRefreshInterval()" + >{{ __('Off') }}</gl-new-dropdown-item + > + <gl-new-dropdown-divider /> + <gl-new-dropdown-item + v-for="(option, i) in $options.refreshIntervals" + :key="i" + :is-check-item="true" + :is-checked="isChecked(option)" + @click="setRefreshInterval(option)" + >{{ option.label }}</gl-new-dropdown-item + > + </gl-new-dropdown> + </gl-button-group> +</template> diff --git a/app/assets/javascripts/monitoring/components/variables/custom_variable.vue b/app/assets/javascripts/monitoring/components/variables/dropdown_field.vue index 0ac7c0b80df..4e48292c48d 100644 --- a/app/assets/javascripts/monitoring/components/variables/custom_variable.vue +++ b/app/assets/javascripts/monitoring/components/variables/dropdown_field.vue @@ -22,29 +22,32 @@ export default { default: '', }, options: { - type: Array, + type: Object, required: true, }, }, computed: { - defaultText() { - const selectedOpt = this.options.find(opt => opt.value === this.value); + text() { + const selectedOpt = this.options.values?.find(opt => opt.value === this.value); return selectedOpt?.text || this.value; }, }, methods: { onUpdate(value) { - this.$emit('onUpdate', this.name, value); + this.$emit('input', value); }, }, }; </script> <template> <gl-form-group :label="label"> - <gl-dropdown toggle-class="dropdown-menu-toggle" :text="defaultText"> - <gl-dropdown-item v-for="(opt, key) in options" :key="key" @click="onUpdate(opt.value)">{{ - opt.text - }}</gl-dropdown-item> + <gl-dropdown toggle-class="dropdown-menu-toggle" :text="text || s__('Metrics|Select a value')"> + <gl-dropdown-item + v-for="val in options.values" + :key="val.value" + @click="onUpdate(val.value)" + >{{ val.text }}</gl-dropdown-item + > </gl-dropdown> </gl-form-group> </template> diff --git a/app/assets/javascripts/monitoring/components/variables/text_variable.vue b/app/assets/javascripts/monitoring/components/variables/text_field.vue index ce0d19760e2..a0418806e5f 100644 --- a/app/assets/javascripts/monitoring/components/variables/text_variable.vue +++ b/app/assets/javascripts/monitoring/components/variables/text_field.vue @@ -22,7 +22,7 @@ export default { }, methods: { onUpdate(event) { - this.$emit('onUpdate', this.name, event.target.value); + this.$emit('input', event.target.value); }, }, }; diff --git a/app/assets/javascripts/monitoring/components/variables_section.vue b/app/assets/javascripts/monitoring/components/variables_section.vue index 3d1d111d5b3..25d900b07ad 100644 --- a/app/assets/javascripts/monitoring/components/variables_section.vue +++ b/app/assets/javascripts/monitoring/components/variables_section.vue @@ -1,13 +1,14 @@ <script> import { mapState, mapActions } from 'vuex'; -import CustomVariable from './variables/custom_variable.vue'; -import TextVariable from './variables/text_variable.vue'; +import DropdownField from './variables/dropdown_field.vue'; +import TextField from './variables/text_field.vue'; import { setCustomVariablesFromUrl } from '../utils'; +import { VARIABLE_TYPES } from '../constants'; export default { components: { - CustomVariable, - TextVariable, + DropdownField, + TextField, }, computed: { ...mapState('monitoringDashboard', ['variables']), @@ -15,10 +16,9 @@ export default { methods: { ...mapActions('monitoringDashboard', ['updateVariablesAndFetchData']), refreshDashboard(variable, value) { - if (this.variables[variable].value !== value) { - const changedVariable = { key: variable, value }; + if (variable.value !== value) { + this.updateVariablesAndFetchData({ name: variable.name, value }); // update the Vuex store - this.updateVariablesAndFetchData(changedVariable); // the below calls can ideally be moved out of the // component and into the actions and let the // mutation respond directly. @@ -27,27 +27,26 @@ export default { setCustomVariablesFromUrl(this.variables); } }, - variableComponent(type) { - const types = { - text: TextVariable, - custom: CustomVariable, - }; - return types[type] || TextVariable; + variableField(type) { + if (type === VARIABLE_TYPES.custom || type === VARIABLE_TYPES.metric_label_values) { + return DropdownField; + } + return TextField; }, }, }; </script> <template> <div ref="variablesSection" class="d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 variables-section"> - <div v-for="(variable, key) in variables" :key="key" class="mb-1 pr-2 d-flex d-sm-block"> + <div v-for="variable in variables" :key="variable.name" class="mb-1 pr-2 d-flex d-sm-block"> <component - :is="variableComponent(variable.type)" + :is="variableField(variable.type)" class="mb-0 flex-grow-1" :label="variable.label" :value="variable.value" - :name="key" + :name="variable.name" :options="variable.options" - @onUpdate="refreshDashboard" + @input="refreshDashboard(variable, $event)" /> </div> </div> diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js index 50330046c99..afeb3318eb9 100644 --- a/app/assets/javascripts/monitoring/constants.js +++ b/app/assets/javascripts/monitoring/constants.js @@ -1,5 +1,12 @@ export const PROMETHEUS_TIMEOUT = 120000; // TWO_MINUTES +export const dashboardEmptyStates = { + GETTING_STARTED: 'gettingStarted', + LOADING: 'loading', + NO_DATA: 'noData', + UNABLE_TO_CONNECT: 'unableToConnect', +}; + /** * States and error states in Prometheus Queries (PromQL) for metrics */ @@ -208,6 +215,14 @@ export const annotationsSymbolIcon = 'path://m5 229 5 8h-10z'; */ export const DEFAULT_DASHBOARD_PATH = 'config/prometheus/common_metrics.yml'; +/** + * GitLab provide metrics dashboards that are available to a user once + * the Prometheus managed app has been installed, without any extra setup + * required. These "out of the box" dashboards are defined under the + * `config/prometheus` path. + */ +export const OUT_OF_THE_BOX_DASHBOARDS_PATH_PREFIX = 'config/prometheus/'; + export const OPERATORS = { greaterThan: '>', equalTo: '==', @@ -230,6 +245,7 @@ export const OPERATORS = { export const VARIABLE_TYPES = { custom: 'custom', text: 'text', + metric_label_values: 'metric_label_values', }; /** @@ -242,3 +258,17 @@ export const VARIABLE_TYPES = { * before passing the data to the backend. */ export const VARIABLE_PREFIX = 'var-'; + +/** + * All of the actions inside each panel dropdown can be accessed + * via keyboard shortcuts than can be activated via mouse hovers + * and or focus via tabs. + */ + +export const keyboardShortcutKeys = { + EXPAND: 'e', + VISIT_LOGS: 'l', + SHOW_ALERT: 'a', + DOWNLOAD_CSV: 'd', + CHART_COPY: 'c', +}; diff --git a/app/assets/javascripts/monitoring/format_date.js b/app/assets/javascripts/monitoring/format_date.js index a50d441a09e..c7bc626eb11 100644 --- a/app/assets/javascripts/monitoring/format_date.js +++ b/app/assets/javascripts/monitoring/format_date.js @@ -14,6 +14,7 @@ export const timezones = { export const formats = { shortTime: 'h:MM TT', + shortDateTime: 'm/d h:MM TT', default: 'dd mmm yyyy, h:MMTT (Z)', }; diff --git a/app/assets/javascripts/monitoring/monitoring_app.js b/app/assets/javascripts/monitoring/monitoring_app.js index 08543fa6eb3..307154c9a84 100644 --- a/app/assets/javascripts/monitoring/monitoring_app.js +++ b/app/assets/javascripts/monitoring/monitoring_app.js @@ -1,9 +1,8 @@ import Vue from 'vue'; import { GlToast } from '@gitlab/ui'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import { getParameterValues } from '~/lib/utils/url_utility'; import { createStore } from './stores'; import createRouter from './router'; +import { stateAndPropsFromDataset } from './utils'; Vue.use(GlToast); @@ -11,36 +10,10 @@ export default (props = {}) => { const el = document.getElementById('prometheus-graphs'); if (el && el.dataset) { - const [currentDashboard] = getParameterValues('dashboard'); - - const { - deploymentsEndpoint, - dashboardEndpoint, - dashboardsEndpoint, - projectPath, - logsPath, - currentEnvironmentName, - dashboardTimezone, - metricsDashboardBasePath, - ...dataProps - } = el.dataset; - - const store = createStore({ - currentDashboard, - deploymentsEndpoint, - dashboardEndpoint, - dashboardsEndpoint, - dashboardTimezone, - projectPath, - logsPath, - currentEnvironmentName, - }); - - // HTML attributes are always strings, parse other types. - dataProps.hasMetrics = parseBoolean(dataProps.hasMetrics); - dataProps.customMetricsAvailable = parseBoolean(dataProps.customMetricsAvailable); - dataProps.prometheusAlertsAvailable = parseBoolean(dataProps.prometheusAlertsAvailable); + const { metricsDashboardBasePath, ...dataset } = el.dataset; + const { initState, dataProps } = stateAndPropsFromDataset(dataset); + const store = createStore(initState); const router = createRouter(metricsDashboardBasePath); // eslint-disable-next-line no-new diff --git a/app/assets/javascripts/monitoring/pages/dashboard_page.vue b/app/assets/javascripts/monitoring/pages/dashboard_page.vue index 519a20d7be3..df0e2d7f8f6 100644 --- a/app/assets/javascripts/monitoring/pages/dashboard_page.vue +++ b/app/assets/javascripts/monitoring/pages/dashboard_page.vue @@ -1,4 +1,5 @@ <script> +import { mapActions } from 'vuex'; import Dashboard from '../components/dashboard.vue'; export default { @@ -11,6 +12,16 @@ export default { required: true, }, }, + created() { + // This is to support the older URL <project>/-/environments/:env_id/metrics?dashboard=:path + // and the new format <project>/-/metrics/:dashboardPath + const encodedDashboard = this.$route.query.dashboard || this.$route.params.dashboard; + const currentDashboard = encodedDashboard ? decodeURIComponent(encodedDashboard) : null; + this.setCurrentDashboard({ currentDashboard }); + }, + methods: { + ...mapActions('monitoringDashboard', ['setCurrentDashboard']), + }, }; </script> <template> diff --git a/app/assets/javascripts/monitoring/queries/getDashboardValidationWarnings.query.graphql b/app/assets/javascripts/monitoring/queries/getDashboardValidationWarnings.query.graphql new file mode 100644 index 00000000000..302383512d3 --- /dev/null +++ b/app/assets/javascripts/monitoring/queries/getDashboardValidationWarnings.query.graphql @@ -0,0 +1,18 @@ +query getDashboardValidationWarnings( + $projectPath: ID! + $environmentName: String + $dashboardPath: String! +) { + project(fullPath: $projectPath) { + id + environments(name: $environmentName) { + nodes { + name + metricsDashboard(path: $dashboardPath) { + path + schemaValidationWarnings + } + } + } + } +} diff --git a/app/assets/javascripts/monitoring/router/constants.js b/app/assets/javascripts/monitoring/router/constants.js index acfcd03f928..fedfebe33e9 100644 --- a/app/assets/javascripts/monitoring/router/constants.js +++ b/app/assets/javascripts/monitoring/router/constants.js @@ -1,3 +1,4 @@ export const BASE_DASHBOARD_PAGE = 'dashboard'; +export const CUSTOM_DASHBOARD_PAGE = 'custom_dashboard'; export default {}; diff --git a/app/assets/javascripts/monitoring/router/routes.js b/app/assets/javascripts/monitoring/router/routes.js index 1e0cc1715a7..4b82791178a 100644 --- a/app/assets/javascripts/monitoring/router/routes.js +++ b/app/assets/javascripts/monitoring/router/routes.js @@ -1,6 +1,6 @@ import DashboardPage from '../pages/dashboard_page.vue'; -import { BASE_DASHBOARD_PAGE } from './constants'; +import { BASE_DASHBOARD_PAGE, CUSTOM_DASHBOARD_PAGE } from './constants'; /** * Because the cluster health page uses the dashboard @@ -12,7 +12,12 @@ import { BASE_DASHBOARD_PAGE } from './constants'; export default [ { name: BASE_DASHBOARD_PAGE, - path: '*', + path: '/', + component: DashboardPage, + }, + { + name: CUSTOM_DASHBOARD_PAGE, + path: '/:dashboard(.*)', component: DashboardPage, }, ]; diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 3a9cccec438..a441882a47d 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -12,6 +12,7 @@ import { import trackDashboardLoad from '../monitoring_tracking_helper'; import getEnvironments from '../queries/getEnvironments.query.graphql'; import getAnnotations from '../queries/getAnnotations.query.graphql'; +import getDashboardValidationWarnings from '../queries/getDashboardValidationWarnings.query.graphql'; import statusCodes from '../../lib/utils/http_status'; import { backOff, convertObjectPropsToCamelCase } from '../../lib/utils/common_utils'; import { s__, sprintf } from '../../locale'; @@ -20,6 +21,7 @@ import { PROMETHEUS_TIMEOUT, ENVIRONMENT_AVAILABLE_STATE, DEFAULT_DASHBOARD_PATH, + VARIABLE_TYPES, } from '../constants'; function prometheusMetricQueryParams(timeRange) { @@ -50,15 +52,14 @@ function backOffRequest(makeRequestCallback) { }, PROMETHEUS_TIMEOUT); } -function getPrometheusMetricResult(prometheusEndpoint, params) { +function getPrometheusQueryData(prometheusEndpoint, params) { return backOffRequest(() => axios.get(prometheusEndpoint, { params })) .then(res => res.data) .then(response => { if (response.status === 'error') { throw new Error(response.error); } - - return response.data.result; + return response.data; }); } @@ -76,10 +77,6 @@ export const setTimeRange = ({ commit }, timeRange) => { commit(types.SET_TIME_RANGE, timeRange); }; -export const setVariables = ({ commit }, variables) => { - commit(types.SET_VARIABLES, variables); -}; - export const filterEnvironments = ({ commit, dispatch }, searchTerm) => { commit(types.SET_ENVIRONMENTS_FILTER, searchTerm); dispatch('fetchEnvironmentsData'); @@ -100,6 +97,10 @@ export const clearExpandedPanel = ({ commit }) => { }); }; +export const setCurrentDashboard = ({ commit }, { currentDashboard }) => { + commit(types.SET_CURRENT_DASHBOARD, currentDashboard); +}; + // All Data /** @@ -117,17 +118,27 @@ export const fetchData = ({ dispatch }) => { // Metrics dashboard -export const fetchDashboard = ({ state, commit, dispatch }) => { +export const fetchDashboard = ({ state, commit, dispatch, getters }) => { dispatch('requestMetricsDashboard'); const params = {}; - if (state.currentDashboard) { - params.dashboard = state.currentDashboard; + if (getters.fullDashboardPath) { + params.dashboard = getters.fullDashboardPath; } return backOffRequest(() => axios.get(state.dashboardEndpoint, { params })) .then(resp => resp.data) - .then(response => dispatch('receiveMetricsDashboardSuccess', { response })) + .then(response => { + dispatch('receiveMetricsDashboardSuccess', { response }); + /** + * After the dashboard is fetched, there can be non-blocking invalid syntax + * in the dashboard file. This call will fetch such syntax warnings + * and surface a warning on the UI. If the invalid syntax is blocking, + * the `fetchDashboard` returns a 404 with error messages that are displayed + * on the UI. + */ + dispatch('fetchDashboardValidationWarnings'); + }) .catch(error => { Sentry.captureException(error); @@ -181,8 +192,12 @@ export const fetchDashboardData = ({ state, dispatch, getters }) => { return Promise.reject(); } + // Time range params must be pre-calculated once for all metrics and options + // A subsequent call, may calculate a different time range const defaultQueryParams = prometheusMetricQueryParams(state.timeRange); + dispatch('fetchVariableMetricLabelValues', { defaultQueryParams }); + const promises = []; state.dashboard.panelGroups.forEach(group => { group.panels.forEach(panel => { @@ -194,7 +209,7 @@ export const fetchDashboardData = ({ state, dispatch, getters }) => { return Promise.all(promises) .then(() => { - const dashboardType = state.currentDashboard === '' ? 'default' : 'custom'; + const dashboardType = getters.fullDashboardPath === '' ? 'default' : 'custom'; trackDashboardLoad({ label: `${dashboardType}_metrics_dashboard`, value: getters.metricsWithData().length, @@ -220,7 +235,7 @@ export const fetchPrometheusMetric = ( queryParams.step = metric.step; } - if (Object.keys(state.variables).length > 0) { + if (state.variables.length > 0) { queryParams = { ...queryParams, ...getters.getCustomVariablesParams, @@ -229,9 +244,9 @@ export const fetchPrometheusMetric = ( commit(types.REQUEST_METRIC_RESULT, { metricId: metric.metricId }); - return getPrometheusMetricResult(metric.prometheusEndpointPath, queryParams) - .then(result => { - commit(types.RECEIVE_METRIC_RESULT_SUCCESS, { metricId: metric.metricId, result }); + return getPrometheusQueryData(metric.prometheusEndpointPath, queryParams) + .then(data => { + commit(types.RECEIVE_METRIC_RESULT_SUCCESS, { metricId: metric.metricId, data }); }) .catch(error => { Sentry.captureException(error); @@ -312,9 +327,9 @@ export const receiveEnvironmentsDataFailure = ({ commit }) => { commit(types.RECEIVE_ENVIRONMENTS_DATA_FAILURE); }; -export const fetchAnnotations = ({ state, dispatch }) => { +export const fetchAnnotations = ({ state, dispatch, getters }) => { const { start } = convertToFixedRange(state.timeRange); - const dashboardPath = state.currentDashboard || DEFAULT_DASHBOARD_PATH; + const dashboardPath = getters.fullDashboardPath || DEFAULT_DASHBOARD_PATH; return gqClient .mutate({ mutation: getAnnotations, @@ -345,6 +360,46 @@ export const receiveAnnotationsSuccess = ({ commit }, data) => commit(types.RECEIVE_ANNOTATIONS_SUCCESS, data); export const receiveAnnotationsFailure = ({ commit }) => commit(types.RECEIVE_ANNOTATIONS_FAILURE); +export const fetchDashboardValidationWarnings = ({ state, dispatch, getters }) => { + /** + * Normally, the default dashboard won't throw any validation warnings. + * + * However, if a bug sneaks into the default dashboard making it invalid, + * this might come handy for our clients + */ + const dashboardPath = getters.fullDashboardPath || DEFAULT_DASHBOARD_PATH; + return gqClient + .mutate({ + mutation: getDashboardValidationWarnings, + variables: { + projectPath: removeLeadingSlash(state.projectPath), + environmentName: state.currentEnvironmentName, + dashboardPath, + }, + }) + .then(resp => resp.data?.project?.environments?.nodes?.[0]?.metricsDashboard) + .then(({ schemaValidationWarnings } = {}) => { + const hasWarnings = schemaValidationWarnings && schemaValidationWarnings.length !== 0; + /** + * The payload of the dispatch is a boolean, because at the moment a standard + * warning message is shown instead of the warnings the BE returns + */ + dispatch('receiveDashboardValidationWarningsSuccess', hasWarnings || false); + }) + .catch(err => { + Sentry.captureException(err); + dispatch('receiveDashboardValidationWarningsFailure'); + createFlash( + s__('Metrics|There was an error getting dashboard validation warnings information.'), + ); + }); +}; + +export const receiveDashboardValidationWarningsSuccess = ({ commit }, hasWarnings) => + commit(types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS, hasWarnings); +export const receiveDashboardValidationWarningsFailure = ({ commit }) => + commit(types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_FAILURE); + // Dashboard manipulation export const toggleStarredValue = ({ commit, state, getters }) => { @@ -416,10 +471,41 @@ export const duplicateSystemDashboard = ({ state }, payload) => { // Variables manipulation export const updateVariablesAndFetchData = ({ commit, dispatch }, updatedVariable) => { - commit(types.UPDATE_VARIABLES, updatedVariable); + commit(types.UPDATE_VARIABLE_VALUE, updatedVariable); return dispatch('fetchDashboardData'); }; +export const fetchVariableMetricLabelValues = ({ state, commit }, { defaultQueryParams }) => { + const { start_time, end_time } = defaultQueryParams; + const optionsRequests = []; + + state.variables.forEach(variable => { + if (variable.type === VARIABLE_TYPES.metric_label_values) { + const { prometheusEndpointPath, label } = variable.options; + + const optionsRequest = backOffRequest(() => + axios.get(prometheusEndpointPath, { + params: { start_time, end_time }, + }), + ) + .then(({ data }) => data.data) + .then(data => { + commit(types.UPDATE_VARIABLE_METRIC_LABEL_VALUES, { variable, label, data }); + }) + .catch(() => { + createFlash( + sprintf(s__('Metrics|There was an error getting options for variable "%{name}".'), { + name: variable.name, + }), + ); + }); + optionsRequests.push(optionsRequest); + } + }); + + return Promise.all(optionsRequests); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/monitoring/stores/getters.js b/app/assets/javascripts/monitoring/stores/getters.js index b7681012472..3aa711a0509 100644 --- a/app/assets/javascripts/monitoring/stores/getters.js +++ b/app/assets/javascripts/monitoring/stores/getters.js @@ -1,5 +1,9 @@ import { NOT_IN_DB_PREFIX } from '../constants'; -import { addPrefixToCustomVariableParams, addDashboardMetaDataToLink } from './utils'; +import { + addPrefixToCustomVariableParams, + addDashboardMetaDataToLink, + normalizeCustomDashboardPath, +} from './utils'; const metricsIdsInPanel = panel => panel.metrics.filter(metric => metric.metricId && metric.result).map(metric => metric.metricId); @@ -10,10 +14,10 @@ const metricsIdsInPanel = panel => * * @param {Object} state */ -export const selectedDashboard = state => { +export const selectedDashboard = (state, getters) => { const { allDashboards } = state; return ( - allDashboards.find(d => d.path === state.currentDashboard) || + allDashboards.find(d => d.path === getters.fullDashboardPath) || allDashboards.find(d => d.default) || null ); @@ -129,8 +133,8 @@ export const linksWithMetadata = state => { }; /** - * Maps an variables object to an array along with stripping - * the variable prefix. + * Maps a variables array to an object for replacement in + * prometheus queries. * * This method outputs an object in the below format * @@ -143,16 +147,29 @@ export const linksWithMetadata = state => { * user-defined variables coming through the URL and differentiate * from other variables used for Prometheus API endpoint. * - * @param {Object} variables - Custom variables provided by the user - * @returns {Array} The custom variables array to be send to the API + * @param {Object} state - State containing variables provided by the user + * @returns {Array} The custom variables object to be send to the API * in the format of {variables[key1]=value1, variables[key2]=value2} */ export const getCustomVariablesParams = state => - Object.keys(state.variables).reduce((acc, variable) => { - acc[addPrefixToCustomVariableParams(variable)] = state.variables[variable]?.value; + state.variables.reduce((acc, variable) => { + const { name, value } = variable; + if (value !== null) { + acc[addPrefixToCustomVariableParams(name)] = value; + } return acc; }, {}); +/** + * For a given custom dashboard file name, this method + * returns the full file path. + * + * @param {Object} state + * @returns {String} full dashboard path + */ +export const fullDashboardPath = state => + normalizeCustomDashboardPath(state.currentDashboard, state.customDashboardBasePath); + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js index 4593461776b..d408628fc4d 100644 --- a/app/assets/javascripts/monitoring/stores/mutation_types.js +++ b/app/assets/javascripts/monitoring/stores/mutation_types.js @@ -2,17 +2,25 @@ export const REQUEST_METRICS_DASHBOARD = 'REQUEST_METRICS_DASHBOARD'; export const RECEIVE_METRICS_DASHBOARD_SUCCESS = 'RECEIVE_METRICS_DASHBOARD_SUCCESS'; export const RECEIVE_METRICS_DASHBOARD_FAILURE = 'RECEIVE_METRICS_DASHBOARD_FAILURE'; -export const SET_VARIABLES = 'SET_VARIABLES'; -export const UPDATE_VARIABLES = 'UPDATE_VARIABLES'; +export const UPDATE_VARIABLE_VALUE = 'UPDATE_VARIABLE_VALUE'; +export const UPDATE_VARIABLE_METRIC_LABEL_VALUES = 'UPDATE_VARIABLE_METRIC_LABEL_VALUES'; export const REQUEST_DASHBOARD_STARRING = 'REQUEST_DASHBOARD_STARRING'; export const RECEIVE_DASHBOARD_STARRING_SUCCESS = 'RECEIVE_DASHBOARD_STARRING_SUCCESS'; export const RECEIVE_DASHBOARD_STARRING_FAILURE = 'RECEIVE_DASHBOARD_STARRING_FAILURE'; +export const SET_CURRENT_DASHBOARD = 'SET_CURRENT_DASHBOARD'; + // Annotations export const RECEIVE_ANNOTATIONS_SUCCESS = 'RECEIVE_ANNOTATIONS_SUCCESS'; export const RECEIVE_ANNOTATIONS_FAILURE = 'RECEIVE_ANNOTATIONS_FAILURE'; +// Dashboard validation warnings +export const RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS = + 'RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS'; +export const RECEIVE_DASHBOARD_VALIDATION_WARNINGS_FAILURE = + 'RECEIVE_DASHBOARD_VALIDATION_WARNINGS_FAILURE'; + // Git project deployments export const REQUEST_DEPLOYMENTS_DATA = 'REQUEST_DEPLOYMENTS_DATA'; export const RECEIVE_DEPLOYMENTS_DATA_SUCCESS = 'RECEIVE_DEPLOYMENTS_DATA_SUCCESS'; @@ -34,7 +42,6 @@ export const SET_ALL_DASHBOARDS = 'SET_ALL_DASHBOARDS'; export const SET_ENDPOINTS = 'SET_ENDPOINTS'; export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE'; -export const SET_NO_DATA_EMPTY_STATE = 'SET_NO_DATA_EMPTY_STATE'; export const SET_SHOW_ERROR_BANNER = 'SET_SHOW_ERROR_BANNER'; export const SET_PANEL_GROUP_METRICS = 'SET_PANEL_GROUP_METRICS'; export const SET_ENVIRONMENTS_FILTER = 'SET_ENVIRONMENTS_FILTER'; diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index 2d63fdd6e34..744441c8935 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -1,10 +1,11 @@ import Vue from 'vue'; import { pick } from 'lodash'; import * as types from './mutation_types'; -import { mapToDashboardViewModel, normalizeQueryResult } from './utils'; -import { BACKOFF_TIMEOUT } from '../../lib/utils/common_utils'; -import { endpointKeys, initialStateKeys, metricStates } from '../constants'; +import { mapToDashboardViewModel, normalizeQueryResponseData } from './utils'; import httpStatusCodes from '~/lib/utils/http_status'; +import { BACKOFF_TIMEOUT } from '../../lib/utils/common_utils'; +import { dashboardEmptyStates, endpointKeys, initialStateKeys, metricStates } from '../constants'; +import { optionsFromSeriesData } from './variable_mapping'; /** * Locate and return a metric in the dashboard by its id @@ -57,8 +58,7 @@ export default { * Dashboard panels structure and global state */ [types.REQUEST_METRICS_DASHBOARD](state) { - state.emptyState = 'loading'; - state.showEmptyState = true; + state.emptyState = dashboardEmptyStates.LOADING; }, [types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, dashboardYML) { const { dashboard, panelGroups, variables, links } = mapToDashboardViewModel(dashboardYML); @@ -70,12 +70,15 @@ export default { state.links = links; if (!state.dashboard.panelGroups.length) { - state.emptyState = 'noData'; + state.emptyState = dashboardEmptyStates.NO_DATA; + } else { + state.emptyState = null; } }, [types.RECEIVE_METRICS_DASHBOARD_FAILURE](state, error) { - state.emptyState = error ? 'unableToConnect' : 'noData'; - state.showEmptyState = true; + state.emptyState = error + ? dashboardEmptyStates.UNABLE_TO_CONNECT + : dashboardEmptyStates.NO_DATA; }, [types.REQUEST_DASHBOARD_STARRING](state) { @@ -94,6 +97,10 @@ export default { state.isUpdatingStarredValue = false; }, + [types.SET_CURRENT_DASHBOARD](state, currentDashboard) { + state.currentDashboard = currentDashboard; + }, + /** * Deployments and environments */ @@ -126,6 +133,16 @@ export default { }, /** + * Dashboard Validation Warnings + */ + [types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS](state, hasDashboardValidationWarnings) { + state.hasDashboardValidationWarnings = hasDashboardValidationWarnings; + }, + [types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_FAILURE](state) { + state.hasDashboardValidationWarnings = false; + }, + + /** * Individual panel/metric results */ [types.REQUEST_METRIC_RESULT](state, { metricId }) { @@ -135,19 +152,18 @@ export default { metric.state = metricStates.LOADING; } }, - [types.RECEIVE_METRIC_RESULT_SUCCESS](state, { metricId, result }) { + [types.RECEIVE_METRIC_RESULT_SUCCESS](state, { metricId, data }) { const metric = findMetricInDashboard(metricId, state.dashboard); metric.loading = false; - state.showEmptyState = false; - if (!result || result.length === 0) { + if (!data.result || data.result.length === 0) { metric.state = metricStates.NO_DATA; metric.result = null; } else { - const normalizedResults = result.map(normalizeQueryResult); + const result = normalizeQueryResponseData(data); metric.state = metricStates.OK; - metric.result = Object.freeze(normalizedResults); + metric.result = Object.freeze(result); } }, [types.RECEIVE_METRIC_RESULT_FAILURE](state, { metricId, error }) { @@ -169,11 +185,7 @@ export default { state.timeRange = timeRange; }, [types.SET_GETTING_STARTED_EMPTY_STATE](state) { - state.emptyState = 'gettingStarted'; - }, - [types.SET_NO_DATA_EMPTY_STATE](state) { - state.showEmptyState = true; - state.emptyState = 'noData'; + state.emptyState = dashboardEmptyStates.GETTING_STARTED; }, [types.SET_ALL_DASHBOARDS](state, dashboards) { state.allDashboards = dashboards || []; @@ -192,13 +204,18 @@ export default { state.expandedPanel.group = group; state.expandedPanel.panel = panel; }, - [types.SET_VARIABLES](state, variables) { - state.variables = variables; + [types.UPDATE_VARIABLE_VALUE](state, { name, value }) { + const variable = state.variables.find(v => v.name === name); + if (variable) { + Object.assign(variable, { + value, + }); + } }, - [types.UPDATE_VARIABLES](state, updatedVariable) { - Object.assign(state.variables[updatedVariable.key], { - ...state.variables[updatedVariable.key], - value: updatedVariable.value, - }); + [types.UPDATE_VARIABLE_METRIC_LABEL_VALUES](state, { variable, label, data = [] }) { + const values = optionsFromSeriesData({ label, data }); + + // Add new options with assign to ensure Vue reactivity + Object.assign(variable.options, { values }); }, }; diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js index 8000f27c0d5..89738756ffe 100644 --- a/app/assets/javascripts/monitoring/stores/state.js +++ b/app/assets/javascripts/monitoring/stores/state.js @@ -1,5 +1,6 @@ import invalidUrl from '~/lib/utils/invalid_url'; import { timezones } from '../format_date'; +import { dashboardEmptyStates } from '../constants'; export default () => ({ // API endpoints @@ -9,11 +10,24 @@ export default () => ({ // Dashboard request parameters timeRange: null, + /** + * Currently selected dashboard. For custom dashboards, + * this could be the filename or the file path. + * + * If this is the filename and full path is required, + * getters.fullDashboardPath should be used. + */ currentDashboard: null, // Dashboard data - emptyState: 'gettingStarted', - showEmptyState: true, + hasDashboardValidationWarnings: false, + + /** + * {?String} If set, dashboard should display a global + * empty state, there is no way to interact (yet) + * with the dashboard. + */ + emptyState: dashboardEmptyStates.GETTING_STARTED, showErrorBanner: true, isUpdatingStarredValue: false, dashboard: { @@ -39,7 +53,7 @@ export default () => ({ * User-defined custom variables are passed * via the dashboard yml file. */ - variables: {}, + variables: [], /** * User-defined custom links are passed * via the dashboard yml file. @@ -56,5 +70,16 @@ export default () => ({ // GitLab paths to other pages projectPath: null, + operationsSettingsPath: '', logsPath: invalidUrl, + + // static paths + customDashboardBasePath: '', + + // current user data + /** + * Flag that denotes if the currently logged user can access + * the project Settings -> Operations + */ + canAccessOperationsSettings: false, }); diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js index 5795e756282..51562593ee8 100644 --- a/app/assets/javascripts/monitoring/stores/utils.js +++ b/app/assets/javascripts/monitoring/stores/utils.js @@ -2,11 +2,11 @@ import { slugify } from '~/lib/utils/text_utility'; import createGqClient, { fetchPolicies } from '~/lib/graphql'; import { SUPPORTED_FORMATS } from '~/lib/utils/unit_format'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { NOT_IN_DB_PREFIX, linkTypes } from '../constants'; import { mergeURLVariables, parseTemplatingVariables } from './variable_mapping'; import { DATETIME_RANGE_TYPES } from '~/lib/utils/constants'; import { timeRangeToParams, getRangeType } from '~/lib/utils/datetime_range'; import { isSafeURL, mergeUrlParams } from '~/lib/utils/url_utility'; +import { NOT_IN_DB_PREFIX, linkTypes, OUT_OF_THE_BOX_DASHBOARDS_PATH_PREFIX } from '../constants'; export const gqClient = createGqClient( {}, @@ -165,7 +165,7 @@ const mapLinksToViewModel = ({ url = null, title = '', type } = {}) => { * @param {Object} panel - Metrics panel * @returns {Object} */ -const mapPanelToViewModel = ({ +export const mapPanelToViewModel = ({ id = null, title = '', type, @@ -173,6 +173,7 @@ const mapPanelToViewModel = ({ x_label, y_label, y_axis = {}, + field, metrics = [], links = [], max_value, @@ -193,6 +194,7 @@ const mapPanelToViewModel = ({ y_label: yAxis.name, // Changing y_label to yLabel is pending https://gitlab.com/gitlab-org/gitlab/issues/207198 yAxis, xAxis, + field, maxValue: max_value, links: links.map(mapLinksToViewModel), metrics: mapToMetricsViewModel(metrics), @@ -289,49 +291,157 @@ export const mapToDashboardViewModel = ({ }) => { return { dashboard, - variables: mergeURLVariables(parseTemplatingVariables(templating)), + variables: mergeURLVariables(parseTemplatingVariables(templating.variables)), links: links.map(mapLinksToViewModel), panelGroups: panel_groups.map(mapToPanelGroupViewModel), }; }; +// Prometheus Results Parsing + +const dateTimeFromUnixTime = unixTime => new Date(unixTime * 1000).toISOString(); + +const mapScalarValue = ([unixTime, value]) => [dateTimeFromUnixTime(unixTime), Number(value)]; + +// Note: `string` value type is unused as of prometheus 2.19. +const mapStringValue = ([unixTime, value]) => [dateTimeFromUnixTime(unixTime), value]; + +/** + * Processes a scalar result. + * + * The corresponding result property has the following format: + * + * [ <unix_time>, "<scalar_value>" ] + * + * @param {array} result + * @returns {array} + */ +const normalizeScalarResult = result => [ + { + metric: {}, + value: mapScalarValue(result), + values: [mapScalarValue(result)], + }, +]; + +/** + * Processes a string result. + * + * The corresponding result property has the following format: + * + * [ <unix_time>, "<string_value>" ] + * + * Note: This value type is unused as of prometheus 2.19. + * + * @param {array} result + * @returns {array} + */ +const normalizeStringResult = result => [ + { + metric: {}, + value: mapStringValue(result), + values: [mapStringValue(result)], + }, +]; + +/** + * Proccesses an instant vector. + * + * Instant vectors are returned as result type `vector`. + * + * The corresponding result property has the following format: + * + * [ + * { + * "metric": { "<label_name>": "<label_value>", ... }, + * "value": [ <unix_time>, "<sample_value>" ], + * "values": [ [ <unix_time>, "<sample_value>" ] ] + * }, + * ... + * ] + * + * `metric` - Key-value pairs object representing metric measured + * `value` - The vector result + * `values` - An array with a single value representing the result + * + * This method also adds the matrix version of the vector + * by introducing a `values` array with a single element. This + * allows charts to default to `values` if needed. + * + * @param {array} result + * @returns {array} + */ +const normalizeVectorResult = result => + result.map(({ metric, value }) => { + const scalar = mapScalarValue(value); + // Add a single element to `values`, to support matrix + // style charts. + return { metric, value: scalar, values: [scalar] }; + }); + /** - * Processes a single Range vector, part of the result - * of type `matrix` in the form: + * Range vectors are returned as result type matrix. + * + * The corresponding result property has the following format: * * { * "metric": { "<label_name>": "<label_value>", ... }, + * "value": [ <unix_time>, "<sample_value>" ], * "values": [ [ <unix_time>, "<sample_value>" ], ... ] * }, * + * `metric` - Key-value pairs object representing metric measured + * `value` - The last (more recent) result + * `values` - A range of results for the metric + * * See https://prometheus.io/docs/prometheus/latest/querying/api/#range-vectors * - * @param {*} timeSeries + * @param {array} result + * @returns {object} Normalized result. */ -export const normalizeQueryResult = timeSeries => { - let normalizedResult = {}; - - if (timeSeries.values) { - normalizedResult = { - ...timeSeries, - values: timeSeries.values.map(([timestamp, value]) => [ - new Date(timestamp * 1000).toISOString(), - Number(value), - ]), - }; - // Check result for empty data - normalizedResult.values = normalizedResult.values.filter(series => { - const hasValue = d => !Number.isNaN(d[1]) && (d[1] !== null || d[1] !== undefined); - return series.find(hasValue); - }); - } else if (timeSeries.value) { - normalizedResult = { - ...timeSeries, - value: [new Date(timeSeries.value[0] * 1000).toISOString(), Number(timeSeries.value[1])], +const normalizeResultMatrix = result => + result.map(({ metric, values }) => { + const mappedValues = values.map(mapScalarValue); + return { + metric, + value: mappedValues[mappedValues.length - 1], + values: mappedValues, }; - } + }); - return normalizedResult; +/** + * Parse response data from a Prometheus Query that comes + * in the format: + * + * { + * "resultType": "matrix" | "vector" | "scalar" | "string", + * "result": <value> + * } + * + * @see https://prometheus.io/docs/prometheus/latest/querying/api/#expression-query-result-formats + * + * @param {object} data - Data containing results and result type. + * @returns {object} - A result array of metric results: + * [ + * { + * metric: { ... }, + * value: ['2015-07-01T20:10:51.781Z', '1'], + * values: [['2015-07-01T20:10:51.781Z', '1'] , ... ], + * }, + * ... + * ] + * + */ +export const normalizeQueryResponseData = data => { + const { resultType, result } = data; + if (resultType === 'vector') { + return normalizeVectorResult(result); + } else if (resultType === 'scalar') { + return normalizeScalarResult(result); + } else if (resultType === 'string') { + return normalizeStringResult(result); + } + return normalizeResultMatrix(result); }; /** @@ -345,7 +455,35 @@ export const normalizeQueryResult = timeSeries => { * * This is currently only used by getters/getCustomVariablesParams * - * @param {String} key Variable key that needs to be prefixed + * @param {String} name Variable key that needs to be prefixed * @returns {String} */ -export const addPrefixToCustomVariableParams = key => `variables[${key}]`; +export const addPrefixToCustomVariableParams = name => `variables[${name}]`; + +/** + * Normalize custom dashboard paths. This method helps support + * metrics dashboard to work with custom dashboard file names instead + * of the entire path. + * + * If dashboard is empty, it is the default dashboard. + * If dashboard is set, it usually is a custom dashboard unless + * explicitly it is set to default dashboard path. + * + * @param {String} dashboard dashboard path + * @param {String} dashboardPrefix custom dashboard directory prefix + * @returns {String} normalized dashboard path + */ +export const normalizeCustomDashboardPath = (dashboard, dashboardPrefix = '') => { + const currDashboard = dashboard || ''; + let dashboardPath = `${dashboardPrefix}/${currDashboard}`; + + if (!currDashboard) { + dashboardPath = ''; + } else if ( + currDashboard.startsWith(dashboardPrefix) || + currDashboard.startsWith(OUT_OF_THE_BOX_DASHBOARDS_PATH_PREFIX) + ) { + dashboardPath = currDashboard; + } + return dashboardPath; +}; diff --git a/app/assets/javascripts/monitoring/stores/variable_mapping.js b/app/assets/javascripts/monitoring/stores/variable_mapping.js index c0a8150063b..9245ffdb3b9 100644 --- a/app/assets/javascripts/monitoring/stores/variable_mapping.js +++ b/app/assets/javascripts/monitoring/stores/variable_mapping.js @@ -46,7 +46,7 @@ const textAdvancedVariableParser = advTextVar => ({ * @param {Object} custom variable option * @returns {Object} normalized custom variable options */ -const normalizeCustomVariableOptions = ({ default: defaultOpt = false, text, value }) => ({ +const normalizeVariableValues = ({ default: defaultOpt = false, text, value = null }) => ({ default: defaultOpt, text: text || value, value, @@ -59,17 +59,19 @@ const normalizeCustomVariableOptions = ({ default: defaultOpt = false, text, val * The default value is the option with default set to true or the first option * if none of the options have default prop true. * - * @param {Object} advVariable advance custom variable + * @param {Object} advVariable advanced custom variable * @returns {Object} */ const customAdvancedVariableParser = advVariable => { - const options = (advVariable?.options?.values ?? []).map(normalizeCustomVariableOptions); - const defaultOpt = options.find(opt => opt.default === true) || options[0]; + const values = (advVariable?.options?.values ?? []).map(normalizeVariableValues); + const defaultValue = values.find(opt => opt.default === true) || values[0]; return { type: VARIABLE_TYPES.custom, label: advVariable.label, - value: defaultOpt?.value, - options, + options: { + values, + }, + value: defaultValue?.value || null, }; }; @@ -80,7 +82,7 @@ const customAdvancedVariableParser = advVariable => { * @param {String} opt option from simple custom variable * @returns {Object} */ -const parseSimpleCustomOptions = opt => ({ text: opt, value: opt }); +export const parseSimpleCustomValues = opt => ({ text: opt, value: opt }); /** * Custom simple variables are rendered as dropdown elements in the dashboard @@ -95,15 +97,28 @@ const parseSimpleCustomOptions = opt => ({ text: opt, value: opt }); * @returns {Object} */ const customSimpleVariableParser = simpleVar => { - const options = (simpleVar || []).map(parseSimpleCustomOptions); + const values = (simpleVar || []).map(parseSimpleCustomValues); return { type: VARIABLE_TYPES.custom, - value: options[0].value, label: null, - options: options.map(normalizeCustomVariableOptions), + value: values[0].value || null, + options: { + values: values.map(normalizeVariableValues), + }, }; }; +const metricLabelValuesVariableParser = ({ label, options = {} }) => ({ + type: VARIABLE_TYPES.metric_label_values, + label, + value: null, + options: { + prometheusEndpointPath: options.prometheus_endpoint_path || '', + label: options.label || null, + values: [], // values are initially empty + }, +}); + /** * Utility method to determine if a custom variable is * simple or not. If its not simple, it is advanced. @@ -123,14 +138,16 @@ const isSimpleCustomVariable = customVar => Array.isArray(customVar); * @return {Function} parser method */ const getVariableParser = variable => { - if (isSimpleCustomVariable(variable)) { + if (isString(variable)) { + return textSimpleVariableParser; + } else if (isSimpleCustomVariable(variable)) { return customSimpleVariableParser; - } else if (variable.type === VARIABLE_TYPES.custom) { - return customAdvancedVariableParser; } else if (variable.type === VARIABLE_TYPES.text) { return textAdvancedVariableParser; - } else if (isString(variable)) { - return textSimpleVariableParser; + } else if (variable.type === VARIABLE_TYPES.custom) { + return customAdvancedVariableParser; + } else if (variable.type === VARIABLE_TYPES.metric_label_values) { + return metricLabelValuesVariableParser; } return () => null; }; @@ -141,29 +158,26 @@ const getVariableParser = variable => { * for the user to edit. The values from input elements are relayed to * backend and eventually Prometheus API. * - * This method currently is not used anywhere. Once the issue - * https://gitlab.com/gitlab-org/gitlab/-/issues/214536 is completed, - * this method will have been used by the monitoring dashboard. - * - * @param {Object} templating templating variables from the dashboard yml file - * @returns {Object} a map of processed templating variables + * @param {Object} templating variables from the dashboard yml file + * @returns {array} An array of variables to display as inputs */ -export const parseTemplatingVariables = ({ variables = {} } = {}) => - Object.entries(variables).reduce((acc, [key, variable]) => { +export const parseTemplatingVariables = (ymlVariables = {}) => + Object.entries(ymlVariables).reduce((acc, [name, ymlVariable]) => { // get the parser - const parser = getVariableParser(variable); + const parser = getVariableParser(ymlVariable); // parse the variable - const parsedVar = parser(variable); + const variable = parser(ymlVariable); // for simple custom variable label is null and it should be // replace with key instead - if (parsedVar) { - acc[key] = { - ...parsedVar, - label: parsedVar.label || key, - }; + if (variable) { + acc.push({ + ...variable, + name, + label: variable.label || name, + }); } return acc; - }, {}); + }, []); /** * Custom variables are defined in the dashboard yml file @@ -181,23 +195,81 @@ export const parseTemplatingVariables = ({ variables = {} } = {}) => * This method can be improved further. See the below issue * https://gitlab.com/gitlab-org/gitlab/-/issues/217713 * - * @param {Object} varsFromYML template variables from yml file + * @param {array} parsedYmlVariables - template variables from yml file * @returns {Object} */ -export const mergeURLVariables = (varsFromYML = {}) => { +export const mergeURLVariables = (parsedYmlVariables = []) => { const varsFromURL = templatingVariablesFromUrl(); - const variables = {}; - Object.keys(varsFromYML).forEach(key => { - if (Object.prototype.hasOwnProperty.call(varsFromURL, key)) { - variables[key] = { - ...varsFromYML[key], - value: varsFromURL[key], - }; - } else { - variables[key] = varsFromYML[key]; + parsedYmlVariables.forEach(variable => { + const { name } = variable; + if (Object.prototype.hasOwnProperty.call(varsFromURL, name)) { + Object.assign(variable, { value: varsFromURL[name] }); } }); - return variables; + return parsedYmlVariables; +}; + +/** + * Converts series data to options that can be added to a + * variable. Series data is returned from the Prometheus API + * `/api/v1/series`. + * + * Finds a `label` in the series data, so it can be used as + * a filter. + * + * For example, for the arguments: + * + * { + * "label": "job" + * "data" : [ + * { + * "__name__" : "up", + * "job" : "prometheus", + * "instance" : "localhost:9090" + * }, + * { + * "__name__" : "up", + * "job" : "node", + * "instance" : "localhost:9091" + * }, + * { + * "__name__" : "process_start_time_seconds", + * "job" : "prometheus", + * "instance" : "localhost:9090" + * } + * ] + * } + * + * It returns all the different "job" values: + * + * [ + * { + * "label": "node", + * "value": "node" + * }, + * { + * "label": "prometheus", + * "value": "prometheus" + * } + * ] + * + * @param {options} options object + * @param {options.seriesLabel} name of the searched series label + * @param {options.data} series data from the series API + * @return {array} Options objects with the shape `{ label, value }` + * + * @see https://prometheus.io/docs/prometheus/latest/querying/api/#finding-series-by-label-matchers + */ +export const optionsFromSeriesData = ({ label, data = [] }) => { + const optionsSet = data.reduce((set, seriesObject) => { + // Use `new Set` to deduplicate options + if (seriesObject[label]) { + set.add(seriesObject[label]); + } + return set; + }, new Set()); + + return [...optionsSet].map(parseSimpleCustomValues); }; export default {}; diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js index 4d2927a066e..0c6fcad9dd0 100644 --- a/app/assets/javascripts/monitoring/utils.js +++ b/app/assets/javascripts/monitoring/utils.js @@ -5,6 +5,7 @@ import { removeParams, updateHistory, } from '~/lib/utils/url_utility'; +import { parseBoolean } from '~/lib/utils/common_utils'; import { timeRangeParamNames, timeRangeFromParams, @@ -13,6 +14,50 @@ import { import { VARIABLE_PREFIX } from './constants'; /** + * Extracts the initial state and props from HTML dataset + * and places them in separate objects to setup bundle. + * @param {*} dataset + */ +export const stateAndPropsFromDataset = (dataset = {}) => { + const { + currentDashboard, + deploymentsEndpoint, + dashboardEndpoint, + dashboardsEndpoint, + dashboardTimezone, + canAccessOperationsSettings, + operationsSettingsPath, + projectPath, + logsPath, + currentEnvironmentName, + customDashboardBasePath, + ...dataProps + } = dataset; + + // HTML attributes are always strings, parse other types. + dataProps.hasMetrics = parseBoolean(dataProps.hasMetrics); + dataProps.customMetricsAvailable = parseBoolean(dataProps.customMetricsAvailable); + dataProps.prometheusAlertsAvailable = parseBoolean(dataProps.prometheusAlertsAvailable); + + return { + initState: { + currentDashboard, + deploymentsEndpoint, + dashboardEndpoint, + dashboardsEndpoint, + dashboardTimezone, + canAccessOperationsSettings, + operationsSettingsPath, + projectPath, + logsPath, + currentEnvironmentName, + customDashboardBasePath, + }, + dataProps, + }; +}; + +/** * List of non time range url parameters * This will be removed once we add support for free text variables * via the dashboard yaml files in https://gitlab.com/gitlab-org/gitlab/-/issues/215689 @@ -160,8 +205,10 @@ export const removePrefixFromLabel = label => * @returns {Object} */ export const convertVariablesForURL = variables => - Object.keys(variables || {}).reduce((acc, key) => { - acc[addPrefixToLabel(key)] = variables[key]?.value; + variables.reduce((acc, { name, value }) => { + if (value !== null) { + acc[addPrefixToLabel(name)] = value; + } return acc; }, {}); diff --git a/app/assets/javascripts/namespace_storage_limit_alert.js b/app/assets/javascripts/namespace_storage_limit_alert.js deleted file mode 100644 index 34ad93c127d..00000000000 --- a/app/assets/javascripts/namespace_storage_limit_alert.js +++ /dev/null @@ -1,20 +0,0 @@ -import Cookies from 'js-cookie'; - -const handleOnDismiss = ({ currentTarget }) => { - const { - dataset: { id, level }, - } = currentTarget; - - Cookies.set(`hide_storage_limit_alert_${id}_${level}`, true, { expires: 365 }); - - const notification = document.querySelector('.js-namespace-storage-alert'); - notification.parentNode.removeChild(notification); -}; - -export default () => { - const alert = document.querySelector('.js-namespace-storage-alert-dismiss'); - - if (alert) { - alert.addEventListener('click', handleOnDismiss); - } -}; diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 6e695de447d..f4982507adb 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -1617,7 +1617,7 @@ export default class Notes { } tempFormContent = formContent; - if (this.hasQuickActions(formContent)) { + if (this.glForm.supportsQuickActions && this.hasQuickActions(formContent)) { tempFormContent = this.stripQuickActions(formContent); hasQuickActions = true; } diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 16dcde46262..ac93d3df654 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -17,7 +17,7 @@ import { import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import * as constants from '../constants'; import eventHub from '../event_hub'; -import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; +import NoteableWarning from '../../vue_shared/components/notes/noteable_warning.vue'; import markdownField from '../../vue_shared/components/markdown/field.vue'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import loadingButton from '../../vue_shared/components/loading_button.vue'; @@ -28,8 +28,7 @@ import issuableStateMixin from '../mixins/issuable_state'; export default { name: 'CommentForm', components: { - issueWarning, - epicWarning: () => import('ee_component/vue_shared/components/epic/epic_warning.vue'), + NoteableWarning, noteSignedOutWidget, discussionLockedWidget, markdownField, @@ -126,9 +125,13 @@ export default { canToggleIssueState() { return ( this.getNoteableData.current_user.can_update && - this.getNoteableData.state !== constants.MERGED + this.getNoteableData.state !== constants.MERGED && + !this.closedAndLocked ); }, + closedAndLocked() { + return !this.isOpen && this.isLocked(this.getNoteableData); + }, endpoint() { return this.getNoteableData.create_note_path; }, @@ -350,14 +353,15 @@ export default { <form ref="commentForm" class="new-note common-note-form gfm-form js-main-target-form"> <div class="error-alert"></div> - <issue-warning - v-if="hasWarning(getNoteableData) && isIssueType" + <noteable-warning + v-if="hasWarning(getNoteableData)" :is-locked="isLocked(getNoteableData)" :is-confidential="isConfidential(getNoteableData)" - :locked-issue-docs-path="lockedIssueDocsPath" - :confidential-issue-docs-path="confidentialIssueDocsPath" + :noteable-type="noteableType" + :locked-noteable-docs-path="lockedIssueDocsPath" + :confidential-noteable-docs-path="confidentialIssueDocsPath" /> - <epic-warning :is-confidential="isConfidential(getNoteableData)" /> + <markdown-field ref="markdownField" :is-submitting="isSubmitting" @@ -374,20 +378,18 @@ export default { dir="auto" :disabled="isSubmitting" name="note[note]" - class="note-textarea js-vue-comment-form js-note-text -js-gfm-input js-autosize markdown-area js-vue-textarea qa-comment-input" + class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area js-vue-textarea qa-comment-input" data-supports-quick-actions="true" :aria-label="__('Description')" :placeholder="__('Write a comment or drag your files here…')" @keydown.up="editCurrentUserLastNote()" @keydown.meta.enter="handleSave()" @keydown.ctrl.enter="handleSave()" - > - </textarea> + ></textarea> </markdown-field> <gl-alert v-if="isToggleBlockedIssueWarning" - class="prepend-top-16" + class="gl-mt-5" :title="__('Are you sure you want to close this blocked issue?')" :primary-button-text="__('Yes, close issue')" :secondary-button-text="__('Cancel')" @@ -417,13 +419,11 @@ js-gfm-input js-autosize markdown-area js-vue-textarea qa-comment-input" </gl-alert> <div class="note-form-actions"> <div - class="btn-group -append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" + class="btn-group gl-mr-3 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" > <button :disabled="isSubmitButtonDisabled" - class="btn btn-success js-comment-button js-comment-submit-button - qa-comment-button" + class="btn btn-success js-comment-button js-comment-submit-button qa-comment-button" type="submit" :data-track-label="trackingLabel" data-track-event="click_button" @@ -440,7 +440,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" data-toggle="dropdown" :aria-label="__('Open comment type dropdown')" > - <i aria-hidden="true" class="fa fa-caret-down toggle-icon"> </i> + <i aria-hidden="true" class="fa fa-caret-down toggle-icon"></i> </button> <ul class="note-type-dropdown dropdown-open-top dropdown-menu"> @@ -450,7 +450,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" class="btn btn-transparent" @click.prevent="setNoteType('comment')" > - <i aria-hidden="true" class="fa fa-check icon"> </i> + <i aria-hidden="true" class="fa fa-check icon"></i> <div class="description"> <strong>{{ __('Comment') }}</strong> <p> @@ -470,7 +470,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" class="btn btn-transparent qa-discussion-option" @click.prevent="setNoteType('discussion')" > - <i aria-hidden="true" class="fa fa-check icon"> </i> + <i aria-hidden="true" class="fa fa-check icon"></i> <div class="description"> <strong>{{ __('Start thread') }}</strong> <p>{{ startDiscussionDescription }}</p> diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue index 0b136549c14..458da5cf67f 100644 --- a/app/assets/javascripts/notes/components/discussion_notes.vue +++ b/app/assets/javascripts/notes/components/discussion_notes.vue @@ -74,7 +74,7 @@ export default { }, }, methods: { - ...mapActions(['toggleDiscussion']), + ...mapActions(['toggleDiscussion', 'setSelectedCommentPositionHover']), componentName(note) { if (note.isPlaceholderNote) { if (note.placeholderType === SYSTEM_NOTE) { @@ -99,7 +99,11 @@ export default { <template> <div class="discussion-notes"> - <ul class="notes"> + <ul + class="notes" + @mouseenter="setSelectedCommentPositionHover(discussion.position.line_range)" + @mouseleave="setSelectedCommentPositionHover()" + > <template v-if="shouldGroupReplies"> <component :is="componentName(firstNote)" @@ -108,6 +112,7 @@ export default { :commit="commit" :help-page-path="helpPagePath" :show-reply-button="userCanReply" + :discussion-root="true" @handleDeleteNote="$emit('deleteNote')" @startReplying="$emit('startReplying')" > @@ -151,6 +156,7 @@ export default { :note="componentData(note)" :help-page-path="helpPagePath" :line="diffLine" + :discussion-root="index === 0" @handleDeleteNote="$emit('deleteNote')" > <slot v-if="index === 0" slot="avatar-badge" name="avatar-badge"></slot> diff --git a/app/assets/javascripts/notes/components/multiline_comment_form.vue b/app/assets/javascripts/notes/components/multiline_comment_form.vue index 5fba011a153..bb13eb87af7 100644 --- a/app/assets/javascripts/notes/components/multiline_comment_form.vue +++ b/app/assets/javascripts/notes/components/multiline_comment_form.vue @@ -1,4 +1,5 @@ <script> +import { mapActions } from 'vuex'; import { GlFormSelect, GlSprintf } from '@gitlab/ui'; import { getSymbol, getLineClasses } from './multiline_comment_utils'; @@ -21,19 +22,51 @@ export default { }, data() { return { - commentLineStart: { - lineCode: this.lineRange ? this.lineRange.start_line_code : this.line.line_code, - type: this.lineRange ? this.lineRange.start_line_type : this.line.type, - }, + commentLineStart: {}, + commentLineEndType: this.lineRange?.end?.line_type || this.line.type, }; }, + computed: { + lineNumber() { + return this.commentLineOptions[this.commentLineOptions.length - 1].text; + }, + }, + created() { + const line = this.lineRange?.start || this.line; + + this.commentLineStart = { + line_code: line.line_code, + type: line.type, + old_line: line.old_line, + new_line: line.new_line, + }; + this.highlightSelection(); + }, + destroyed() { + this.setSelectedCommentPosition(); + }, methods: { + ...mapActions(['setSelectedCommentPosition']), getSymbol({ type }) { return getSymbol(type); }, getLineClasses(line) { return getLineClasses(line); }, + updateCommentLineStart(value) { + this.commentLineStart = value; + this.$emit('input', value); + this.highlightSelection(); + }, + highlightSelection() { + const { line_code, new_line, old_line, type } = this.line; + const updatedLineRange = { + start: { ...this.commentLineStart }, + end: { line_code, new_line, old_line, type }, + }; + + this.setSelectedCommentPosition(updatedLineRange); + }, }, }; </script> @@ -55,12 +88,12 @@ export default { :options="commentLineOptions" size="sm" class="gl-w-auto gl-vertical-align-baseline" - @change="$emit('input', $event)" + @change="updateCommentLineStart" /> </template> <template #end> <span :class="getLineClasses(line)"> - {{ getSymbol(line) + (line.new_line || line.old_line) }} + {{ lineNumber }} </span> </template> </gl-sprintf> diff --git a/app/assets/javascripts/notes/components/multiline_comment_utils.js b/app/assets/javascripts/notes/components/multiline_comment_utils.js index dc9c55e9b30..dbae10c8f6c 100644 --- a/app/assets/javascripts/notes/components/multiline_comment_utils.js +++ b/app/assets/javascripts/notes/components/multiline_comment_utils.js @@ -7,11 +7,19 @@ export function getSymbol(type) { } function getLineNumber(lineRange, key) { - if (!lineRange || !key) return ''; - const lineCode = lineRange[`${key}_line_code`] || ''; - const lineType = lineRange[`${key}_line_type`] || ''; - const lines = lineCode.split('_') || []; - const lineNumber = lineType === 'old' ? lines[1] : lines[2]; + if (!lineRange || !key || !lineRange[key]) return ''; + const { new_line: newLine, old_line: oldLine, type } = lineRange[key]; + const otherKey = key === 'start' ? 'end' : 'start'; + + // By default we want to see the "old" or "left side" line number + // The exception is if the "end" line is on the "right" side + // `otherLineType` is only used if `type` is null to make sure the line + // number relfects the "right" side number, if that is the side + // the comment form is located on + const otherLineType = !type ? lineRange[otherKey]?.type : null; + const lineType = type || ''; + let lineNumber = oldLine; + if (lineType === 'new' || otherLineType === 'new') lineNumber = newLine; return (lineNumber && getSymbol(lineType) + lineNumber) || ''; } @@ -37,21 +45,67 @@ export function getLineClasses(line) { ]; } -export function commentLineOptions(diffLines, lineCode) { - const selectedIndex = diffLines.findIndex(line => line.line_code === lineCode); +export function commentLineOptions(diffLines, startingLine, lineCode, side = 'left') { + const preferredSide = side === 'left' ? 'old_line' : 'new_line'; + const fallbackSide = preferredSide === 'new_line' ? 'old_line' : 'new_line'; const notMatchType = l => l.type !== 'match'; + const linesCopy = [...diffLines]; // don't mutate the argument + const startingLineCode = startingLine.line_code; + + const currentIndex = linesCopy.findIndex(line => line.line_code === lineCode); // We're limiting adding comments to only lines above the current line // to make rendering simpler. Future interations will use a more // intuitive dragging interface that will make this unnecessary - const upToSelected = diffLines.slice(0, selectedIndex + 1); + const upToSelected = linesCopy.slice(0, currentIndex + 1); // Only include the lines up to the first "Show unchanged lines" block // i.e. not a "match" type const lines = takeRightWhile(upToSelected, notMatchType); - return lines.map(l => ({ - value: { lineCode: l.line_code, type: l.type }, - text: `${getSymbol(l.type)}${l.new_line || l.old_line}`, - })); + // If the selected line is "hidden" in an unchanged line block + // or "above" the current group of lines add it to the array so + // that the drop down is not defaulted to empty + const selectedIndex = lines.findIndex(line => line.line_code === startingLineCode); + if (selectedIndex < 0) lines.unshift(startingLine); + + return lines.map(l => { + const { line_code, type, old_line, new_line } = l; + return { + value: { line_code, type, old_line, new_line }, + text: `${getSymbol(type)}${l[preferredSide] || l[fallbackSide]}`, + }; + }); +} + +export function formatLineRange(start, end) { + const extractProps = ({ line_code, type, old_line, new_line }) => ({ + line_code, + type, + old_line, + new_line, + }); + return { + start: extractProps(start), + end: extractProps(end), + }; +} + +export function getCommentedLines(selectedCommentPosition, diffLines) { + if (!selectedCommentPosition) { + // This structure simplifies the logic that consumes this result + // by keeping the returned shape the same and adjusting the bounds + // to something unreachable. This way our component logic stays: + // "if index between start and end" + return { + startLine: diffLines.length + 1, + endLine: diffLines.length + 1, + }; + } + + const { start, end } = selectedCommentPosition; + const startLine = diffLines.findIndex(l => l.line_code === start.line_code); + const endLine = diffLines.findIndex(l => l.line_code === end.line_code); + + return { startLine, endLine }; } diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index f1af8be590a..7615b0518b7 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -128,6 +128,9 @@ export default { isIssue() { return this.targetType === 'issue'; }, + canAssign() { + return this.getNoteableData.current_user?.can_update && this.isIssue; + }, }, methods: { onEdit() { @@ -257,23 +260,23 @@ export default { {{ __('Copy link') }} </button> </li> - <li v-if="canEdit"> + <li v-if="canAssign"> <button - class="btn btn-transparent js-note-delete js-note-delete" + class="btn-default btn-transparent" + data-testid="assign-user" type="button" - @click.prevent="onDelete" + @click="assignUser" > - <span class="text-danger">{{ __('Delete comment') }}</span> + {{ displayAssignUserText }} </button> </li> - <li v-if="isIssue"> + <li v-if="canEdit"> <button - class="btn-default btn-transparent" - data-testid="assign-user" + class="btn btn-transparent js-note-delete js-note-delete" type="button" - @click="assignUser" + @click.prevent="onDelete" > - {{ displayAssignUserText }} + <span class="text-danger">{{ __('Delete comment') }}</span> </button> </li> </ul> diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 795ee10ca0f..24227d55ebf 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -2,7 +2,7 @@ import { mapGetters, mapActions, mapState } from 'vuex'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import eventHub from '../event_hub'; -import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; +import NoteableWarning from '../../vue_shared/components/notes/noteable_warning.vue'; import markdownField from '../../vue_shared/components/markdown/field.vue'; import issuableStateMixin from '../mixins/issuable_state'; import resolvable from '../mixins/resolvable'; @@ -12,7 +12,7 @@ import { getDraft, updateDraft } from '~/lib/utils/autosave'; export default { name: 'NoteForm', components: { - issueWarning, + NoteableWarning, markdownField, }, mixins: [issuableStateMixin, resolvable], @@ -101,6 +101,7 @@ export default { isResolving: this.resolveDiscussion, isUnresolving: !this.resolveDiscussion, resolveAsThread: true, + isSubmittingWithKeydown: false, }; }, computed: { @@ -241,6 +242,10 @@ export default { this.$emit('cancelForm', shouldConfirm, this.noteBody !== this.updatedNoteBody); }, onInput() { + if (this.isSubmittingWithKeydown) { + return; + } + if (this.autosaveKey) { const { autosaveKey, updatedNoteBody: text } = this; updateDraft(autosaveKey, text); @@ -250,6 +255,7 @@ export default { if (this.showBatchCommentsActions) { this.handleAddToReview(); } else { + this.isSubmittingWithKeydown = true; this.handleUpdate(); } }, @@ -303,12 +309,12 @@ export default { ></div> <div class="flash-container timeline-content"></div> <form :data-line-code="lineCode" class="edit-note common-note-form js-quick-submit gfm-form"> - <issue-warning + <noteable-warning v-if="hasWarning(getNoteableData)" :is-locked="isLocked(getNoteableData)" :is-confidential="isConfidential(getNoteableData)" - :locked-issue-docs-path="lockedIssueDocsPath" - :confidential-issue-docs-path="confidentialIssueDocsPath" + :locked-noteable-docs-path="lockedIssueDocsPath" + :confidential-noteable-docs-path="confidentialIssueDocsPath" /> <markdown-field @@ -404,7 +410,7 @@ export default { </button> <button v-if="discussion.resolvable" - class="btn btn-nr btn-default append-right-10 js-comment-resolve-button" + class="btn btn-nr btn-default gl-mr-3 js-comment-resolve-button" @click.prevent="handleUpdate(true)" > {{ resolveButtonTitle }} diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 0e4dd1b9c84..9bf8cffe940 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -21,6 +21,7 @@ import { getEndLineNumber, getLineClasses, commentLineOptions, + formatLineRange, } from './multiline_comment_utils'; import MultilineCommentForm from './multiline_comment_form.vue'; @@ -62,10 +63,15 @@ export default { default: false, }, diffLines: { - type: Object, + type: Array, required: false, default: null, }, + discussionRoot: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -73,10 +79,7 @@ export default { isDeleting: false, isRequesting: false, isResolving: false, - commentLineStart: { - line_code: this.line?.line_code, - type: this.line?.type, - }, + commentLineStart: {}, }; }, computed: { @@ -144,28 +147,46 @@ export default { return getEndLineNumber(this.lineRange); }, showMultiLineComment() { - return ( - this.glFeatures.multilineComments && - this.startLineNumber && - this.endLineNumber && - (this.startLineNumber !== this.endLineNumber || this.isEditing) - ); + if (!this.glFeatures.multilineComments || !this.discussionRoot) return false; + if (this.isEditing) return true; + + return this.line && this.startLineNumber !== this.endLineNumber; }, commentLineOptions() { - if (this.diffLines) { - return commentLineOptions(this.diffLines, this.line.line_code); + if (!this.diffFile || !this.line) return []; + + const sideA = this.line.type === 'new' ? 'right' : 'left'; + const sideB = sideA === 'left' ? 'right' : 'left'; + const lines = this.diffFile.highlighted_diff_lines.length + ? this.diffFile.highlighted_diff_lines + : this.diffFile.parallel_diff_lines.map(l => l[sideA] || l[sideB]); + return commentLineOptions(lines, this.commentLineStart, this.line.line_code, sideA); + }, + diffFile() { + if (this.commentLineStart.line_code) { + const lineCode = this.commentLineStart.line_code.split('_')[0]; + return this.getDiffFileByHash(lineCode); } - const diffFile = this.diffFile || this.getDiffFileByHash(this.targetNoteHash); - if (!diffFile) return null; - return commentLineOptions(diffFile.highlighted_diff_lines, this.line.line_code); + return null; }, }, - created() { + const line = this.note.position?.line_range?.start || this.line; + + this.commentLineStart = line + ? { + line_code: line.line_code, + type: line.type, + old_line: line.old_line, + new_line: line.new_line, + } + : {}; + eventHub.$on('enterEditMode', ({ noteId }) => { if (noteId === this.note.id) { this.isEditing = true; + this.setSelectedCommentPositionHover(); this.scrollToNoteIfNeeded($(this.$el)); } }); @@ -185,9 +206,11 @@ export default { 'toggleResolveNote', 'scrollToNoteIfNeeded', 'updateAssignees', + 'setSelectedCommentPositionHover', ]), editHandler() { this.isEditing = true; + this.setSelectedCommentPositionHover(); this.$emit('handleEdit'); }, deleteHandler() { @@ -224,13 +247,11 @@ export default { formUpdateHandler(noteText, parentElement, callback, resolveDiscussion) { const position = { ...this.note.position, - line_range: { - start_line_code: this.commentLineStart?.lineCode, - start_line_type: this.commentLineStart?.type, - end_line_code: this.line?.line_code, - end_line_type: this.line?.type, - }, }; + + if (this.commentLineStart && this.line) + position.line_range = formatLineRange(this.commentLineStart, this.line); + this.$emit('handleUpdateNote', { note: this.note, noteText, @@ -246,7 +267,7 @@ export default { note: { target_type: this.getNoteableData.targetType, target_id: this.note.noteable_id, - note: { note: noteText }, + note: { note: noteText, position: JSON.stringify(position) }, }, }; this.isRequesting = true; @@ -266,6 +287,7 @@ export default { } else { this.isRequesting = false; this.isEditing = true; + this.setSelectedCommentPositionHover(); this.$nextTick(() => { const msg = __('Something went wrong while editing your comment. Please try again.'); Flash(msg, 'alert', this.$el); @@ -317,14 +339,17 @@ export default { > <div v-if="showMultiLineComment" data-testid="multiline-comment"> <multiline-comment-form - v-if="isEditing && commentLineOptions && line" + v-if="isEditing && note.position" v-model="commentLineStart" :line="line" :comment-line-options="commentLineOptions" :line-range="note.position.line_range" - class="gl-mb-3 gl-text-gray-700 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-pb-3" + class="gl-mb-3 gl-text-gray-700 gl-pb-3" /> - <div v-else class="gl-mb-3 gl-text-gray-700"> + <div + v-else + class="gl-mb-3 gl-text-gray-700 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-pb-3" + > <gl-sprintf :message="__('Comment on lines %{startLine} to %{endLine}')"> <template #startLine> <span :class="getLineClasses(startLineNumber)">{{ startLineNumber }}</span> diff --git a/app/assets/javascripts/notes/components/sort_discussion.vue b/app/assets/javascripts/notes/components/sort_discussion.vue index 4a7543819eb..60b531d7597 100644 --- a/app/assets/javascripts/notes/components/sort_discussion.vue +++ b/app/assets/javascripts/notes/components/sort_discussion.vue @@ -49,7 +49,10 @@ export default { </script> <template> - <div class="mr-2 d-inline-block align-bottom full-width-mobile"> + <div + data-testid="sort-discussion-filter" + class="gl-mr-2 gl-display-inline-block gl-vertical-align-bottom full-width-mobile" + > <local-storage-sync :value="sortDirection" :storage-key="storageKey" diff --git a/app/assets/javascripts/notes/mixins/diff_line_note_form.js b/app/assets/javascripts/notes/mixins/diff_line_note_form.js index 5930b5f3321..9a2e86aeed2 100644 --- a/app/assets/javascripts/notes/mixins/diff_line_note_form.js +++ b/app/assets/javascripts/notes/mixins/diff_line_note_form.js @@ -4,6 +4,7 @@ import { TEXT_DIFF_POSITION_TYPE, IMAGE_DIFF_POSITION_TYPE } from '~/diffs/const import createFlash from '~/flash'; import { s__ } from '~/locale'; import { clearDraft } from '~/lib/utils/autosave'; +import { formatLineRange } from '~/notes/components/multiline_comment_utils'; export default { computed: { @@ -45,6 +46,9 @@ export default { }); }, addToReview(note) { + const lineRange = + (this.line && this.commentLineStart && formatLineRange(this.commentLineStart, this.line)) || + {}; const positionType = this.diffFileCommentForm ? IMAGE_DIFF_POSITION_TYPE : TEXT_DIFF_POSITION_TYPE; @@ -60,6 +64,7 @@ export default { linePosition: this.position, positionType, ...this.diffFileCommentForm, + lineRange, }); const diffFileHeadSha = this.commit && this?.diffFile?.diff_refs?.head_sha; diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js index 9281149d9d3..889883a23d0 100644 --- a/app/assets/javascripts/notes/mixins/discussion_navigation.js +++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js @@ -78,8 +78,16 @@ function handleDiscussionJump(self, fn, discussionId = self.currentDiscussionId) const isDiffView = window.mrTabs.currentAction === 'diffs'; const targetId = fn(discussionId, isDiffView); const discussion = self.getDiscussion(targetId); - jumpToDiscussion(self, discussion); - self.setCurrentDiscussionId(targetId); + const discussionFilePath = discussion.diff_file?.file_path; + + if (discussionFilePath) { + self.scrollToFile(discussionFilePath); + } + + self.$nextTick(() => { + jumpToDiscussion(self, discussion); + self.setCurrentDiscussionId(targetId); + }); } export default { @@ -95,6 +103,7 @@ export default { }, methods: { ...mapActions(['expandDiscussion', 'setCurrentDiscussionId']), + ...mapActions('diffs', ['scrollToFile']), jumpToNextDiscussion() { handleDiscussionJump(this, this.nextUnresolvedDiscussionId); diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index a5b006fc301..5b2ab255557 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -13,11 +13,35 @@ import sidebarTimeTrackingEventHub from '../../sidebar/event_hub'; import { isInViewport, scrollToElement, isInMRPage } from '../../lib/utils/common_utils'; import { mergeUrlParams } from '../../lib/utils/url_utility'; import mrWidgetEventHub from '../../vue_merge_request_widget/event_hub'; +import updateIssueConfidentialMutation from '~/sidebar/components/confidential/queries/update_issue_confidential.mutation.graphql'; import { __, sprintf } from '~/locale'; import Api from '~/api'; let eTagPoll; +export const updateConfidentialityOnIssue = ({ commit, getters }, { confidential, fullPath }) => { + const { iid } = getters.getNoteableData; + + return utils.gqClient + .mutate({ + mutation: updateIssueConfidentialMutation, + variables: { + input: { + projectPath: fullPath, + iid: String(iid), + confidential, + }, + }, + }) + .then(({ data }) => { + const { + issueSetConfidential: { issue }, + } = data; + + commit(types.SET_ISSUE_CONFIDENTIAL, issue.confidential); + }); +}; + export const expandDiscussion = ({ commit, dispatch }, data) => { if (data.discussionId) { dispatch('diffs/renderFileForDiscussionId', data.discussionId, { root: true }); @@ -32,6 +56,8 @@ export const setNotesData = ({ commit }, data) => commit(types.SET_NOTES_DATA, d export const setNoteableData = ({ commit }, data) => commit(types.SET_NOTEABLE_DATA, data); +export const setConfidentiality = ({ commit }, data) => commit(types.SET_ISSUE_CONFIDENTIAL, data); + export const setUserData = ({ commit }, data) => commit(types.SET_USER_DATA, data); export const setLastFetchedAt = ({ commit }, data) => commit(types.SET_LAST_FETCHED_AT, data); @@ -73,6 +99,14 @@ export const setDiscussionSortDirection = ({ commit }, direction) => { commit(types.SET_DISCUSSIONS_SORT, direction); }; +export const setSelectedCommentPosition = ({ commit }, position) => { + commit(types.SET_SELECTED_COMMENT_POSITION, position); +}; + +export const setSelectedCommentPositionHover = ({ commit }, position) => { + commit(types.SET_SELECTED_COMMENT_POSITION_HOVER, position); +}; + export const removeNote = ({ commit, dispatch, state }, note) => { const discussion = state.discussions.find(({ id }) => id === note.discussion_id); @@ -205,7 +239,6 @@ export const closeIssue = ({ commit, dispatch, state }) => { commit(types.CLOSE_ISSUE); dispatch('emitStateChangedEvent', data); dispatch('toggleStateButtonLoading', false); - dispatch('toggleBlockedIssueWarning', false); }); }; @@ -377,9 +410,8 @@ export const saveNote = ({ commit, dispatch }, noteData) => { }; const pollSuccessCallBack = (resp, commit, state, getters, dispatch) => { - if (resp.notes && resp.notes.length) { - updateOrCreateNotes({ commit, state, getters, dispatch }, resp.notes); - + if (resp.notes?.length) { + dispatch('updateOrCreateNotes', resp.notes); dispatch('startTaskList'); } @@ -399,12 +431,12 @@ const getFetchDataParams = state => { return { endpoint, options }; }; -export const fetchData = ({ commit, state, getters }) => { +export const fetchData = ({ commit, state, getters, dispatch }) => { const { endpoint, options } = getFetchDataParams(state); axios .get(endpoint, options) - .then(({ data }) => pollSuccessCallBack(data, commit, state, getters)) + .then(({ data }) => pollSuccessCallBack(data, commit, state, getters, dispatch)) .catch(() => Flash(__('Something went wrong while fetching latest comments.'))); }; @@ -424,7 +456,7 @@ export const poll = ({ commit, state, getters, dispatch }) => { if (!Visibility.hidden()) { eTagPoll.makeRequest(); } else { - fetchData({ commit, state, getters }); + dispatch('fetchData'); } Visibility.change(() => { diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js index 329bf5e147e..1649e63c61f 100644 --- a/app/assets/javascripts/notes/stores/modules/index.js +++ b/app/assets/javascripts/notes/stores/modules/index.js @@ -12,6 +12,15 @@ export default () => ({ lastFetchedAt: null, currentDiscussionId: null, batchSuggestionsInfo: [], + /** + * selectedCommentPosition & selectedCommentPosition structures are the same as `position.line_range`: + * { + * start: { line_code: string, new_line: number, old_line:number, type: string }, + * end: { line_code: string, new_line: number, old_line:number, type: string }, + * } + */ + selectedCommentPosition: null, + selectedCommentPositionHover: null, // View layer isToggleStateButtonLoading: false, @@ -26,6 +35,7 @@ export default () => ({ }, userData: {}, noteableData: { + discussion_locked: false, confidential: false, // TODO: Move data like this to Issue Store, should not be apart of notes. current_user: {}, preview_note_path: 'path/to/preview', diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index 538774ee467..f2236b18beb 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -33,12 +33,15 @@ export const SET_EXPAND_DISCUSSIONS = 'SET_EXPAND_DISCUSSIONS'; export const UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS = 'UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS'; export const SET_CURRENT_DISCUSSION_ID = 'SET_CURRENT_DISCUSSION_ID'; export const SET_DISCUSSIONS_SORT = 'SET_DISCUSSIONS_SORT'; +export const SET_SELECTED_COMMENT_POSITION = 'SET_SELECTED_COMMENT_POSITION'; +export const SET_SELECTED_COMMENT_POSITION_HOVER = 'SET_SELECTED_COMMENT_POSITION_HOVER'; // Issue export const CLOSE_ISSUE = 'CLOSE_ISSUE'; export const REOPEN_ISSUE = 'REOPEN_ISSUE'; export const TOGGLE_STATE_BUTTON_LOADING = 'TOGGLE_STATE_BUTTON_LOADING'; export const TOGGLE_BLOCKED_ISSUE_WARNING = 'TOGGLE_BLOCKED_ISSUE_WARNING'; +export const SET_ISSUE_CONFIDENTIAL = 'SET_ISSUE_CONFIDENTIAL'; // Description version export const REQUEST_DESCRIPTION_VERSION = 'REQUEST_DESCRIPTION_VERSION'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index 2aeadcb2da1..e5f1c11fb35 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -95,6 +95,10 @@ export default { Object.assign(state, { noteableData: data }); }, + [types.SET_ISSUE_CONFIDENTIAL](state, data) { + state.noteableData.confidential = data; + }, + [types.SET_USER_DATA](state, data) { Object.assign(state, { userData: data }); }, @@ -304,6 +308,14 @@ export default { state.discussionSortOrder = sort; }, + [types.SET_SELECTED_COMMENT_POSITION](state, position) { + state.selectedCommentPosition = position; + }, + + [types.SET_SELECTED_COMMENT_POSITION_HOVER](state, position) { + state.selectedCommentPositionHover = position; + }, + [types.DISABLE_COMMENTS](state, value) { state.commentsDisabled = value; }, diff --git a/app/assets/javascripts/notes/stores/utils.js b/app/assets/javascripts/notes/stores/utils.js index 97dcd54fe88..10faac0c32b 100644 --- a/app/assets/javascripts/notes/stores/utils.js +++ b/app/assets/javascripts/notes/stores/utils.js @@ -1,6 +1,7 @@ import AjaxCache from '~/lib/utils/ajax_cache'; import { trimFirstCharOfLineContent } from '~/diffs/store/utils'; import { sprintf, __ } from '~/locale'; +import createGqClient, { fetchPolicies } from '~/lib/graphql'; // factory function because global flag makes RegExp stateful const createQuickActionsRegex = () => /^\/\w+.*$/gm; @@ -34,3 +35,10 @@ export const stripQuickActions = note => note.replace(createQuickActionsRegex(), export const prepareDiffLines = diffLines => diffLines.map(line => ({ ...trimFirstCharOfLineContent(line) })); + +export const gqClient = createGqClient( + {}, + { + fetchPolicy: fetchPolicies.NO_CACHE, + }, +); diff --git a/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js b/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js index 674b807edbe..da7f81759ea 100644 --- a/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js +++ b/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js @@ -32,7 +32,7 @@ export default class AbuseReports { $messageCellElement.text(originalMessage); } else { $messageCellElement.data('messageTruncated', 'true'); - $messageCellElement.text(`${originalMessage.substr(0, MAX_MESSAGE_LENGTH - 3)}...`); + $messageCellElement.text(truncate(originalMessage, MAX_MESSAGE_LENGTH)); } } } diff --git a/app/assets/javascripts/pages/admin/clusters/show/index.js b/app/assets/javascripts/pages/admin/clusters/show/index.js index 8001d2dd1da..ccf631b2c53 100644 --- a/app/assets/javascripts/pages/admin/clusters/show/index.js +++ b/app/assets/javascripts/pages/admin/clusters/show/index.js @@ -1,5 +1,7 @@ import ClustersBundle from '~/clusters/clusters_bundle'; +import initClusterHealth from '~/pages/projects/clusters/show/cluster_health'; document.addEventListener('DOMContentLoaded', () => { new ClustersBundle(); // eslint-disable-line no-new + initClusterHealth(); }); diff --git a/app/assets/javascripts/pages/admin/groups/show/index.js b/app/assets/javascripts/pages/admin/groups/show/index.js index b0cdad627a6..69d219d29f7 100644 --- a/app/assets/javascripts/pages/admin/groups/show/index.js +++ b/app/assets/javascripts/pages/admin/groups/show/index.js @@ -1,3 +1,23 @@ -import UsersSelect from '../../../../users_select'; +import Vue from 'vue'; +import UsersSelect from '~/users_select'; +import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue'; -document.addEventListener('DOMContentLoaded', () => new UsersSelect()); +function mountRemoveMemberModal() { + const el = document.querySelector('.js-remove-member-modal'); + if (!el) { + return false; + } + + return new Vue({ + el, + render(createComponent) { + return createComponent(RemoveMemberModal); + }, + }); +} + +document.addEventListener('DOMContentLoaded', () => { + mountRemoveMemberModal(); + + new UsersSelect(); // eslint-disable-line no-new +}); diff --git a/app/assets/javascripts/pages/admin/projects/index.js b/app/assets/javascripts/pages/admin/projects/index.js index d6b1e747aec..d86c5e2ddb8 100644 --- a/app/assets/javascripts/pages/admin/projects/index.js +++ b/app/assets/javascripts/pages/admin/projects/index.js @@ -1,7 +1,25 @@ -import ProjectsList from '../../../projects_list'; -import NamespaceSelect from '../../../namespace_select'; +import Vue from 'vue'; +import ProjectsList from '~/projects_list'; +import NamespaceSelect from '~/namespace_select'; +import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue'; + +function mountRemoveMemberModal() { + const el = document.querySelector('.js-remove-member-modal'); + if (!el) { + return false; + } + + return new Vue({ + el, + render(createComponent) { + return createComponent(RemoveMemberModal); + }, + }); +} document.addEventListener('DOMContentLoaded', () => { + mountRemoveMemberModal(); + new ProjectsList(); // eslint-disable-line no-new document diff --git a/app/assets/javascripts/pages/constants.js b/app/assets/javascripts/pages/constants.js index 5e119454ce1..35c67190b62 100644 --- a/app/assets/javascripts/pages/constants.js +++ b/app/assets/javascripts/pages/constants.js @@ -4,4 +4,5 @@ export const FILTERED_SEARCH = { MERGE_REQUESTS: 'merge_requests', ISSUES: 'issues', ADMIN_RUNNERS: 'admin/runners', + GROUP_RUNNERS_ANCHOR: 'runners-settings', }; diff --git a/app/assets/javascripts/pages/groups/clusters/show/index.js b/app/assets/javascripts/pages/groups/clusters/show/index.js index 8001d2dd1da..ccf631b2c53 100644 --- a/app/assets/javascripts/pages/groups/clusters/show/index.js +++ b/app/assets/javascripts/pages/groups/clusters/show/index.js @@ -1,5 +1,7 @@ import ClustersBundle from '~/clusters/clusters_bundle'; +import initClusterHealth from '~/pages/projects/clusters/show/cluster_health'; document.addEventListener('DOMContentLoaded', () => { new ClustersBundle(); // eslint-disable-line no-new + initClusterHealth(); }); diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js new file mode 100644 index 00000000000..e146592e134 --- /dev/null +++ b/app/assets/javascripts/pages/groups/group_members/index.js @@ -0,0 +1,30 @@ +import Vue from 'vue'; +import Members from 'ee_else_ce/members'; +import memberExpirationDate from '~/member_expiration_date'; +import UsersSelect from '~/users_select'; +import groupsSelect from '~/groups_select'; +import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue'; + +function mountRemoveMemberModal() { + const el = document.querySelector('.js-remove-member-modal'); + if (!el) { + return false; + } + + return new Vue({ + el, + render(createComponent) { + return createComponent(RemoveMemberModal); + }, + }); +} + +document.addEventListener('DOMContentLoaded', () => { + groupsSelect(); + memberExpirationDate(); + memberExpirationDate('.js-access-expiration-date-groups'); + mountRemoveMemberModal(); + + new Members(); // eslint-disable-line no-new + new UsersSelect(); // eslint-disable-line no-new +}); diff --git a/app/assets/javascripts/pages/groups/group_members/index/index.js b/app/assets/javascripts/pages/groups/group_members/index/index.js deleted file mode 100644 index 0c732922e81..00000000000 --- a/app/assets/javascripts/pages/groups/group_members/index/index.js +++ /dev/null @@ -1,14 +0,0 @@ -/* eslint-disable no-new */ - -import Members from 'ee_else_ce/members'; -import memberExpirationDate from '~/member_expiration_date'; -import UsersSelect from '~/users_select'; -import groupsSelect from '~/groups_select'; - -document.addEventListener('DOMContentLoaded', () => { - memberExpirationDate(); - memberExpirationDate('.js-access-expiration-date-groups'); - new Members(); - groupsSelect(); - new UsersSelect(); -}); diff --git a/app/assets/javascripts/pages/groups/new/group_path_validator.js b/app/assets/javascripts/pages/groups/new/group_path_validator.js index eeaa6527431..d2684b6af59 100644 --- a/app/assets/javascripts/pages/groups/new/group_path_validator.js +++ b/app/assets/javascripts/pages/groups/new/group_path_validator.js @@ -12,6 +12,7 @@ const successMessageSelector = '.validation-success'; const pendingMessageSelector = '.validation-pending'; const unavailableMessageSelector = '.validation-error'; const suggestionsMessageSelector = '.gl-path-suggestions'; +const inputGroupSelector = '.input-group'; export default class GroupPathValidator extends InputValidator { constructor(opts = {}) { @@ -39,7 +40,7 @@ export default class GroupPathValidator extends InputValidator { static validateGroupPathInput(inputDomElement) { const groupPath = inputDomElement.value; - if (inputDomElement.checkValidity() && groupPath.length > 0) { + if (inputDomElement.checkValidity() && groupPath.length > 1) { GroupPathValidator.setMessageVisibility(inputDomElement, pendingMessageSelector); fetchGroupPathAvailability(groupPath) @@ -69,9 +70,10 @@ export default class GroupPathValidator extends InputValidator { } static setMessageVisibility(inputDomElement, messageSelector, isVisible = true) { - const messageElement = inputDomElement.parentElement.parentElement.querySelector( - messageSelector, - ); + const messageElement = inputDomElement + .closest(inputGroupSelector) + .parentElement.querySelector(messageSelector); + messageElement.classList.toggle('hide', !isVisible); } diff --git a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js index 479c82265f2..23283f46a5d 100644 --- a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js @@ -1,11 +1,20 @@ import initSettingsPanels from '~/settings_panels'; import AjaxVariableList from '~/ci_variable_list/ajax_variable_list'; import initVariableList from '~/ci_variable_list'; +import initFilteredSearch from '~/pages/search/init_filtered_search'; +import AdminRunnersFilteredSearchTokenKeys from '~/filtered_search/admin_runners_filtered_search_token_keys'; +import { FILTERED_SEARCH } from '~/pages/constants'; document.addEventListener('DOMContentLoaded', () => { // Initialize expandable settings panels initSettingsPanels(); + initFilteredSearch({ + page: FILTERED_SEARCH.ADMIN_RUNNERS, + filteredSearchTokenKeys: AdminRunnersFilteredSearchTokenKeys, + anchor: FILTERED_SEARCH.GROUP_RUNNERS_ANCHOR, + }); + if (gon.features.newVariablesUi) { initVariableList(); } else { diff --git a/app/assets/javascripts/pages/groups/shared/group_details.js b/app/assets/javascripts/pages/groups/shared/group_details.js index 85daff3f60f..37b253d7c48 100644 --- a/app/assets/javascripts/pages/groups/shared/group_details.js +++ b/app/assets/javascripts/pages/groups/shared/group_details.js @@ -8,7 +8,6 @@ import NotificationsForm from '~/notifications_form'; import ProjectsList from '~/projects_list'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import GroupTabs from './group_tabs'; -import initNamespaceStorageLimitAlert from '~/namespace_storage_limit_alert'; export default function initGroupDetails(actionName = 'show') { const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup'); @@ -28,6 +27,4 @@ export default function initGroupDetails(actionName = 'show') { if (newGroupChildWrapper) { new NewGroupChild(newGroupChildWrapper); } - - initNamespaceStorageLimitAlert(); } diff --git a/app/assets/javascripts/pages/profiles/show/index.js b/app/assets/javascripts/pages/profiles/show/index.js index ad003181728..74ab1bc13a9 100644 --- a/app/assets/javascripts/pages/profiles/show/index.js +++ b/app/assets/javascripts/pages/profiles/show/index.js @@ -4,6 +4,7 @@ import emojiRegex from 'emoji-regex'; import createFlash from '~/flash'; import EmojiMenu from './emoji_menu'; import { __ } from '~/locale'; +import * as Emoji from '~/emoji'; const defaultStatusEmoji = 'speech_balloon'; @@ -55,8 +56,8 @@ document.addEventListener('DOMContentLoaded', () => { } }); - import(/* webpackChunkName: 'emoji' */ '~/emoji') - .then(Emoji => { + Emoji.initEmojiMap() + .then(() => { const emojiMenu = new EmojiMenu( Emoji, toggleEmojiMenuButtonSelector, diff --git a/app/assets/javascripts/pages/projects/clusters/show/cluster_health.js b/app/assets/javascripts/pages/projects/clusters/show/cluster_health.js new file mode 100644 index 00000000000..382d39645a9 --- /dev/null +++ b/app/assets/javascripts/pages/projects/clusters/show/cluster_health.js @@ -0,0 +1,18 @@ +import monitoringApp from '~/monitoring/monitoring_app'; + +export default () => { + const el = document.getElementById('prometheus-graphs'); + + if (el && el.dataset) { + monitoringApp({ + ...el.dataset, + showLegend: false, + showHeader: false, + showPanels: false, + forceSmallGraph: true, + smallEmptyState: true, + currentEnvironmentName: '', + hasMetrics: true, + }); + } +}; diff --git a/app/assets/javascripts/pages/projects/clusters/show/index.js b/app/assets/javascripts/pages/projects/clusters/show/index.js index 397f9faf6fe..d20e2c19583 100644 --- a/app/assets/javascripts/pages/projects/clusters/show/index.js +++ b/app/assets/javascripts/pages/projects/clusters/show/index.js @@ -1,7 +1,9 @@ import ClustersBundle from '~/clusters/clusters_bundle'; import initGkeNamespace from '~/create_cluster/gke_cluster_namespace'; +import initClusterHealth from './cluster_health'; document.addEventListener('DOMContentLoaded', () => { new ClustersBundle(); // eslint-disable-line no-new initGkeNamespace(); + initClusterHealth(); }); diff --git a/app/assets/javascripts/pages/projects/commit/pipelines/index.js b/app/assets/javascripts/pages/projects/commit/pipelines/index.js index 9f08260c3d6..1415a6f60c8 100644 --- a/app/assets/javascripts/pages/projects/commit/pipelines/index.js +++ b/app/assets/javascripts/pages/projects/commit/pipelines/index.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown'; import initPipelines from '~/commit/pipelines/pipelines_bundle'; +import { fetchCommitMergeRequests } from '~/commit_merge_requests'; document.addEventListener('DOMContentLoaded', () => { new MiniPipelineGraph({ @@ -8,5 +9,6 @@ document.addEventListener('DOMContentLoaded', () => { }).bindEvents(); // eslint-disable-next-line no-jquery/no-load $('.commit-info.branches').load(document.querySelector('.js-commit-box').dataset.commitPath); + fetchCommitMergeRequests(); initPipelines(); }); diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js index 9fb07917f9b..e65c18c07a9 100644 --- a/app/assets/javascripts/pages/projects/edit/index.js +++ b/app/assets/javascripts/pages/projects/edit/index.js @@ -7,13 +7,20 @@ import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory'; import initFilePickers from '~/file_pickers'; import initProjectLoadingSpinner from '../shared/save_project_loader'; import initProjectPermissionsSettings from '../shared/permissions'; +import initProjectRemoveModal from '~/projects/project_remove_modal'; +import UserCallout from '~/user_callout'; +import initServiceDesk from '~/projects/settings_service_desk'; document.addEventListener('DOMContentLoaded', () => { initFilePickers(); initConfirmDangerModal(); initSettingsPanels(); + initProjectRemoveModal(); mountBadgeSettings(PROJECT_BADGE); + new UserCallout({ className: 'js-service-desk-callout' }); // eslint-disable-line no-new + initServiceDesk(); + initProjectLoadingSpinner(); initProjectPermissionsSettings(); setupTransferEdit('.js-project-transfer-form', 'select.select2'); diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue new file mode 100644 index 00000000000..77753521342 --- /dev/null +++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue @@ -0,0 +1,91 @@ +<script> +import { GlTabs, GlTab, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import createFlash from '~/flash'; +import ForkGroupsListItem from './fork_groups_list_item.vue'; + +export default { + components: { + GlTabs, + GlTab, + GlLoadingIcon, + GlSearchBoxByType, + ForkGroupsListItem, + }, + props: { + hasReachedProjectLimit: { + type: Boolean, + required: true, + }, + endpoint: { + type: String, + required: true, + }, + }, + data() { + return { + namespaces: null, + filter: '', + }; + }, + computed: { + filteredNamespaces() { + return this.namespaces.filter(n => n.name.toLowerCase().includes(this.filter.toLowerCase())); + }, + }, + + mounted() { + this.loadGroups(); + }, + + methods: { + loadGroups() { + axios + .get(this.endpoint) + .then(response => { + this.namespaces = response.data.namespaces; + }) + .catch(() => createFlash(__('There was a problem fetching groups.'))); + }, + }, + + i18n: { + searchPlaceholder: __('Search by name'), + }, +}; +</script> +<template> + <gl-tabs class="fork-groups"> + <gl-tab :title="__('Groups and subgroups')"> + <gl-loading-icon v-if="!namespaces" size="md" class="gl-mt-3" /> + <template v-else-if="namespaces.length === 0"> + <div class="gl-text-center"> + <div class="h5">{{ __('No available groups to fork the project.') }}</div> + <p class="gl-mt-5"> + {{ __('You must have permission to create a project in a group before forking.') }} + </p> + </div> + </template> + <div v-else-if="filteredNamespaces.length === 0" class="gl-text-center gl-mt-3"> + {{ s__('GroupsTree|No groups matched your search') }} + </div> + <ul v-else class="groups-list group-list-tree"> + <fork-groups-list-item + v-for="(namespace, index) in filteredNamespaces" + :key="index" + :group="namespace" + :has-reached-project-limit="hasReachedProjectLimit" + /> + </ul> + </gl-tab> + <template #tabs-end> + <gl-search-box-by-type + v-if="namespaces && namespaces.length" + v-model="filter" + :placeholder="$options.i18n.searchPlaceholder" + class="gl-align-self-center gl-ml-auto fork-filtered-search" + /> + </template> + </gl-tabs> +</template> diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue new file mode 100644 index 00000000000..792c2f3db34 --- /dev/null +++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue @@ -0,0 +1,147 @@ +<script> +import { + GlLink, + GlButton, + GlIcon, + GlAvatar, + GlTooltipDirective, + GlTooltip, + GlBadge, +} from '@gitlab/ui'; +import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE } from '~/groups/constants'; +import { __ } from '~/locale'; +import csrf from '~/lib/utils/csrf'; + +export default { + components: { + GlIcon, + GlAvatar, + GlBadge, + GlButton, + GlTooltip, + GlLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + group: { + type: Object, + required: true, + }, + hasReachedProjectLimit: { + type: Boolean, + required: true, + }, + }, + data() { + return { namespaces: null }; + }, + + computed: { + rowClass() { + return { + 'has-description': this.group.description, + 'being-removed': this.isGroupPendingRemoval, + }; + }, + isGroupPendingRemoval() { + return this.group.marked_for_deletion; + }, + hasForkedProject() { + return Boolean(this.group.forked_project_path); + }, + visibilityIcon() { + return VISIBILITY_TYPE_ICON[this.group.visibility]; + }, + visibilityTooltip() { + return GROUP_VISIBILITY_TYPE[this.group.visibility]; + }, + isSelectButtonDisabled() { + return this.hasReachedProjectLimit || !this.group.can_create_project; + }, + selectButtonDisabledTooltip() { + return this.hasReachedProjectLimit + ? this.$options.i18n.hasReachedProjectLimitMessage + : this.$options.i18n.insufficientPermissionsMessage; + }, + }, + + i18n: { + hasReachedProjectLimitMessage: __('You have reached your project limit'), + insufficientPermissionsMessage: __( + 'You must have permission to create a project in a namespace before forking.', + ), + }, + + csrf, +}; +</script> +<template> + <li :class="rowClass" class="group-row"> + <div class="group-row-contents gl-display-flex gl-align-items-center gl-py-3 gl-pr-5"> + <div class="folder-toggle-wrap gl-mr-2 gl-display-flex gl-align-items-center"> + <gl-icon name="folder-o" /> + </div> + <gl-link + :href="group.relative_path" + class="gl-display-none gl-flex-shrink-0 gl-display-sm-flex gl-mr-3" + > + <gl-avatar :size="32" shape="rect" :entity-name="group.name" :src="group.avatarUrl" /> + </gl-link> + <div class="gl-min-w-0 gl-display-flex gl-flex-grow-1 gl-flex-shrink-1 gl-align-items-center"> + <div class="gl-min-w-0 gl-flex-grow-1 flex-shrink-1"> + <div class="title gl-display-flex gl-align-items-center gl-flex-wrap gl-mr-3"> + <gl-link :href="group.relative_path" class="gl-mt-3 gl-mr-3 gl-text-gray-900!">{{ + group.full_name + }}</gl-link> + <gl-icon + v-gl-tooltip.hover.bottom + class="gl-mr-0 gl-inline-flex gl-mt-3 text-secondary" + :name="visibilityIcon" + :title="visibilityTooltip" + /> + <gl-badge + v-if="isGroupPendingRemoval" + variant="warning" + class="gl-display-none gl-display-sm-flex gl-mt-3 gl-mr-1" + >{{ __('pending removal') }}</gl-badge + > + <span v-if="group.permission" class="user-access-role gl-mt-3"> + {{ group.permission }} + </span> + </div> + <div v-if="group.description" class="description"> + <span v-html="group.markdown_description"> </span> + </div> + </div> + <div class="gl-display-flex gl-flex-shrink-0"> + <gl-button + v-if="hasForkedProject" + class="gl-h-7 gl-text-decoration-none!" + :href="group.forked_project_path" + >{{ __('Go to fork') }}</gl-button + > + <template v-else> + <div ref="selectButtonWrapper"> + <form method="POST" :action="group.fork_path"> + <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> + <gl-button + type="submit" + class="gl-h-7 gl-text-decoration-none!" + :data-qa-name="group.full_name" + variant="success" + :disabled="isSelectButtonDisabled" + >{{ __('Select') }}</gl-button + > + </form> + </div> + <gl-tooltip v-if="isSelectButtonDisabled" :target="() => $refs.selectButtonWrapper"> + {{ selectButtonDisabledTooltip }} + </gl-tooltip> + </template> + </div> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue index af8fb032c22..39d6df33a85 100644 --- a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue +++ b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue @@ -63,17 +63,19 @@ export default { selectedDailyCoverageName() { return this.selectedDailyCoverage?.group_name; }, - formattedData() { - if (this.selectedDailyCoverage?.data) { - return this.selectedDailyCoverage.data.map(value => [ - dateFormat(value.date, 'mmm dd'), - value.coverage, - ]); - } - + sortedData() { // If the fetching failed, we return an empty array which // allow the graph to render while empty - return []; + if (!this.selectedDailyCoverage?.data) { + return []; + } + + return [...this.selectedDailyCoverage.data].sort( + (a, b) => new Date(a.date) - new Date(b.date), + ); + }, + formattedData() { + return this.sortedData.map(value => [dateFormat(value.date, 'mmm dd'), value.coverage]); }, chartData() { return [ diff --git a/app/assets/javascripts/pages/projects/integrations/jira/issues/index/index.js b/app/assets/javascripts/pages/projects/integrations/jira/issues/index/index.js new file mode 100644 index 00000000000..260ba69b4bc --- /dev/null +++ b/app/assets/javascripts/pages/projects/integrations/jira/issues/index/index.js @@ -0,0 +1,5 @@ +import initIssuablesList from '~/issuables_list'; + +document.addEventListener('DOMContentLoaded', () => { + initIssuablesList(); +}); diff --git a/app/assets/javascripts/pages/projects/issues/service_desk/filtered_search.js b/app/assets/javascripts/pages/projects/issues/service_desk/filtered_search.js new file mode 100644 index 00000000000..72003b61c8a --- /dev/null +++ b/app/assets/javascripts/pages/projects/issues/service_desk/filtered_search.js @@ -0,0 +1,30 @@ +/* eslint-disable class-methods-use-this */ +import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; +import FilteredSearchManager from 'ee_else_ce/filtered_search/filtered_search_manager'; + +const AUTHOR_PARAM_KEY = 'author_username'; + +export default class FilteredSearchServiceDesk extends FilteredSearchManager { + constructor(supportBotData) { + super({ + page: 'service_desk', + filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, + }); + + this.supportBotData = supportBotData; + } + + canEdit(tokenName) { + return tokenName !== 'author'; + } + + modifyUrlParams(paramsArray) { + const supportBotParamPair = `${AUTHOR_PARAM_KEY}=${this.supportBotData.username}`; + const onlyValidParams = paramsArray.filter(param => param.indexOf(AUTHOR_PARAM_KEY) === -1); + + // unshift ensures author param is always first token element + onlyValidParams.unshift(supportBotParamPair); + + return onlyValidParams; + } +} diff --git a/app/assets/javascripts/pages/projects/issues/service_desk/index.js b/app/assets/javascripts/pages/projects/issues/service_desk/index.js new file mode 100644 index 00000000000..56054f5fc80 --- /dev/null +++ b/app/assets/javascripts/pages/projects/issues/service_desk/index.js @@ -0,0 +1,11 @@ +import FilteredSearchServiceDesk from './filtered_search'; + +document.addEventListener('DOMContentLoaded', () => { + const supportBotData = JSON.parse( + document.querySelector('.js-service-desk-issues').dataset.supportBot, + ); + + const filteredSearchManager = new FilteredSearchServiceDesk(supportBotData); + + filteredSearchManager.setup(); +}); diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js index 46c9b2fe0af..32f77465347 100644 --- a/app/assets/javascripts/pages/projects/issues/show.js +++ b/app/assets/javascripts/pages/projects/issues/show.js @@ -3,7 +3,7 @@ import Issue from '~/issue'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import ZenMode from '~/zen_mode'; import '~/notes/index'; -import initIssueableApp from '~/issue_show'; +import initIssueableApp, { issuableHeaderWarnings } from '~/issue_show'; import initSentryErrorStackTraceApp from '~/sentry_error_stack_trace'; import initRelatedMergeRequestsApp from '~/related_merge_requests'; import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle'; @@ -12,15 +12,17 @@ export default function() { initIssueableApp(); initSentryErrorStackTraceApp(); initRelatedMergeRequestsApp(); + issuableHeaderWarnings(); - // .js-design-management is currently EE-only. - // This will be moved to CE as part of https://gitlab.com/gitlab-org/gitlab/-/issues/212566#frontend - // at which point this conditional can be removed. - if (document.querySelector('.js-design-management')) { - import(/* webpackChunkName: 'design_management' */ '~/design_management') - .then(module => module.default()) - .catch(() => {}); - } + import(/* webpackChunkName: 'design_management' */ '~/design_management') + .then(module => module.default()) + .catch(() => {}); + + // This will be removed when we remove the `design_management_moved` feature flag + // See https://gitlab.com/gitlab-org/gitlab/-/issues/223197 + import(/* webpackChunkName: 'design_management' */ '~/design_management_new') + .then(module => module.default()) + .catch(() => {}); new Issue(); // eslint-disable-line no-new new ShortcutsIssuable(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/metrics_dashboard/index.js b/app/assets/javascripts/pages/projects/metrics_dashboard/index.js new file mode 100644 index 00000000000..d3028aec313 --- /dev/null +++ b/app/assets/javascripts/pages/projects/metrics_dashboard/index.js @@ -0,0 +1,3 @@ +import monitoringApp from '~/monitoring/monitoring_app'; + +document.addEventListener('DOMContentLoaded', monitoringApp); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue index 4efabcb7df3..5ef1f959b2c 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue @@ -1,12 +1,19 @@ <script> -import { GlSprintf, GlLink } from '@gitlab/ui'; +import { GlFormRadio, GlFormRadioGroup, GlLink, GlSprintf } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; import { getWeekdayNames } from '~/lib/utils/datetime_utility'; +const KEY_EVERY_DAY = 'everyDay'; +const KEY_EVERY_WEEK = 'everyWeek'; +const KEY_EVERY_MONTH = 'everyMonth'; +const KEY_CUSTOM = 'custom'; + export default { components: { - GlSprintf, + GlFormRadio, + GlFormRadioGroup, GlLink, + GlSprintf, }, props: { initialCronInterval: { @@ -22,6 +29,7 @@ export default { randomWeekDayIndex: this.generateRandomWeekDayIndex(), randomDay: this.generateRandomDay(), inputNameAttribute: 'schedule[cron]', + radioValue: this.initialCronInterval ? KEY_CUSTOM : KEY_EVERY_DAY, cronInterval: this.initialCronInterval, cronSyntaxUrl: 'https://en.wikipedia.org/wiki/Cron', }; @@ -29,14 +37,11 @@ export default { computed: { cronIntervalPresets() { return { - everyDay: `0 ${this.randomHour} * * *`, - everyWeek: `0 ${this.randomHour} * * ${this.randomWeekDayIndex}`, - everyMonth: `0 ${this.randomHour} ${this.randomDay} * *`, + [KEY_EVERY_DAY]: `0 ${this.randomHour} * * *`, + [KEY_EVERY_WEEK]: `0 ${this.randomHour} * * ${this.randomWeekDayIndex}`, + [KEY_EVERY_MONTH]: `0 ${this.randomHour} ${this.randomDay} * *`, }; }, - intervalIsPreset() { - return Object.values(this.cronIntervalPresets).includes(this.cronInterval); - }, formattedTime() { if (this.randomHour > 12) { return `${this.randomHour - 12}:00pm`; @@ -45,24 +50,36 @@ export default { } return `${this.randomHour}:00am`; }, + radioOptions() { + return [ + { + value: KEY_EVERY_DAY, + text: sprintf(s__(`Every day (at %{time})`), { time: this.formattedTime }), + }, + { + value: KEY_EVERY_WEEK, + text: sprintf(s__('Every week (%{weekday} at %{time})'), { + weekday: this.weekday, + time: this.formattedTime, + }), + }, + { + value: KEY_EVERY_MONTH, + text: sprintf(s__('Every month (Day %{day} at %{time})'), { + day: this.randomDay, + time: this.formattedTime, + }), + }, + { + value: KEY_CUSTOM, + text: s__('PipelineScheduleIntervalPattern|Custom (%{linkStart}Cron syntax%{linkEnd})'), + link: this.cronSyntaxUrl, + }, + ]; + }, weekday() { return getWeekdayNames()[this.randomWeekDayIndex]; }, - everyDayText() { - return sprintf(s__(`Every day (at %{time})`), { time: this.formattedTime }); - }, - everyWeekText() { - return sprintf(s__('Every week (%{weekday} at %{time})'), { - weekday: this.weekday, - time: this.formattedTime, - }); - }, - everyMonthText() { - return sprintf(s__('Every month (Day %{day} at %{time})'), { - day: this.randomDay, - time: this.formattedTime, - }); - }, }, watch: { cronInterval() { @@ -72,38 +89,18 @@ export default { gl.pipelineScheduleFieldErrors.updateFormValidityState(); }); }, - }, - // If at the mounting stage the default is still an empty string, we - // know we are not editing an existing field so we update it so - // that the default is the first radio option - mounted() { - if (this.cronInterval === '') { - this.cronInterval = this.cronIntervalPresets.everyDay; - } + radioValue: { + immediate: true, + handler(val) { + if (val !== KEY_CUSTOM) { + this.cronInterval = this.cronIntervalPresets[val]; + } + }, + }, }, methods: { - setCustomInput(e) { - if (!this.isEditingCustom) { - this.isEditingCustom = true; - this.$refs.customInput.click(); - // Because we need to manually trigger the click on the radio btn, - // it will add a space to update the v-model. If the user is typing - // and the space is added, it will feel very unituitive so we reset - // the value to the original - this.cronInterval = e.target.value; - } - if (this.intervalIsPreset) { - this.isEditingCustom = false; - } - }, - toggleCustomInput(shouldEnable) { - this.isEditingCustom = shouldEnable; - - if (shouldEnable) { - // We need to change the value so other radios don't remain selected - // because the model (cronInterval) hasn't changed. The server trims it. - this.cronInterval = `${this.cronInterval} `; - } + onCustomInput() { + this.radioValue = KEY_CUSTOM; }, generateRandomHour() { return Math.floor(Math.random() * 23); @@ -119,89 +116,33 @@ export default { </script> <template> - <div class="interval-pattern-form-group"> - <div class="cron-preset-radio-input"> - <input - id="every-day" - v-model="cronInterval" - :name="inputNameAttribute" - :value="cronIntervalPresets.everyDay" - class="label-bold" - type="radio" - @click="toggleCustomInput(false)" - /> - - <label class="label-bold" for="every-day"> - {{ everyDayText }} - </label> - </div> - - <div class="cron-preset-radio-input"> - <input - id="every-week" - v-model="cronInterval" - :name="inputNameAttribute" - :value="cronIntervalPresets.everyWeek" - class="label-bold" - type="radio" - @click="toggleCustomInput(false)" - /> - - <label class="label-bold" for="every-week"> - {{ everyWeekText }} - </label> - </div> - - <div class="cron-preset-radio-input"> - <input - id="every-month" - v-model="cronInterval" - :name="inputNameAttribute" - :value="cronIntervalPresets.everyMonth" - class="label-bold" - type="radio" - @click="toggleCustomInput(false)" - /> - - <label class="label-bold" for="every-month"> - {{ everyMonthText }} - </label> - </div> - - <div class="cron-preset-radio-input"> - <input - id="custom" - ref="customInput" - v-model="cronInterval" - :name="inputNameAttribute" - :value="cronInterval" - class="label-bold" - type="radio" - @click="toggleCustomInput(true)" - /> - - <label for="custom"> {{ s__('PipelineSheduleIntervalPattern|Custom') }} </label> - - <gl-sprintf :message="__('(%{linkStart}Cron syntax%{linkEnd})')"> - <template #link="{content}"> - <gl-link :href="cronSyntaxUrl" target="_blank" class="gl-font-sm"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </div> - - <div class="cron-interval-input-wrapper"> - <input - id="schedule_cron" - v-model="cronInterval" - :placeholder="__('Define a custom pattern with cron syntax')" - :name="inputNameAttribute" - class="form-control inline cron-interval-input" - type="text" - required="true" - @input="setCustomInput" - /> - </div> + <div> + <gl-form-radio-group v-model="radioValue" :name="inputNameAttribute"> + <gl-form-radio + v-for="option in radioOptions" + :key="option.value" + :value="option.value" + :data-testid="option.value" + > + <gl-sprintf v-if="option.link" :message="option.text"> + <template #link="{content}"> + <gl-link :href="option.link" target="_blank" class="gl-font-sm"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + <template v-else>{{ option.text }}</template> + </gl-form-radio> + </gl-form-radio-group> + <input + id="schedule_cron" + v-model="cronInterval" + :placeholder="__('Define a custom pattern with cron syntax')" + :name="inputNameAttribute" + class="form-control inline cron-interval-input" + type="text" + required="true" + @input="onCustomInput" + /> </div> </template> diff --git a/app/assets/javascripts/pages/projects/pipelines/index/index.js b/app/assets/javascripts/pages/projects/pipelines/index/index.js index 2c37d7da4a7..bed9a751d4c 100644 --- a/app/assets/javascripts/pages/projects/pipelines/index/index.js +++ b/app/assets/javascripts/pages/projects/pipelines/index/index.js @@ -8,7 +8,7 @@ import { } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import PipelinesStore from '../../../../pipelines/stores/pipelines_store'; -import pipelinesComponent from '../../../../pipelines/components/pipelines.vue'; +import pipelinesComponent from '../../../../pipelines/components/pipelines_list/pipelines.vue'; import Translate from '../../../../vue_shared/translate'; Vue.use(Translate); @@ -40,6 +40,7 @@ document.addEventListener( props: { store: this.store, endpoint: this.dataset.endpoint, + pipelineScheduleUrl: this.dataset.pipelineScheduleUrl, helpPagePath: this.dataset.helpPagePath, emptyStateSvgPath: this.dataset.emptyStateSvgPath, errorStateSvgPath: this.dataset.errorStateSvgPath, diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js index f39765818e7..e146592e134 100644 --- a/app/assets/javascripts/pages/projects/project_members/index.js +++ b/app/assets/javascripts/pages/projects/project_members/index.js @@ -1,12 +1,30 @@ +import Vue from 'vue'; import Members from 'ee_else_ce/members'; -import memberExpirationDate from '../../../member_expiration_date'; -import UsersSelect from '../../../users_select'; -import groupsSelect from '../../../groups_select'; +import memberExpirationDate from '~/member_expiration_date'; +import UsersSelect from '~/users_select'; +import groupsSelect from '~/groups_select'; +import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue'; + +function mountRemoveMemberModal() { + const el = document.querySelector('.js-remove-member-modal'); + if (!el) { + return false; + } + + return new Vue({ + el, + render(createComponent) { + return createComponent(RemoveMemberModal); + }, + }); +} document.addEventListener('DOMContentLoaded', () => { - memberExpirationDate('.js-access-expiration-date-groups'); groupsSelect(); memberExpirationDate(); + memberExpirationDate('.js-access-expiration-date-groups'); + mountRemoveMemberModal(); + new Members(); // eslint-disable-line no-new new UsersSelect(); // eslint-disable-line no-new }); diff --git a/app/assets/javascripts/pages/projects/releases/new/index.js b/app/assets/javascripts/pages/projects/releases/new/index.js new file mode 100644 index 00000000000..0e314aacf8a --- /dev/null +++ b/app/assets/javascripts/pages/projects/releases/new/index.js @@ -0,0 +1,7 @@ +import ZenMode from '~/zen_mode'; +import initNewRelease from '~/releases/mount_new'; + +document.addEventListener('DOMContentLoaded', () => { + new ZenMode(); // eslint-disable-line no-new + initNewRelease(); +}); diff --git a/app/assets/javascripts/pages/projects/settings/operations/show/index.js b/app/assets/javascripts/pages/projects/settings/operations/show/index.js index 721d4a31fe4..1b9ec44ed4a 100644 --- a/app/assets/javascripts/pages/projects/settings/operations/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/operations/show/index.js @@ -1,13 +1,17 @@ import mountErrorTrackingForm from '~/error_tracking_settings'; +import mountAlertsSettings from '~/alerts_settings'; import mountOperationSettings from '~/operation_settings'; import mountGrafanaIntegration from '~/grafana_integration'; import initSettingsPanels from '~/settings_panels'; +import initIncidentsSettings from '~/incidents_settings'; document.addEventListener('DOMContentLoaded', () => { + initIncidentsSettings(); mountErrorTrackingForm(); mountOperationSettings(); mountGrafanaIntegration(); if (!IS_EE) { initSettingsPanels(); } + mountAlertsSettings(document.querySelector('.js-alerts-settings')); }); diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue index 7181332a1d6..a95f0af46cd 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue @@ -426,7 +426,7 @@ export default { v-if="lfsAvailable" ref="git-lfs-settings" :help-path="lfsHelpPath" - :label="s__('ProjectSettings|Git Large File Storage')" + :label="s__('ProjectSettings|Git Large File Storage (LFS)')" :help-text=" s__('ProjectSettings|Manages large files such as audio, video, and graphics files') " diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js index 3c44053e2b2..c65cc3e4c57 100644 --- a/app/assets/javascripts/pages/projects/show/index.js +++ b/app/assets/javascripts/pages/projects/show/index.js @@ -1,25 +1,18 @@ -import $ from 'jquery'; -import 'jquery.waitforimages'; - import initBlob from '~/blob_edit/blob_bundle'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import NotificationsForm from '~/notifications_form'; import UserCallout from '~/user_callout'; -import TreeView from '~/tree'; import BlobViewer from '~/blob/viewer/index'; import Activities from '~/activities'; -import { ajaxGet } from '~/lib/utils/common_utils'; -import GpgBadges from '~/gpg_badges'; import initReadMore from '~/read_more'; import leaveByUrl from '~/namespaces/leave_by_url'; import Star from '../../../star'; import notificationsDropdown from '../../../notifications_dropdown'; -import initNamespaceStorageLimitAlert from '~/namespace_storage_limit_alert'; import { showLearnGitLabProjectPopover } from '~/onboarding_issues'; +import initTree from 'ee_else_ce/repository'; document.addEventListener('DOMContentLoaded', () => { initReadMore(); - initNamespaceStorageLimitAlert(); new Star(); // eslint-disable-line no-new notificationsDropdown(); new ShortcutsNavigation(); // eslint-disable-line no-new @@ -31,10 +24,10 @@ document.addEventListener('DOMContentLoaded', () => { }); // Project show page loads different overview content based on user preferences - const treeSlider = document.querySelector('#tree-slider'); + const treeSlider = document.getElementById('js-tree-list'); if (treeSlider) { - new TreeView(); // eslint-disable-line no-new initBlob(); + initTree(); } if (document.querySelector('.blob-viewer')) { @@ -45,21 +38,7 @@ document.addEventListener('DOMContentLoaded', () => { new Activities(); // eslint-disable-line no-new } - $(treeSlider).waitForImages(() => { - ajaxGet(document.querySelector('.js-tree-content').dataset.logsPath); - }); - - GpgBadges.fetch(); leaveByUrl('project'); - if (document.getElementById('js-tree-list')) { - initBlob(); - import('ee_else_ce/repository') - .then(m => m.default()) - .catch(e => { - throw e; - }); - } - showLearnGitLabProjectPopover(); }); diff --git a/app/assets/javascripts/pages/projects/tree/show/index.js b/app/assets/javascripts/pages/projects/tree/show/index.js index 0d1d32317fe..78a4ea23f1a 100644 --- a/app/assets/javascripts/pages/projects/tree/show/index.js +++ b/app/assets/javascripts/pages/projects/tree/show/index.js @@ -1,53 +1,12 @@ import $ from 'jquery'; -import 'jquery.waitforimages'; - -import Vue from 'vue'; import initBlob from '~/blob_edit/blob_bundle'; -import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; -import GpgBadges from '~/gpg_badges'; -import TreeView from '../../../../tree'; import ShortcutsNavigation from '../../../../behaviors/shortcuts/shortcuts_navigation'; -import BlobViewer from '../../../../blob/viewer'; import NewCommitForm from '../../../../new_commit_form'; -import { ajaxGet } from '../../../../lib/utils/common_utils'; +import initTree from 'ee_else_ce/repository'; document.addEventListener('DOMContentLoaded', () => { new ShortcutsNavigation(); // eslint-disable-line no-new - new TreeView(); // eslint-disable-line no-new - new BlobViewer(); // eslint-disable-line no-new new NewCommitForm($('.js-create-dir-form')); // eslint-disable-line no-new - $('#tree-slider').waitForImages(() => - ajaxGet(document.querySelector('.js-tree-content').dataset.logsPath), - ); - initBlob(); - const commitPipelineStatusEl = document.querySelector('.js-commit-pipeline-status'); - const statusLink = document.querySelector('.commit-actions .ci-status-link'); - if (statusLink != null) { - statusLink.remove(); - // eslint-disable-next-line no-new - new Vue({ - el: commitPipelineStatusEl, - components: { - commitPipelineStatus, - }, - render(createElement) { - return createElement('commit-pipeline-status', { - props: { - endpoint: commitPipelineStatusEl.dataset.endpoint, - }, - }); - }, - }); - } - - GpgBadges.fetch(); - - if (document.getElementById('js-tree-list')) { - import('ee_else_ce/repository') - .then(m => m.default()) - .catch(e => { - throw e; - }); - } + initTree(); }); diff --git a/app/assets/javascripts/pages/search/init_filtered_search.js b/app/assets/javascripts/pages/search/init_filtered_search.js index e54e32199f0..b331a2bee6a 100644 --- a/app/assets/javascripts/pages/search/init_filtered_search.js +++ b/app/assets/javascripts/pages/search/init_filtered_search.js @@ -7,6 +7,7 @@ export default ({ isGroupAncestor, isGroupDecendent, stateFiltersSelector, + anchor, }) => { const filteredSearchEnabled = FilteredSearchManager && document.querySelector('.filtered-search'); if (filteredSearchEnabled) { @@ -17,6 +18,7 @@ export default ({ isGroupDecendent, filteredSearchTokenKeys, stateFiltersSelector, + anchor, }); filteredSearchManager.setup(); } diff --git a/app/assets/javascripts/pages/sessions/new/length_validator.js b/app/assets/javascripts/pages/sessions/new/length_validator.js index 3d687ca08cc..92482c81f3c 100644 --- a/app/assets/javascripts/pages/sessions/new/length_validator.js +++ b/app/assets/javascripts/pages/sessions/new/length_validator.js @@ -21,11 +21,24 @@ export default class LengthValidator extends InputValidator { ); const { value } = this.inputDomElement; - const { maxLengthMessage, maxLength } = this.inputDomElement.dataset; - - this.errorMessage = maxLengthMessage; - - this.invalidInput = value.length > parseInt(maxLength, 10); + const { + minLength, + minLengthMessage, + maxLengthMessage, + maxLength, + } = this.inputDomElement.dataset; + + this.invalidInput = false; + + if (value.length > parseInt(maxLength, 10)) { + this.invalidInput = true; + this.errorMessage = maxLengthMessage; + } + + if (value.length < parseInt(minLength, 10)) { + this.invalidInput = true; + this.errorMessage = minLengthMessage; + } this.setValidationStateAndMessage(); } diff --git a/app/assets/javascripts/pages/sessions/new/username_validator.js b/app/assets/javascripts/pages/sessions/new/username_validator.js index 1048e3b4548..ecb5e677290 100644 --- a/app/assets/javascripts/pages/sessions/new/username_validator.js +++ b/app/assets/javascripts/pages/sessions/new/username_validator.js @@ -39,7 +39,7 @@ export default class UsernameValidator extends InputValidator { static validateUsernameInput(inputDomElement) { const username = inputDomElement.value; - if (inputDomElement.checkValidity() && username.length > 0) { + if (inputDomElement.checkValidity() && username.length > 1) { UsernameValidator.setMessageVisibility(inputDomElement, pendingMessageSelector); UsernameValidator.fetchUsernameAvailability(username) .then(usernameTaken => { diff --git a/app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue b/app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue index 580cca49b5e..a7b7d597fb7 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue @@ -55,13 +55,22 @@ export default { <template> <div class="d-inline-block"> - <button v-gl-modal="modalId" type="button" class="btn btn-danger">{{ __('Delete') }}</button> + <button + v-gl-modal="modalId" + type="button" + class="btn btn-danger" + data-qa-selector="delete_button" + > + {{ __('Delete') }} + </button> <gl-modal :title="title" - :ok-title="s__('WikiPageConfirmDelete|Delete page')" + :action-primary="{ + text: s__('WikiPageConfirmDelete|Delete page'), + attributes: { variant: 'danger', 'data-qa-selector': 'confirm_deletion_button' }, + }" :modal-id="modalId" title-tag="h4" - ok-variant="danger" @ok="onSubmit" > {{ message }} diff --git a/app/assets/javascripts/pages/shared/wikis/wikis.js b/app/assets/javascripts/pages/shared/wikis/wikis.js index ed67219383b..41d43812b5d 100644 --- a/app/assets/javascripts/pages/shared/wikis/wikis.js +++ b/app/assets/javascripts/pages/shared/wikis/wikis.js @@ -1,5 +1,6 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { s__, sprintf } from '~/locale'; +import Tracking from '~/tracking'; const MARKDOWN_LINK_TEXT = { markdown: '[Link Title](page-slug)', @@ -8,6 +9,9 @@ const MARKDOWN_LINK_TEXT = { org: '[[page-slug]]', }; +const TRACKING_EVENT_NAME = 'view_wiki_page'; +const TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/wiki_page_context/jsonschema/1-0-0'; + export default class Wikis { constructor() { this.sidebarEl = document.querySelector('.js-wiki-sidebar'); @@ -57,6 +61,8 @@ export default class Wikis { window.onbeforeunload = null; }); } + + Wikis.trackPageView(); } handleWikiTitleChange(e) { @@ -97,4 +103,17 @@ export default class Wikis { classList.remove('right-sidebar-expanded'); } } + + static trackPageView() { + const wikiPageContent = document.querySelector('.js-wiki-page-content[data-tracking-context]'); + if (!wikiPageContent) return; + + Tracking.event(document.body.dataset.page, TRACKING_EVENT_NAME, { + label: TRACKING_EVENT_NAME, + context: { + schema: TRACKING_CONTEXT_SCHEMA, + data: JSON.parse(wikiPageContent.dataset.trackingContext), + }, + }); + } } diff --git a/app/assets/javascripts/performance_bar/components/request_selector.vue b/app/assets/javascripts/performance_bar/components/request_selector.vue index 115b2ff08ac..c22a648d17f 100644 --- a/app/assets/javascripts/performance_bar/components/request_selector.vue +++ b/app/assets/javascripts/performance_bar/components/request_selector.vue @@ -45,7 +45,7 @@ export default { }; </script> <template> - <div id="peek-request-selector" data-qa-selector="request_dropdown"> + <div id="peek-request-selector" data-qa-selector="request_dropdown" class="view"> <select v-model="currentRequestId"> <option v-for="request in requests" diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js index b3068c46bcb..b8a1397d8f6 100644 --- a/app/assets/javascripts/persistent_user_callout.js +++ b/app/assets/javascripts/persistent_user_callout.js @@ -18,17 +18,21 @@ export default class PersistentUserCallout { init() { const closeButton = this.container.querySelector('.js-close'); + const followLink = this.container.querySelector('.js-follow-link'); - if (!closeButton) { - return; + if (closeButton) { + this.handleCloseButtonCallout(closeButton); + } else if (followLink) { + this.handleFollowLinkCallout(followLink); } + } + handleCloseButtonCallout(closeButton) { closeButton.addEventListener('click', event => this.dismiss(event)); if (this.deferLinks) { this.container.addEventListener('click', event => { const isDeferredLink = event.target.classList.contains(DEFERRED_LINK_CLASS); - if (isDeferredLink) { const { href, target } = event.target; @@ -38,6 +42,10 @@ export default class PersistentUserCallout { } } + handleFollowLinkCallout(followLink) { + followLink.addEventListener('click', event => this.registerCalloutWithLink(event)); + } + dismiss(event, deferredLinkOptions = null) { event.preventDefault(); @@ -58,6 +66,27 @@ export default class PersistentUserCallout { }); } + registerCalloutWithLink(event) { + event.preventDefault(); + + const { href } = event.currentTarget; + + axios + .post(this.dismissEndpoint, { + feature_name: this.featureId, + }) + .then(() => { + window.location.assign(href); + }) + .catch(() => { + Flash( + __( + 'An error occurred while acknowledging the notification. Refresh the page and try again.', + ), + ); + }); + } + static factory(container, options) { if (!container) { return undefined; diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js index 6e292299778..f4fe605f0a2 100644 --- a/app/assets/javascripts/persistent_user_callouts.js +++ b/app/assets/javascripts/persistent_user_callouts.js @@ -4,6 +4,9 @@ const PERSISTENT_USER_CALLOUTS = [ '.js-recovery-settings-callout', '.js-users-over-license-callout', '.js-admin-licensed-user-count-threshold', + '.js-buy-pipeline-minutes-notification-callout', + '.js-alerts-moved-alert', + '.js-token-expiry-callout', ]; const initCallouts = () => { diff --git a/app/assets/javascripts/pipelines/components/dag/constants.js b/app/assets/javascripts/pipelines/components/dag/constants.js index 51b1fb4f4cc..b6a98fdc488 100644 --- a/app/assets/javascripts/pipelines/components/dag/constants.js +++ b/app/assets/javascripts/pipelines/components/dag/constants.js @@ -8,3 +8,8 @@ export const DEFAULT = 'default'; export const IS_HIGHLIGHTED = 'dag-highlighted'; export const LINK_SELECTOR = 'dag-link'; export const NODE_SELECTOR = 'dag-node'; + +/* Annotation types */ +export const ADD_NOTE = 'add'; +export const REMOVE_NOTE = 'remove'; +export const REPLACE_NOTES = 'replace'; diff --git a/app/assets/javascripts/pipelines/components/dag/dag.vue b/app/assets/javascripts/pipelines/components/dag/dag.vue index 6e0d23ef87f..85163a666e2 100644 --- a/app/assets/javascripts/pipelines/components/dag/dag.vue +++ b/app/assets/javascripts/pipelines/components/dag/dag.vue @@ -1,19 +1,32 @@ <script> -import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; +import { GlAlert, GlButton, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; +import { isEmpty } from 'lodash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import DagGraph from './dag_graph.vue'; -import { DEFAULT, PARSE_FAILURE, LOAD_FAILURE, UNSUPPORTED_DATA } from './constants'; +import DagAnnotations from './dag_annotations.vue'; +import { + DEFAULT, + PARSE_FAILURE, + LOAD_FAILURE, + UNSUPPORTED_DATA, + ADD_NOTE, + REMOVE_NOTE, + REPLACE_NOTES, +} from './constants'; import { parseData } from './parsing_utils'; export default { // eslint-disable-next-line @gitlab/require-i18n-strings name: 'Dag', components: { + DagAnnotations, DagGraph, GlAlert, GlLink, GlSprintf, + GlEmptyState, + GlButton, }, props: { graphUrl: { @@ -21,21 +34,43 @@ export default { required: false, default: '', }, + emptySvgPath: { + type: String, + required: true, + default: '', + }, + dagDocPath: { + type: String, + required: true, + default: '', + }, }, data() { return { - showFailureAlert: false, - showBetaInfo: true, + annotationsMap: {}, failureType: null, graphData: null, + showFailureAlert: false, + showBetaInfo: true, + hasNoDependentJobs: false, }; }, errorTexts: { [LOAD_FAILURE]: __('We are currently unable to fetch data for this graph.'), [PARSE_FAILURE]: __('There was an error parsing the data for this graph.'), - [UNSUPPORTED_DATA]: __('A DAG must have two dependent jobs to be visualized on this tab.'), + [UNSUPPORTED_DATA]: __('DAG visualization requires at least 3 dependent jobs.'), [DEFAULT]: __('An unknown error occurred while loading this graph.'), }, + emptyStateTexts: { + title: __('Start using Directed Acyclic Graphs (DAG)'), + firstDescription: __( + "This pipeline does not use the %{codeStart}needs%{codeEnd} keyword and can't be represented as a directed acyclic graph.", + ), + secondDescription: __( + 'Using %{codeStart}needs%{codeEnd} allows jobs to run before their stage is reached, as soon as their individual dependencies are met, which speeds up your pipelines.', + ), + button: __('Learn more about job dependencies'), + }, computed: { betaMessage() { return __( @@ -66,6 +101,9 @@ export default { }; } }, + shouldDisplayAnnotations() { + return !isEmpty(this.annotationsMap); + }, shouldDisplayGraph() { return Boolean(!this.showFailureAlert && this.graphData); }, @@ -86,6 +124,9 @@ export default { .catch(() => reportFailure(LOAD_FAILURE)); }, methods: { + addAnnotationToMap({ uid, source, target }) { + this.$set(this.annotationsMap, uid, { source, target }); + }, processGraphData(data) { let parsed; @@ -96,11 +137,18 @@ export default { return; } - if (parsed.links.length < 2) { + if (parsed.links.length === 1) { this.reportFailure(UNSUPPORTED_DATA); return; } + // If there are no links, we don't report failure + // as it simply means the user does not use job dependencies + if (parsed.links.length === 0) { + this.hasNoDependentJobs = true; + return; + } + this.graphData = parsed; }, hideAlert() { @@ -109,10 +157,28 @@ export default { hideBetaInfo() { this.showBetaInfo = false; }, + removeAnnotationFromMap({ uid }) { + this.$delete(this.annotationsMap, uid); + }, reportFailure(type) { this.showFailureAlert = true; this.failureType = type; }, + updateAnnotation({ type, data }) { + switch (type) { + case ADD_NOTE: + this.addAnnotationToMap(data); + break; + case REMOVE_NOTE: + this.removeAnnotationFromMap(data); + break; + case REPLACE_NOTES: + this.annotationsMap = data; + break; + default: + break; + } + }, }, }; </script> @@ -131,6 +197,43 @@ export default { </template> </gl-sprintf> </gl-alert> - <dag-graph v-if="shouldDisplayGraph" :graph-data="graphData" @onFailure="reportFailure" /> + <div class="gl-relative"> + <dag-annotations v-if="shouldDisplayAnnotations" :annotations="annotationsMap" /> + <dag-graph + v-if="shouldDisplayGraph" + :graph-data="graphData" + @onFailure="reportFailure" + @update-annotation="updateAnnotation" + /> + <gl-empty-state + v-else-if="hasNoDependentJobs" + :svg-path="emptySvgPath" + :title="$options.emptyStateTexts.title" + > + <template #description> + <div class="gl-text-left"> + <p> + <gl-sprintf :message="$options.emptyStateTexts.firstDescription"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + <p> + <gl-sprintf :message="$options.emptyStateTexts.secondDescription"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + </div> + </template> + <template #actions> + <gl-button :href="dagDocPath" target="__blank" variant="success"> + {{ $options.emptyStateTexts.button }} + </gl-button> + </template> + </gl-empty-state> + </div> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/dag/dag_annotations.vue b/app/assets/javascripts/pipelines/components/dag/dag_annotations.vue new file mode 100644 index 00000000000..a1500166cdc --- /dev/null +++ b/app/assets/javascripts/pipelines/components/dag/dag_annotations.vue @@ -0,0 +1,73 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + name: 'DagAnnotations', + components: { + GlButton, + }, + props: { + annotations: { + type: Object, + required: true, + }, + }, + data() { + return { + showList: true, + }; + }, + computed: { + linkText() { + return this.showList ? __('Hide list') : __('Show list'); + }, + shouldShowLink() { + return Object.keys(this.annotations).length > 1; + }, + wrapperClasses() { + return [ + 'gl-display-flex', + 'gl-flex-direction-column', + 'gl-fixed', + 'gl-right-1', + 'gl-top-66vh', + 'gl-w-max-content', + 'gl-px-5', + 'gl-py-4', + 'gl-rounded-base', + 'gl-bg-white', + ].join(' '); + }, + }, + methods: { + toggleList() { + this.showList = !this.showList; + }, + }, +}; +</script> +<template> + <div :class="wrapperClasses"> + <div v-if="showList"> + <div + v-for="note in annotations" + :key="note.uid" + class="gl-display-flex gl-align-items-center" + > + <div + data-testid="dag-color-block" + class="gl-w-6 gl-h-5" + :style="{ + background: `linear-gradient(0.25turn, ${note.source.color} 40%, ${note.target.color} 60%)`, + }" + ></div> + <div data-testid="dag-note-text" class="gl-px-2 gl-font-base gl-align-items-center"> + {{ note.source.name }} → {{ note.target.name }} + </div> + </div> + </div> + + <gl-button v-if="shouldShowLink" variant="link" @click="toggleList">{{ linkText }}</gl-button> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/dag/dag_graph.vue b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue index 063ec091e4d..d12baa9617e 100644 --- a/app/assets/javascripts/pipelines/components/dag/dag_graph.vue +++ b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue @@ -1,8 +1,17 @@ <script> import * as d3 from 'd3'; import { uniqueId } from 'lodash'; -import { LINK_SELECTOR, NODE_SELECTOR, PARSE_FAILURE } from './constants'; import { + LINK_SELECTOR, + NODE_SELECTOR, + PARSE_FAILURE, + ADD_NOTE, + REMOVE_NOTE, + REPLACE_NOTES, +} from './constants'; +import { + currentIsLive, + getLiveLinksAsDict, highlightLinks, restoreLinks, toggleLinkHighlight, @@ -25,6 +34,11 @@ export default { containerClasses: ['dag-graph-container', 'gl-display-flex', 'gl-flex-direction-column'].join( ' ', ), + hoverFadeClasses: [ + 'gl-cursor-pointer', + 'gl-transition-duration-slow', + 'gl-transition-timing-function-ease', + ].join(' '), }, gitLabColorRotation: [ '#e17223', @@ -50,8 +64,8 @@ export default { data() { return { color: () => {}, - width: 0, height: 0, + width: 0, }; }, mounted() { @@ -60,7 +74,7 @@ export default { try { countedAndTransformed = this.transformData(this.graphData); } catch { - this.$emit('onFailure', PARSE_FAILURE); + this.$emit('on-failure', PARSE_FAILURE); return; } @@ -90,17 +104,33 @@ export default { }, appendLinkInteractions(link) { + const { baseOpacity } = this.$options.viewOptions; return link - .on('mouseover', highlightLinks) - .on('mouseout', restoreLinks.bind(null, this.$options.viewOptions.baseOpacity)) - .on('click', toggleLinkHighlight.bind(null, this.$options.viewOptions.baseOpacity)); + .on('mouseover', (d, idx, collection) => { + if (currentIsLive(idx, collection)) { + return; + } + this.$emit('update-annotation', { type: ADD_NOTE, data: d }); + highlightLinks(d, idx, collection); + }) + .on('mouseout', (d, idx, collection) => { + if (currentIsLive(idx, collection)) { + return; + } + this.$emit('update-annotation', { type: REMOVE_NOTE, data: d }); + restoreLinks(baseOpacity); + }) + .on('click', (d, idx, collection) => { + toggleLinkHighlight(baseOpacity, d, idx, collection); + this.$emit('update-annotation', { type: REPLACE_NOTES, data: getLiveLinksAsDict() }); + }); }, appendNodeInteractions(node) { - return node.on( - 'click', - togglePathHighlights.bind(null, this.$options.viewOptions.baseOpacity), - ); + return node.on('click', (d, idx, collection) => { + togglePathHighlights(this.$options.viewOptions.baseOpacity, d, idx, collection); + this.$emit('update-annotation', { type: REPLACE_NOTES, data: getLiveLinksAsDict() }); + }); }, appendLabelAsForeignObject(d, i, n) { @@ -230,7 +260,10 @@ export default { .attr('id', d => { return this.createAndAssignId(d, 'uid', LINK_SELECTOR); }) - .classed(`${LINK_SELECTOR} gl-cursor-pointer`, true); + .classed( + `${LINK_SELECTOR} gl-transition-property-stroke-opacity ${this.$options.viewOptions.hoverFadeClasses}`, + true, + ); }, generateNodes(svg, nodeData) { @@ -242,7 +275,10 @@ export default { .data(nodeData) .enter() .append('line') - .classed(`${NODE_SELECTOR} gl-cursor-pointer`, true) + .classed( + `${NODE_SELECTOR} gl-transition-property-stroke ${this.$options.viewOptions.hoverFadeClasses}`, + true, + ) .attr('id', d => { return this.createAndAssignId(d, 'uid', NODE_SELECTOR); }) @@ -260,6 +296,11 @@ export default { .attr('y2', d => d.y1 - 4); }, + initColors() { + const colorFn = d3.scaleOrdinal(this.$options.gitLabColorRotation); + return ({ name }) => colorFn(name); + }, + labelNodes(svg, nodeData) { return svg .append('g') @@ -271,11 +312,6 @@ export default { .each(this.appendLabelAsForeignObject); }, - initColors() { - const colorFn = d3.scaleOrdinal(this.$options.gitLabColorRotation); - return ({ name }) => colorFn(name); - }, - transformData(parsed) { const baseLayout = createSankey()(parsed); const cleanedNodes = removeOrphanNodes(baseLayout.nodes); diff --git a/app/assets/javascripts/pipelines/components/dag/interactions.js b/app/assets/javascripts/pipelines/components/dag/interactions.js index c9008730c90..e9f3e9f0e2c 100644 --- a/app/assets/javascripts/pipelines/components/dag/interactions.js +++ b/app/assets/javascripts/pipelines/components/dag/interactions.js @@ -5,10 +5,20 @@ export const highlightIn = 1; export const highlightOut = 0.2; const getCurrent = (idx, collection) => d3.select(collection[idx]); -const currentIsLive = (idx, collection) => getCurrent(idx, collection).classed(IS_HIGHLIGHTED); +const getLiveLinks = () => d3.selectAll(`.${LINK_SELECTOR}.${IS_HIGHLIGHTED}`); const getOtherLinks = () => d3.selectAll(`.${LINK_SELECTOR}:not(.${IS_HIGHLIGHTED})`); const getNodesNotLive = () => d3.selectAll(`.${NODE_SELECTOR}:not(.${IS_HIGHLIGHTED})`); +export const getLiveLinksAsDict = () => { + return Object.fromEntries( + getLiveLinks() + .data() + .map(d => [d.uid, d]), + ); +}; +export const currentIsLive = (idx, collection) => + getCurrent(idx, collection).classed(IS_HIGHLIGHTED); + const backgroundLinks = selection => selection.style('stroke-opacity', highlightOut); const backgroundNodes = selection => selection.attr('stroke', '#f2f2f2'); const foregroundLinks = selection => selection.style('stroke-opacity', highlightIn); @@ -16,10 +26,10 @@ const foregroundNodes = selection => selection.attr('stroke', d => d.color); const renewLinks = (selection, baseOpacity) => selection.style('stroke-opacity', baseOpacity); const renewNodes = selection => selection.attr('stroke', d => d.color); -const getAllLinkAncestors = node => { +export const getAllLinkAncestors = node => { if (node.targetLinks) { return node.targetLinks.flatMap(n => { - return [n.uid, ...getAllLinkAncestors(n.source)]; + return [n, ...getAllLinkAncestors(n.source)]; }); } @@ -59,8 +69,8 @@ const highlightPath = (parentLinks, parentNodes) => { backgroundNodes(getNodesNotLive()); /* highlight correct links */ - parentLinks.forEach(id => { - foregroundLinks(d3.select(`#${id}`)).classed(IS_HIGHLIGHTED, true); + parentLinks.forEach(({ uid }) => { + foregroundLinks(d3.select(`#${uid}`)).classed(IS_HIGHLIGHTED, true); }); /* highlight correct nodes */ @@ -69,9 +79,22 @@ const highlightPath = (parentLinks, parentNodes) => { }); }; +const restoreNodes = () => { + /* + When paths are unclicked, they can take down nodes that + are still in use for other paths. This checks the live paths and + rehighlights their nodes. + */ + + getLiveLinks().each(d => { + foregroundNodes(d3.select(`#${d.source.uid}`)).classed(IS_HIGHLIGHTED, true); + foregroundNodes(d3.select(`#${d.target.uid}`)).classed(IS_HIGHLIGHTED, true); + }); +}; + const restorePath = (parentLinks, parentNodes, baseOpacity) => { - parentLinks.forEach(id => { - renewLinks(d3.select(`#${id}`), baseOpacity).classed(IS_HIGHLIGHTED, false); + parentLinks.forEach(({ uid }) => { + renewLinks(d3.select(`#${uid}`), baseOpacity).classed(IS_HIGHLIGHTED, false); }); parentNodes.forEach(id => { @@ -86,14 +109,10 @@ const restorePath = (parentLinks, parentNodes, baseOpacity) => { backgroundLinks(getOtherLinks()); backgroundNodes(getNodesNotLive()); + restoreNodes(); }; -export const restoreLinks = (baseOpacity, d, idx, collection) => { - /* in this case, it has just been clicked */ - if (currentIsLive(idx, collection)) { - return; - } - +export const restoreLinks = baseOpacity => { /* if there exist live links, reset to highlight out / pale otherwise, reset to base @@ -111,11 +130,12 @@ export const restoreLinks = (baseOpacity, d, idx, collection) => { export const toggleLinkHighlight = (baseOpacity, d, idx, collection) => { if (currentIsLive(idx, collection)) { - restorePath([d.uid], [d.source.uid, d.target.uid], baseOpacity); + restorePath([d], [d.source.uid, d.target.uid], baseOpacity); + restoreNodes(); return; } - highlightPath([d.uid], [d.source.uid, d.target.uid]); + highlightPath([d], [d.source.uid, d.target.uid]); }; export const togglePathHighlights = (baseOpacity, d, idx, collection) => { diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 1ff5b662d18..6b890688a48 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -43,6 +43,7 @@ export default { data() { return { downstreamMarginTop: null, + jobName: null, }; }, computed: { @@ -91,13 +92,9 @@ export default { /** * Calculates the margin top of the clicked downstream pipeline by * subtracting the clicked downstream pipelines offsetTop by it's parent's - * offsetTop and then subtracting either 15 (if child) or 30 (if not a child) - * due to the height of node and stage name margin bottom. + * offsetTop and then subtracting 15 */ - this.downstreamMarginTop = this.calculateMarginTop( - downstreamNode, - downstreamNode.classList.contains('child-pipeline') ? 15 : 30, - ); + this.downstreamMarginTop = this.calculateMarginTop(downstreamNode, 15); /** * If the expanded trigger is defined and the id is different than the @@ -120,6 +117,9 @@ export default { hasUpstream(index) { return index === 0 && this.hasTriggeredBy; }, + setJob(jobName) { + this.jobName = jobName; + }, }, }; </script> @@ -172,7 +172,7 @@ export default { :class="{ 'has-upstream prepend-left-64': hasUpstream(index), 'has-only-one-job': hasOnlyOneJob(stage), - 'append-right-46': shouldAddRightMargin(index), + 'gl-mr-26': shouldAddRightMargin(index), }" :title="capitalizeStageName(stage.name)" :groups="stage.groups" @@ -180,6 +180,7 @@ export default { :is-first-column="isFirstColumn(index)" :has-triggered-by="hasTriggeredBy" :action="stage.status.action" + :job-hovered="jobName" @refreshPipelineGraph="refreshPipelineGraph" /> </ul> @@ -191,6 +192,7 @@ export default { :project-id="pipelineProjectId" graph-position="right" @linkedPipelineClick="handleClickedDownstream" + @downstreamHovered="setJob" /> <pipeline-graph diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index bfd314e0439..4d72cc55b34 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -31,6 +31,7 @@ import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; */ export default { + hoverClass: 'gl-inset-border-1-blue-500', components: { ActionComponent, JobNameComponent, @@ -55,6 +56,11 @@ export default { required: false, default: Infinity, }, + jobHovered: { + type: String, + required: false, + default: '', + }, }, computed: { boundary() { @@ -95,6 +101,11 @@ export default { hasAction() { return this.job.status && this.job.status.action && this.job.status.action.path; }, + jobClasses() { + return this.job.name === this.jobHovered + ? `${this.$options.hoverClass} ${this.cssClassJobName}` + : this.cssClassJobName; + }, }, methods: { pipelineActionRequestComplete() { @@ -120,8 +131,9 @@ export default { v-else v-gl-tooltip="{ boundary, placement: 'bottom' }" :title="tooltipText" - :class="cssClassJobName" + :class="jobClasses" class="js-job-component-tooltip non-details-job-component" + data-testid="job-without-link" > <job-name-component :name="job.name" :status="job.status" /> </div> diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue index 550b9daa521..733553e02c0 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue @@ -1,7 +1,7 @@ <script> import { GlLoadingIcon, GlTooltipDirective, GlDeprecatedButton } from '@gitlab/ui'; import CiStatus from '~/vue_shared/components/ci_icon.vue'; -import { __ } from '~/locale'; +import { __, sprintf } from '~/locale'; export default { directives: { @@ -28,7 +28,8 @@ export default { }, computed: { tooltipText() { - return `${this.projectName} - ${this.pipelineStatus.label}`; + return `${this.downstreamTitle} #${this.pipeline.id} - ${this.pipelineStatus.label} + ${this.sourceJobInfo}`; }, buttonId() { return `js-linked-pipeline-${this.pipeline.id}`; @@ -39,25 +40,32 @@ export default { projectName() { return this.pipeline.project.name; }, + downstreamTitle() { + return this.childPipeline ? __('child-pipeline') : this.pipeline.project.name; + }, parentPipeline() { // Refactor string match when BE returns Upstream/Downstream indicators return this.projectId === this.pipeline.project.id && this.columnTitle === __('Upstream'); }, childPipeline() { // Refactor string match when BE returns Upstream/Downstream indicators - return this.projectId === this.pipeline.project.id && this.columnTitle === __('Downstream'); + return this.projectId === this.pipeline.project.id && this.isDownstream; }, label() { - return this.parentPipeline ? __('Parent') : __('Child'); - }, - childTooltipText() { - return __('This pipeline was triggered by a parent pipeline'); + if (this.parentPipeline) { + return __('Parent'); + } else if (this.childPipeline) { + return __('Child'); + } + return __('Multi-project'); }, - parentTooltipText() { - return __('This pipeline triggered a child pipeline'); + isDownstream() { + return this.columnTitle === __('Downstream'); }, - labelToolTipText() { - return this.label === __('Parent') ? this.parentTooltipText : this.childTooltipText; + sourceJobInfo() { + return this.isDownstream + ? sprintf(__('Created by %{job}'), { job: this.pipeline.source_job.name }) + : ''; }, }, methods: { @@ -68,6 +76,12 @@ export default { hideTooltips() { this.$root.$emit('bv::hide::tooltip'); }, + onDownstreamHovered() { + this.$emit('downstreamHovered', this.pipeline.source_job.name); + }, + onDownstreamHoverLeave() { + this.$emit('downstreamHovered', ''); + }, }, }; </script> @@ -76,7 +90,10 @@ export default { <li ref="linkedPipeline" class="linked-pipeline build" - :class="{ 'child-pipeline': childPipeline }" + :class="{ 'downstream-pipeline': isDownstream }" + data-qa-selector="child_pipeline" + @mouseover="onDownstreamHovered" + @mouseleave="onDownstreamHoverLeave" > <gl-deprecated-button :id="buttonId" @@ -94,15 +111,9 @@ export default { css-classes="position-top-0" class="js-linked-pipeline-status" /> - <span class="str-truncated align-bottom"> {{ projectName }} • #{{ pipeline.id }} </span> - <div v-if="parentPipeline || childPipeline" class="parent-child-label-container"> - <span - v-gl-tooltip.bottom - :title="labelToolTipText" - class="badge badge-primary" - @mouseover="hideTooltips" - >{{ label }}</span - > + <span class="str-truncated"> {{ downstreamTitle }} • #{{ pipeline.id }} </span> + <div class="gl-pt-2"> + <span class="badge badge-primary" data-testid="downstream-pipeline-label">{{ label }}</span> </div> </gl-deprecated-button> </li> diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue index e3429184c05..c4dfd3382a2 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue @@ -28,7 +28,7 @@ export default { columnClass() { const positionValues = { right: 'prepend-left-64', - left: 'append-right-32', + left: 'gl-mr-7', }; return `graph-position-${this.graphPosition} ${positionValues[this.graphPosition]}`; }, @@ -41,6 +41,9 @@ export default { onPipelineClick(downstreamNode, pipeline, index) { this.$emit('linkedPipelineClick', pipeline, index, downstreamNode); }, + onDownstreamHovered(jobName) { + this.$emit('downstreamHovered', jobName); + }, }, }; </script> @@ -61,6 +64,7 @@ export default { :column-title="columnTitle" :project-id="projectId" @pipelineClicked="onPipelineClick($event, pipeline, index)" + @downstreamHovered="onDownstreamHovered" /> </ul> </div> diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue index bed0ed51d5f..9de6ba819c2 100644 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -36,6 +36,11 @@ export default { required: false, default: () => ({}), }, + jobHovered: { + type: String, + required: false, + default: '', + }, }, computed: { hasAction() { @@ -80,6 +85,7 @@ export default { <job-item v-if="group.size === 1" :job="group.jobs[0]" + :job-hovered="jobHovered" css-class-job-name="build-content" @pipelineActionRequestComplete="pipelineActionRequestComplete" /> diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index e7777d0d3af..dff642161db 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -108,7 +108,7 @@ export default { /> </ci-header> - <gl-loading-icon v-if="isLoading" size="lg" class="prepend-top-default append-bottom-default" /> + <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3 gl-mb-3" /> <gl-modal :modal-id="$options.DELETE_MODAL_ID" diff --git a/app/assets/javascripts/pipelines/components/blank_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/blank_state.vue index 6c3a4a27606..6c3a4a27606 100644 --- a/app/assets/javascripts/pipelines/components/blank_state.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/blank_state.vue diff --git a/app/assets/javascripts/pipelines/components/empty_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue index 74ada6a4d15..74ada6a4d15 100644 --- a/app/assets/javascripts/pipelines/components/empty_state.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue diff --git a/app/assets/javascripts/pipelines/components/nav_controls.vue b/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue index 4f6c9d2bd90..a66bbb7e5ba 100644 --- a/app/assets/javascripts/pipelines/components/nav_controls.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue @@ -1,6 +1,6 @@ <script> import { GlDeprecatedButton } from '@gitlab/ui'; -import LoadingButton from '../../vue_shared/components/loading_button.vue'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; export default { name: 'PipelineNavControls', diff --git a/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue index f604edd8859..f604edd8859 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue diff --git a/app/assets/javascripts/pipelines/components/pipeline_triggerer.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue index 740b54cd8e0..35fd9837b3e 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_triggerer.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue @@ -26,9 +26,9 @@ export default { :img-src="user.avatar_url" :img-size="26" :tooltip-text="user.name" - class="prepend-left-default js-pipeline-url-user" + class="gl-ml-3 js-pipeline-url-user" /> - <span v-else class="prepend-left-default js-pipeline-url-api api"> + <span v-else class="gl-ml-3 js-pipeline-url-api api"> {{ s__('Pipelines|API') }} </span> </div> diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue index 6c977b841af..2905b2ca26f 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue @@ -1,6 +1,7 @@ <script> import { GlLink, GlTooltipDirective } from '@gitlab/ui'; import { escape } from 'lodash'; +import { SCHEDULE_ORIGIN } from '../../constants'; import { __, sprintf } from '~/locale'; import popover from '~/vue_shared/directives/popover'; @@ -27,6 +28,10 @@ export default { type: Object, required: true, }, + pipelineScheduleUrl: { + type: String, + required: true, + }, autoDevopsHelpPath: { type: String, required: true, @@ -36,6 +41,9 @@ export default { user() { return this.pipeline.user; }, + isScheduled() { + return this.pipeline.source === SCHEDULE_ORIGIN; + }, popoverOptions() { return { html: true, @@ -61,16 +69,28 @@ export default { <gl-link :href="pipeline.path" class="js-pipeline-url-link js-onboarding-pipeline-item" + data-testid="pipeline-url-link" data-qa-selector="pipeline_url_link" > <span class="pipeline-id">#{{ pipeline.id }}</span> </gl-link> <div class="label-container"> + <gl-link v-if="isScheduled" :href="pipelineScheduleUrl" target="__blank"> + <span + v-gl-tooltip + :title="__('This pipeline was triggered by a schedule.')" + class="badge badge-info" + data-testid="pipeline-url-scheduled" + > + {{ __('Scheduled') }} + </span> + </gl-link> <span v-if="pipeline.flags.latest" v-gl-tooltip :title="__('Latest pipeline for the most recent commit on this branch')" class="js-pipeline-url-latest badge badge-success" + data-testid="pipeline-url-latest" > {{ __('latest') }} </span> @@ -79,6 +99,7 @@ export default { v-gl-tooltip :title="pipeline.yaml_errors" class="js-pipeline-url-yaml badge badge-danger" + data-testid="pipeline-url-yaml" > {{ __('yaml invalid') }} </span> @@ -87,6 +108,7 @@ export default { v-gl-tooltip :title="pipeline.failure_reason" class="js-pipeline-url-failure badge badge-danger" + data-testid="pipeline-url-failure" > {{ __('error') }} </span> @@ -95,10 +117,15 @@ export default { v-popover="popoverOptions" tabindex="0" class="js-pipeline-url-autodevops badge badge-info autodevops-badge" + data-testid="pipeline-url-autodevops" role="button" >{{ __('Auto DevOps') }}</gl-link > - <span v-if="pipeline.flags.stuck" class="js-pipeline-url-stuck badge badge-warning"> + <span + v-if="pipeline.flags.stuck" + class="js-pipeline-url-stuck badge badge-warning" + data-testid="pipeline-url-stuck" + > {{ __('stuck') }} </span> <span @@ -110,6 +137,7 @@ export default { ) " class="js-pipeline-url-detached badge badge-info" + data-testid="pipeline-url-detached" > {{ __('detached') }} </span> diff --git a/app/assets/javascripts/pipelines/components/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue index dbf29b0c29c..0c531650fd2 100644 --- a/app/assets/javascripts/pipelines/components/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue @@ -1,17 +1,18 @@ <script> import { isEqual } from 'lodash'; -import { __, sprintf, s__ } from '../../locale'; -import createFlash from '../../flash'; -import PipelinesService from '../services/pipelines_service'; -import pipelinesMixin from '../mixins/pipelines'; -import TablePagination from '../../vue_shared/components/pagination/table_pagination.vue'; -import NavigationTabs from '../../vue_shared/components/navigation_tabs.vue'; +import { __, s__ } from '~/locale'; +import createFlash from '~/flash'; +import PipelinesService from '../../services/pipelines_service'; +import pipelinesMixin from '../../mixins/pipelines'; +import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; +import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; import NavigationControls from './nav_controls.vue'; -import { getParameterByName } from '../../lib/utils/common_utils'; -import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin'; +import { getParameterByName } from '~/lib/utils/common_utils'; +import CIPaginationMixin from '~/vue_shared/mixins/ci_pagination_api_mixin'; +import Icon from '~/vue_shared/components/icon.vue'; import PipelinesFilteredSearch from './pipelines_filtered_search.vue'; -import { validateParams } from '../utils'; -import { ANY_TRIGGER_AUTHOR, RAW_TEXT_WARNING, FILTER_TAG_IDENTIFIER } from '../constants'; +import { validateParams } from '../../utils'; +import { ANY_TRIGGER_AUTHOR, RAW_TEXT_WARNING, FILTER_TAG_IDENTIFIER } from '../../constants'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { @@ -20,6 +21,7 @@ export default { NavigationTabs, NavigationControls, PipelinesFilteredSearch, + Icon, }, mixins: [pipelinesMixin, CIPaginationMixin, glFeatureFlagsMixin()], props: { @@ -40,6 +42,11 @@ export default { type: String, required: true, }, + pipelineScheduleUrl: { + type: String, + required: false, + default: '', + }, helpPagePath: { type: String, required: true, @@ -115,8 +122,6 @@ export default { }, scopes: { all: 'all', - pending: 'pending', - running: 'running', finished: 'finished', branches: 'branches', tags: 'tags', @@ -169,13 +174,8 @@ export default { }, emptyTabMessage() { - const { scopes } = this.$options; - const possibleScopes = [scopes.pending, scopes.running, scopes.finished]; - - if (possibleScopes.includes(this.scope)) { - return sprintf(s__('Pipelines|There are currently no %{scope} pipelines.'), { - scope: this.scope, - }); + if (this.scope === this.$options.scopes.finished) { + return s__('Pipelines|There are currently no finished pipelines.'); } return s__('Pipelines|There are currently no pipelines.'); @@ -193,21 +193,8 @@ export default { isActive: this.scope === 'all', }, { - name: __('Pending'), - scope: scopes.pending, - count: count.pending, - isActive: this.scope === 'pending', - }, - { - name: __('Running'), - scope: scopes.running, - count: count.running, - isActive: this.scope === 'running', - }, - { name: __('Finished'), scope: scopes.finished, - count: count.finished, isActive: this.scope === 'finished', }, { @@ -298,8 +285,8 @@ export default { v-if="shouldRenderTabs || shouldRenderButtons" class="top-area scrolling-tabs-container inner-page-scroll-tabs" > - <div class="fade-left"><i class="fa fa-angle-left" aria-hidden="true"> </i></div> - <div class="fade-right"><i class="fa fa-angle-right" aria-hidden="true"> </i></div> + <div class="fade-left"><icon name="chevron-lg-left" :size="12" /></div> + <div class="fade-right"><icon name="chevron-lg-right" :size="12" /></div> <navigation-tabs v-if="shouldRenderTabs" @@ -358,6 +345,7 @@ export default { <div v-else-if="stateToRender === $options.stateMap.tableList" class="table-holder"> <pipelines-table-component :pipelines="state.pipelines" + :pipeline-schedule-url="pipelineScheduleUrl" :update-graph-dropdown="updateGraphDropdown" :auto-devops-help-path="autoDevopsPath" :view-type="viewType" diff --git a/app/assets/javascripts/pipelines/components/pipelines_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue index 7d4276e8d2e..3009ca7a775 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_actions.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue @@ -5,7 +5,7 @@ import flash from '~/flash'; import { s__, __, sprintf } from '~/locale'; import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; import Icon from '~/vue_shared/components/icon.vue'; -import eventHub from '../event_hub'; +import eventHub from '../../event_hub'; export default { directives: { diff --git a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue index 59c066b2683..59c066b2683 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue diff --git a/app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue index 0505a8668d1..0505a8668d1 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue diff --git a/app/assets/javascripts/pipelines/components/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue index d3ba0c97f6b..b8112149778 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue @@ -2,7 +2,7 @@ import { GlTooltipDirective } from '@gitlab/ui'; import PipelinesTableRowComponent from './pipelines_table_row.vue'; import PipelineStopModal from './pipeline_stop_modal.vue'; -import eventHub from '../event_hub'; +import eventHub from '../../event_hub'; /** * Pipelines Table Component. @@ -22,6 +22,11 @@ export default { type: Array, required: true, }, + pipelineScheduleUrl: { + type: String, + required: false, + default: '', + }, updateGraphDropdown: { type: Boolean, required: false, @@ -91,6 +96,7 @@ export default { v-for="model in pipelines" :key="model.id" :pipeline="model" + :pipeline-schedule-url="pipelineScheduleUrl" :update-graph-dropdown="updateGraphDropdown" :auto-devops-help-path="autoDevopsHelpPath" :view-type="viewType" diff --git a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue index 981914dd046..f25994a7506 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue @@ -1,16 +1,16 @@ <script> -import eventHub from '../event_hub'; +import eventHub from '../../event_hub'; import PipelinesActionsComponent from './pipelines_actions.vue'; import PipelinesArtifactsComponent from './pipelines_artifacts.vue'; -import CiBadge from '../../vue_shared/components/ci_badge_link.vue'; +import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; import PipelineStage from './stage.vue'; import PipelineUrl from './pipeline_url.vue'; import PipelineTriggerer from './pipeline_triggerer.vue'; import PipelinesTimeago from './time_ago.vue'; -import CommitComponent from '../../vue_shared/components/commit.vue'; -import LoadingButton from '../../vue_shared/components/loading_button.vue'; -import Icon from '../../vue_shared/components/icon.vue'; -import { PIPELINES_TABLE } from '../constants'; +import CommitComponent from '~/vue_shared/components/commit.vue'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import { PIPELINES_TABLE } from '../../constants'; /** * Pipeline table row. @@ -35,6 +35,11 @@ export default { type: Object, required: true, }, + pipelineScheduleUrl: { + type: String, + required: false, + default: '', + }, updateGraphDropdown: { type: Boolean, required: false, @@ -274,7 +279,11 @@ export default { </div> </div> - <pipeline-url :pipeline="pipeline" :auto-devops-help-path="autoDevopsHelpPath" /> + <pipeline-url + :pipeline="pipeline" + :pipeline-schedule-url="pipelineScheduleUrl" + :auto-devops-help-path="autoDevopsHelpPath" + /> <pipeline-triggerer :pipeline="pipeline" /> <div class="table-section section-wrap section-20"> @@ -300,7 +309,8 @@ export default { <div v-for="(stage, index) in pipeline.details.stages" :key="index" - class="stage-container dropdown js-mini-pipeline-graph" + class="stage-container dropdown" + data-testid="widget-mini-pipeline-graph" > <pipeline-stage :type="$options.pipelinesTable" diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue index 569920a4f31..99492bd8357 100644 --- a/app/assets/javascripts/pipelines/components/stage.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue @@ -14,13 +14,13 @@ import $ from 'jquery'; import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '../../locale'; -import Flash from '../../flash'; -import axios from '../../lib/utils/axios_utils'; -import eventHub from '../event_hub'; -import Icon from '../../vue_shared/components/icon.vue'; -import JobItem from './graph/job_item.vue'; -import { PIPELINES_TABLE } from '../constants'; +import { __ } from '~/locale'; +import Flash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import eventHub from '../../event_hub'; +import Icon from '~/vue_shared/components/icon.vue'; +import JobItem from '../graph/job_item.vue'; +import { PIPELINES_TABLE } from '../../constants'; export default { components: { diff --git a/app/assets/javascripts/pipelines/components/time_ago.vue b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue index 2a23a0f6744..8a01e1fe3f5 100644 --- a/app/assets/javascripts/pipelines/components/time_ago.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue @@ -1,8 +1,8 @@ <script> import iconTimerSvg from 'icons/_icon_timer.svg'; -import '../../lib/utils/datetime_utility'; -import tooltip from '../../vue_shared/directives/tooltip'; -import timeagoMixin from '../../vue_shared/mixins/timeago'; +import '~/lib/utils/datetime_utility'; +import tooltip from '~/vue_shared/directives/tooltip'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; export default { directives: { diff --git a/app/assets/javascripts/pipelines/components/tokens/pipeline_branch_name_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue index da14bb2d308..b6eff2931d3 100644 --- a/app/assets/javascripts/pipelines/components/tokens/pipeline_branch_name_token.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue @@ -1,7 +1,7 @@ <script> import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; import Api from '~/api'; -import { FETCH_BRANCH_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../constants'; +import { FETCH_BRANCH_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../../constants'; import createFlash from '~/flash'; import { debounce } from 'lodash'; diff --git a/app/assets/javascripts/pipelines/components/tokens/pipeline_status_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_status_token.vue index dc43d94f4fd..dc43d94f4fd 100644 --- a/app/assets/javascripts/pipelines/components/tokens/pipeline_status_token.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_status_token.vue diff --git a/app/assets/javascripts/pipelines/components/tokens/pipeline_tag_name_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue index 7b209c5fa12..64de6d2a053 100644 --- a/app/assets/javascripts/pipelines/components/tokens/pipeline_tag_name_token.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue @@ -1,7 +1,7 @@ <script> import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; import Api from '~/api'; -import { FETCH_TAG_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../constants'; +import { FETCH_TAG_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../../constants'; import createFlash from '~/flash'; import { debounce } from 'lodash'; diff --git a/app/assets/javascripts/pipelines/components/tokens/pipeline_trigger_author_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue index 4062a3b11bb..b5aeb3fe9e0 100644 --- a/app/assets/javascripts/pipelines/components/tokens/pipeline_trigger_author_token.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue @@ -13,7 +13,7 @@ import { ANY_TRIGGER_AUTHOR, FETCH_AUTHOR_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY, -} from '../../constants'; +} from '../../../constants'; export default { anyTriggerAuthor: ANY_TRIGGER_AUTHOR, diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue index 06ab45adf80..8746784aa57 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue @@ -1,10 +1,9 @@ <script> -import { mapActions, mapState } from 'vuex'; +import { mapActions, mapGetters, mapState } from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; import TestSuiteTable from './test_suite_table.vue'; import TestSummary from './test_summary.vue'; import TestSummaryTable from './test_summary_table.vue'; -import store from '~/pipelines/stores/test_reports'; export default { name: 'TestReports', @@ -14,24 +13,37 @@ export default { TestSummary, TestSummaryTable, }, - store, computed: { - ...mapState(['isLoading', 'selectedSuite', 'testReports']), + ...mapState(['hasFullReport', 'isLoading', 'selectedSuiteIndex', 'testReports']), + ...mapGetters(['getSelectedSuite']), showSuite() { - return this.selectedSuite.total_count > 0; + return this.selectedSuiteIndex !== null; }, showTests() { const { test_suites: testSuites = [] } = this.testReports; return testSuites.length > 0; }, }, + created() { + this.fetchSummary(); + }, methods: { - ...mapActions(['setSelectedSuite', 'removeSelectedSuite']), + ...mapActions([ + 'fetchFullReport', + 'fetchSummary', + 'setSelectedSuiteIndex', + 'removeSelectedSuiteIndex', + ]), summaryBackClick() { - this.removeSelectedSuite(); + this.removeSelectedSuiteIndex(); }, - summaryTableRowClick(suite) { - this.setSelectedSuite(suite); + summaryTableRowClick(index) { + this.setSelectedSuiteIndex(index); + + // Fetch the full report when the user clicks to see more details + if (!this.hasFullReport) { + this.fetchFullReport(); + } }, beforeEnterTransition() { document.documentElement.style.overflowX = 'hidden'; @@ -45,7 +57,7 @@ export default { <template> <div v-if="isLoading"> - <gl-loading-icon size="lg" class="prepend-top-default js-loading-spinner" /> + <gl-loading-icon size="lg" class="gl-mt-3 js-loading-spinner" /> </div> <div @@ -59,7 +71,7 @@ export default { @after-leave="afterLeaveTransition" > <div v-if="showSuite" key="detail" class="w-100 position-absolute slide-enter-to-element"> - <test-summary :report="selectedSuite" show-back @on-back-click="summaryBackClick" /> + <test-summary :report="getSelectedSuite" show-back @on-back-click="summaryBackClick" /> <test-suite-table /> </div> @@ -73,7 +85,7 @@ export default { </div> <div v-else> - <div class="row prepend-top-default"> + <div class="row gl-mt-3"> <div class="col-12"> <p class="js-no-tests-to-show">{{ s__('TestReports|There are no tests to show.') }}</p> </div> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue index be7f27f210d..d57b1466177 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue @@ -1,8 +1,8 @@ <script> import { mapGetters } from 'vuex'; import Icon from '~/vue_shared/components/icon.vue'; -import store from '~/pipelines/stores/test_reports'; import { __ } from '~/locale'; +import { GlTooltipDirective } from '@gitlab/ui'; import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; export default { @@ -11,7 +11,9 @@ export default { Icon, SmartVirtualList, }, - store, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { heading: { type: String, @@ -32,16 +34,16 @@ export default { <template> <div> - <div class="row prepend-top-default"> + <div class="row gl-mt-3"> <div class="col-12"> <h4>{{ heading }}</h4> </div> </div> - <div v-if="hasSuites" class="test-reports-table append-bottom-default js-test-cases-table"> + <div v-if="hasSuites" class="test-reports-table gl-mb-3 js-test-cases-table"> <div role="row" class="gl-responsive-table-row table-row-header font-weight-bold fgray"> <div role="rowheader" class="table-section section-20"> - {{ __('Class') }} + {{ __('Suite') }} </div> <div role="rowheader" class="table-section section-20"> {{ __('Name') }} @@ -68,13 +70,25 @@ export default { class="gl-responsive-table-row rounded align-items-md-start mt-xs-3 js-case-row" > <div class="table-section section-20 section-wrap"> - <div role="rowheader" class="table-mobile-header">{{ __('Class') }}</div> - <div class="table-mobile-content pr-md-1 text-truncate">{{ testCase.classname }}</div> + <div role="rowheader" class="table-mobile-header">{{ __('Suite') }}</div> + <div + v-gl-tooltip + :title="testCase.classname" + class="table-mobile-content pr-md-1 text-truncate" + > + {{ testCase.classname }} + </div> </div> <div class="table-section section-20 section-wrap"> <div role="rowheader" class="table-mobile-header">{{ __('Name') }}</div> - <div class="table-mobile-content pr-md-1 text-truncate">{{ testCase.name }}</div> + <div + v-gl-tooltip + :title="testCase.name" + class="table-mobile-content pr-md-1 text-truncate" + > + {{ testCase.name }} + </div> </div> <div class="table-section section-10 section-wrap"> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue index 67646c537bd..712ac5eb0e5 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue @@ -72,7 +72,7 @@ export default { <gl-deprecated-button v-if="showBack" size="sm" - class="append-right-default js-back-button" + class="gl-mr-3 js-back-button" @click="onBackClick" > <icon name="angle-left" /> @@ -85,7 +85,7 @@ export default { <div class="row mt-2"> <div class="col-4 col-md"> <span class="js-total-tests">{{ - sprintf(s__('TestReports|%{count} jobs'), { count: report.total_count }) + sprintf(s__('TestReports|%{count} tests'), { count: report.total_count }) }}</span> </div> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue index 4dfb67dd8e8..6cfb795595d 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue @@ -2,7 +2,6 @@ import { mapGetters } from 'vuex'; import { s__ } from '~/locale'; import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import store from '~/pipelines/stores/test_reports'; import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; export default { @@ -14,12 +13,11 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - store, props: { heading: { type: String, required: false, - default: s__('TestReports|Test suites'), + default: s__('TestReports|Jobs'), }, }, computed: { @@ -29,8 +27,8 @@ export default { }, }, methods: { - tableRowClick(suite) { - this.$emit('row-click', suite); + tableRowClick(index) { + this.$emit('row-click', index); }, }, maxShownRows: 20, @@ -40,16 +38,16 @@ export default { <template> <div> - <div class="row prepend-top-default"> + <div class="row gl-mt-3"> <div class="col-12"> <h4>{{ heading }}</h4> </div> </div> - <div v-if="hasSuites" class="test-reports-table append-bottom-default js-test-suites-table"> + <div v-if="hasSuites" class="test-reports-table gl-mb-3 js-test-suites-table"> <div role="row" class="gl-responsive-table-row table-row-header font-weight-bold"> <div role="rowheader" class="table-section section-25 pl-3"> - {{ __('Suite') }} + {{ __('Job') }} </div> <div role="rowheader" class="table-section section-25"> {{ __('Duration') }} @@ -84,7 +82,7 @@ export default { :class="{ 'gl-responsive-table-row-clickable cursor-pointer': !testSuite.suite_error, }" - @click="tableRowClick(testSuite)" + @click="tableRowClick(index)" > <div class="table-section section-25"> <div role="rowheader" class="table-mobile-header font-weight-bold"> diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js index c709f329728..abe5e1060c8 100644 --- a/app/assets/javascripts/pipelines/constants.js +++ b/app/assets/javascripts/pipelines/constants.js @@ -7,6 +7,7 @@ export const FILTER_PIPELINES_SEARCH_DELAY = 200; export const ANY_TRIGGER_AUTHOR = 'Any'; export const SUPPORTED_FILTER_PARAMETERS = ['username', 'ref', 'status']; export const FILTER_TAG_IDENTIFIER = 'tag'; +export const SCHEDULE_ORIGIN = 'schedule'; export const TestStatus = { FAILED: 'failed', diff --git a/app/assets/javascripts/pipelines/mixins/pipelines.js b/app/assets/javascripts/pipelines/mixins/pipelines.js index 876b30299fb..7710a96e5fb 100644 --- a/app/assets/javascripts/pipelines/mixins/pipelines.js +++ b/app/assets/javascripts/pipelines/mixins/pipelines.js @@ -1,11 +1,11 @@ import Visibility from 'visibilityjs'; import { GlLoadingIcon } from '@gitlab/ui'; -import { __ } from '../../locale'; -import createFlash from '../../flash'; -import Poll from '../../lib/utils/poll'; -import EmptyState from '../components/empty_state.vue'; -import SvgBlankState from '../components/blank_state.vue'; -import PipelinesTableComponent from '../components/pipelines_table.vue'; +import { __ } from '~/locale'; +import createFlash from '~/flash'; +import Poll from '~/lib/utils/poll'; +import EmptyState from '../components/pipelines_list/empty_state.vue'; +import SvgBlankState from '../components/pipelines_list/blank_state.vue'; +import PipelinesTableComponent from '../components/pipelines_list/pipelines_table.vue'; import eventHub from '../event_hub'; import { CANCEL_REQUEST } from '../constants'; diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index 90109598542..f1102a9bddf 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -10,8 +10,7 @@ import PipelinesMediator from './pipeline_details_mediator'; import pipelineHeader from './components/header_component.vue'; import eventHub from './event_hub'; import TestReports from './components/test_reports/test_reports.vue'; -import testReportsStore from './stores/test_reports'; -import axios from '~/lib/utils/axios_utils'; +import createTestReportsStore from './stores/test_reports'; Vue.use(Translate); @@ -93,15 +92,11 @@ const createPipelineHeaderApp = mediator => { }); }; -const createPipelinesTabs = dataset => { +const createPipelinesTabs = testReportsStore => { const tabsElement = document.querySelector('.pipelines-tabs'); - const testReportsEnabled = - window.gon && window.gon.features && window.gon.features.junitPipelineView; - - if (tabsElement && testReportsEnabled) { - const fetchReportsAction = 'fetchReports'; - testReportsStore.dispatch('setEndpoint', dataset.testReportEndpoint); + if (tabsElement) { + const fetchReportsAction = 'fetchFullReport'; const isTestTabActive = Boolean( document.querySelector('.pipelines-tabs > li > a.test-tab.active'), ); @@ -121,28 +116,35 @@ const createPipelinesTabs = dataset => { } }; -const createTestDetails = detailsEndpoint => { +const createTestDetails = () => { + if (!window.gon?.features?.junitPipelineView) { + return; + } + + const el = document.querySelector('#js-pipeline-tests-detail'); + const { fullReportEndpoint, summaryEndpoint, countEndpoint } = el?.dataset || {}; + + const testReportsStore = createTestReportsStore({ + fullReportEndpoint, + summaryEndpoint: summaryEndpoint || countEndpoint, + useBuildSummaryReport: window.gon?.features?.buildReportSummary, + }); + + if (!window.gon?.features?.buildReportSummary) { + createPipelinesTabs(testReportsStore); + } + // eslint-disable-next-line no-new new Vue({ - el: '#js-pipeline-tests-detail', + el, components: { TestReports, }, + store: testReportsStore, render(createElement) { return createElement('test-reports'); }, }); - - axios - .get(detailsEndpoint) - .then(({ data }) => { - if (!data.total_count) { - return; - } - - document.querySelector('.js-test-report-badge-counter').innerHTML = data.total_count; - }) - .catch(() => {}); }; const createDagApp = () => { @@ -151,7 +153,8 @@ const createDagApp = () => { } const el = document.querySelector('#js-pipeline-dag-vue'); - const graphUrl = el?.dataset?.pipelineDataPath; + const { pipelineDataPath, emptySvgPath, dagDocPath } = el?.dataset; + // eslint-disable-next-line no-new new Vue({ el, @@ -161,7 +164,9 @@ const createDagApp = () => { render(createElement) { return createElement('dag', { props: { - graphUrl, + graphUrl: pipelineDataPath, + emptySvgPath, + dagDocPath, }, }); }, @@ -175,7 +180,6 @@ export default () => { createPipelinesDetailApp(mediator); createPipelineHeaderApp(mediator); - createPipelinesTabs(dataset); - createTestDetails(dataset.testReportsCountEndpoint); + createTestDetails(); createDagApp(); }; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/actions.js b/app/assets/javascripts/pipelines/stores/test_reports/actions.js index 71d875c1a83..ccacb9f7e97 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/actions.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/actions.js @@ -3,17 +3,42 @@ import * as types from './mutation_types'; import createFlash from '~/flash'; import { s__ } from '~/locale'; -export const setEndpoint = ({ commit }, data) => commit(types.SET_ENDPOINT, data); +export const fetchSummary = ({ state, commit, dispatch }) => { + // If we do this without the build_report_summary feature flag enabled + // it causes a race condition for toggleLoading and ruins the loading + // state in the application + if (state.useBuildSummaryReport) { + dispatch('toggleLoading'); + } -export const fetchReports = ({ state, commit, dispatch }) => { + return axios + .get(state.summaryEndpoint) + .then(({ data }) => { + commit(types.SET_SUMMARY, data); + + if (!state.useBuildSummaryReport) { + // Set the tab counter badge to total_count + // This is temporary until we can server-side render that count number + // (see https://gitlab.com/gitlab-org/gitlab/-/issues/223134) + document.querySelector('.js-test-report-badge-counter').innerHTML = data.total_count || 0; + } + }) + .catch(() => { + createFlash(s__('TestReports|There was an error fetching the summary.')); + }) + .finally(() => { + if (state.useBuildSummaryReport) { + dispatch('toggleLoading'); + } + }); +}; + +export const fetchFullReport = ({ state, commit, dispatch }) => { dispatch('toggleLoading'); return axios - .get(state.endpoint) - .then(response => { - const { data } = response; - commit(types.SET_REPORTS, data); - }) + .get(state.fullReportEndpoint) + .then(({ data }) => commit(types.SET_REPORTS, data)) .catch(() => { createFlash(s__('TestReports|There was an error fetching the test reports.')); }) @@ -22,8 +47,10 @@ export const fetchReports = ({ state, commit, dispatch }) => { }); }; -export const setSelectedSuite = ({ commit }, data) => commit(types.SET_SELECTED_SUITE, data); -export const removeSelectedSuite = ({ commit }) => commit(types.SET_SELECTED_SUITE, {}); +export const setSelectedSuiteIndex = ({ commit }, data) => + commit(types.SET_SELECTED_SUITE_INDEX, data); +export const removeSelectedSuiteIndex = ({ commit }) => + commit(types.SET_SELECTED_SUITE_INDEX, null); export const toggleLoading = ({ commit }) => commit(types.TOGGLE_LOADING); // prevent babel-plugin-rewire from generating an invalid default during karma tests diff --git a/app/assets/javascripts/pipelines/stores/test_reports/getters.js b/app/assets/javascripts/pipelines/stores/test_reports/getters.js index 788c1d32987..877762b77c9 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/getters.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/getters.js @@ -9,14 +9,12 @@ export const getTestSuites = state => { })); }; -export const getSuiteTests = state => { - const { selectedSuite } = state; - - if (selectedSuite.test_cases) { - return selectedSuite.test_cases.sort(sortTestCases).map(addIconStatus); - } +export const getSelectedSuite = state => + state.testReports?.test_suites?.[state.selectedSuiteIndex] || {}; - return []; +export const getSuiteTests = state => { + const { test_cases: testCases = [] } = getSelectedSuite(state); + return testCases.sort(sortTestCases).map(addIconStatus); }; // prevent babel-plugin-rewire from generating an invalid default during karma tests diff --git a/app/assets/javascripts/pipelines/stores/test_reports/index.js b/app/assets/javascripts/pipelines/stores/test_reports/index.js index 318dff5bcb2..88f61b09025 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/index.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/index.js @@ -7,9 +7,10 @@ import mutations from './mutations'; Vue.use(Vuex); -export default new Vuex.Store({ - actions, - getters, - mutations, - state, -}); +export default initialState => + new Vuex.Store({ + actions, + getters, + mutations, + state: state(initialState), + }); diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js b/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js index 832e45cf7a1..76405557b51 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js @@ -1,4 +1,4 @@ -export const SET_ENDPOINT = 'SET_ENDPOINT'; export const SET_REPORTS = 'SET_REPORTS'; -export const SET_SELECTED_SUITE = 'SET_SELECTED_SUITE'; +export const SET_SELECTED_SUITE_INDEX = 'SET_SELECTED_SUITE_INDEX'; +export const SET_SUMMARY = 'SET_SUMMARY'; export const TOGGLE_LOADING = 'TOGGLE_LOADING'; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutations.js b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js index 349e6ec0469..2531ab1e87c 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/mutations.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js @@ -1,16 +1,16 @@ import * as types from './mutation_types'; export default { - [types.SET_ENDPOINT](state, endpoint) { - Object.assign(state, { endpoint }); + [types.SET_REPORTS](state, testReports) { + Object.assign(state, { testReports, hasFullReport: true }); }, - [types.SET_REPORTS](state, testReports) { - Object.assign(state, { testReports }); + [types.SET_SELECTED_SUITE_INDEX](state, selectedSuiteIndex) { + Object.assign(state, { selectedSuiteIndex }); }, - [types.SET_SELECTED_SUITE](state, selectedSuite) { - Object.assign(state, { selectedSuite }); + [types.SET_SUMMARY](state, summary) { + Object.assign(state, { testReports: { ...state.testReports, ...summary } }); }, [types.TOGGLE_LOADING](state) { diff --git a/app/assets/javascripts/pipelines/stores/test_reports/state.js b/app/assets/javascripts/pipelines/stores/test_reports/state.js index 80a0c2a46a0..bcf5c147916 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/state.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/state.js @@ -1,6 +1,13 @@ -export default () => ({ - endpoint: '', +export default ({ + fullReportEndpoint = '', + summaryEndpoint = '', + useBuildSummaryReport = false, +}) => ({ + summaryEndpoint, + fullReportEndpoint, testReports: {}, - selectedSuite: {}, + selectedSuiteIndex: null, + hasFullReport: false, isLoading: false, + useBuildSummaryReport, }); diff --git a/app/assets/javascripts/projects/commits/store/actions.js b/app/assets/javascripts/projects/commits/store/actions.js index 0a52a92ae9d..f0832bd36a5 100644 --- a/app/assets/javascripts/projects/commits/store/actions.js +++ b/app/assets/javascripts/projects/commits/store/actions.js @@ -18,7 +18,7 @@ export default { fetchAuthors({ dispatch, state }, author = null) { const { projectId } = state; return axios - .get(joinPaths(gon.relative_url_root || '', '/autocomplete/users.json'), { + .get(joinPaths(gon.relative_url_root || '', '/-/autocomplete/users.json'), { params: { project_id: projectId, active: true, diff --git a/app/assets/javascripts/projects/components/remove_modal.vue b/app/assets/javascripts/projects/components/remove_modal.vue new file mode 100644 index 00000000000..37f58efcb30 --- /dev/null +++ b/app/assets/javascripts/projects/components/remove_modal.vue @@ -0,0 +1,108 @@ +<script> +import { GlModal, GlModalDirective, GlSprintf, GlFormInput, GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { rstrip } from '~/lib/utils/common_utils'; +import csrf from '~/lib/utils/csrf'; + +export default { + components: { + GlModal, + GlSprintf, + GlFormInput, + GlButton, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + confirmPhrase: { + type: String, + required: true, + }, + warningMessage: { + type: String, + required: true, + }, + formPath: { + type: String, + required: true, + }, + }, + data() { + return { + userInput: null, + }; + }, + computed: { + buttonDisabled() { + return rstrip(this.userInput) !== this.confirmPhrase; + }, + csrfToken() { + return csrf.token; + }, + }, + methods: { + submitForm() { + this.$refs.form.submit(); + }, + }, + strings: { + removeProject: __('Remove project'), + title: __('Confirmation required'), + confirm: __('Confirm'), + dataLoss: __( + 'This action can lead to data loss. To prevent accidental actions we ask you to confirm your intention.', + ), + confirmText: __('Please type %{phrase_code} to proceed or close this modal to cancel.'), + }, + modalId: 'remove-project-modal', +}; +</script> + +<template> + <form ref="form" :action="formPath" method="post"> + <input type="hidden" name="_method" value="delete" /> + <input :value="csrfToken" type="hidden" name="authenticity_token" /> + <gl-button v-gl-modal="$options.modalId" category="primary" variant="danger">{{ + $options.strings.removeProject + }}</gl-button> + <gl-modal + ref="removeModal" + :modal-id="$options.modalId" + size="sm" + ok-variant="danger" + footer-class="bg-gray-light gl-p-5" + > + <template #modal-title>{{ $options.strings.title }}</template> + <template #modal-footer> + <div class="gl-w-full gl-display-flex gl-just-content-start gl-m-0"> + <gl-button + :disabled="buttonDisabled" + category="primary" + variant="danger" + @click="submitForm" + > + {{ $options.strings.confirm }} + </gl-button> + </div> + </template> + <div> + <p class="gl-text-red-500 gl-font-weight-bold">{{ warningMessage }}</p> + <p class="gl-mb-0">{{ $options.strings.dataLoss }}</p> + <p> + <gl-sprintf :message="$options.strings.confirmText"> + <template #phrase_code> + <code>{{ confirmPhrase }}</code> + </template> + </gl-sprintf> + </p> + <gl-form-input + id="confirm_name_input" + v-model="userInput" + name="confirm_name_input" + type="text" + /> + </div> + </gl-modal> + </form> +</template> diff --git a/app/assets/javascripts/projects/pipelines/charts/components/pipelines_area_chart.vue b/app/assets/javascripts/projects/pipelines/charts/components/pipelines_area_chart.vue index d701f238a2e..d726196aadf 100644 --- a/app/assets/javascripts/projects/pipelines/charts/components/pipelines_area_chart.vue +++ b/app/assets/javascripts/projects/pipelines/charts/components/pipelines_area_chart.vue @@ -28,7 +28,7 @@ export default { }; </script> <template> - <div class="prepend-top-default"> + <div class="gl-mt-3"> <p> <slot></slot> </p> diff --git a/app/assets/javascripts/projects/project_remove_modal.js b/app/assets/javascripts/projects/project_remove_modal.js new file mode 100644 index 00000000000..dbdad1bf6f1 --- /dev/null +++ b/app/assets/javascripts/projects/project_remove_modal.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import RemoveProjectModal from './components/remove_modal.vue'; + +export default (selector = '#js-confirm-project-remove') => { + const el = document.querySelector(selector); + + if (!el) return; + + const { formPath, confirmPhrase, warningMessage } = el.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el, + render(createElement) { + return createElement(RemoveProjectModal, { + props: { + confirmPhrase, + warningMessage, + formPath, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue new file mode 100644 index 00000000000..d61569fcd6e --- /dev/null +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue @@ -0,0 +1,160 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import { __ } from '~/locale'; +import ServiceDeskSetting from './service_desk_setting.vue'; +import ServiceDeskService from '../services/service_desk_service'; +import eventHub from '../event_hub'; + +export default { + name: 'ServiceDeskRoot', + components: { + GlAlert, + ServiceDeskSetting, + }, + props: { + initialIsEnabled: { + type: Boolean, + required: true, + }, + endpoint: { + type: String, + required: true, + }, + initialIncomingEmail: { + type: String, + required: false, + default: '', + }, + selectedTemplate: { + type: String, + required: false, + default: '', + }, + outgoingName: { + type: String, + required: false, + default: '', + }, + projectKey: { + type: String, + required: false, + default: '', + }, + templates: { + type: Array, + required: false, + default: () => [], + }, + }, + + data() { + return { + isEnabled: this.initialIsEnabled, + incomingEmail: this.initialIncomingEmail, + isTemplateSaving: false, + isAlertShowing: false, + alertVariant: 'danger', + alertMessage: '', + }; + }, + + created() { + eventHub.$on('serviceDeskEnabledCheckboxToggled', this.onEnableToggled); + eventHub.$on('serviceDeskTemplateSave', this.onSaveTemplate); + + this.service = new ServiceDeskService(this.endpoint); + + if (this.isEnabled && !this.incomingEmail) { + this.fetchIncomingEmail(); + } + }, + + beforeDestroy() { + eventHub.$off('serviceDeskEnabledCheckboxToggled', this.onEnableToggled); + eventHub.$off('serviceDeskTemplateSave', this.onSaveTemplate); + }, + + methods: { + fetchIncomingEmail() { + this.service + .fetchIncomingEmail() + .then(({ data }) => { + const email = data.service_desk_address; + if (!email) { + throw new Error(__("Response didn't include `service_desk_address`")); + } + + this.incomingEmail = email; + }) + .catch(() => + this.showAlert(__('An error occurred while fetching the Service Desk address.')), + ); + }, + + onEnableToggled(isChecked) { + this.isEnabled = isChecked; + this.incomingEmail = ''; + + this.service + .toggleServiceDesk(isChecked) + .then(({ data }) => { + const email = data.service_desk_address; + if (isChecked && !email) { + throw new Error(__("Response didn't include `service_desk_address`")); + } + + this.incomingEmail = email; + }) + .catch(() => { + const message = isChecked + ? __('An error occurred while enabling Service Desk.') + : __('An error occurred while disabling Service Desk.'); + + this.showAlert(message); + }); + }, + + onSaveTemplate({ selectedTemplate, outgoingName, projectKey }) { + this.isTemplateSaving = true; + this.service + .updateTemplate({ selectedTemplate, outgoingName, projectKey }, this.isEnabled) + .then(() => this.showAlert(__('Template was successfully saved.'), 'success')) + .catch(() => + this.showAlert( + __('An error occurred while saving the template. Please check if the template exists.'), + ), + ) + .finally(() => { + this.isTemplateSaving = false; + }); + }, + + showAlert(message, variant = 'danger') { + this.isAlertShowing = true; + this.alertMessage = message; + this.alertVariant = variant; + }, + + onDismiss() { + this.isAlertShowing = false; + }, + }, +}; +</script> + +<template> + <div> + <gl-alert v-if="isAlertShowing" class="mb-3" :variant="alertVariant" @dismiss="onDismiss"> + {{ alertMessage }} + </gl-alert> + <service-desk-setting + :is-enabled="isEnabled" + :incoming-email="incomingEmail" + :initial-selected-template="selectedTemplate" + :initial-outgoing-name="outgoingName" + :initial-project-key="projectKey" + :templates="templates" + :is-template-saving="isTemplateSaving" + /> + </div> +</template> diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue new file mode 100644 index 00000000000..43c20fea43e --- /dev/null +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue @@ -0,0 +1,169 @@ +<script> +import { GlDeprecatedButton, GlFormSelect, GlToggle, GlLoadingIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; +import tooltip from '~/vue_shared/directives/tooltip'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import eventHub from '../event_hub'; + +export default { + name: 'ServiceDeskSetting', + directives: { + tooltip, + }, + components: { + ClipboardButton, + GlDeprecatedButton, + GlFormSelect, + GlToggle, + GlLoadingIcon, + }, + mixins: [glFeatureFlagsMixin()], + props: { + isEnabled: { + type: Boolean, + required: true, + }, + incomingEmail: { + type: String, + required: false, + default: '', + }, + initialSelectedTemplate: { + type: String, + required: false, + default: '', + }, + initialOutgoingName: { + type: String, + required: false, + default: '', + }, + initialProjectKey: { + type: String, + required: false, + default: '', + }, + templates: { + type: Array, + required: false, + default: () => [], + }, + isTemplateSaving: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + selectedTemplate: this.initialSelectedTemplate, + outgoingName: this.initialOutgoingName || __('GitLab Support Bot'), + projectKey: this.initialProjectKey, + }; + }, + computed: { + templateOptions() { + return [''].concat(this.templates); + }, + hasProjectKeySupport() { + return Boolean(this.glFeatures.serviceDeskCustomAddress); + }, + }, + methods: { + onCheckboxToggle(isChecked) { + eventHub.$emit('serviceDeskEnabledCheckboxToggled', isChecked); + }, + onSaveTemplate() { + eventHub.$emit('serviceDeskTemplateSave', { + selectedTemplate: this.selectedTemplate, + outgoingName: this.outgoingName, + projectKey: this.projectKey, + }); + }, + }, +}; +</script> + +<template> + <div> + <gl-toggle + id="service-desk-checkbox" + :value="isEnabled" + class="d-inline-block align-middle mr-1" + label="Service desk" + label-position="left" + @change="onCheckboxToggle" + /> + <label class="align-middle" for="service-desk-checkbox"> + {{ __('Activate Service Desk') }} + </label> + <div v-if="isEnabled" class="row mt-3"> + <div class="col-md-9 mb-0"> + <strong id="incoming-email-describer" class="d-block mb-1"> + {{ __('Forward external support email address to') }} + </strong> + <template v-if="incomingEmail"> + <div class="input-group"> + <input + ref="service-desk-incoming-email" + type="text" + class="form-control incoming-email h-auto" + :placeholder="__('Incoming email')" + :aria-label="__('Incoming email')" + aria-describedby="incoming-email-describer" + :value="incomingEmail" + disabled="true" + /> + <div class="input-group-append"> + <clipboard-button + :title="__('Copy')" + :text="incomingEmail" + css-class="btn qa-clipboard-button" + /> + </div> + </div> + </template> + <template v-else> + <gl-loading-icon :inline="true" /> + <span class="sr-only">{{ __('Fetching incoming email') }}</span> + </template> + + <label for="service-desk-template-select" class="mt-3"> + {{ __('Template to append to all Service Desk issues') }} + </label> + <gl-form-select + id="service-desk-template-select" + v-model="selectedTemplate" + :options="templateOptions" + /> + <label for="service-desk-email-from-name" class="mt-3"> + {{ __('Email display name') }} + </label> + <input id="service-desk-email-from-name" v-model.trim="outgoingName" class="form-control" /> + <span class="form-text text-muted"> + {{ __('Emails sent from Service Desk will have this name') }} + </span> + <template v-if="hasProjectKeySupport"> + <label for="service-desk-project-suffix" class="mt-3"> + {{ __('Project name suffix') }} + </label> + <input id="service-desk-project-suffix" v-model.trim="projectKey" class="form-control" /> + <span class="form-text text-muted mb-3"> + {{ + __( + 'Project name suffix is a user-defined string which will be appended to the project path, and will form the Service Desk email address.', + ) + }} + </span> + </template> + <gl-deprecated-button + variant="success" + :disabled="isTemplateSaving" + @click="onSaveTemplate" + >{{ __('Save template') }}</gl-deprecated-button + > + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/projects/settings_service_desk/event_hub.js b/app/assets/javascripts/projects/settings_service_desk/event_hub.js new file mode 100644 index 00000000000..e31806ad199 --- /dev/null +++ b/app/assets/javascripts/projects/settings_service_desk/event_hub.js @@ -0,0 +1,3 @@ +import createEventHub from '~/helpers/event_hub_factory'; + +export default createEventHub(); diff --git a/app/assets/javascripts/projects/settings_service_desk/index.js b/app/assets/javascripts/projects/settings_service_desk/index.js new file mode 100644 index 00000000000..15c077de72e --- /dev/null +++ b/app/assets/javascripts/projects/settings_service_desk/index.js @@ -0,0 +1,41 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import ServiceDeskRoot from './components/service_desk_root.vue'; + +export default () => { + const serviceDeskRootElement = document.querySelector('.js-service-desk-setting-root'); + if (serviceDeskRootElement) { + // eslint-disable-next-line no-new + new Vue({ + el: serviceDeskRootElement, + components: { + ServiceDeskRoot, + }, + data() { + const { dataset } = serviceDeskRootElement; + return { + initialIsEnabled: parseBoolean(dataset.enabled), + endpoint: dataset.endpoint, + incomingEmail: dataset.incomingEmail, + selectedTemplate: dataset.selectedTemplate, + outgoingName: dataset.outgoingName, + projectKey: dataset.projectKey, + templates: JSON.parse(dataset.templates), + }; + }, + render(createElement) { + return createElement('service-desk-root', { + props: { + initialIsEnabled: this.initialIsEnabled, + endpoint: this.endpoint, + initialIncomingEmail: this.incomingEmail, + selectedTemplate: this.selectedTemplate, + outgoingName: this.outgoingName, + projectKey: this.projectKey, + templates: this.templates, + }, + }); + }, + }); + } +}; diff --git a/app/assets/javascripts/projects/settings_service_desk/services/service_desk_service.js b/app/assets/javascripts/projects/settings_service_desk/services/service_desk_service.js new file mode 100644 index 00000000000..d707763c64e --- /dev/null +++ b/app/assets/javascripts/projects/settings_service_desk/services/service_desk_service.js @@ -0,0 +1,27 @@ +import axios from '~/lib/utils/axios_utils'; + +class ServiceDeskService { + constructor(endpoint) { + this.endpoint = endpoint; + } + + fetchIncomingEmail() { + return axios.get(this.endpoint); + } + + toggleServiceDesk(enable) { + return axios.put(this.endpoint, { service_desk_enabled: enable }); + } + + updateTemplate({ selectedTemplate, outgoingName, projectKey = '' }, isEnabled) { + const body = { + issue_template_key: selectedTemplate, + outgoing_name: outgoingName, + project_key: projectKey, + service_desk_enabled: isEnabled, + }; + return axios.put(this.endpoint, body); + } +} + +export default ServiceDeskService; diff --git a/app/assets/javascripts/prometheus_alerts/components/reset_key.vue b/app/assets/javascripts/prometheus_alerts/components/reset_key.vue index 15b6a29e5cf..941a05583ad 100644 --- a/app/assets/javascripts/prometheus_alerts/components/reset_key.vue +++ b/app/assets/javascripts/prometheus_alerts/components/reset_key.vue @@ -41,6 +41,11 @@ export default { type: String, required: true, }, + disabled: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -88,7 +93,11 @@ export default { <div class="input-group"> <gl-form-input id="notify-url" :readonly="true" :value="notifyUrl" /> <span class="input-group-append"> - <clipboard-button :text="notifyUrl" :title="$options.copyToClipboard" /> + <clipboard-button + :text="notifyUrl" + :title="$options.copyToClipboard" + :disabled="disabled" + /> </span> </div> </gl-form-group> @@ -100,7 +109,11 @@ export default { <div class="input-group"> <gl-form-input id="authorization-key" :readonly="true" :value="authorizationKey" /> <span class="input-group-append"> - <clipboard-button :text="authorizationKey" :title="$options.copyToClipboard" /> + <clipboard-button + :text="authorizationKey" + :title="$options.copyToClipboard" + :disabled="disabled" + /> </span> </div> </gl-form-group> @@ -118,13 +131,20 @@ export default { ) }} </gl-modal> - <gl-deprecated-button v-gl-modal.authKeyModal class="js-reset-auth-key">{{ - __('Reset key') - }}</gl-deprecated-button> + <gl-deprecated-button + v-gl-modal.authKeyModal + class="js-reset-auth-key" + :disabled="disabled" + >{{ __('Reset key') }}</gl-deprecated-button + > </template> - <gl-deprecated-button v-else class="js-reset-auth-key" @click="resetKey">{{ - __('Generate key') - }}</gl-deprecated-button> + <gl-deprecated-button + v-else + :disabled="disabled" + class="js-reset-auth-key" + @click="resetKey" + >{{ __('Generate key') }}</gl-deprecated-button + > </div> </div> </template> diff --git a/app/assets/javascripts/prometheus_alerts/index.js b/app/assets/javascripts/prometheus_alerts/index.js index a42f19e5245..7efe6ed186b 100644 --- a/app/assets/javascripts/prometheus_alerts/index.js +++ b/app/assets/javascripts/prometheus_alerts/index.js @@ -8,7 +8,7 @@ export default () => { return; } - const { authorizationKey, changeKeyUrl, notifyUrl, learnMoreUrl } = el.dataset; + const { authorizationKey, changeKeyUrl, notifyUrl, learnMoreUrl, disabled } = el.dataset; // eslint-disable-next-line no-new new Vue({ @@ -20,6 +20,7 @@ export default () => { changeKeyUrl, notifyUrl, learnMoreUrl, + disabled, }, }); }, diff --git a/app/assets/javascripts/ref/components/ref_results_section.vue b/app/assets/javascripts/ref/components/ref_results_section.vue new file mode 100644 index 00000000000..32e916052c4 --- /dev/null +++ b/app/assets/javascripts/ref/components/ref_results_section.vue @@ -0,0 +1,124 @@ +<script> +import { GlNewDropdownHeader, GlNewDropdownItem, GlBadge, GlIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + name: 'RefResultsSection', + components: { + GlNewDropdownHeader, + GlNewDropdownItem, + GlBadge, + GlIcon, + }, + props: { + sectionTitle: { + type: String, + required: true, + }, + + totalCount: { + type: Number, + required: true, + }, + + /** + * An array of object that have the following properties: + * + * - name (String, required): The name of the ref that will be displayed + * - value (String, optional): The value that will be selected when the ref + * is selected. If not provided, `name` will be used as the value. + * For example, commits use the short SHA for `name` + * and long SHA for `value`. + * - subtitle (String, optional): Text to render underneath the name. + * For example, used to render the commit's title underneath its SHA. + * - default (Boolean, optional): Whether or not to render a "default" + * indicator next to the item. Used to indicate + * the project's default branch. + * + */ + items: { + type: Array, + required: true, + validator: items => Array.isArray(items) && items.every(item => item.name), + }, + + /** + * The currently selected ref. + * Used to render a check mark by the selected item. + * */ + selectedRef: { + type: String, + required: false, + default: '', + }, + + /** + * An error object that indicates that an error + * occurred while fetching items for this section + */ + error: { + type: Error, + required: false, + default: null, + }, + + /** The message to display if an error occurs */ + errorMessage: { + type: String, + required: false, + default: '', + }, + }, + computed: { + totalCountText() { + return this.totalCount > 999 ? s__('TotalRefCountIndicator|1000+') : `${this.totalCount}`; + }, + }, + methods: { + showCheck(item) { + return item.name === this.selectedRef || item.value === this.selectedRef; + }, + }, +}; +</script> + +<template> + <div> + <gl-new-dropdown-header> + <div class="gl-display-flex align-items-center" data-testid="section-header"> + <span class="gl-mr-2 gl-mb-1">{{ sectionTitle }}</span> + <gl-badge variant="neutral">{{ totalCountText }}</gl-badge> + </div> + </gl-new-dropdown-header> + <template v-if="error"> + <div class="gl-display-flex align-items-start text-danger gl-ml-4 gl-mr-4 gl-mb-3"> + <gl-icon name="error" class="gl-mr-2 gl-mt-2 gl-flex-shrink-0" /> + <span>{{ errorMessage }}</span> + </div> + </template> + <template v-else> + <gl-new-dropdown-item + v-for="item in items" + :key="item.name" + @click="$emit('selected', item.value || item.name)" + > + <div class="gl-display-flex align-items-start"> + <gl-icon + name="mobile-issue-close" + class="gl-mr-2 gl-flex-shrink-0" + :class="{ 'gl-visibility-hidden': !showCheck(item) }" + /> + + <div class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column"> + <span class="gl-font-monospace">{{ item.name }}</span> + <span class="gl-text-gray-600">{{ item.subtitle }}</span> + </div> + + <gl-badge v-if="item.default" size="sm" variant="info">{{ + s__('DefaultBranchLabel|default') + }}</gl-badge> + </div> + </gl-new-dropdown-item> + </template> + </div> +</template> diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue new file mode 100644 index 00000000000..012a391a3da --- /dev/null +++ b/app/assets/javascripts/ref/components/ref_selector.vue @@ -0,0 +1,186 @@ +<script> +import { mapActions, mapGetters, mapState } from 'vuex'; +import { + GlNewDropdown, + GlNewDropdownDivider, + GlNewDropdownHeader, + GlSearchBoxByType, + GlSprintf, + GlIcon, + GlLoadingIcon, +} from '@gitlab/ui'; +import { debounce } from 'lodash'; +import createStore from '../stores'; +import { SEARCH_DEBOUNCE_MS, DEFAULT_I18N } from '../constants'; +import RefResultsSection from './ref_results_section.vue'; + +export default { + name: 'RefSelector', + store: createStore(), + components: { + GlNewDropdown, + GlNewDropdownDivider, + GlNewDropdownHeader, + GlSearchBoxByType, + GlSprintf, + GlIcon, + GlLoadingIcon, + RefResultsSection, + }, + props: { + value: { + type: String, + required: false, + default: '', + }, + projectId: { + type: String, + required: true, + }, + translations: { + type: Object, + required: false, + default: () => ({}), + }, + }, + data() { + return { + query: '', + }; + }, + computed: { + ...mapState({ + matches: state => state.matches, + lastQuery: state => state.query, + selectedRef: state => state.selectedRef, + }), + ...mapGetters(['isLoading', 'isQueryPossiblyASha']), + i18n() { + return { + ...DEFAULT_I18N, + ...this.translations, + }; + }, + showBranchesSection() { + return Boolean(this.matches.branches.totalCount > 0 || this.matches.branches.error); + }, + showTagsSection() { + return Boolean(this.matches.tags.totalCount > 0 || this.matches.tags.error); + }, + showCommitsSection() { + return Boolean(this.matches.commits.totalCount > 0 || this.matches.commits.error); + }, + showNoResults() { + return !this.showBranchesSection && !this.showTagsSection && !this.showCommitsSection; + }, + }, + created() { + this.setProjectId(this.projectId); + this.search(this.query); + }, + methods: { + ...mapActions(['setProjectId', 'setSelectedRef', 'search']), + focusSearchBox() { + this.$refs.searchBox.$el.querySelector('input').focus(); + }, + onSearchBoxInput: debounce(function search() { + this.search(this.query); + }, SEARCH_DEBOUNCE_MS), + selectRef(ref) { + this.setSelectedRef(ref); + this.$emit('input', this.selectedRef); + }, + }, +}; +</script> + +<template> + <gl-new-dropdown class="ref-selector" @shown="focusSearchBox"> + <template slot="button-content"> + <span class="gl-flex-grow-1 gl-ml-2 gl-text-gray-600" data-testid="button-content"> + <span v-if="selectedRef" class="gl-font-monospace">{{ selectedRef }}</span> + <span v-else>{{ i18n.noRefSelected }}</span> + </span> + <gl-icon name="chevron-down" /> + </template> + + <div class="gl-display-flex gl-flex-direction-column ref-selector-dropdown-content"> + <gl-new-dropdown-header> + <span class="gl-text-center gl-display-block">{{ i18n.dropdownHeader }}</span> + </gl-new-dropdown-header> + + <gl-new-dropdown-divider /> + + <gl-search-box-by-type + ref="searchBox" + v-model.trim="query" + class="gl-m-3" + :placeholder="i18n.searchPlaceholder" + @input="onSearchBoxInput" + /> + + <div class="gl-flex-grow-1 gl-overflow-y-auto"> + <gl-loading-icon v-if="isLoading" size="lg" class="gl-my-3" /> + + <div + v-else-if="showNoResults" + class="gl-text-center gl-mx-3 gl-py-3" + data-testid="no-results" + > + <gl-sprintf v-if="lastQuery" :message="i18n.noResultsWithQuery"> + <template #query> + <b class="gl-word-break-all">{{ lastQuery }}</b> + </template> + </gl-sprintf> + + <span v-else>{{ i18n.noResults }}</span> + </div> + + <template v-else> + <template v-if="showBranchesSection"> + <ref-results-section + :section-title="i18n.branches" + :total-count="matches.branches.totalCount" + :items="matches.branches.list" + :selected-ref="selectedRef" + :error="matches.branches.error" + :error-message="i18n.branchesErrorMessage" + data-testid="branches-section" + @selected="selectRef($event)" + /> + + <gl-new-dropdown-divider v-if="showTagsSection || showCommitsSection" /> + </template> + + <template v-if="showTagsSection"> + <ref-results-section + :section-title="i18n.tags" + :total-count="matches.tags.totalCount" + :items="matches.tags.list" + :selected-ref="selectedRef" + :error="matches.tags.error" + :error-message="i18n.tagsErrorMessage" + data-testid="tags-section" + @selected="selectRef($event)" + /> + + <gl-new-dropdown-divider v-if="showCommitsSection" /> + </template> + + <template v-if="showCommitsSection"> + <ref-results-section + :section-title="i18n.commits" + :total-count="matches.commits.totalCount" + :items="matches.commits.list" + :selected-ref="selectedRef" + :error="matches.commits.error" + :error-message="i18n.commitsErrorMessage" + data-testid="commits-section" + @selected="selectRef($event)" + /> + </template> + </template> + </div> + </div> + </gl-new-dropdown> +</template> diff --git a/app/assets/javascripts/ref/constants.js b/app/assets/javascripts/ref/constants.js new file mode 100644 index 00000000000..ca82b951377 --- /dev/null +++ b/app/assets/javascripts/ref/constants.js @@ -0,0 +1,19 @@ +import { __ } from '~/locale'; + +export const X_TOTAL_HEADER = 'x-total'; + +export const SEARCH_DEBOUNCE_MS = 250; + +export const DEFAULT_I18N = Object.freeze({ + dropdownHeader: __('Select Git revision'), + searchPlaceholder: __('Search by Git revision'), + noResultsWithQuery: __('No matching results for "%{query}"'), + noResults: __('No matching results'), + branchesErrorMessage: __('An error occurred while fetching branches. Retry the search.'), + tagsErrorMessage: __('An error occurred while fetching tags. Retry the search.'), + commitsErrorMessage: __('An error occurred while fetching commits. Retry the search.'), + branches: __('Branches'), + tags: __('Tags'), + commits: __('Commits'), + noRefSelected: __('No ref selected'), +}); diff --git a/app/assets/javascripts/ref/stores/actions.js b/app/assets/javascripts/ref/stores/actions.js new file mode 100644 index 00000000000..8fcc99cef38 --- /dev/null +++ b/app/assets/javascripts/ref/stores/actions.js @@ -0,0 +1,65 @@ +import Api from '~/api'; +import * as types from './mutation_types'; + +export const setProjectId = ({ commit }, projectId) => commit(types.SET_PROJECT_ID, projectId); + +export const setSelectedRef = ({ commit }, selectedRef) => + commit(types.SET_SELECTED_REF, selectedRef); + +export const search = ({ dispatch, commit }, query) => { + commit(types.SET_QUERY, query); + + dispatch('searchBranches'); + dispatch('searchTags'); + dispatch('searchCommits'); +}; + +export const searchBranches = ({ commit, state }) => { + commit(types.REQUEST_START); + + Api.branches(state.projectId, state.query) + .then(response => { + commit(types.RECEIVE_BRANCHES_SUCCESS, response); + }) + .catch(error => { + commit(types.RECEIVE_BRANCHES_ERROR, error); + }) + .finally(() => { + commit(types.REQUEST_FINISH); + }); +}; + +export const searchTags = ({ commit, state }) => { + commit(types.REQUEST_START); + + Api.tags(state.projectId, state.query) + .then(response => { + commit(types.RECEIVE_TAGS_SUCCESS, response); + }) + .catch(error => { + commit(types.RECEIVE_TAGS_ERROR, error); + }) + .finally(() => { + commit(types.REQUEST_FINISH); + }); +}; + +export const searchCommits = ({ commit, state, getters }) => { + // Only query the Commit API if the search query looks like a commit SHA + if (getters.isQueryPossiblyASha) { + commit(types.REQUEST_START); + + Api.commit(state.projectId, state.query) + .then(response => { + commit(types.RECEIVE_COMMITS_SUCCESS, response); + }) + .catch(error => { + commit(types.RECEIVE_COMMITS_ERROR, error); + }) + .finally(() => { + commit(types.REQUEST_FINISH); + }); + } else { + commit(types.RESET_COMMIT_MATCHES); + } +}; diff --git a/app/assets/javascripts/ref/stores/getters.js b/app/assets/javascripts/ref/stores/getters.js new file mode 100644 index 00000000000..02d4ae8ff91 --- /dev/null +++ b/app/assets/javascripts/ref/stores/getters.js @@ -0,0 +1,5 @@ +/** Returns `true` if the query string looks like it could be a commit SHA */ +export const isQueryPossiblyASha = ({ query }) => /^[0-9a-f]{4,40}$/i.test(query); + +/** Returns `true` if there is at least one in-progress request */ +export const isLoading = ({ requestCount }) => requestCount > 0; diff --git a/app/assets/javascripts/ref/stores/index.js b/app/assets/javascripts/ref/stores/index.js new file mode 100644 index 00000000000..2bebffc19ab --- /dev/null +++ b/app/assets/javascripts/ref/stores/index.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; +import createState from './state'; + +Vue.use(Vuex); + +export default () => + new Vuex.Store({ + actions, + getters, + mutations, + state: createState(), + }); diff --git a/app/assets/javascripts/ref/stores/mutation_types.js b/app/assets/javascripts/ref/stores/mutation_types.js new file mode 100644 index 00000000000..9f6195f5f3f --- /dev/null +++ b/app/assets/javascripts/ref/stores/mutation_types.js @@ -0,0 +1,16 @@ +export const SET_PROJECT_ID = 'SET_PROJECT_ID'; +export const SET_SELECTED_REF = 'SET_SELECTED_REF'; +export const SET_QUERY = 'SET_QUERY'; + +export const REQUEST_START = 'REQUEST_START'; +export const REQUEST_FINISH = 'REQUEST_FINISH'; + +export const RECEIVE_BRANCHES_SUCCESS = 'RECEIVE_BRANCHES_SUCCESS'; +export const RECEIVE_BRANCHES_ERROR = 'RECEIVE_BRANCHES_ERROR'; + +export const RECEIVE_TAGS_SUCCESS = 'RECEIVE_TAGS_SUCCESS'; +export const RECEIVE_TAGS_ERROR = 'RECEIVE_TAGS_ERROR'; + +export const RECEIVE_COMMITS_SUCCESS = 'RECEIVE_COMMITS_SUCCESS'; +export const RECEIVE_COMMITS_ERROR = 'RECEIVE_COMMITS_ERROR'; +export const RESET_COMMIT_MATCHES = 'RESET_COMMIT_MATCHES'; diff --git a/app/assets/javascripts/ref/stores/mutations.js b/app/assets/javascripts/ref/stores/mutations.js new file mode 100644 index 00000000000..73f9d7ee487 --- /dev/null +++ b/app/assets/javascripts/ref/stores/mutations.js @@ -0,0 +1,91 @@ +import * as types from './mutation_types'; +import { X_TOTAL_HEADER } from '../constants'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import httpStatusCodes from '~/lib/utils/http_status'; + +export default { + [types.SET_PROJECT_ID](state, projectId) { + state.projectId = projectId; + }, + [types.SET_SELECTED_REF](state, selectedRef) { + state.selectedRef = selectedRef; + }, + [types.SET_QUERY](state, query) { + state.query = query; + }, + + [types.REQUEST_START](state) { + state.requestCount += 1; + }, + [types.REQUEST_FINISH](state) { + state.requestCount -= 1; + }, + + [types.RECEIVE_BRANCHES_SUCCESS](state, response) { + state.matches.branches = { + list: convertObjectPropsToCamelCase(response.data).map(b => ({ + name: b.name, + default: b.default, + })), + totalCount: parseInt(response.headers[X_TOTAL_HEADER], 10), + error: null, + }; + }, + [types.RECEIVE_BRANCHES_ERROR](state, error) { + state.matches.branches = { + list: [], + totalCount: 0, + error, + }; + }, + + [types.RECEIVE_TAGS_SUCCESS](state, response) { + state.matches.tags = { + list: convertObjectPropsToCamelCase(response.data).map(b => ({ + name: b.name, + })), + totalCount: parseInt(response.headers[X_TOTAL_HEADER], 10), + error: null, + }; + }, + [types.RECEIVE_TAGS_ERROR](state, error) { + state.matches.tags = { + list: [], + totalCount: 0, + error, + }; + }, + + [types.RECEIVE_COMMITS_SUCCESS](state, response) { + const commit = convertObjectPropsToCamelCase(response.data); + + state.matches.commits = { + list: [ + { + name: commit.shortId, + value: commit.id, + subtitle: commit.title, + }, + ], + totalCount: 1, + error: null, + }; + }, + [types.RECEIVE_COMMITS_ERROR](state, error) { + state.matches.commits = { + list: [], + totalCount: 0, + + // 404's are expected when the search query doesn't match any commits + // and shouldn't be treated as an actual error + error: error.response?.status !== httpStatusCodes.NOT_FOUND ? error : null, + }; + }, + [types.RESET_COMMIT_MATCHES](state) { + state.matches.commits = { + list: [], + totalCount: 0, + error: null, + }; + }, +}; diff --git a/app/assets/javascripts/ref/stores/state.js b/app/assets/javascripts/ref/stores/state.js new file mode 100644 index 00000000000..65b9d6449d7 --- /dev/null +++ b/app/assets/javascripts/ref/stores/state.js @@ -0,0 +1,24 @@ +export default () => ({ + projectId: null, + + query: '', + matches: { + branches: { + list: [], + totalCount: 0, + error: null, + }, + tags: { + list: [], + totalCount: 0, + error: null, + }, + commits: { + list: [], + totalCount: 0, + error: null, + }, + }, + selectedRef: null, + requestCount: 0, +}); diff --git a/app/assets/javascripts/registry/explorer/components/delete_button.vue b/app/assets/javascripts/registry/explorer/components/delete_button.vue new file mode 100644 index 00000000000..dab6a26ea16 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/delete_button.vue @@ -0,0 +1,56 @@ +<script> +import { GlTooltipDirective, GlButton } from '@gitlab/ui'; + +export default { + name: 'DeleteButton', + components: { + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + title: { + type: String, + required: true, + }, + tooltipTitle: { + type: String, + required: true, + }, + disabled: { + type: Boolean, + default: false, + required: false, + }, + tooltipDisabled: { + type: Boolean, + default: false, + required: false, + }, + }, + computed: { + tooltipConfiguration() { + return { + disabled: this.tooltipDisabled, + title: this.tooltipTitle, + }; + }, + }, +}; +</script> + +<template> + <div v-gl-tooltip="tooltipConfiguration"> + <gl-button + v-gl-tooltip + :disabled="disabled" + :title="title" + :aria-label="title" + category="secondary" + variant="danger" + icon="remove" + @click="$emit('delete')" + /> + </div> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/details_row.vue b/app/assets/javascripts/registry/explorer/components/details_page/details_row.vue new file mode 100644 index 00000000000..c4358b83e23 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/details_page/details_row.vue @@ -0,0 +1,26 @@ +<script> +import { GlIcon } from '@gitlab/ui'; + +export default { + components: { + GlIcon, + }, + props: { + icon: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div + class="gl-display-flex gl-align-items-center gl-font-monospace gl-font-sm gl-py-2 gl-word-break-all" + > + <gl-icon :name="icon" class="gl-mr-4" /> + <span> + <slot></slot> + </span> + </div> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue new file mode 100644 index 00000000000..8494967ab57 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue @@ -0,0 +1,77 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import TagsListRow from './tags_list_row.vue'; +import { REMOVE_TAGS_BUTTON_TITLE, TAGS_LIST_TITLE } from '../../constants/index'; + +export default { + components: { + GlButton, + TagsListRow, + }, + props: { + tags: { + type: Array, + required: false, + default: () => [], + }, + isDesktop: { + type: Boolean, + default: false, + required: false, + }, + }, + i18n: { + REMOVE_TAGS_BUTTON_TITLE, + TAGS_LIST_TITLE, + }, + data() { + return { + selectedItems: {}, + }; + }, + computed: { + hasSelectedItems() { + return this.tags.some(tag => this.selectedItems[tag.name]); + }, + showMultiDeleteButton() { + return this.tags.some(tag => tag.destroy_path) && this.isDesktop; + }, + }, + methods: { + updateSelectedItems(name) { + this.$set(this.selectedItems, name, !this.selectedItems[name]); + }, + }, +}; +</script> + +<template> + <div> + <div class="gl-display-flex gl-justify-content-space-between gl-mb-3"> + <h5 data-testid="list-title"> + {{ $options.i18n.TAGS_LIST_TITLE }} + </h5> + + <gl-button + v-if="showMultiDeleteButton" + :disabled="!hasSelectedItems" + category="secondary" + variant="danger" + @click="$emit('delete', selectedItems)" + > + {{ $options.i18n.REMOVE_TAGS_BUTTON_TITLE }} + </gl-button> + </div> + <tags-list-row + v-for="(tag, index) in tags" + :key="tag.path" + :tag="tag" + :first="index === 0" + :last="index === tags.length - 1" + :selected="selectedItems[tag.name]" + :is-desktop="isDesktop" + @select="updateSelectedItems(tag.name)" + @delete="$emit('delete', { [tag.name]: true })" + /> + </div> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue new file mode 100644 index 00000000000..51ba2337db6 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue @@ -0,0 +1,220 @@ +<script> +import { GlFormCheckbox, GlTooltipDirective, GlSprintf, GlIcon } from '@gitlab/ui'; +import { n__ } from '~/locale'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { formatDate } from '~/lib/utils/datetime_utility'; +import DeleteButton from '../delete_button.vue'; +import ListItem from '../list_item.vue'; +import DetailsRow from './details_row.vue'; +import { + REMOVE_TAG_BUTTON_TITLE, + DIGEST_LABEL, + CREATED_AT_LABEL, + REMOVE_TAG_BUTTON_DISABLE_TOOLTIP, + PUBLISHED_DETAILS_ROW_TEXT, + MANIFEST_DETAILS_ROW_TEST, + CONFIGURATION_DETAILS_ROW_TEST, + MISSING_MANIFEST_WARNING_TOOLTIP, + NOT_AVAILABLE_TEXT, + NOT_AVAILABLE_SIZE, +} from '../../constants/index'; + +export default { + components: { + GlSprintf, + GlFormCheckbox, + GlIcon, + DeleteButton, + ListItem, + ClipboardButton, + TimeAgoTooltip, + DetailsRow, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + tag: { + type: Object, + required: true, + }, + isDesktop: { + type: Boolean, + default: false, + required: false, + }, + selected: { + type: Boolean, + default: false, + required: false, + }, + }, + i18n: { + REMOVE_TAG_BUTTON_TITLE, + DIGEST_LABEL, + CREATED_AT_LABEL, + REMOVE_TAG_BUTTON_DISABLE_TOOLTIP, + PUBLISHED_DETAILS_ROW_TEXT, + MANIFEST_DETAILS_ROW_TEST, + CONFIGURATION_DETAILS_ROW_TEST, + MISSING_MANIFEST_WARNING_TOOLTIP, + }, + computed: { + formattedSize() { + return this.tag.total_size ? numberToHumanSize(this.tag.total_size) : NOT_AVAILABLE_SIZE; + }, + layers() { + return this.tag.layers ? n__('%d layer', '%d layers', this.tag.layers) : ''; + }, + mobileClasses() { + return this.isDesktop ? '' : 'mw-s'; + }, + shortDigest() { + // remove sha256: from the string, and show only the first 7 char + return this.tag.digest?.substring(7, 14) ?? NOT_AVAILABLE_TEXT; + }, + publishedDate() { + return formatDate(this.tag.created_at, 'isoDate'); + }, + publishedTime() { + return formatDate(this.tag.created_at, 'hh:MM Z'); + }, + formattedRevision() { + // to be removed when API response is adjusted + // see https://gitlab.com/gitlab-org/gitlab/-/issues/225324 + // eslint-disable-next-line @gitlab/require-i18n-strings + return `sha256:${this.tag.revision}`; + }, + tagLocation() { + return this.tag.path?.replace(`:${this.tag.name}`, ''); + }, + invalidTag() { + return !this.tag.digest; + }, + }, +}; +</script> + +<template> + <list-item v-bind="$attrs" :selected="selected"> + <template #left-action> + <gl-form-checkbox + v-if="Boolean(tag.destroy_path)" + :disabled="invalidTag" + class="gl-m-0" + :checked="selected" + @change="$emit('select')" + /> + </template> + <template #left-primary> + <div class="gl-display-flex gl-align-items-center"> + <div + v-gl-tooltip="{ title: tag.name }" + data-testid="name" + class="gl-text-overflow-ellipsis gl-overflow-hidden gl-white-space-nowrap" + :class="mobileClasses" + > + {{ tag.name }} + </div> + + <clipboard-button + v-if="tag.location" + :title="tag.location" + :text="tag.location" + css-class="btn-default btn-transparent btn-clipboard" + /> + + <gl-icon + v-if="invalidTag" + v-gl-tooltip="{ title: $options.i18n.MISSING_MANIFEST_WARNING_TOOLTIP }" + name="warning" + class="gl-text-orange-500 gl-mb-2 gl-ml-2" + /> + </div> + </template> + + <template #left-secondary> + <span data-testid="size"> + {{ formattedSize }} + <template v-if="formattedSize && layers" + >·</template + > + {{ layers }} + </span> + </template> + <template #right-primary> + <span data-testid="time"> + <gl-sprintf :message="$options.i18n.CREATED_AT_LABEL"> + <template #timeInfo> + <time-ago-tooltip :time="tag.created_at" /> + </template> + </gl-sprintf> + </span> + </template> + <template #right-secondary> + <span data-testid="digest"> + <gl-sprintf :message="$options.i18n.DIGEST_LABEL"> + <template #imageId>{{ shortDigest }}</template> + </gl-sprintf> + </span> + </template> + <template #right-action> + <delete-button + :disabled="!tag.destroy_path || invalidTag" + :title="$options.i18n.REMOVE_TAG_BUTTON_TITLE" + :tooltip-title="$options.i18n.REMOVE_TAG_BUTTON_DISABLE_TOOLTIP" + :tooltip-disabled="Boolean(tag.destroy_path)" + data-testid="single-delete-button" + @delete="$emit('delete')" + /> + </template> + + <template v-if="!invalidTag" #details_published> + <details-row icon="clock" data-testid="published-date-detail"> + <gl-sprintf :message="$options.i18n.PUBLISHED_DETAILS_ROW_TEXT"> + <template #repositoryPath> + <i>{{ tagLocation }}</i> + </template> + <template #time> + {{ publishedTime }} + </template> + <template #date> + {{ publishedDate }} + </template> + </gl-sprintf> + </details-row> + </template> + <template v-if="!invalidTag" #details_manifest_digest> + <details-row icon="log" data-testid="manifest-detail"> + <gl-sprintf :message="$options.i18n.MANIFEST_DETAILS_ROW_TEST"> + <template #digest> + {{ tag.digest }} + </template> + </gl-sprintf> + <clipboard-button + v-if="tag.digest" + :title="tag.digest" + :text="tag.digest" + css-class="btn-default btn-transparent btn-clipboard gl-p-0" + /> + </details-row> + </template> + <template v-if="!invalidTag" #details_configuration_digest> + <details-row icon="cloud-gear" data-testid="configuration-detail"> + <gl-sprintf :message="$options.i18n.CONFIGURATION_DETAILS_ROW_TEST"> + <template #digest> + {{ formattedRevision }} + </template> + </gl-sprintf> + <clipboard-button + v-if="formattedRevision" + :title="formattedRevision" + :text="formattedRevision" + css-class="btn-default btn-transparent btn-clipboard gl-p-0" + /> + </details-row> + </template> + </list-item> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_table.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_table.vue deleted file mode 100644 index 81be778e1e5..00000000000 --- a/app/assets/javascripts/registry/explorer/components/details_page/tags_table.vue +++ /dev/null @@ -1,210 +0,0 @@ -<script> -import { GlTable, GlFormCheckbox, GlButton, GlTooltipDirective } from '@gitlab/ui'; -import { n__ } from '~/locale'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import { numberToHumanSize } from '~/lib/utils/number_utils'; -import timeagoMixin from '~/vue_shared/mixins/timeago'; -import { - LIST_KEY_TAG, - LIST_KEY_IMAGE_ID, - LIST_KEY_SIZE, - LIST_KEY_LAST_UPDATED, - LIST_KEY_ACTIONS, - LIST_KEY_CHECKBOX, - LIST_LABEL_TAG, - LIST_LABEL_IMAGE_ID, - LIST_LABEL_SIZE, - LIST_LABEL_LAST_UPDATED, - REMOVE_TAGS_BUTTON_TITLE, - REMOVE_TAG_BUTTON_TITLE, -} from '../../constants/index'; - -export default { - components: { - GlTable, - GlFormCheckbox, - GlButton, - ClipboardButton, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - mixins: [timeagoMixin], - props: { - tags: { - type: Array, - required: false, - default: () => [], - }, - isLoading: { - type: Boolean, - required: false, - default: false, - }, - isDesktop: { - type: Boolean, - required: false, - default: false, - }, - }, - i18n: { - REMOVE_TAGS_BUTTON_TITLE, - REMOVE_TAG_BUTTON_TITLE, - }, - data() { - return { - selectedItems: [], - }; - }, - computed: { - fields() { - const tagClass = this.isDesktop ? 'w-25' : ''; - const tagInnerClass = this.isDesktop ? 'mw-m' : 'gl-justify-content-end'; - return [ - { key: LIST_KEY_CHECKBOX, label: '', class: 'gl-w-16' }, - { - key: LIST_KEY_TAG, - label: LIST_LABEL_TAG, - class: `${tagClass} js-tag-column`, - innerClass: tagInnerClass, - }, - { key: LIST_KEY_IMAGE_ID, label: LIST_LABEL_IMAGE_ID }, - { key: LIST_KEY_SIZE, label: LIST_LABEL_SIZE }, - { key: LIST_KEY_LAST_UPDATED, label: LIST_LABEL_LAST_UPDATED }, - { key: LIST_KEY_ACTIONS, label: '' }, - ].filter(f => f.key !== LIST_KEY_CHECKBOX || this.isDesktop); - }, - tagsNames() { - return this.tags.map(t => t.name); - }, - selectAllChecked() { - return this.selectedItems.length === this.tags.length && this.tags.length > 0; - }, - }, - watch: { - tagsNames: { - immediate: false, - handler(tagsNames) { - this.selectedItems = this.selectedItems.filter(t => tagsNames.includes(t)); - }, - }, - }, - methods: { - formatSize(size) { - return numberToHumanSize(size); - }, - layers(layers) { - return layers ? n__('%d layer', '%d layers', layers) : ''; - }, - onSelectAllChange() { - if (this.selectAllChecked) { - this.selectedItems = []; - } else { - this.selectedItems = this.tags.map(x => x.name); - } - }, - updateSelectedItems(name) { - const delIndex = this.selectedItems.findIndex(x => x === name); - - if (delIndex > -1) { - this.selectedItems.splice(delIndex, 1); - } else { - this.selectedItems.push(name); - } - }, - }, -}; -</script> - -<template> - <gl-table :items="tags" :fields="fields" :stacked="!isDesktop" show-empty :busy="isLoading"> - <template v-if="isDesktop" #head(checkbox)> - <gl-form-checkbox - data-testid="mainCheckbox" - :checked="selectAllChecked" - @change="onSelectAllChange" - /> - </template> - <template #head(actions)> - <span class="gl-display-flex gl-justify-content-end"> - <gl-button - v-gl-tooltip - data-testid="bulkDeleteButton" - :disabled="!selectedItems || selectedItems.length === 0" - icon="remove" - variant="danger" - :title="$options.i18n.REMOVE_TAGS_BUTTON_TITLE" - :aria-label="$options.i18n.REMOVE_TAGS_BUTTON_TITLE" - @click="$emit('delete', selectedItems)" - /> - </span> - </template> - - <template #cell(checkbox)="{item}"> - <gl-form-checkbox - data-testid="rowCheckbox" - :checked="selectedItems.includes(item.name)" - @change="updateSelectedItems(item.name)" - /> - </template> - <template #cell(name)="{item, field}"> - <div data-testid="rowName" :class="[field.innerClass, 'gl-display-flex']"> - <span - v-gl-tooltip - data-testid="rowNameText" - :title="item.name" - class="gl-text-overflow-ellipsis gl-overflow-hidden gl-white-space-nowrap" - > - {{ item.name }} - </span> - <clipboard-button - v-if="item.location" - data-testid="rowClipboardButton" - :title="item.location" - :text="item.location" - css-class="btn-default btn-transparent btn-clipboard" - /> - </div> - </template> - <template #cell(short_revision)="{value}"> - <span data-testid="rowShortRevision"> - {{ value }} - </span> - </template> - <template #cell(total_size)="{item}"> - <span data-testid="rowSize"> - {{ formatSize(item.total_size) }} - <template v-if="item.total_size && item.layers"> - · - </template> - {{ layers(item.layers) }} - </span> - </template> - <template #cell(created_at)="{value}"> - <span v-gl-tooltip data-testid="rowTime" :title="tooltipTitle(value)"> - {{ timeFormatted(value) }} - </span> - </template> - <template #cell(actions)="{item}"> - <span class="gl-display-flex gl-justify-content-end"> - <gl-button - data-testid="singleDeleteButton" - :title="$options.i18n.REMOVE_TAG_BUTTON_TITLE" - :aria-label="$options.i18n.REMOVE_TAG_BUTTON_TITLE" - :disabled="!item.destroy_path" - variant="danger" - icon="remove" - category="secondary" - @click="$emit('delete', [item.name])" - /> - </span> - </template> - - <template #empty> - <slot name="empty"></slot> - </template> - <template #table-busy> - <slot name="loader"></slot> - </template> - </gl-table> -</template> diff --git a/app/assets/javascripts/registry/explorer/components/list_item.vue b/app/assets/javascripts/registry/explorer/components/list_item.vue new file mode 100644 index 00000000000..7b5afe8fd9d --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/list_item.vue @@ -0,0 +1,128 @@ +<script> +import { GlButton } from '@gitlab/ui'; + +export default { + name: 'ListItem', + components: { GlButton }, + props: { + first: { + type: Boolean, + default: false, + required: false, + }, + last: { + type: Boolean, + default: false, + required: false, + }, + disabled: { + type: Boolean, + default: false, + required: false, + }, + selected: { + type: Boolean, + default: false, + required: false, + }, + }, + data() { + return { + isDetailsShown: false, + detailsSlots: [], + }; + }, + computed: { + optionalClasses() { + return { + 'gl-border-t-1': !this.first, + 'gl-border-t-2': this.first, + 'gl-border-b-1': !this.last, + 'gl-border-b-2': this.last, + 'disabled-content': this.disabled, + 'gl-border-gray-100': !this.selected, + 'gl-bg-blue-50 gl-border-blue-200': this.selected, + }; + }, + }, + mounted() { + this.detailsSlots = Object.keys(this.$slots).filter(k => k.startsWith('details_')); + }, + methods: { + toggleDetails() { + this.isDetailsShown = !this.isDetailsShown; + }, + }, +}; +</script> + +<template> + <div + class="gl-display-flex gl-flex-direction-column gl-border-b-solid gl-border-t-solid" + :class="optionalClasses" + > + <div class="gl-display-flex gl-align-items-center gl-py-4 gl-px-2"> + <div + v-if="$slots['left-action']" + class="gl-w-7 gl-display-none gl-display-sm-flex gl-justify-content-start gl-pl-2" + > + <slot name="left-action"></slot> + </div> + <div class="gl-display-flex gl-flex-direction-column gl-flex-fill-1"> + <div + class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-text-black-normal gl-font-weight-bold" + > + <div class="gl-display-flex gl-align-items-center"> + <slot name="left-primary"></slot> + <gl-button + v-if="detailsSlots.length > 0" + :selected="isDetailsShown" + icon="ellipsis_h" + size="small" + class="gl-ml-2 gl-display-none gl-display-sm-block" + @click="toggleDetails" + /> + </div> + <div> + <slot name="right-primary"></slot> + </div> + </div> + <div + class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-font-sm gl-text-gray-500" + > + <div> + <slot name="left-secondary"></slot> + </div> + <div> + <slot name="right-secondary"></slot> + </div> + </div> + </div> + <div + v-if="$slots['right-action']" + class="gl-w-9 gl-display-none gl-display-sm-flex gl-justify-content-end gl-pr-2" + > + <slot name="right-action"></slot> + </div> + </div> + <div class="gl-display-flex"> + <div class="gl-w-7"></div> + <div + v-if="isDetailsShown" + class="gl-display-flex gl-flex-direction-column gl-flex-fill-1 gl-bg-gray-10 gl-rounded-base gl-inset-border-1-gray-100 gl-mb-3" + > + <div + v-for="(row, detailIndex) in detailsSlots" + :key="detailIndex" + class="gl-px-5 gl-py-2" + :class="{ + 'gl-border-gray-100 gl-border-t-solid gl-border-t-1': detailIndex !== 0, + }" + > + <slot :name="row"></slot> + </div> + </div> + <div class="gl-w-9"></div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue b/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue index a29a9bd23c3..80cc392f86a 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue @@ -18,10 +18,9 @@ export default { <gl-empty-state :title="s__('ContainerRegistry|There are no container images available in this group')" :svg-path="config.noContainersImage" - class="container-message" > <template #description> - <p class="js-no-container-images-text"> + <p> <gl-sprintf :message=" s__( diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue b/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue index 9d48769cbad..65cf51fd1d1 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue @@ -37,7 +37,8 @@ export default { v-for="(listItem, index) in images" :key="index" :item="listItem" - :show-top-border="index === 0" + :first="index === 0" + :last="index === images.length - 1" @delete="$emit('delete', $event)" /> diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue index cd878c38081..2874d89d913 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue @@ -1,7 +1,9 @@ <script> -import { GlTooltipDirective, GlButton, GlIcon, GlSprintf } from '@gitlab/ui'; +import { GlTooltipDirective, GlIcon, GlSprintf } from '@gitlab/ui'; import { n__ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import ListItem from '../list_item.vue'; +import DeleteButton from '../delete_button.vue'; import { ASYNC_DELETE_IMAGE_ERROR_MESSAGE, @@ -14,9 +16,10 @@ export default { name: 'ImageListrow', components: { ClipboardButton, - GlButton, + DeleteButton, GlSprintf, GlIcon, + ListItem, }, directives: { GlTooltip: GlTooltipDirective, @@ -26,11 +29,6 @@ export default { type: Object, required: true, }, - showTopBorder: { - type: Boolean, - default: false, - required: false, - }, }, i18n: { LIST_DELETE_BUTTON_DISABLED, @@ -62,75 +60,55 @@ export default { </script> <template> - <div + <list-item v-gl-tooltip="{ placement: 'left', disabled: !item.deleting, title: $options.i18n.ROW_SCHEDULED_FOR_DELETION, }" + v-bind="$attrs" + :disabled="item.deleting" > - <div - class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-py-2 gl-px-1 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-py-4 " - :class="{ - 'gl-border-t-solid gl-border-t-1': showTopBorder, - 'disabled-content': item.deleting, - }" - > - <div class="gl-display-flex gl-flex-direction-column"> - <div class="gl-display-flex gl-align-items-center"> - <router-link - class="gl-text-black-normal gl-font-weight-bold" - data-testid="detailsLink" - :to="{ name: 'details', params: { id: encodedItem } }" - > - {{ item.path }} - </router-link> - <clipboard-button - v-if="item.location" - :disabled="item.deleting" - :text="item.location" - :title="item.location" - css-class="btn-default btn-transparent btn-clipboard gl-text-gray-500" - /> - <gl-icon - v-if="item.failedDelete" - v-gl-tooltip - :title="$options.i18n.ASYNC_DELETE_IMAGE_ERROR_MESSAGE" - name="warning" - class="text-warning" - /> - </div> - <div class="gl-font-sm gl-text-gray-500"> - <span class="gl-display-flex gl-align-items-center" data-testid="tagsCount"> - <gl-icon name="tag" class="gl-mr-2" /> - <gl-sprintf :message="tagsCountText"> - <template #count> - {{ item.tags_count }} - </template> - </gl-sprintf> - </span> - </div> - </div> - <div - v-gl-tooltip="{ - disabled: item.destroy_path, - title: $options.i18n.LIST_DELETE_BUTTON_DISABLED, - }" - class="d-none d-sm-block" - data-testid="deleteButtonWrapper" + <template #left-primary> + <router-link + class="gl-text-black-normal gl-font-weight-bold" + data-testid="detailsLink" + :to="{ name: 'details', params: { id: encodedItem } }" > - <gl-button - v-gl-tooltip - data-testid="deleteImageButton" - :disabled="disabledDelete" - :title="$options.i18n.REMOVE_REPOSITORY_LABEL" - :aria-label="$options.i18n.REMOVE_REPOSITORY_LABEL" - category="secondary" - variant="danger" - icon="remove" - @click="$emit('delete', item)" - /> - </div> - </div> - </div> + {{ item.path }} + </router-link> + <clipboard-button + v-if="item.location" + :disabled="item.deleting" + :text="item.location" + :title="item.location" + css-class="btn-default btn-transparent btn-clipboard gl-text-gray-500" + /> + <gl-icon + v-if="item.failedDelete" + v-gl-tooltip="{ title: $options.i18n.ASYNC_DELETE_IMAGE_ERROR_MESSAGE }" + name="warning" + class="gl-text-orange-500" + /> + </template> + <template #left-secondary> + <span class="gl-display-flex gl-align-items-center" data-testid="tagsCount"> + <gl-icon name="tag" class="gl-mr-2" /> + <gl-sprintf :message="tagsCountText"> + <template #count> + {{ item.tags_count }} + </template> + </gl-sprintf> + </span> + </template> + <template #right-action> + <delete-button + :title="$options.i18n.REMOVE_REPOSITORY_LABEL" + :disabled="disabledDelete" + :tooltip-disabled="Boolean(item.destroy_path)" + :tooltip-title="$options.i18n.LIST_DELETE_BUTTON_DISABLED" + @delete="$emit('delete', item)" + /> + </template> + </list-item> </template> diff --git a/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue b/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue index c27d53f4351..35eb0b11e40 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue @@ -1,5 +1,5 @@ <script> -import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui'; +import { GlEmptyState, GlSprintf, GlLink, GlFormInputGroup, GlFormInput } from '@gitlab/ui'; import { mapState, mapGetters } from 'vuex'; import { s__ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; @@ -17,6 +17,8 @@ export default { GlEmptyState, GlSprintf, GlLink, + GlFormInputGroup, + GlFormInput, }, i18n: { quickStart: QUICK_START, @@ -43,10 +45,9 @@ export default { <gl-empty-state :title="s__('ContainerRegistry|There are no container images stored for this project')" :svg-path="config.noContainersImage" - class="container-message" > <template #description> - <p class="js-no-container-images-text"> + <p> <gl-sprintf :message="$options.i18n.introText"> <template #docLink="{content}"> <gl-link :href="config.helpPagePath" target="_blank">{{ content }}</gl-link> @@ -54,7 +55,7 @@ export default { </gl-sprintf> </p> <h5>{{ $options.i18n.quickStart }}</h5> - <p class="js-not-logged-in-to-registry-text"> + <p> <gl-sprintf :message="$options.i18n.notLoggedInMessage"> <template #twofaDocLink="{content}"> <gl-link :href="config.twoFactorAuthHelpLink" target="_blank">{{ content }}</gl-link> @@ -66,42 +67,49 @@ export default { </template> </gl-sprintf> </p> - <div class="input-group append-bottom-10"> - <input :value="dockerLoginCommand" type="text" class="form-control monospace" readonly /> - <span class="input-group-append"> + <gl-form-input-group class="gl-mb-4"> + <gl-form-input + :value="dockerLoginCommand" + readonly + type="text" + class="gl-font-monospace!" + /> + <template #append> <clipboard-button :text="dockerLoginCommand" :title="$options.i18n.copyLoginTitle" - class="input-group-text" + class="gl-m-0!" /> - </span> - </div> - <p></p> - <p> + </template> + </gl-form-input-group> + <p class="gl-mb-4"> {{ $options.i18n.addImageText }} </p> - - <div class="input-group append-bottom-10"> - <input :value="dockerBuildCommand" type="text" class="form-control monospace" readonly /> - <span class="input-group-append"> + <gl-form-input-group class="gl-mb-4 "> + <gl-form-input + :value="dockerBuildCommand" + readonly + type="text" + class="gl-font-monospace!" + /> + <template #append> <clipboard-button :text="dockerBuildCommand" :title="$options.i18n.copyBuildTitle" - class="input-group-text" + class="gl-m-0!" /> - </span> - </div> - - <div class="input-group"> - <input :value="dockerPushCommand" type="text" class="form-control monospace" readonly /> - <span class="input-group-append"> + </template> + </gl-form-input-group> + <gl-form-input-group> + <gl-form-input :value="dockerPushCommand" readonly type="text" class="gl-font-monospace!" /> + <template #append> <clipboard-button :text="dockerPushCommand" :title="$options.i18n.copyPushTitle" - class="input-group-text" + class="gl-m-0!" /> - </span> - </div> + </template> + </gl-form-input-group> </template> </gl-empty-state> </template> diff --git a/app/assets/javascripts/registry/explorer/constants/details.js b/app/assets/javascripts/registry/explorer/constants/details.js index a1fa995c17f..1dc5882d415 100644 --- a/app/assets/javascripts/registry/explorer/constants/details.js +++ b/app/assets/javascripts/registry/explorer/constants/details.js @@ -1,4 +1,4 @@ -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; // Translations strings export const DETAILS_PAGE_TITLE = s__('ContainerRegistry|%{imageName} tags'); @@ -14,12 +14,20 @@ export const DELETE_TAGS_ERROR_MESSAGE = s__( export const DELETE_TAGS_SUCCESS_MESSAGE = s__( 'ContainerRegistry|Tags successfully marked for deletion.', ); -export const LIST_LABEL_TAG = s__('ContainerRegistry|Tag'); -export const LIST_LABEL_IMAGE_ID = s__('ContainerRegistry|Image ID'); -export const LIST_LABEL_SIZE = s__('ContainerRegistry|Compressed Size'); -export const LIST_LABEL_LAST_UPDATED = s__('ContainerRegistry|Last Updated'); + +export const TAGS_LIST_TITLE = s__('ContainerRegistry|Image tags'); +export const DIGEST_LABEL = s__('ContainerRegistry|Digest: %{imageId}'); +export const CREATED_AT_LABEL = s__('ContainerRegistry|Published %{timeInfo}'); +export const PUBLISHED_DETAILS_ROW_TEXT = s__( + 'ContainerRegistry|Published to the %{repositoryPath} image repository at %{time} on %{date}', +); +export const MANIFEST_DETAILS_ROW_TEST = s__('ContainerRegistry|Manifest digest: %{digest}'); +export const CONFIGURATION_DETAILS_ROW_TEST = s__( + 'ContainerRegistry|Configuration digest: %{digest}', +); + export const REMOVE_TAG_BUTTON_TITLE = s__('ContainerRegistry|Remove tag'); -export const REMOVE_TAGS_BUTTON_TITLE = s__('ContainerRegistry|Remove selected tags'); +export const REMOVE_TAGS_BUTTON_TITLE = s__('ContainerRegistry|Delete selected'); export const REMOVE_TAG_CONFIRMATION_TEXT = s__( `ContainerRegistry|You are about to remove %{item}. Are you sure?`, ); @@ -36,17 +44,21 @@ export const ADMIN_GARBAGE_COLLECTION_TIP = s__( 'ContainerRegistry|Remember to run %{docLinkStart}garbage collection%{docLinkEnd} to remove the stale data from storage.', ); +export const REMOVE_TAG_BUTTON_DISABLE_TOOLTIP = s__( + 'ContainerRegistry|Deletion disabled due to missing or insufficient permissions.', +); + +export const MISSING_MANIFEST_WARNING_TOOLTIP = s__( + 'ContainerRegistry|Invalid tag: missing manifest digest', +); + +export const NOT_AVAILABLE_TEXT = __('N/A'); +export const NOT_AVAILABLE_SIZE = __('0 bytes'); // Parameters export const DEFAULT_PAGE = 1; export const DEFAULT_PAGE_SIZE = 10; export const GROUP_PAGE_TYPE = 'groups'; -export const LIST_KEY_TAG = 'name'; -export const LIST_KEY_IMAGE_ID = 'short_revision'; -export const LIST_KEY_SIZE = 'total_size'; -export const LIST_KEY_LAST_UPDATED = 'created_at'; -export const LIST_KEY_ACTIONS = 'actions'; -export const LIST_KEY_CHECKBOX = 'checkbox'; export const ALERT_SUCCESS_TAG = 'success_tag'; export const ALERT_DANGER_TAG = 'danger_tag'; export const ALERT_SUCCESS_TAGS = 'success_tags'; diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue index 598e643ce1a..cf811156704 100644 --- a/app/assets/javascripts/registry/explorer/pages/details.vue +++ b/app/assets/javascripts/registry/explorer/pages/details.vue @@ -6,7 +6,7 @@ import Tracking from '~/tracking'; import DeleteAlert from '../components/details_page/delete_alert.vue'; import DeleteModal from '../components/details_page/delete_modal.vue'; import DetailsHeader from '../components/details_page/details_header.vue'; -import TagsTable from '../components/details_page/tags_table.vue'; +import TagsList from '../components/details_page/tags_list.vue'; import TagsLoader from '../components/details_page/tags_loader.vue'; import EmptyTagsState from '../components/details_page/empty_tags_state.vue'; @@ -24,7 +24,7 @@ export default { DetailsHeader, GlPagination, DeleteModal, - TagsTable, + TagsList, TagsLoader, EmptyTagsState, }, @@ -65,10 +65,8 @@ export default { }, methods: { ...mapActions(['requestTagsList', 'requestDeleteTag', 'requestDeleteTags']), - deleteTags(toBeDeletedList) { - this.itemsToBeDeleted = toBeDeletedList.map(name => ({ - ...this.tags.find(t => t.name === name), - })); + deleteTags(toBeDeleted) { + this.itemsToBeDeleted = this.tags.filter(tag => toBeDeleted[tag.name]); this.track('click_button'); this.$refs.deleteModal.show(); }, @@ -114,24 +112,21 @@ export default { </script> <template> - <div v-gl-resize-observer="handleResize" class="my-3 w-100 slide-enter-to-element"> + <div v-gl-resize-observer="handleResize" class="gl-my-3 gl-w-full slide-enter-to-element"> <delete-alert v-model="deleteAlertType" :garbage-collection-help-page-path="config.garbageCollectionHelpPagePath" :is-admin="config.isAdmin" - class="my-2" + class="gl-my-2" /> <details-header :image-name="imageName" /> - <tags-table :tags="tags" :is-loading="isLoading" :is-desktop="isDesktop" @delete="deleteTags"> - <template #empty> - <empty-tags-state :no-containers-image="config.noContainersImage" /> - </template> - <template #loader> - <tags-loader v-once /> - </template> - </tags-table> + <tags-loader v-if="isLoading" /> + <template v-else> + <empty-tags-state v-if="tags.length === 0" :no-containers-image="config.noContainersImage" /> + <tags-list v-else :tags="tags" :is-desktop="isDesktop" @delete="deleteTags" /> + </template> <gl-pagination v-if="!isLoading" @@ -140,7 +135,7 @@ export default { :per-page="tagsPagination.perPage" :total-items="tagsPagination.total" align="center" - class="w-100" + class="gl-w-full gl-mt-3" /> <delete-modal diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue index e8a26dc58f2..1d353651c38 100644 --- a/app/assets/javascripts/registry/explorer/pages/list.vue +++ b/app/assets/javascripts/registry/explorer/pages/list.vue @@ -217,7 +217,6 @@ export default { :svg-path="config.noContainersImage" data-testid="emptySearch" :title="$options.i18n.EMPTY_RESULT_TITLE" - class="container-message" > <template #description> {{ $options.i18n.EMPTY_RESULT_MESSAGE }} diff --git a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue index b4a59fd0178..2ee7bbef4c6 100644 --- a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue +++ b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue @@ -1,11 +1,16 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; -import { s__ } from '~/locale'; import { FETCH_SETTINGS_ERROR_MESSAGE } from '../../shared/constants'; import SettingsForm from './settings_form.vue'; +import { + UNAVAILABLE_FEATURE_TITLE, + UNAVAILABLE_FEATURE_INTRO_TEXT, + UNAVAILABLE_USER_FEATURE_TEXT, + UNAVAILABLE_ADMIN_FEATURE_TEXT, +} from '../constants'; export default { components: { @@ -15,17 +20,9 @@ export default { GlLink, }, i18n: { - unavailableFeatureTitle: s__( - `ContainerRegistry|Container Registry tag expiration and retention policy is disabled`, - ), - unavailableFeatureIntroText: s__( - `ContainerRegistry|The Container Registry tag expiration and retention policies for this project have not been enabled.`, - ), - unavailableUserFeatureText: s__(`ContainerRegistry|Please contact your administrator.`), - unavailableAdminFeatureText: s__( - `ContainerRegistry| Please visit the %{linkStart}administration settings%{linkEnd} to enable this feature.`, - ), - fetchSettingsErrorText: FETCH_SETTINGS_ERROR_MESSAGE, + UNAVAILABLE_FEATURE_TITLE, + UNAVAILABLE_FEATURE_INTRO_TEXT, + FETCH_SETTINGS_ERROR_MESSAGE, }, data() { return { @@ -42,9 +39,7 @@ export default { return this.isDisabled && !this.fetchSettingsError; }, unavailableFeatureMessage() { - return this.isAdmin - ? this.$options.i18n.unavailableAdminFeatureText - : this.$options.i18n.unavailableUserFeatureText; + return this.isAdmin ? UNAVAILABLE_ADMIN_FEATURE_TEXT : UNAVAILABLE_USER_FEATURE_TEXT; }, }, mounted() { @@ -60,39 +55,24 @@ export default { <template> <div> - <p> - {{ s__('ContainerRegistry|Tag expiration policy is designed to:') }} - </p> - <ul> - <li>{{ s__('ContainerRegistry|Keep and protect the images that matter most.') }}</li> - <li> - {{ - s__( - "ContainerRegistry|Automatically remove extra images that aren't designed to be kept.", - ) - }} - </li> - </ul> <settings-form v-if="showSettingForm" /> <template v-else> <gl-alert v-if="showDisabledFormMessage" :dismissible="false" - :title="$options.i18n.unavailableFeatureTitle" + :title="$options.i18n.UNAVAILABLE_FEATURE_TITLE" variant="tip" > - {{ $options.i18n.unavailableFeatureIntroText }} + {{ $options.i18n.UNAVAILABLE_FEATURE_INTRO_TEXT }} <gl-sprintf :message="unavailableFeatureMessage"> <template #link="{ content }"> - <gl-link :href="adminSettingsPath" target="_blank"> - {{ content }} - </gl-link> + <gl-link :href="adminSettingsPath" target="_blank">{{ content }}</gl-link> </template> </gl-sprintf> </gl-alert> <gl-alert v-else-if="fetchSettingsError" variant="warning" :dismissible="false"> - <gl-sprintf :message="$options.i18n.fetchSettingsErrorText" /> + <gl-sprintf :message="$options.i18n.FETCH_SETTINGS_ERROR_MESSAGE" /> </gl-alert> </template> </div> diff --git a/app/assets/javascripts/registry/settings/components/settings_form.vue b/app/assets/javascripts/registry/settings/components/settings_form.vue index afd502109bf..f129922c1d2 100644 --- a/app/assets/javascripts/registry/settings/components/settings_form.vue +++ b/app/assets/javascripts/registry/settings/components/settings_form.vue @@ -1,13 +1,15 @@ <script> +import { get } from 'lodash'; import { mapActions, mapState, mapGetters } from 'vuex'; import { GlCard, GlDeprecatedButton, GlLoadingIcon } from '@gitlab/ui'; import Tracking from '~/tracking'; +import { mapComputed } from '~/vuex_shared/bindings'; import { UPDATE_SETTINGS_ERROR_MESSAGE, UPDATE_SETTINGS_SUCCESS_MESSAGE, } from '../../shared/constants'; -import { mapComputed } from '~/vuex_shared/bindings'; import ExpirationPolicyFields from '../../shared/components/expiration_policy_fields.vue'; +import { SET_CLEANUP_POLICY_BUTTON, CLEANUP_POLICY_CARD_HEADER } from '../constants'; export default { components: { @@ -21,12 +23,17 @@ export default { cols: 3, align: 'right', }, + i18n: { + CLEANUP_POLICY_CARD_HEADER, + SET_CLEANUP_POLICY_BUTTON, + }, data() { return { tracking: { label: 'docker_container_retention_and_expiration_policies', }, - formIsValid: true, + fieldsAreValid: true, + apiErrors: null, }; }, computed: { @@ -34,7 +41,7 @@ export default { ...mapGetters({ isEdited: 'getIsEdited' }), ...mapComputed([{ key: 'settings', getter: 'getSettings' }], 'updateSettings'), isSubmitButtonDisabled() { - return !this.formIsValid || this.isLoading; + return !this.fieldsAreValid || this.isLoading; }, isCancelButtonDisabled() { return !this.isEdited || this.isLoading; @@ -44,13 +51,35 @@ export default { ...mapActions(['resetSettings', 'saveSettings']), reset() { this.track('reset_form'); + this.apiErrors = null; this.resetSettings(); }, + setApiErrors(response) { + const messages = get(response, 'data.message', []); + + this.apiErrors = Object.keys(messages).reduce((acc, curr) => { + if (curr.startsWith('container_expiration_policy.')) { + const key = curr.replace('container_expiration_policy.', ''); + acc[key] = get(messages, [curr, 0], ''); + } + return acc; + }, {}); + }, submit() { this.track('submit_form'); + this.apiErrors = null; this.saveSettings() .then(() => this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE, { type: 'success' })) - .catch(() => this.$toast.show(UPDATE_SETTINGS_ERROR_MESSAGE, { type: 'error' })); + .catch(({ response }) => { + this.setApiErrors(response); + this.$toast.show(UPDATE_SETTINGS_ERROR_MESSAGE, { type: 'error' }); + }); + }, + onModelChange(changePayload) { + this.settings = changePayload.newValue; + if (this.apiErrors) { + this.apiErrors[changePayload.modified] = undefined; + } }, }, }; @@ -60,23 +89,25 @@ export default { <form ref="form-element" @submit.prevent="submit" @reset.prevent="reset"> <gl-card> <template #header> - {{ s__('ContainerRegistry|Tag expiration policy') }} + {{ $options.i18n.CLEANUP_POLICY_CARD_HEADER }} </template> <template #default> <expiration-policy-fields - v-model="settings" + :value="settings" :form-options="formOptions" :is-loading="isLoading" - @validated="formIsValid = true" - @invalidated="formIsValid = false" + :api-errors="apiErrors" + @validated="fieldsAreValid = true" + @invalidated="fieldsAreValid = false" + @input="onModelChange" /> </template> <template #footer> - <div class="d-flex justify-content-end"> + <div class="gl-display-flex gl-justify-content-end"> <gl-deprecated-button ref="cancel-button" type="reset" - class="mr-2 d-block" + class="gl-mr-3 gl-display-block" :disabled="isCancelButtonDisabled" > {{ __('Cancel') }} @@ -86,10 +117,10 @@ export default { type="submit" :disabled="isSubmitButtonDisabled" variant="success" - class="d-flex justify-content-center align-items-center js-no-auto-disable" + class="gl-display-flex gl-justify-content-center gl-align-items-center js-no-auto-disable" > - {{ __('Save expiration policy') }} - <gl-loading-icon v-if="isLoading" class="ml-2" /> + {{ $options.i18n.SET_CLEANUP_POLICY_BUTTON }} + <gl-loading-icon v-if="isLoading" class="gl-ml-3" /> </gl-deprecated-button> </div> </template> diff --git a/app/assets/javascripts/registry/settings/constants.js b/app/assets/javascripts/registry/settings/constants.js new file mode 100644 index 00000000000..e790658f491 --- /dev/null +++ b/app/assets/javascripts/registry/settings/constants.js @@ -0,0 +1,14 @@ +import { s__, __ } from '~/locale'; + +export const SET_CLEANUP_POLICY_BUTTON = s__('ContainerRegistry|Set cleanup policy'); +export const CLEANUP_POLICY_CARD_HEADER = s__('ContainerRegistry|Tag expiration policy'); +export const UNAVAILABLE_FEATURE_TITLE = s__( + `ContainerRegistry|Cleanup policy for tags is disabled`, +); +export const UNAVAILABLE_FEATURE_INTRO_TEXT = s__( + `ContainerRegistry|This project's cleanup policy for tags is not enabled.`, +); +export const UNAVAILABLE_USER_FEATURE_TEXT = __(`Please contact your administrator.`); +export const UNAVAILABLE_ADMIN_FEATURE_TEXT = s__( + `ContainerRegistry| Please visit the %{linkStart}administration settings%{linkEnd} to enable this feature.`, +); diff --git a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue index 04a547db07e..1ff2f6f99e5 100644 --- a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue +++ b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue @@ -34,6 +34,11 @@ export default { required: false, default: () => ({}), }, + apiErrors: { + type: Object, + required: false, + default: null, + }, isLoading: { type: Boolean, required: false, @@ -56,9 +61,8 @@ export default { }, }, i18n: { - textAreaInvalidFeedback: TEXT_AREA_INVALID_FEEDBACK, - enableToggleLabel: ENABLE_TOGGLE_LABEL, - enableToggleDescription: ENABLE_TOGGLE_DESCRIPTION, + ENABLE_TOGGLE_LABEL, + ENABLE_TOGGLE_DESCRIPTION, }, selectList: [ { @@ -86,7 +90,6 @@ export default { label: NAME_REGEX_LABEL, model: 'name_regex', placeholder: NAME_REGEX_PLACEHOLDER, - stateVariable: 'nameRegexState', description: NAME_REGEX_DESCRIPTION, }, { @@ -94,7 +97,6 @@ export default { label: NAME_REGEX_KEEP_LABEL, model: 'name_regex_keep', placeholder: NAME_REGEX_KEEP_PLACEHOLDER, - stateVariable: 'nameKeepRegexState', description: NAME_REGEX_KEEP_DESCRIPTION, }, ], @@ -111,16 +113,34 @@ export default { policyEnabledText() { return this.enabled ? ENABLED_TEXT : DISABLED_TEXT; }, - textAreaState() { + textAreaValidation() { + const nameRegexErrors = + this.apiErrors?.name_regex || this.validateRegexLength(this.name_regex); + const nameKeepRegexErrors = + this.apiErrors?.name_regex_keep || this.validateRegexLength(this.name_regex_keep); + return { - nameRegexState: this.validateNameRegex(this.name_regex), - nameKeepRegexState: this.validateNameRegex(this.name_regex_keep), + /* + * The state has this form: + * null: gray border, no message + * true: green border, no message ( because none is configured) + * false: red border, error message + * So in this function we keep null if the are no message otherwise we 'invert' the error message + */ + name_regex: { + state: nameRegexErrors === null ? null : !nameRegexErrors, + message: nameRegexErrors, + }, + name_regex_keep: { + state: nameKeepRegexErrors === null ? null : !nameKeepRegexErrors, + message: nameKeepRegexErrors, + }, }; }, fieldsValidity() { return ( - this.textAreaState.nameRegexState !== false && - this.textAreaState.nameKeepRegexState !== false + this.textAreaValidation.name_regex.state !== false && + this.textAreaValidation.name_regex_keep.state !== false ); }, isFormElementDisabled() { @@ -140,8 +160,11 @@ export default { }, }, methods: { - validateNameRegex(value) { - return value ? value.length <= NAME_REGEX_LENGTH : null; + validateRegexLength(value) { + if (!value) { + return null; + } + return value.length <= NAME_REGEX_LENGTH ? '' : TEXT_AREA_INVALID_FEEDBACK; }, idGenerator(id) { return `${id}_${this.uniqueId}`; @@ -154,22 +177,22 @@ export default { </script> <template> - <div ref="form-elements" class="lh-2"> + <div ref="form-elements" class="gl-line-height-20"> <gl-form-group :id="idGenerator('expiration-policy-toggle-group')" :label-cols="labelCols" :label-align="labelAlign" :label-for="idGenerator('expiration-policy-toggle')" - :label="$options.i18n.enableToggleLabel" + :label="$options.i18n.ENABLE_TOGGLE_LABEL" > - <div class="d-flex align-items-start"> + <div class="gl-display-flex"> <gl-toggle :id="idGenerator('expiration-policy-toggle')" v-model="enabled" :disabled="isLoading" /> - <span class="mb-2 ml-1 lh-2"> - <gl-sprintf :message="$options.i18n.enableToggleDescription"> + <span class="gl-mb-3 gl-ml-3 gl-line-height-20"> + <gl-sprintf :message="$options.i18n.ENABLE_TOGGLE_DESCRIPTION"> <template #toggleStatus> <strong>{{ policyEnabledText }}</strong> </template> @@ -210,8 +233,8 @@ export default { :label-cols="labelCols" :label-align="labelAlign" :label-for="idGenerator(textarea.name)" - :state="textAreaState[textarea.stateVariable]" - :invalid-feedback="$options.i18n.textAreaInvalidFeedback" + :state="textAreaValidation[textarea.model].state" + :invalid-feedback="textAreaValidation[textarea.model].message" > <template #label> <gl-sprintf :message="textarea.label"> @@ -224,7 +247,7 @@ export default { :id="idGenerator(textarea.name)" :value="value[textarea.model]" :placeholder="textarea.placeholder" - :state="textAreaState[textarea.stateVariable]" + :state="textAreaValidation[textarea.model].state" :disabled="isFormElementDisabled" trim @input="updateModel($event, textarea.model)" diff --git a/app/assets/javascripts/registry/shared/constants.js b/app/assets/javascripts/registry/shared/constants.js index 4689d01b1c8..36d55c7610e 100644 --- a/app/assets/javascripts/registry/shared/constants.js +++ b/app/assets/javascripts/registry/shared/constants.js @@ -1,29 +1,29 @@ import { s__, __ } from '~/locale'; export const FETCH_SETTINGS_ERROR_MESSAGE = s__( - 'ContainerRegistry|Something went wrong while fetching the expiration policy.', + 'ContainerRegistry|Something went wrong while fetching the cleanup policy.', ); export const UPDATE_SETTINGS_ERROR_MESSAGE = s__( - 'ContainerRegistry|Something went wrong while updating the expiration policy.', + 'ContainerRegistry|Something went wrong while updating the cleanup policy.', ); export const UPDATE_SETTINGS_SUCCESS_MESSAGE = s__( - 'ContainerRegistry|Expiration policy successfully saved.', + 'ContainerRegistry|Cleanup policy successfully saved.', ); export const NAME_REGEX_LENGTH = 255; -export const ENABLED_TEXT = __('enabled'); -export const DISABLED_TEXT = __('disabled'); +export const ENABLED_TEXT = __('Enabled'); +export const DISABLED_TEXT = __('Disabled'); -export const ENABLE_TOGGLE_LABEL = s__('ContainerRegistry|Expiration policy:'); +export const ENABLE_TOGGLE_LABEL = s__('ContainerRegistry|Cleanup policy:'); export const ENABLE_TOGGLE_DESCRIPTION = s__( - 'ContainerRegistry|Docker tag expiration policy is %{toggleStatus}', + 'ContainerRegistry|%{toggleStatus} - Tags matching the patterns defined below will be scheduled for deletion', ); export const TEXT_AREA_INVALID_FEEDBACK = s__( - 'ContainerRegistry|The value of this input should be less than 255 characters', + 'ContainerRegistry|The value of this input should be less than 256 characters', ); export const EXPIRATION_INTERVAL_LABEL = s__('ContainerRegistry|Expiration interval:'); @@ -34,12 +34,12 @@ export const NAME_REGEX_LABEL = s__( ); export const NAME_REGEX_PLACEHOLDER = '.*'; export const NAME_REGEX_DESCRIPTION = s__( - 'ContainerRegistry|Regular expressions such as %{codeStart}.*-test%{codeEnd} or %{codeStart}dev-.*%{codeEnd} are supported. To select all tags, use %{codeStart}.*%{codeEnd}', + 'ContainerRegistry|Wildcards such as %{codeStart}.*-test%{codeEnd} or %{codeStart}dev-.*%{codeEnd} are supported. To select all tags, use %{codeStart}.*%{codeEnd}', ); export const NAME_REGEX_KEEP_LABEL = s__( 'ContainerRegistry|Tags with names matching this regex pattern will %{italicStart}be preserved:%{italicEnd}', ); export const NAME_REGEX_KEEP_PLACEHOLDER = ''; export const NAME_REGEX_KEEP_DESCRIPTION = s__( - 'ContainerRegistry|Regular expressions such as %{codeStart}.*-master%{codeEnd} or %{codeStart}release-.*%{codeEnd} are supported', + 'ContainerRegistry|Wildcards such as %{codeStart}.*-master%{codeEnd} or %{codeStart}release-.*%{codeEnd} are supported', ); diff --git a/app/assets/javascripts/registry/shared/utils.js b/app/assets/javascripts/registry/shared/utils.js index d85a3ad28c2..a7377773842 100644 --- a/app/assets/javascripts/registry/shared/utils.js +++ b/app/assets/javascripts/registry/shared/utils.js @@ -11,7 +11,7 @@ export const mapComputedToEvent = (list, root) => { return this[root][e]; }, set(value) { - this.$emit('input', { ...this[root], [e]: value }); + this.$emit('input', { newValue: { ...this[root], [e]: value }, modified: e }); }, }; }); diff --git a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue index 05803ba09ab..15e9b8559d4 100644 --- a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue +++ b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue @@ -82,7 +82,7 @@ export default { {{ __('Related merge requests') }} </span> <div v-if="totalCount" class="d-inline-flex lh-100 align-middle"> - <div class="mr-count-badge border-width-1px border-style-solid border-color-default"> + <div class="mr-count-badge gl-display-inline-flex"> <div class="mr-count-badge-count"> <svg class="s16 mr-1 text-secondary"> <icon name="merge-request" class="mr-1 text-secondary" /> diff --git a/app/assets/javascripts/releases/components/app_new.vue b/app/assets/javascripts/releases/components/app_new.vue new file mode 100644 index 00000000000..563f76b3281 --- /dev/null +++ b/app/assets/javascripts/releases/components/app_new.vue @@ -0,0 +1,9 @@ +<script> +export default { + name: 'ReleaseNewApp', + components: {}, +}; +</script> +<template> + <div></div> +</template> diff --git a/app/assets/javascripts/releases/components/app_show.vue b/app/assets/javascripts/releases/components/app_show.vue index d521edcc361..0e65d722952 100644 --- a/app/assets/javascripts/releases/components/app_show.vue +++ b/app/assets/javascripts/releases/components/app_show.vue @@ -21,7 +21,7 @@ export default { }; </script> <template> - <div class="prepend-top-default"> + <div class="gl-mt-3"> <gl-skeleton-loading v-if="isFetchingRelease" /> <release-block v-else-if="!fetchError" :release="release" /> diff --git a/app/assets/javascripts/releases/components/evidence_block.vue b/app/assets/javascripts/releases/components/evidence_block.vue index 2cc15777343..6468e2ded62 100644 --- a/app/assets/javascripts/releases/components/evidence_block.vue +++ b/app/assets/javascripts/releases/components/evidence_block.vue @@ -59,7 +59,7 @@ export default { <template> <div> - <div class="card-text prepend-top-default"> + <div class="card-text gl-mt-3"> <b>{{ __('Evidence collection') }}</b> </div> <div v-for="(evidence, index) in evidences" :key="evidenceTitle(index)" class="mb-2"> diff --git a/app/assets/javascripts/releases/components/release_block.vue b/app/assets/javascripts/releases/components/release_block.vue index adb0e69b786..e0061d88ccb 100644 --- a/app/assets/javascripts/releases/components/release_block.vue +++ b/app/assets/javascripts/releases/components/release_block.vue @@ -108,7 +108,7 @@ export default { <release-block-assets v-if="shouldRenderAssets" :assets="assets" /> <evidence-block v-if="hasEvidence && shouldShowEvidence" :release="release" /> - <div ref="gfm-content" class="card-text prepend-top-default"> + <div ref="gfm-content" class="card-text gl-mt-3"> <div class="md" v-html="release.descriptionHtml"></div> </div> </div> diff --git a/app/assets/javascripts/releases/components/release_block_assets.vue b/app/assets/javascripts/releases/components/release_block_assets.vue index e07646e9a9f..ab29ceb0ce6 100644 --- a/app/assets/javascripts/releases/components/release_block_assets.vue +++ b/app/assets/javascripts/releases/components/release_block_assets.vue @@ -4,7 +4,7 @@ import Icon from '~/vue_shared/components/icon.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { ASSET_LINK_TYPE } from '../constants'; import { __, s__, sprintf } from '~/locale'; -import { difference } from 'lodash'; +import { difference, get } from 'lodash'; export default { name: 'ReleaseBlockAssets', @@ -54,7 +54,7 @@ export default { sections() { return [ { - links: this.assets.sources.map(s => ({ + links: get(this.assets, 'sources', []).map(s => ({ url: s.url, name: sprintf(__('Source code (%{fileExtension})'), { fileExtension: s.format }), })), @@ -96,7 +96,7 @@ export default { </script> <template> - <div class="card-text prepend-top-default"> + <div class="card-text gl-mt-3"> <template v-if="glFeatures.releaseAssetLinkType"> <gl-button data-testid="accordion-button" @@ -157,7 +157,7 @@ export default { <ul v-if="assets.links.length" class="pl-0 mb-0 gl-mt-3 list-unstyled js-assets-list"> <li v-for="link in assets.links" :key="link.name" class="gl-mb-3"> <gl-link v-gl-tooltip.bottom :title="__('Download asset')" :href="link.directAssetUrl"> - <icon name="package" class="align-middle append-right-4 align-text-bottom" /> + <icon name="package" class="align-middle gl-mr-2 align-text-bottom" /> {{ link.name }} <span v-if="link.external" data-testid="external-link-indicator">{{ __('(external source)') @@ -174,7 +174,7 @@ export default { aria-haspopup="true" aria-expanded="false" > - <icon name="doc-code" class="align-top append-right-4" /> + <icon name="doc-code" class="align-top gl-mr-2" /> {{ __('Source code') }} <icon name="chevron-down" /> </button> diff --git a/app/assets/javascripts/releases/components/release_block_header.vue b/app/assets/javascripts/releases/components/release_block_header.vue index ed49841757a..310fba0fe76 100644 --- a/app/assets/javascripts/releases/components/release_block_header.vue +++ b/app/assets/javascripts/releases/components/release_block_header.vue @@ -56,7 +56,7 @@ export default { v-gl-tooltip category="primary" variant="default" - class="append-right-10 js-edit-button ml-2 pb-2" + class="gl-mr-3 js-edit-button ml-2 pb-2" :title="__('Edit this release')" :href="editLink" > diff --git a/app/assets/javascripts/releases/components/release_block_metadata.vue b/app/assets/javascripts/releases/components/release_block_metadata.vue index a3377ce044a..861c2e11798 100644 --- a/app/assets/javascripts/releases/components/release_block_metadata.vue +++ b/app/assets/javascripts/releases/components/release_block_metadata.vue @@ -75,7 +75,7 @@ export default { <release-block-milestones v-if="shouldRenderMilestones" :milestones="release.milestones" /> - <div class="append-right-4"> + <div class="gl-mr-2"> • <span v-gl-tooltip.bottom diff --git a/app/assets/javascripts/releases/components/release_block_milestone_info.vue b/app/assets/javascripts/releases/components/release_block_milestone_info.vue index 4f75e15a149..b16ae400d6b 100644 --- a/app/assets/javascripts/releases/components/release_block_milestone_info.vue +++ b/app/assets/javascripts/releases/components/release_block_milestone_info.vue @@ -126,12 +126,12 @@ export default { v-gl-tooltip :title="milestone.description" :href="milestone.webUrl" - class="append-right-4" + class="gl-mr-2" > {{ milestone.title }} </gl-link> <template v-if="shouldRenderBullet(index)"> - <span :key="'bullet-' + milestone.id" class="append-right-4">•</span> + <span :key="'bullet-' + milestone.id" class="gl-mr-2">•</span> </template> <template v-if="shouldRenderShowMoreLink(index)"> <gl-button :key="'more-button-' + milestone.id" variant="link" @click="toggleShowAll"> diff --git a/app/assets/javascripts/releases/mount_new.js b/app/assets/javascripts/releases/mount_new.js new file mode 100644 index 00000000000..eb02c194c59 --- /dev/null +++ b/app/assets/javascripts/releases/mount_new.js @@ -0,0 +1,20 @@ +import Vue from 'vue'; +import ReleaseNewApp from './components/app_new.vue'; +import createStore from './stores'; +import createDetailModule from './stores/modules/detail'; + +export default () => { + const el = document.getElementById('js-new-release-page'); + + const store = createStore({ + modules: { + detail: createDetailModule(el.dataset), + }, + }); + + return new Vue({ + el, + store, + render: h => h(ReleaseNewApp), + }); +}; diff --git a/app/assets/javascripts/releases/stores/modules/detail/state.js b/app/assets/javascripts/releases/stores/modules/detail/state.js index 6d0d102c719..966c1c00ef5 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/state.js +++ b/app/assets/javascripts/releases/stores/modules/detail/state.js @@ -1,17 +1,17 @@ export default ({ projectId, - tagName, - releasesPagePath, markdownDocsPath, markdownPreviewPath, updateReleaseApiDocsPath, releaseAssetsDocsPath, manageMilestonesPath, newMilestonePath, + + tagName = null, + releasesPagePath = null, + defaultBranch = null, }) => ({ projectId, - tagName, - releasesPagePath, markdownDocsPath, markdownPreviewPath, updateReleaseApiDocsPath, @@ -19,6 +19,10 @@ export default ({ manageMilestonesPath, newMilestonePath, + tagName, + releasesPagePath, + defaultBranch, + /** The Release object */ release: null, diff --git a/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue b/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue index 653dcced98b..ed4f3c4e0fe 100644 --- a/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue +++ b/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue @@ -36,13 +36,9 @@ export default { }; </script> <template> - <div class="report-block-list-issue-description prepend-top-5 append-bottom-5"> + <div class="report-block-list-issue-description gl-mt-2 gl-mb-2"> <div ref="accessibility-issue-description" class="report-block-list-issue-description-text"> - <div - v-if="isNew" - ref="accessibility-issue-is-new-badge" - class="badge badge-danger append-right-5" - > + <div v-if="isNew" ref="accessibility-issue-is-new-badge" class="badge badge-danger gl-mr-2"> {{ s__('AccessibilityReport|New') }} </div> <div> @@ -55,7 +51,7 @@ export default { ) }} <gl-link ref="accessibility-issue-learn-more" :href="learnMoreUrl" target="_blank">{{ - s__('AccessibilityReport|Learn More') + s__('AccessibilityReport|Learn more') }}</gl-link> </div> {{ sprintf(s__('AccessibilityReport|Message: %{message}'), { message: issue.message }) }} diff --git a/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue b/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue new file mode 100644 index 00000000000..0c758ee2b5c --- /dev/null +++ b/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue @@ -0,0 +1,42 @@ +<script> +/** + * Renders Code quality body text + * Fixed: [name] in [link]:[line] + */ +import ReportLink from '~/reports/components/report_link.vue'; +import { STATUS_SUCCESS } from '~/reports/constants'; + +export default { + name: 'CodequalityIssueBody', + + components: { + ReportLink, + }, + props: { + status: { + type: String, + required: true, + }, + issue: { + type: Object, + required: true, + }, + }, + computed: { + isStatusSuccess() { + return this.status === STATUS_SUCCESS; + }, + }, +}; +</script> +<template> + <div class="report-block-list-issue-description gl-mt-2 gl-mb-2"> + <div class="report-block-list-issue-description-text"> + <template v-if="isStatusSuccess">{{ s__('ciReport|Fixed:') }}</template> + + {{ issue.name }} + </div> + + <report-link v-if="issue.path" :issue="issue" /> + </div> +</template> diff --git a/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue b/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue new file mode 100644 index 00000000000..f3d5b1a80f8 --- /dev/null +++ b/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue @@ -0,0 +1,83 @@ +<script> +import { mapState, mapActions, mapGetters } from 'vuex'; +import { componentNames } from '~/reports/components/issue_body'; +import { s__, sprintf } from '~/locale'; +import ReportSection from '~/reports/components/report_section.vue'; +import createStore from './store'; + +export default { + name: 'GroupedCodequalityReportsApp', + store: createStore(), + components: { + ReportSection, + }, + props: { + headPath: { + type: String, + required: true, + }, + headBlobPath: { + type: String, + required: true, + }, + basePath: { + type: String, + required: false, + default: null, + }, + baseBlobPath: { + type: String, + required: false, + default: null, + }, + codequalityHelpPath: { + type: String, + required: true, + }, + }, + componentNames, + computed: { + ...mapState(['newIssues', 'resolvedIssues']), + ...mapGetters([ + 'hasCodequalityIssues', + 'codequalityStatus', + 'codequalityText', + 'codequalityPopover', + ]), + }, + created() { + this.setPaths({ + basePath: this.basePath, + headPath: this.headPath, + baseBlobPath: this.baseBlobPath, + headBlobPath: this.headBlobPath, + helpPath: this.codequalityHelpPath, + }); + + this.fetchReports(); + }, + methods: { + ...mapActions(['fetchReports', 'setPaths']), + }, + loadingText: sprintf(s__('ciReport|Loading %{reportName} report'), { + reportName: 'codeclimate', + }), + errorText: sprintf(s__('ciReport|Failed to load %{reportName} report'), { + reportName: 'codeclimate', + }), +}; +</script> +<template> + <report-section + :status="codequalityStatus" + :loading-text="$options.loadingText" + :error-text="$options.errorText" + :success-text="codequalityText" + :unresolved-issues="newIssues" + :resolved-issues="resolvedIssues" + :has-issues="hasCodequalityIssues" + :component="$options.componentNames.CodequalityIssueBody" + :popover-options="codequalityPopover" + class="js-codequality-widget mr-widget-border-top mr-report" + /> +</template> diff --git a/app/assets/javascripts/reports/codequality_report/store/actions.js b/app/assets/javascripts/reports/codequality_report/store/actions.js new file mode 100644 index 00000000000..bf84d27b5ea --- /dev/null +++ b/app/assets/javascripts/reports/codequality_report/store/actions.js @@ -0,0 +1,30 @@ +import axios from '~/lib/utils/axios_utils'; +import * as types from './mutation_types'; +import { parseCodeclimateMetrics, doCodeClimateComparison } from './utils/codequality_comparison'; + +export const setPaths = ({ commit }, paths) => commit(types.SET_PATHS, paths); + +export const fetchReports = ({ state, dispatch, commit }) => { + commit(types.REQUEST_REPORTS); + + if (!state.basePath) { + return dispatch('receiveReportsError'); + } + return Promise.all([axios.get(state.headPath), axios.get(state.basePath)]) + .then(results => + doCodeClimateComparison( + parseCodeclimateMetrics(results[0].data, state.headBlobPath), + parseCodeclimateMetrics(results[1].data, state.baseBlobPath), + ), + ) + .then(data => dispatch('receiveReportsSuccess', data)) + .catch(() => dispatch('receiveReportsError')); +}; + +export const receiveReportsSuccess = ({ commit }, data) => { + commit(types.RECEIVE_REPORTS_SUCCESS, data); +}; + +export const receiveReportsError = ({ commit }) => { + commit(types.RECEIVE_REPORTS_ERROR); +}; diff --git a/app/assets/javascripts/reports/codequality_report/store/getters.js b/app/assets/javascripts/reports/codequality_report/store/getters.js new file mode 100644 index 00000000000..5df58c7f85f --- /dev/null +++ b/app/assets/javascripts/reports/codequality_report/store/getters.js @@ -0,0 +1,58 @@ +import { LOADING, ERROR, SUCCESS } from '../../constants'; +import { sprintf, __, s__, n__ } from '~/locale'; + +export const hasCodequalityIssues = state => + Boolean(state.newIssues?.length || state.resolvedIssues?.length); + +export const codequalityStatus = state => { + if (state.isLoading) { + return LOADING; + } + if (state.hasError) { + return ERROR; + } + + return SUCCESS; +}; + +export const codequalityText = state => { + const { newIssues, resolvedIssues } = state; + const text = []; + + if (!newIssues.length && !resolvedIssues.length) { + text.push(s__('ciReport|No changes to code quality')); + } else { + text.push(s__('ciReport|Code quality')); + + if (resolvedIssues.length) { + text.push(n__(' improved on %d point', ' improved on %d points', resolvedIssues.length)); + } + + if (newIssues.length && resolvedIssues.length) { + text.push(__(' and')); + } + + if (newIssues.length) { + text.push(n__(' degraded on %d point', ' degraded on %d points', newIssues.length)); + } + } + + return text.join(''); +}; + +export const codequalityPopover = state => { + if (state.headPath && !state.basePath) { + return { + title: s__('ciReport|Base pipeline codequality artifact not found'), + content: sprintf( + s__('ciReport|%{linkStartTag}Learn more about codequality reports %{linkEndTag}'), + { + linkStartTag: `<a href="${state.helpPath}" target="_blank" rel="noopener noreferrer">`, + linkEndTag: '<i class="fa fa-external-link" aria-hidden="true"></i></a>', + }, + false, + ), + }; + } + return {}; +}; diff --git a/app/assets/javascripts/reports/codequality_report/store/index.js b/app/assets/javascripts/reports/codequality_report/store/index.js new file mode 100644 index 00000000000..047964260ad --- /dev/null +++ b/app/assets/javascripts/reports/codequality_report/store/index.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; +import state from './state'; + +Vue.use(Vuex); + +export default initialState => + new Vuex.Store({ + actions, + getters, + mutations, + state: state(initialState), + }); diff --git a/app/assets/javascripts/reports/codequality_report/store/mutation_types.js b/app/assets/javascripts/reports/codequality_report/store/mutation_types.js new file mode 100644 index 00000000000..c362c973ae1 --- /dev/null +++ b/app/assets/javascripts/reports/codequality_report/store/mutation_types.js @@ -0,0 +1,5 @@ +export const SET_PATHS = 'SET_PATHS'; + +export const REQUEST_REPORTS = 'REQUEST_REPORTS'; +export const RECEIVE_REPORTS_SUCCESS = 'RECEIVE_REPORTS_SUCCESS'; +export const RECEIVE_REPORTS_ERROR = 'RECEIVE_REPORTS_ERROR'; diff --git a/app/assets/javascripts/reports/codequality_report/store/mutations.js b/app/assets/javascripts/reports/codequality_report/store/mutations.js new file mode 100644 index 00000000000..7ef4f3ce2db --- /dev/null +++ b/app/assets/javascripts/reports/codequality_report/store/mutations.js @@ -0,0 +1,24 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_PATHS](state, paths) { + state.basePath = paths.basePath; + state.headPath = paths.headPath; + state.baseBlobPath = paths.baseBlobPath; + state.headBlobPath = paths.headBlobPath; + state.helpPath = paths.helpPath; + }, + [types.REQUEST_REPORTS](state) { + state.isLoading = true; + }, + [types.RECEIVE_REPORTS_SUCCESS](state, data) { + state.hasError = false; + state.isLoading = false; + state.newIssues = data.newIssues; + state.resolvedIssues = data.resolvedIssues; + }, + [types.RECEIVE_REPORTS_ERROR](state) { + state.isLoading = false; + state.hasError = true; + }, +}; diff --git a/app/assets/javascripts/reports/codequality_report/store/state.js b/app/assets/javascripts/reports/codequality_report/store/state.js new file mode 100644 index 00000000000..38ab53b432e --- /dev/null +++ b/app/assets/javascripts/reports/codequality_report/store/state.js @@ -0,0 +1,15 @@ +export default () => ({ + basePath: null, + headPath: null, + + baseBlobPath: null, + headBlobPath: null, + + isLoading: false, + hasError: false, + + newIssues: [], + resolvedIssues: [], + + helpPath: null, +}); diff --git a/app/assets/javascripts/reports/codequality_report/store/utils/codequality_comparison.js b/app/assets/javascripts/reports/codequality_report/store/utils/codequality_comparison.js new file mode 100644 index 00000000000..eba9e340c4e --- /dev/null +++ b/app/assets/javascripts/reports/codequality_report/store/utils/codequality_comparison.js @@ -0,0 +1,41 @@ +import CodeQualityComparisonWorker from '../../workers/codequality_comparison_worker'; + +export const parseCodeclimateMetrics = (issues = [], path = '') => { + return issues.map(issue => { + const parsedIssue = { + ...issue, + name: issue.description, + }; + + if (issue?.location?.path) { + let parseCodeQualityUrl = `${path}/${issue.location.path}`; + parsedIssue.path = issue.location.path; + + if (issue?.location?.lines?.begin) { + parsedIssue.line = issue.location.lines.begin; + parseCodeQualityUrl += `#L${issue.location.lines.begin}`; + } else if (issue?.location?.positions?.begin?.line) { + parsedIssue.line = issue.location.positions.begin.line; + parseCodeQualityUrl += `#L${issue.location.positions.begin.line}`; + } + + parsedIssue.urlPath = parseCodeQualityUrl; + } + + return parsedIssue; + }); +}; + +export const doCodeClimateComparison = (headIssues, baseIssues) => { + // Do these comparisons in worker threads to avoid blocking the main thread + return new Promise((resolve, reject) => { + const worker = new CodeQualityComparisonWorker(); + worker.addEventListener('message', ({ data }) => + data.newIssues && data.resolvedIssues ? resolve(data) : reject(data), + ); + worker.postMessage({ + headIssues, + baseIssues, + }); + }); +}; diff --git a/app/assets/javascripts/reports/codequality_report/workers/codequality_comparison_worker.js b/app/assets/javascripts/reports/codequality_report/workers/codequality_comparison_worker.js new file mode 100644 index 00000000000..fc55602f95c --- /dev/null +++ b/app/assets/javascripts/reports/codequality_report/workers/codequality_comparison_worker.js @@ -0,0 +1,28 @@ +import { differenceBy } from 'lodash'; + +const KEY_TO_FILTER_BY = 'fingerprint'; + +// eslint-disable-next-line no-restricted-globals +self.addEventListener('message', e => { + const { data } = e; + + if (data === undefined) { + return null; + } + + const { headIssues, baseIssues } = data; + + if (!headIssues || !baseIssues) { + // eslint-disable-next-line no-restricted-globals + return self.postMessage({}); + } + + // eslint-disable-next-line no-restricted-globals + self.postMessage({ + newIssues: differenceBy(headIssues, baseIssues, KEY_TO_FILTER_BY), + resolvedIssues: differenceBy(baseIssues, headIssues, KEY_TO_FILTER_BY), + }); + + // eslint-disable-next-line no-restricted-globals + return self.close(); +}); diff --git a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue index a670cad5f9f..b8a8cb940e7 100644 --- a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue +++ b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue @@ -6,7 +6,9 @@ import ReportSection from './report_section.vue'; import SummaryRow from './summary_row.vue'; import IssuesList from './issues_list.vue'; import Modal from './modal.vue'; +import { GlButton } from '@gitlab/ui'; import createStore from '../store'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { summaryTextBuilder, reportTextBuilder, statusIcon } from '../store/utils'; export default { @@ -17,12 +19,19 @@ export default { SummaryRow, IssuesList, Modal, + GlButton, }, + mixins: [glFeatureFlagsMixin()], props: { endpoint: { type: String, required: true, }, + pipelinePath: { + type: String, + required: false, + default: '', + }, }, componentNames, computed: { @@ -43,6 +52,12 @@ export default { return summaryTextBuilder(s__('Reports|Test summary'), this.summary); }, + testTabURL() { + return `${this.pipelinePath}/test_report`; + }, + showViewFullReport() { + return Boolean(this.glFeatures.junitPipelineView) && this.pipelinePath.length; + }, }, created() { this.setEndpoint(this.endpoint); @@ -98,6 +113,16 @@ export default { :has-issues="reports.length > 0" class="mr-widget-section grouped-security-reports mr-report" > + <template v-if="showViewFullReport" #actionButtons> + <gl-button + :href="testTabURL" + icon="external-link" + data-testid="group-test-reports-full-link" + class="gl-mr-3" + > + {{ s__('ciReport|View full report') }} + </gl-button> + </template> <template #body> <div class="mr-widget-grouped-section report-block"> <template v-for="(report, i) in reports"> diff --git a/app/assets/javascripts/reports/components/issue_body.js b/app/assets/javascripts/reports/components/issue_body.js index e106e60951b..1e6dc4f8b78 100644 --- a/app/assets/javascripts/reports/components/issue_body.js +++ b/app/assets/javascripts/reports/components/issue_body.js @@ -1,12 +1,15 @@ import TestIssueBody from './test_issue_body.vue'; import AccessibilityIssueBody from '../accessibility_report/components/accessibility_issue_body.vue'; +import CodequalityIssueBody from '../codequality_report/components/codequality_issue_body.vue'; export const components = { AccessibilityIssueBody, + CodequalityIssueBody, TestIssueBody, }; export const componentNames = { AccessibilityIssueBody: AccessibilityIssueBody.name, + CodequalityIssueBody: CodequalityIssueBody.name, TestIssueBody: TestIssueBody.name, }; diff --git a/app/assets/javascripts/reports/components/report_item.vue b/app/assets/javascripts/reports/components/report_item.vue index 51062cd7928..1b47d03aa01 100644 --- a/app/assets/javascripts/reports/components/report_item.vue +++ b/app/assets/javascripts/reports/components/report_item.vue @@ -52,7 +52,7 @@ export default { v-if="showReportSectionStatusIcon" :status="status" :status-icon-size="statusIconSize" - class="append-right-default" + class="gl-mr-3" /> <component :is="component" v-if="component" :issue="issue" :status="status" :is-new="isNew" /> diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue index 68956fc6d2b..63af8a5a9ac 100644 --- a/app/assets/javascripts/reports/components/report_section.vue +++ b/app/assets/javascripts/reports/components/report_section.vue @@ -91,6 +91,11 @@ export default { required: false, default: undefined, }, + shouldEmitToggleEvent: { + type: Boolean, + required: false, + default: false, + }, }, data() { @@ -157,6 +162,9 @@ export default { }, methods: { toggleCollapsed() { + if (this.shouldEmitToggleEvent) { + this.$emit('toggleEvent'); + } this.isCollapsed = !this.isCollapsed; }, }, @@ -171,7 +179,7 @@ export default { <div> {{ headerText }} <slot :name="slotName"></slot> - <popover v-if="hasPopover" :options="popoverOptions" class="prepend-left-5" /> + <popover v-if="hasPopover" :options="popoverOptions" class="gl-ml-2" /> </div> <slot name="subHeading"></slot> </div> diff --git a/app/assets/javascripts/reports/components/summary_row.vue b/app/assets/javascripts/reports/components/summary_row.vue index b9fc902cd3a..3232c0edf96 100644 --- a/app/assets/javascripts/reports/components/summary_row.vue +++ b/app/assets/javascripts/reports/components/summary_row.vue @@ -21,7 +21,8 @@ export default { props: { summary: { type: String, - required: true, + required: false, + default: '', }, statusIcon: { type: String, @@ -45,7 +46,7 @@ export default { </script> <template> <div class="report-block-list-issue report-block-list-issue-parent align-items-center"> - <div class="report-block-list-icon append-right-default"> + <div class="report-block-list-icon gl-mr-3"> <gl-loading-icon v-if="statusIcon === 'loading'" css-class="report-block-list-loading-icon" @@ -58,8 +59,8 @@ export default { class="report-block-list-issue-description-text" data-testid="test-summary-row-description" > - {{ summary - }}<span v-if="popoverOptions" class="text-nowrap" + <slot name="summary">{{ summary }}</slot + ><span v-if="popoverOptions" class="text-nowrap" > <popover v-if="popoverOptions" :options="popoverOptions" class="align-top" /> </span> </div> diff --git a/app/assets/javascripts/reports/components/test_issue_body.vue b/app/assets/javascripts/reports/components/test_issue_body.vue index c41238070b1..4e0631740d8 100644 --- a/app/assets/javascripts/reports/components/test_issue_body.vue +++ b/app/assets/javascripts/reports/components/test_issue_body.vue @@ -25,14 +25,14 @@ export default { }; </script> <template> - <div class="report-block-list-issue-description prepend-top-5 append-bottom-5"> + <div class="report-block-list-issue-description gl-mt-2 gl-mb-2"> <div class="report-block-list-issue-description-text" data-testid="test-issue-body-description"> <button type="button" class="btn-link btn-blank text-left break-link vulnerability-name-button" @click="openModal({ issue })" > - <div v-if="isNew" class="badge badge-danger append-right-5">{{ s__('New') }}</div> + <div v-if="isNew" class="badge badge-danger gl-mr-2">{{ s__('New') }}</div> {{ issue.name }} </button> </div> diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue index c8549180a25..5e0ad7acdfd 100644 --- a/app/assets/javascripts/repository/components/table/index.vue +++ b/app/assets/javascripts/repository/components/table/index.vue @@ -80,7 +80,7 @@ export default { <table-header v-once /> <tbody> <parent-row - v-show="showParentRow" + v-if="showParentRow" :commit-ref="escapedRef" :path="path" :loading-path="loadingPath" @@ -97,6 +97,7 @@ export default { :path="entry.flatPath" :type="entry.type" :url="entry.webUrl" + :mode="entry.mode" :submodule-tree-url="entry.treeUrl" :lfs-oid="entry.lfsOid" :loading-path="loadingPath" diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index d5363016335..615e329f415 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -66,6 +66,11 @@ export default { type: String, required: true, }, + mode: { + type: String, + required: false, + default: '', + }, type: { type: String, required: true, @@ -140,6 +145,7 @@ export default { > <file-icon :file-name="fullPath" + :file-mode="mode" :folder="isFolder" :submodule="isSubmodule" :loading="path === loadingPath" diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue index 7b34e9ef60d..59ba1caa8c9 100644 --- a/app/assets/javascripts/repository/components/tree_content.vue +++ b/app/assets/javascripts/repository/components/tree_content.vue @@ -5,7 +5,6 @@ import FileTable from './table/index.vue'; import getRefMixin from '../mixins/get_ref'; import getFiles from '../queries/getFiles.query.graphql'; import getProjectPath from '../queries/getProjectPath.query.graphql'; -import getVueFileListLfsBadge from '../queries/getVueFileListLfsBadge.query.graphql'; import FilePreview from './preview/index.vue'; import { readmeFile } from '../utils/readme'; @@ -21,9 +20,6 @@ export default { projectPath: { query: getProjectPath, }, - vueFileListLfsBadge: { - query: getVueFileListLfsBadge, - }, }, props: { path: { @@ -47,7 +43,6 @@ export default { blobs: [], }, isLoadingFiles: false, - vueFileListLfsBadge: false, }; }, computed: { @@ -82,7 +77,6 @@ export default { path: this.path || '/', nextPageCursor: this.nextPageCursor, pageSize: PAGE_SIZE, - vueLfsEnabled: this.vueFileListLfsBadge, }, }) .then(({ data }) => { diff --git a/app/assets/javascripts/repository/components/web_ide_link.vue b/app/assets/javascripts/repository/components/web_ide_link.vue new file mode 100644 index 00000000000..6549d5a3878 --- /dev/null +++ b/app/assets/javascripts/repository/components/web_ide_link.vue @@ -0,0 +1,47 @@ +<script> +import TreeActionLink from './tree_action_link.vue'; +import { __ } from '~/locale'; +import { webIDEUrl } from '~/lib/utils/url_utility'; + +export default { + components: { + TreeActionLink, + }, + props: { + projectPath: { + type: String, + required: true, + }, + refSha: { + type: String, + required: true, + }, + canPushCode: { + type: Boolean, + required: false, + default: true, + }, + forkPath: { + type: String, + required: false, + default: '', + }, + }, + computed: { + showLinkToFork() { + return !this.canPushCode && this.forkPath; + }, + text() { + return this.showLinkToFork ? __('Edit fork in Web IDE') : __('Web IDE'); + }, + path() { + const path = this.showLinkToFork ? this.forkPath : this.projectPath; + return webIDEUrl(`/${path}/edit/${this.refSha}/-/${this.$route.params.path || ''}`); + }, + }, +}; +</script> + +<template> + <tree-action-link :path="path" :text="text" data-qa-selector="web_ide_button" /> +</template> diff --git a/app/assets/javascripts/repository/graphql.js b/app/assets/javascripts/repository/graphql.js index 6640b636597..450a1571165 100644 --- a/app/assets/javascripts/repository/graphql.js +++ b/app/assets/javascripts/repository/graphql.js @@ -30,7 +30,7 @@ const defaultClient = createDefaultClient( }, readme(_, { url }) { return axios - .get(url, { params: { viewer: 'rich', format: 'json' } }) + .get(url, { params: { format: 'json', viewer: 'rich' } }) .then(({ data }) => ({ ...data, __typename: 'ReadmeFile' })); }, }, diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 6528e283372..4f80ab4ff5d 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -4,18 +4,26 @@ import App from './components/app.vue'; import Breadcrumbs from './components/breadcrumbs.vue'; import LastCommit from './components/last_commit.vue'; import TreeActionLink from './components/tree_action_link.vue'; +import WebIdeLink from './components/web_ide_link.vue'; import DirectoryDownloadLinks from './components/directory_download_links.vue'; import apolloProvider from './graphql'; import { setTitle } from './utils/title'; import { updateFormAction } from './utils/dom'; import { parseBoolean } from '../lib/utils/common_utils'; -import { webIDEUrl } from '../lib/utils/url_utility'; import { __ } from '../locale'; export default function setupVueRepositoryList() { const el = document.getElementById('js-tree-list'); const { dataset } = el; - const { projectPath, projectShortPath, ref, escapedRef, fullName } = dataset; + const { + canPushCode, + projectPath, + projectShortPath, + forkPath, + ref, + escapedRef, + fullName, + } = dataset; const router = createRouter(projectPath, escapedRef); apolloProvider.clients.defaultClient.cache.writeData({ @@ -24,7 +32,6 @@ export default function setupVueRepositoryList() { projectShortPath, ref, escapedRef, - vueFileListLfsBadge: gon.features?.vueFileListLfsBadge || false, commits: [], }, }); @@ -118,11 +125,12 @@ export default function setupVueRepositoryList() { el: webIdeLinkEl, router, render(h) { - return h(TreeActionLink, { + return h(WebIdeLink, { props: { - path: webIDEUrl(`/${projectPath}/edit/${ref}/-/${this.$route.params.path || ''}`), - text: __('Web IDE'), - cssClass: 'qa-web-ide-button', + projectPath, + refSha: ref, + forkPath, + canPushCode: parseBoolean(canPushCode), }, }); }, diff --git a/app/assets/javascripts/repository/queries/getFiles.query.graphql b/app/assets/javascripts/repository/queries/getFiles.query.graphql index 01ad72ef752..feb89df0492 100644 --- a/app/assets/javascripts/repository/queries/getFiles.query.graphql +++ b/app/assets/javascripts/repository/queries/getFiles.query.graphql @@ -14,7 +14,6 @@ query getFiles( $ref: String! $pageSize: Int! $nextPageCursor: String - $vueLfsEnabled: Boolean = false ) { project(fullPath: $projectPath) { repository { @@ -46,8 +45,9 @@ query getFiles( edges { node { ...TreeEntry + mode webUrl - lfsOid @include(if: $vueLfsEnabled) + lfsOid } } pageInfo { diff --git a/app/assets/javascripts/repository/queries/getVueFileListLfsBadge.query.graphql b/app/assets/javascripts/repository/queries/getVueFileListLfsBadge.query.graphql deleted file mode 100644 index eb21c1e73d8..00000000000 --- a/app/assets/javascripts/repository/queries/getVueFileListLfsBadge.query.graphql +++ /dev/null @@ -1,3 +0,0 @@ -query getVueFileListLfsBadge { - vueFileListLfsBadge @client -} diff --git a/app/assets/javascripts/global_search_input.js b/app/assets/javascripts/search_autocomplete.js index a7c121259d4..05e0b9e7089 100644 --- a/app/assets/javascripts/global_search_input.js +++ b/app/assets/javascripts/search_autocomplete.js @@ -1,8 +1,10 @@ /* eslint-disable no-return-assign, consistent-return, class-methods-use-this */ import $ from 'jquery'; -import { throttle } from 'lodash'; +import { escape, throttle } from 'lodash'; import { s__, __, sprintf } from '~/locale'; +import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar_helper'; +import axios from './lib/utils/axios_utils'; import { isInGroupsPage, isInProjectPage, @@ -65,11 +67,15 @@ function setSearchOptions() { } } -export class GlobalSearchInput { - constructor({ wrap } = {}) { +export class SearchAutocomplete { + constructor({ wrap, optsEl, autocompletePath, projectId, projectRef } = {}) { setSearchOptions(); this.bindEventContext(); this.wrap = wrap || $('.search'); + this.optsEl = optsEl || this.wrap.find('.search-autocomplete-opts'); + this.autocompletePath = autocompletePath || this.optsEl.data('autocompletePath'); + this.projectId = projectId || (this.optsEl.data('autocompleteProjectId') || ''); + this.projectRef = projectRef || (this.optsEl.data('autocompleteProjectRef') || ''); this.dropdown = this.wrap.find('.dropdown'); this.dropdownToggle = this.wrap.find('.js-dropdown-search-toggle'); this.dropdownMenu = this.dropdown.find('.dropdown-menu'); @@ -86,7 +92,7 @@ export class GlobalSearchInput { // Only when user is logged in if (gon.current_user_id) { - this.createGlobalSearchInput(); + this.createAutocomplete(); } this.bindEvents(); @@ -111,7 +117,7 @@ export class GlobalSearchInput { return (this.originalState = this.serializeState()); } - createGlobalSearchInput() { + createAutocomplete() { return this.searchInput.glDropdown({ filterInputBlur: false, filterable: true, @@ -143,17 +149,116 @@ export class GlobalSearchInput { if (glDropdownInstance) { glDropdownInstance.filter.options.callback(contents); } - this.enableDropdown(); + this.enableAutocomplete(); } return; } - const options = this.scopedSearchOptions(term); + // Prevent multiple ajax calls + if (this.loadingSuggestions) { + return; + } - callback(options); + this.loadingSuggestions = true; + + return axios + .get(this.autocompletePath, { + params: { + project_id: this.projectId, + project_ref: this.projectRef, + term, + }, + }) + .then(response => { + const options = this.scopedSearchOptions(term); + + // List results + let lastCategory = null; + for (let i = 0, len = response.data.length; i < len; i += 1) { + const suggestion = response.data[i]; + // Add group header before list each group + if (lastCategory !== suggestion.category) { + options.push({ type: 'separator' }); + options.push({ + type: 'header', + content: suggestion.category, + }); + lastCategory = suggestion.category; + } + + // Add the suggestion + options.push({ + id: `${suggestion.category.toLowerCase()}-${suggestion.id}`, + icon: this.getAvatar(suggestion), + category: suggestion.category, + text: suggestion.label, + url: suggestion.url, + }); + } - this.highlightFirstRow(); - this.setScrollFade(); + callback(options); + + this.loadingSuggestions = false; + this.highlightFirstRow(); + this.setScrollFade(); + }) + .catch(() => { + this.loadingSuggestions = false; + }); + } + + getCategoryContents() { + const userName = gon.current_username; + const { projectOptions, groupOptions, dashboardOptions } = gl; + + // Get options + let options; + if (isInProjectPage() && projectOptions) { + options = projectOptions[getProjectSlug()]; + } else if (isInGroupsPage() && groupOptions) { + options = groupOptions[getGroupSlug()]; + } else if (dashboardOptions) { + options = dashboardOptions; + } + + const { issuesPath, mrPath, name, issuesDisabled } = options; + const baseItems = []; + + if (name) { + baseItems.push({ + type: 'header', + content: `${name}`, + }); + } + + const issueItems = [ + { + text: s__('SearchAutocomplete|Issues assigned to me'), + url: `${issuesPath}/?assignee_username=${userName}`, + }, + { + text: s__("SearchAutocomplete|Issues I've created"), + url: `${issuesPath}/?author_username=${userName}`, + }, + ]; + const mergeRequestItems = [ + { + text: s__('SearchAutocomplete|Merge requests assigned to me'), + url: `${mrPath}/?assignee_username=${userName}`, + }, + { + text: s__("SearchAutocomplete|Merge requests I've created"), + url: `${mrPath}/?author_username=${userName}`, + }, + ]; + + let items; + if (issuesDisabled) { + items = baseItems.concat(mergeRequestItems); + } else { + items = baseItems.concat(...issueItems, ...mergeRequestItems); + } + return items; } // Add option to proceed with the search for each @@ -238,7 +343,7 @@ export class GlobalSearchInput { }); } - enableDropdown() { + enableAutocomplete() { this.setScrollFade(); // No need to enable anything if user is not logged in @@ -255,7 +360,7 @@ export class GlobalSearchInput { } onSearchInputChange() { - this.enableDropdown(); + this.enableAutocomplete(); } onSearchInputKeyUp(e) { @@ -264,7 +369,7 @@ export class GlobalSearchInput { this.restoreOriginalState(); break; case KEYCODE.ENTER: - this.disableDropdown(); + this.disableAutocomplete(); break; default: } @@ -317,7 +422,7 @@ export class GlobalSearchInput { return results; } - disableDropdown() { + disableAutocomplete() { if (!this.searchInput.hasClass('js-autocomplete-disabled') && this.dropdown.hasClass('show')) { this.searchInput.addClass('js-autocomplete-disabled'); this.dropdownToggle.dropdown('toggle'); @@ -333,8 +438,16 @@ export class GlobalSearchInput { onClick(item, $el, e) { if (window.location.pathname.indexOf(item.url) !== -1) { if (!e.metaKey) e.preventDefault(); + /* eslint-disable-next-line @gitlab/require-i18n-strings */ + if (item.category === 'Projects') { + this.projectInputEl.val(item.id); + } + // eslint-disable-next-line @gitlab/require-i18n-strings + if (item.category === 'Groups') { + this.groupInputEl.val(item.id); + } $el.removeClass('is-active'); - this.disableDropdown(); + this.disableAutocomplete(); return this.searchInput.val('').focus(); } } @@ -343,58 +456,20 @@ export class GlobalSearchInput { this.searchInput.data('glDropdown').highlightRowAtIndex(null, 0); } - getCategoryContents() { - const userName = gon.current_username; - const { projectOptions, groupOptions, dashboardOptions } = gl; - - // Get options - let options; - if (isInProjectPage() && projectOptions) { - options = projectOptions[getProjectSlug()]; - } else if (isInGroupsPage() && groupOptions) { - options = groupOptions[getGroupSlug()]; - } else if (dashboardOptions) { - options = dashboardOptions; + getAvatar(item) { + if (!Object.hasOwnProperty.call(item, 'avatar_url')) { + return false; } - const { issuesPath, mrPath, name, issuesDisabled } = options; - const baseItems = []; - - if (name) { - baseItems.push({ - type: 'header', - content: `${name}`, - }); - } + const { label, id } = item; + const avatarUrl = item.avatar_url; + const avatar = avatarUrl + ? `<img class="search-item-avatar" src="${avatarUrl}" />` + : `<div class="s16 avatar identicon ${getIdenticonBackgroundClass(id)}">${getIdenticonTitle( + escape(label), + )}</div>`; - const issueItems = [ - { - text: s__('SearchAutocomplete|Issues assigned to me'), - url: `${issuesPath}/?assignee_username=${userName}`, - }, - { - text: s__("SearchAutocomplete|Issues I've created"), - url: `${issuesPath}/?author_username=${userName}`, - }, - ]; - const mergeRequestItems = [ - { - text: s__('SearchAutocomplete|Merge requests assigned to me'), - url: `${mrPath}/?assignee_username=${userName}`, - }, - { - text: s__("SearchAutocomplete|Merge requests I've created"), - url: `${mrPath}/?author_username=${userName}`, - }, - ]; - - let items; - if (issuesDisabled) { - items = baseItems.concat(mergeRequestItems); - } else { - items = baseItems.concat(...issueItems, ...mergeRequestItems); - } - return items; + return avatar; } isScrolledUp() { @@ -420,6 +495,6 @@ export class GlobalSearchInput { } } -export default function initGlobalSearchInput(opts) { - return new GlobalSearchInput(opts); +export default function initSearchAutocomplete(opts) { + return new SearchAutocomplete(opts); } diff --git a/app/assets/javascripts/serverless/components/environment_row.vue b/app/assets/javascripts/serverless/components/environment_row.vue index 089e0550583..c46dfb66afe 100644 --- a/app/assets/javascripts/serverless/components/environment_row.vue +++ b/app/assets/javascripts/serverless/components/environment_row.vue @@ -54,7 +54,7 @@ export default { <div class="folder-toggle-wrap d-flex align-items-center"> <item-caret :is-group-open="isOpen" /> </div> - <div class="group-text flex-grow title namespace-title prepend-left-default"> + <div class="group-text flex-grow title namespace-title gl-ml-3"> {{ envName }} </div> </div> diff --git a/app/assets/javascripts/serverless/components/function_details.vue b/app/assets/javascripts/serverless/components/function_details.vue index 2ac57ac5bcb..53c78b93254 100644 --- a/app/assets/javascripts/serverless/components/function_details.vue +++ b/app/assets/javascripts/serverless/components/function_details.vue @@ -71,7 +71,7 @@ export default { <template> <section id="serverless-function-details"> <h3 class="serverless-function-name">{{ name }}</h3> - <div class="append-bottom-default serverless-function-description"> + <div class="gl-mb-3 serverless-function-description"> <div v-for="(line, index) in description.split('\n')" :key="index">{{ line }}</div> </div> <url :uri="funcUrl" /> diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue index 2b1291ac70f..8fa48134f1f 100644 --- a/app/assets/javascripts/serverless/components/functions.vue +++ b/app/assets/javascripts/serverless/components/functions.vue @@ -75,11 +75,7 @@ export default { <template> <section id="serverless-functions" class="flex-grow"> - <gl-loading-icon - v-if="checkingInstalled" - size="lg" - class="prepend-top-default append-bottom-default" - /> + <gl-loading-icon v-if="checkingInstalled" size="lg" class="gl-mt-3 gl-mb-3" /> <div v-else-if="isInstalled"> <div v-if="hasFunctionData"> @@ -95,11 +91,7 @@ export default { </ul> </div> </template> - <gl-loading-icon - v-if="isLoading" - size="lg" - class="prepend-top-default append-bottom-default js-functions-loader" - /> + <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3 gl-mb-3 js-functions-loader" /> </div> <div v-else class="empty-state js-empty-state"> <div class="text-content"> diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue index fd1f9eae152..d5ae9b04090 100644 --- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue +++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue @@ -8,6 +8,7 @@ import { __, s__ } from '~/locale'; import Api from '~/api'; import eventHub from './event_hub'; import EmojiMenuInModal from './emoji_menu_in_modal'; +import * as Emoji from '~/emoji'; const emojiMenuClass = 'js-modal-status-emoji-menu'; @@ -64,8 +65,8 @@ export default { const emojiAutocomplete = new GfmAutoComplete(); emojiAutocomplete.setup($(this.$refs.statusMessageField), { emojis: true }); - import(/* webpackChunkName: 'emoji' */ '~/emoji') - .then(Emoji => { + Emoji.initEmojiMap() + .then(() => { if (this.emoji) { this.emojiTag = Emoji.glEmojiTag(this.emoji); } diff --git a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue index 550a1be1e64..0987603cafd 100644 --- a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue @@ -1,5 +1,5 @@ <script> -import { mapState } from 'vuex'; +import { mapState, mapActions } from 'vuex'; import { __ } from '~/locale'; import Flash from '~/flash'; import tooltip from '~/vue_shared/directives/tooltip'; @@ -18,6 +18,10 @@ export default { }, mixins: [recaptchaModalImplementor], props: { + fullPath: { + required: true, + type: String, + }, isEditable: { required: true, type: Boolean, @@ -42,16 +46,24 @@ export default { }, }, created() { + eventHub.$on('updateConfidentialAttribute', this.updateConfidentialAttribute); eventHub.$on('closeConfidentialityForm', this.toggleForm); }, beforeDestroy() { + eventHub.$off('updateConfidentialAttribute', this.updateConfidentialAttribute); eventHub.$off('closeConfidentialityForm', this.toggleForm); }, methods: { + ...mapActions(['setConfidentiality']), toggleForm() { this.edit = !this.edit; }, - updateConfidentialAttribute(confidential) { + closeForm() { + this.edit = false; + }, + updateConfidentialAttribute() { + // TODO: rm when FF is defaulted to on. + const confidential = !this.confidential; this.service .update('issue', { confidential }) .then(({ data }) => this.checkForSpam(data)) @@ -97,12 +109,8 @@ export default { > </div> <div class="value sidebar-item-value hide-collapsed"> - <edit-form - v-if="edit" - :is-confidential="confidential" - :update-confidential-attribute="updateConfidentialAttribute" - /> - <div v-if="!confidential" class="no-value sidebar-item-value"> + <edit-form v-if="edit" :is-confidential="confidential" :full-path="fullPath" /> + <div v-if="!confidential" class="no-value sidebar-item-value" data-testid="not-confidential"> <icon :size="16" name="eye" aria-hidden="true" class="sidebar-item-icon inline" /> {{ __('Not confidential') }} </div> diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue index 0ecbf934c25..9dd4f04acdb 100644 --- a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue +++ b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue @@ -11,9 +11,9 @@ export default { required: true, type: Boolean, }, - updateConfidentialAttribute: { + fullPath: { required: true, - type: Function, + type: String, }, }, computed: { @@ -37,10 +37,7 @@ export default { <div> <p v-if="!isConfidential" v-html="confidentialityOnWarning"></p> <p v-else v-html="confidentialityOffWarning"></p> - <edit-form-buttons - :is-confidential="isConfidential" - :update-confidential-attribute="updateConfidentialAttribute" - /> + <edit-form-buttons :full-path="fullPath" /> </div> </div> </div> diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue index e106afea9f5..80928649a03 100644 --- a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue +++ b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue @@ -1,35 +1,60 @@ <script> import $ from 'jquery'; -import eventHub from '../../event_hub'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { mapActions, mapState } from 'vuex'; import { __ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import Flash from '~/flash'; +import eventHub from '../../event_hub'; export default { + components: { + GlLoadingIcon, + }, + mixins: [glFeatureFlagsMixin()], props: { - isConfidential: { - required: true, - type: Boolean, - }, - updateConfidentialAttribute: { + fullPath: { required: true, - type: Function, + type: String, }, }, + data() { + return { + isLoading: false, + }; + }, computed: { + ...mapState({ confidential: ({ noteableData }) => noteableData.confidential }), toggleButtonText() { - return this.isConfidential ? __('Turn Off') : __('Turn On'); - }, - updateConfidentialBool() { - return !this.isConfidential; + if (this.isLoading) { + return __('Applying'); + } + + return this.confidential ? __('Turn Off') : __('Turn On'); }, }, methods: { + ...mapActions(['updateConfidentialityOnIssue']), closeForm() { eventHub.$emit('closeConfidentialityForm'); $(this.$el).trigger('hidden.gl.dropdown'); }, submitForm() { - this.closeForm(); - this.updateConfidentialAttribute(this.updateConfidentialBool); + this.isLoading = true; + const confidential = !this.confidential; + + if (this.glFeatures.confidentialApolloSidebar) { + this.updateConfidentialityOnIssue({ confidential, fullPath: this.fullPath }) + .catch(() => { + Flash(__('Something went wrong trying to change the confidentiality of this issue')); + }) + .finally(() => { + this.closeForm(); + this.isLoading = false; + }); + } else { + eventHub.$emit('updateConfidentialAttribute'); + } }, }, }; @@ -37,15 +62,17 @@ export default { <template> <div class="sidebar-item-warning-message-actions"> - <button type="button" class="btn btn-default append-right-10" @click="closeForm"> + <button type="button" class="btn btn-default gl-mr-3" @click="closeForm"> {{ __('Cancel') }} </button> <button type="button" class="btn btn-close" data-testid="confidential-toggle" + :disabled="isLoading" @click.prevent="submitForm" > + <gl-loading-icon v-if="isLoading" inline /> {{ toggleButtonText }} </button> </div> diff --git a/app/assets/javascripts/sidebar/components/confidential/queries/update_issue_confidential.mutation.graphql b/app/assets/javascripts/sidebar/components/confidential/queries/update_issue_confidential.mutation.graphql new file mode 100644 index 00000000000..2459aa346c9 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/confidential/queries/update_issue_confidential.mutation.graphql @@ -0,0 +1,7 @@ +mutation updateIssueConfidential($input: IssueSetConfidentialInput!) { + issueSetConfidential(input: $input) { + issue { + confidential + } + } +} diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue index f88bde624b4..2e85ded8ade 100644 --- a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue +++ b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue @@ -41,7 +41,7 @@ export default { <template> <div class="sidebar-item-warning-message-actions"> - <button type="button" class="btn btn-default append-right-10" @click="closeForm"> + <button type="button" class="btn btn-default gl-mr-3" @click="closeForm"> {{ __('Cancel') }} </button> diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index e371091fc53..2c108835c36 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -11,6 +11,7 @@ import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptio import Translate from '../vue_shared/translate'; import createDefaultClient from '~/lib/graphql'; import { store } from '~/notes/stores'; +import { isInIssuePage } from '~/lib/utils/common_utils'; Vue.use(Translate); Vue.use(VueApollo); @@ -43,7 +44,7 @@ function mountAssigneesComponent(mediator) { projectPath: fullPath, field: el.dataset.field, signedIn: el.hasAttribute('data-signed-in'), - issuableType: gl.utils.isInIssuePage() ? 'issue' : 'merge_request', + issuableType: isInIssuePage() ? 'issue' : 'merge_request', }, }), }); @@ -52,20 +53,30 @@ function mountAssigneesComponent(mediator) { function mountConfidentialComponent(mediator) { const el = document.getElementById('js-confidential-entry-point'); + const { fullPath, iid } = getSidebarOptions(); + if (!el) return; const dataNode = document.getElementById('js-confidential-issue-data'); const initialData = JSON.parse(dataNode.innerHTML); - const ConfidentialComp = Vue.extend(ConfidentialIssueSidebar); - - new ConfidentialComp({ + // eslint-disable-next-line no-new + new Vue({ + el, store, - propsData: { - isEditable: initialData.is_editable, - service: mediator.service, + components: { + ConfidentialIssueSidebar, }, - }).$mount(el); + render: createElement => + createElement('confidential-issue-sidebar', { + props: { + iid: String(iid), + fullPath, + isEditable: initialData.is_editable, + service: mediator.service, + }, + }), + }); } function mountLockComponent(mediator) { @@ -83,7 +94,7 @@ function mountLockComponent(mediator) { isLocked: initialData.is_locked, isEditable: initialData.is_editable, mediator, - issuableType: gl.utils.isInIssuePage() ? 'issue' : 'merge_request', + issuableType: isInIssuePage() ? 'issue' : 'merge_request', }, }).$mount(el); } diff --git a/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql b/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql index 8cc68f6ea9a..2aff7da4605 100644 --- a/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql +++ b/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql @@ -1,6 +1,6 @@ -query ($fullPath: ID!, $iid: String!) { - project (fullPath: $fullPath) { - issue (iid: $iid) { +query($fullPath: ID!, $iid: String!) { + project(fullPath: $fullPath) { + issue(iid: $iid) { iid } } diff --git a/app/assets/javascripts/sidebar/queries/sidebarDetailsForHealthStatusFeatureFlag.query.graphql b/app/assets/javascripts/sidebar/queries/sidebarDetailsForHealthStatusFeatureFlag.query.graphql index 8cc68f6ea9a..2aff7da4605 100644 --- a/app/assets/javascripts/sidebar/queries/sidebarDetailsForHealthStatusFeatureFlag.query.graphql +++ b/app/assets/javascripts/sidebar/queries/sidebarDetailsForHealthStatusFeatureFlag.query.graphql @@ -1,6 +1,6 @@ -query ($fullPath: ID!, $iid: String!) { - project (fullPath: $fullPath) { - issue (iid: $iid) { +query($fullPath: ID!, $iid: String!) { + project(fullPath: $fullPath) { + issue(iid: $iid) { iid } } diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue index a6651515e47..c01f9524ca8 100644 --- a/app/assets/javascripts/snippets/components/edit.vue +++ b/app/assets/javascripts/snippets/components/edit.vue @@ -3,9 +3,8 @@ import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import Flash from '~/flash'; import { __, sprintf } from '~/locale'; -import axios from '~/lib/utils/axios_utils'; import TitleField from '~/vue_shared/components/form/title.vue'; -import { getBaseURL, joinPaths, redirectTo } from '~/lib/utils/url_utility'; +import { redirectTo } from '~/lib/utils/url_utility'; import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue'; import UpdateSnippetMutation from '../mutations/updateSnippet.mutation.graphql'; @@ -15,6 +14,9 @@ import { SNIPPET_VISIBILITY_PRIVATE, SNIPPET_CREATE_MUTATION_ERROR, SNIPPET_UPDATE_MUTATION_ERROR, + SNIPPET_BLOB_ACTION_CREATE, + SNIPPET_BLOB_ACTION_UPDATE, + SNIPPET_BLOB_ACTION_MOVE, } from '../constants'; import SnippetBlobEdit from './snippet_blob_edit.vue'; import SnippetVisibilityEdit from './snippet_visibility_edit.vue'; @@ -53,17 +55,25 @@ export default { }, data() { return { - blob: {}, - fileName: '', - content: '', - isContentLoading: true, + blobsActions: {}, isUpdating: false, newSnippet: false, }; }, computed: { + getActionsEntries() { + return Object.values(this.blobsActions); + }, + allBlobsHaveContent() { + const entries = this.getActionsEntries; + return entries.length > 0 && !entries.find(action => !action.content); + }, + allBlobChangesRegistered() { + const entries = this.getActionsEntries; + return entries.length > 0 && !entries.find(action => action.action === ''); + }, updatePrevented() { - return this.snippet.title === '' || this.content === '' || this.isUpdating; + return this.snippet.title === '' || !this.allBlobsHaveContent || this.isUpdating; }, isProjectSnippet() { return Boolean(this.projectPath); @@ -74,8 +84,7 @@ export default { title: this.snippet.title, description: this.snippet.description, visibilityLevel: this.snippet.visibilityLevel, - fileName: this.fileName, - content: this.content, + files: this.getActionsEntries.filter(entry => entry.action !== ''), }; }, saveButtonLabel() { @@ -97,9 +106,57 @@ export default { return `${this.isProjectSnippet ? 'project' : 'personal'}_snippet_description`; }, }, + created() { + window.addEventListener('beforeunload', this.onBeforeUnload); + }, + destroyed() { + window.removeEventListener('beforeunload', this.onBeforeUnload); + }, methods: { - updateFileName(newName) { - this.fileName = newName; + onBeforeUnload(e = {}) { + const returnValue = __('Are you sure you want to lose unsaved changes?'); + + if (!this.allBlobChangesRegistered) return undefined; + + Object.assign(e, { returnValue }); + return returnValue; + }, + updateBlobActions(args = {}) { + // `_constants` is the internal prop that + // should not be sent to the mutation. Hence we filter it out from + // the argsToUpdateAction that is the data-basis for the mutation. + const { _constants: blobConstants, ...argsToUpdateAction } = args; + const { previousPath, filePath, content } = argsToUpdateAction; + let actionEntry = this.blobsActions[blobConstants.id] || {}; + let tunedActions = { + action: '', + previousPath, + }; + + if (this.newSnippet) { + // new snippet, hence new blob + tunedActions = { + action: SNIPPET_BLOB_ACTION_CREATE, + previousPath: '', + }; + } else if (previousPath && filePath) { + // renaming of a blob + renaming & content update + const renamedToOriginal = filePath === blobConstants.originalPath; + tunedActions = { + action: renamedToOriginal ? SNIPPET_BLOB_ACTION_UPDATE : SNIPPET_BLOB_ACTION_MOVE, + previousPath: !renamedToOriginal ? blobConstants.originalPath : '', + }; + } else if (content !== blobConstants.originalContent) { + // content update only + tunedActions = { + action: SNIPPET_BLOB_ACTION_UPDATE, + previousPath: '', + }; + } + + actionEntry = { ...actionEntry, ...argsToUpdateAction, ...tunedActions }; + + this.$set(this.blobsActions, blobConstants.id, actionEntry); }, flashAPIFailure(err) { const defaultErrorMsg = this.newSnippet @@ -111,24 +168,9 @@ export default { onNewSnippetFetched() { this.newSnippet = true; this.snippet = this.$options.newSnippetSchema; - this.blob = this.snippet.blob; - this.isContentLoading = false; }, onExistingSnippetFetched() { this.newSnippet = false; - const { blob } = this.snippet; - this.blob = blob; - this.fileName = blob.name; - const baseUrl = getBaseURL(); - const url = joinPaths(baseUrl, blob.rawPath); - - axios - .get(url) - .then(res => { - this.content = res.data; - this.isContentLoading = false; - }) - .catch(e => this.flashAPIFailure(e)); }, onSnippetFetch(snippetRes) { if (snippetRes.data.snippets.edges.length === 0) { @@ -172,6 +214,7 @@ export default { if (errors.length) { this.flashAPIFailure(errors[0]); } else { + this.originalContent = this.content; redirectTo(baseObj.snippet.webUrl); } }) @@ -184,7 +227,6 @@ export default { title: '', description: '', visibilityLevel: SNIPPET_VISIBILITY_PRIVATE, - blob: {}, }, }; </script> @@ -215,12 +257,16 @@ export default { :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" /> - <snippet-blob-edit - v-model="content" - :file-name="fileName" - :is-loading="isContentLoading" - @name-change="updateFileName" - /> + <template v-if="blobs.length"> + <snippet-blob-edit + v-for="blob in blobs" + :key="blob.name" + :blob="blob" + @blob-updated="updateBlobActions" + /> + </template> + <snippet-blob-edit v-else @blob-updated="updateBlobActions" /> + <snippet-visibility-edit v-model="snippet.visibilityLevel" :help-link="visibilityHelpLink" diff --git a/app/assets/javascripts/snippets/components/show.vue b/app/assets/javascripts/snippets/components/show.vue index bc0034d397e..0779e87e6b6 100644 --- a/app/assets/javascripts/snippets/components/show.vue +++ b/app/assets/javascripts/snippets/components/show.vue @@ -1,19 +1,27 @@ <script> +import BlobEmbeddable from '~/blob/components/blob_embeddable.vue'; import SnippetHeader from './snippet_header.vue'; import SnippetTitle from './snippet_title.vue'; import SnippetBlob from './snippet_blob_view.vue'; import { GlLoadingIcon } from '@gitlab/ui'; import { getSnippetMixin } from '../mixins/snippets'; +import { SNIPPET_VISIBILITY_PUBLIC } from '~/snippets/constants'; export default { components: { + BlobEmbeddable, SnippetHeader, SnippetTitle, GlLoadingIcon, SnippetBlob, }, mixins: [getSnippetMixin], + computed: { + embeddable() { + return this.snippet.visibilityLevel === SNIPPET_VISIBILITY_PUBLIC; + }, + }, }; </script> <template> @@ -27,7 +35,10 @@ export default { <template v-else> <snippet-header :snippet="snippet" /> <snippet-title :snippet="snippet" /> - <snippet-blob :snippet="snippet" /> + <blob-embeddable v-if="embeddable" class="gl-mb-5" :url="snippet.webUrl" /> + <div v-for="blob in blobs" :key="blob.path"> + <snippet-blob :snippet="snippet" :blob="blob" /> + </div> </template> </div> </template> diff --git a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue index 62c29b0c7cd..3c2dbfff6e1 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue @@ -2,6 +2,17 @@ import BlobHeaderEdit from '~/blob/components/blob_edit_header.vue'; import BlobContentEdit from '~/blob/components/blob_edit_content.vue'; import { GlLoadingIcon } from '@gitlab/ui'; +import { getBaseURL, joinPaths } from '~/lib/utils/url_utility'; +import axios from '~/lib/utils/axios_utils'; +import { SNIPPET_BLOB_CONTENT_FETCH_ERROR } from '~/snippets/constants'; +import Flash from '~/flash'; +import { sprintf } from '~/locale'; + +function localId() { + return Math.floor((1 + Math.random()) * 0x10000) + .toString(16) + .substring(1); +} export default { components: { @@ -11,20 +22,70 @@ export default { }, inheritAttrs: false, props: { - value: { - type: String, + blob: { + type: Object, required: false, - default: '', + default: null, + validator: ({ rawPath }) => Boolean(rawPath), }, - fileName: { - type: String, - required: false, - default: '', + }, + data() { + return { + id: localId(), + filePath: this.blob?.path || '', + previousPath: '', + originalPath: this.blob?.path || '', + content: this.blob?.content || '', + originalContent: '', + isContentLoading: this.blob, + }; + }, + watch: { + filePath(filePath, previousPath) { + this.previousPath = previousPath; + this.notifyAboutUpdates({ previousPath }); }, - isLoading: { - type: Boolean, - required: false, - default: true, + content() { + this.notifyAboutUpdates(); + }, + }, + mounted() { + if (this.blob) { + this.fetchBlobContent(); + } + }, + methods: { + notifyAboutUpdates(args = {}) { + const { filePath, previousPath } = args; + this.$emit('blob-updated', { + filePath: filePath || this.filePath, + previousPath: previousPath || this.previousPath, + content: this.content, + _constants: { + originalPath: this.originalPath, + originalContent: this.originalContent, + id: this.id, + }, + }); + }, + fetchBlobContent() { + const baseUrl = getBaseURL(); + const url = joinPaths(baseUrl, this.blob.rawPath); + + axios + .get(url) + .then(res => { + this.originalContent = res.data; + this.content = res.data; + }) + .catch(e => this.flashAPIFailure(e)) + .finally(() => { + this.isContentLoading = false; + }); + }, + flashAPIFailure(err) { + Flash(sprintf(SNIPPET_BLOB_CONTENT_FETCH_ERROR, { err })); + this.isContentLoading = false; }, }, }; @@ -33,23 +94,14 @@ export default { <div class="form-group file-editor"> <label>{{ s__('Snippets|File') }}</label> <div class="file-holder snippet"> - <blob-header-edit - :value="fileName" - data-qa-selector="file_name_field" - @input="$emit('name-change', $event)" - /> + <blob-header-edit v-model="filePath" data-qa-selector="file_name_field" /> <gl-loading-icon - v-if="isLoading" + v-if="isContentLoading" :label="__('Loading snippet')" size="lg" class="loading-animation prepend-top-20 append-bottom-20" /> - <blob-content-edit - v-else - :value="value" - :file-name="fileName" - @input="$emit('input', $event)" - /> + <blob-content-edit v-else v-model="content" :file-name="filePath" /> </div> </div> </template> diff --git a/app/assets/javascripts/snippets/components/snippet_blob_view.vue b/app/assets/javascripts/snippets/components/snippet_blob_view.vue index 7472aff3318..afd038eef58 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_view.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_view.vue @@ -1,6 +1,4 @@ <script> -import BlobEmbeddable from '~/blob/components/blob_embeddable.vue'; -import { SNIPPET_VISIBILITY_PUBLIC } from '../constants'; import BlobHeader from '~/blob/components/blob_header.vue'; import BlobContent from '~/blob/components/blob_content.vue'; import CloneDropdownButton from '~/vue_shared/components/clone_dropdown.vue'; @@ -16,7 +14,6 @@ import { export default { components: { - BlobEmbeddable, BlobHeader, BlobContent, CloneDropdownButton, @@ -49,21 +46,19 @@ export default { type: Object, required: true, }, + blob: { + type: Object, + required: true, + }, }, data() { return { - blob: this.snippet.blob, blobContent: '', activeViewerType: - this.snippet.blob?.richViewer && !window.location.hash - ? RICH_BLOB_VIEWER - : SIMPLE_BLOB_VIEWER, + this.blob?.richViewer && !window.location.hash ? RICH_BLOB_VIEWER : SIMPLE_BLOB_VIEWER, }; }, computed: { - embeddable() { - return this.snippet.visibilityLevel === SNIPPET_VISIBILITY_PUBLIC; - }, isContentLoading() { return this.$apollo.queries.blobContent.loading; }, @@ -92,33 +87,30 @@ export default { }; </script> <template> - <div> - <blob-embeddable v-if="embeddable" class="mb-3" :url="snippet.webUrl" /> - <article class="file-holder snippet-file-content"> - <blob-header - :blob="blob" - :active-viewer-type="viewer.type" - :has-render-error="hasRenderError" - @viewer-changed="switchViewer" - > - <template #actions> - <clone-dropdown-button - v-if="canBeCloned" - class="mr-2" - :ssh-link="snippet.sshUrlToRepo" - :http-link="snippet.httpUrlToRepo" - data-qa-selector="clone_button" - /> - </template> - </blob-header> - <blob-content - :loading="isContentLoading" - :content="blobContent" - :active-viewer="viewer" - :blob="blob" - @[$options.BLOB_RENDER_EVENT_LOAD]="forceQuery" - @[$options.BLOB_RENDER_EVENT_SHOW_SOURCE]="switchViewer" - /> - </article> - </div> + <article class="file-holder snippet-file-content"> + <blob-header + :blob="blob" + :active-viewer-type="viewer.type" + :has-render-error="hasRenderError" + @viewer-changed="switchViewer" + > + <template #actions> + <clone-dropdown-button + v-if="canBeCloned" + class="gl-mr-3" + :ssh-link="snippet.sshUrlToRepo" + :http-link="snippet.httpUrlToRepo" + data-qa-selector="clone_button" + /> + </template> + </blob-header> + <blob-content + :loading="isContentLoading" + :content="blobContent" + :active-viewer="viewer" + :blob="blob" + @[$options.BLOB_RENDER_EVENT_LOAD]="forceQuery" + @[$options.BLOB_RENDER_EVENT_SHOW_SOURCE]="switchViewer" + /> + </article> </template> diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue index 2a06296cb15..707e2b0ea30 100644 --- a/app/assets/javascripts/snippets/components/snippet_header.vue +++ b/app/assets/javascripts/snippets/components/snippet_header.vue @@ -65,14 +65,17 @@ export default { }; }, computed: { + snippetHasBinary() { + return Boolean(this.snippet.blobs.find(blob => blob.binary)); + }, personalSnippetActions() { return [ { condition: this.snippet.userPermissions.updateSnippet, text: __('Edit'), href: this.editLink, - disabled: this.snippet.blob.binary, - title: this.snippet.blob.binary + disabled: this.snippetHasBinary, + title: this.snippetHasBinary ? __('Snippets with non-text files can only be edited via Git.') : undefined, }, @@ -163,7 +166,7 @@ export default { <div class="detail-page-header"> <div class="detail-page-header-body"> <div - class="snippet-box has-tooltip d-flex align-items-center append-right-5 mb-1" + class="snippet-box has-tooltip d-flex align-items-center gl-mr-2 mb-1" data-qa-selector="snippet_container" :title="snippetVisibilityLevelDescription" data-container="body" diff --git a/app/assets/javascripts/snippets/constants.js b/app/assets/javascripts/snippets/constants.js index b3abc73557c..99ee698408d 100644 --- a/app/assets/javascripts/snippets/constants.js +++ b/app/assets/javascripts/snippets/constants.js @@ -25,3 +25,8 @@ export const SNIPPET_VISIBILITY = { export const SNIPPET_CREATE_MUTATION_ERROR = __("Can't create snippet: %{err}"); export const SNIPPET_UPDATE_MUTATION_ERROR = __("Can't update snippet: %{err}"); +export const SNIPPET_BLOB_CONTENT_FETCH_ERROR = __("Can't fetch content for the blob: %{err}"); + +export const SNIPPET_BLOB_ACTION_CREATE = 'create'; +export const SNIPPET_BLOB_ACTION_UPDATE = 'update'; +export const SNIPPET_BLOB_ACTION_MOVE = 'move'; diff --git a/app/assets/javascripts/snippets/fragments/project.fragment.graphql b/app/assets/javascripts/snippets/fragments/project.fragment.graphql index 7d65789c67b..64bb2315c1b 100644 --- a/app/assets/javascripts/snippets/fragments/project.fragment.graphql +++ b/app/assets/javascripts/snippets/fragments/project.fragment.graphql @@ -1,6 +1,6 @@ -fragment Project on Snippet { +fragment SnippetProject on Snippet { project { fullPath webUrl } -}
\ No newline at end of file +} diff --git a/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql b/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql index e7765dfd8ba..2cca71708ca 100644 --- a/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql +++ b/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql @@ -11,7 +11,7 @@ fragment SnippetBase on Snippet { webUrl httpUrlToRepo sshUrlToRepo - blob { + blobs { binary name path diff --git a/app/assets/javascripts/snippets/mixins/snippets.js b/app/assets/javascripts/snippets/mixins/snippets.js index 837c41cdf6b..91331cdf339 100644 --- a/app/assets/javascripts/snippets/mixins/snippets.js +++ b/app/assets/javascripts/snippets/mixins/snippets.js @@ -1,5 +1,7 @@ import GetSnippetQuery from '../queries/snippet.query.graphql'; +const blobsDefault = []; + export const getSnippetMixin = { apollo: { snippet: { @@ -11,6 +13,7 @@ export const getSnippetMixin = { }, update: data => data.snippets.edges[0]?.node, result(res) { + this.blobs = res.data.snippets.edges[0]?.node?.blobs || blobsDefault; if (this.onSnippetFetch) { this.onSnippetFetch(res); } @@ -27,6 +30,7 @@ export const getSnippetMixin = { return { snippet: {}, newSnippet: false, + blobs: blobsDefault, }; }, computed: { diff --git a/app/assets/javascripts/snippets/mutations/deleteSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/deleteSnippet.mutation.graphql index 0c829cbdee6..f43d53661f4 100644 --- a/app/assets/javascripts/snippets/mutations/deleteSnippet.mutation.graphql +++ b/app/assets/javascripts/snippets/mutations/deleteSnippet.mutation.graphql @@ -1,5 +1,5 @@ mutation DeleteSnippet($id: ID!) { - destroySnippet(input: {id: $id}) { + destroySnippet(input: { id: $id }) { errors } -}
\ No newline at end of file +} diff --git a/app/assets/javascripts/snippets/queries/projectPermissions.query.graphql b/app/assets/javascripts/snippets/queries/projectPermissions.query.graphql index 288bd0889bf..03c81460fb5 100644 --- a/app/assets/javascripts/snippets/queries/projectPermissions.query.graphql +++ b/app/assets/javascripts/snippets/queries/projectPermissions.query.graphql @@ -4,4 +4,4 @@ query CanCreateProjectSnippet($fullPath: ID!) { createSnippet } } -}
\ No newline at end of file +} diff --git a/app/assets/javascripts/snippets/queries/snippet.query.graphql b/app/assets/javascripts/snippets/queries/snippet.query.graphql index c58a5168ba3..b23ab862439 100644 --- a/app/assets/javascripts/snippets/queries/snippet.query.graphql +++ b/app/assets/javascripts/snippets/queries/snippet.query.graphql @@ -7,7 +7,7 @@ query GetSnippetQuery($ids: [ID!]) { edges { node { ...SnippetBase - ...Project + ...SnippetProject author { ...Author } diff --git a/app/assets/javascripts/snippets/queries/userPermissions.query.graphql b/app/assets/javascripts/snippets/queries/userPermissions.query.graphql index f5b97b3d0f0..c3e5519e266 100644 --- a/app/assets/javascripts/snippets/queries/userPermissions.query.graphql +++ b/app/assets/javascripts/snippets/queries/userPermissions.query.graphql @@ -4,4 +4,4 @@ query CanCreatePersonalSnippet { createSnippet } } -}
\ No newline at end of file +} diff --git a/app/assets/javascripts/static_site_editor/components/edit_area.vue b/app/assets/javascripts/static_site_editor/components/edit_area.vue index e9efef40632..84a16f327d9 100644 --- a/app/assets/javascripts/static_site_editor/components/edit_area.vue +++ b/app/assets/javascripts/static_site_editor/components/edit_area.vue @@ -5,6 +5,8 @@ import EditHeader from './edit_header.vue'; import UnsavedChangesConfirmDialog from './unsaved_changes_confirm_dialog.vue'; import parseSourceFile from '~/static_site_editor/services/parse_source_file'; import { EDITOR_TYPES } from '~/vue_shared/components/rich_content_editor/constants'; +import { DEFAULT_IMAGE_UPLOAD_PATH } from '../constants'; +import imageRepository from '../image_repository'; export default { components: { @@ -31,46 +33,47 @@ export default { required: false, default: '', }, + imageRoot: { + type: String, + required: false, + default: DEFAULT_IMAGE_UPLOAD_PATH, + validator: prop => prop.endsWith('/'), + }, }, data() { return { saveable: false, parsedSource: parseSourceFile(this.content), editorMode: EDITOR_TYPES.wysiwyg, + isModified: false, }; }, + imageRepository: imageRepository(), computed: { editableContent() { - return this.parsedSource.editable; - }, - editableKey() { - return this.isWysiwygMode ? 'body' : 'raw'; + return this.parsedSource.content(this.isWysiwygMode); }, isWysiwygMode() { return this.editorMode === EDITOR_TYPES.wysiwyg; }, - modified() { - return this.isWysiwygMode - ? this.parsedSource.isModifiedBody() - : this.parsedSource.isModifiedRaw(); - }, }, methods: { - syncSource() { - if (this.isWysiwygMode) { - this.parsedSource.syncBody(); - return; - } - - this.parsedSource.syncRaw(); + onInputChange(newVal) { + this.parsedSource.sync(newVal, this.isWysiwygMode); + this.isModified = this.parsedSource.isModified(); }, onModeChange(mode) { this.editorMode = mode; - this.syncSource(); + this.$refs.editor.resetInitialValue(this.editableContent); + }, + onUploadImage({ file, imageUrl }) { + this.$options.imageRepository.add(file, imageUrl); }, onSubmit() { - this.syncSource(); - this.$emit('submit', { content: this.editableContent.raw }); + this.$emit('submit', { + content: this.parsedSource.content(), + images: this.$options.imageRepository.getAll(), + }); }, }, }; @@ -79,16 +82,20 @@ export default { <div class="d-flex flex-grow-1 flex-column h-100"> <edit-header class="py-2" :title="title" /> <rich-content-editor - v-model="editableContent[editableKey]" + ref="editor" + :content="editableContent" :initial-edit-type="editorMode" + :image-root="imageRoot" class="mb-9 h-100" @modeChange="onModeChange" + @input="onInputChange" + @uploadImage="onUploadImage" /> - <unsaved-changes-confirm-dialog :modified="modified" /> + <unsaved-changes-confirm-dialog :modified="isModified" /> <publish-toolbar class="gl-fixed gl-left-0 gl-bottom-0 gl-w-full" :return-url="returnUrl" - :saveable="modified" + :saveable="isModified" :saving-changes="savingChanges" @submit="onSubmit" /> diff --git a/app/assets/javascripts/static_site_editor/constants.js b/app/assets/javascripts/static_site_editor/constants.js index 947347922f2..49db9ab7ca5 100644 --- a/app/assets/javascripts/static_site_editor/constants.js +++ b/app/assets/javascripts/static_site_editor/constants.js @@ -19,3 +19,5 @@ export const DEFAULT_HEADING = s__('StaticSiteEditor|Static site editor'); export const TRACKING_ACTION_CREATE_COMMIT = 'create_commit'; export const TRACKING_ACTION_CREATE_MERGE_REQUEST = 'create_merge_request'; export const TRACKING_ACTION_INITIALIZE_EDITOR = 'initialize_editor'; + +export const DEFAULT_IMAGE_UPLOAD_PATH = 'source/images/uploads/'; diff --git a/app/assets/javascripts/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql b/app/assets/javascripts/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql index 2840d419966..cd130aa7dbb 100644 --- a/app/assets/javascripts/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql +++ b/app/assets/javascripts/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql @@ -1,5 +1,5 @@ mutation submitContentChanges($input: SubmitContentChangesInput) { - submitContentChanges(input: $input) @client { + submitContentChanges(input: $input) @client { branch commit mergeRequest diff --git a/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql b/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql index fdbf4459aee..946d80efff0 100644 --- a/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql +++ b/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql @@ -3,7 +3,7 @@ query appData { isSupportedContent project sourcePath - username, + username returnUrl } } diff --git a/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql b/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql index e36d244ae57..cfe30c601ed 100644 --- a/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql +++ b/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql @@ -1,6 +1,6 @@ query sourceContent($project: ID!, $sourcePath: String!) { project(fullPath: $project) { - fullPath, + fullPath file(path: $sourcePath) @client { title content diff --git a/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js b/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js index 6c4e3a4d973..0cb26f88785 100644 --- a/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js +++ b/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js @@ -3,10 +3,10 @@ import savedContentMetaQuery from '../queries/saved_content_meta.query.graphql'; const submitContentChangesResolver = ( _, - { input: { project: projectId, username, sourcePath, content } }, + { input: { project: projectId, username, sourcePath, content, images } }, { cache }, ) => { - return submitContentChanges({ projectId, username, sourcePath, content }).then( + return submitContentChanges({ projectId, username, sourcePath, content, images }).then( savedContentMeta => { cache.writeQuery({ query: savedContentMetaQuery, diff --git a/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql b/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql index 59da2e27144..78cc1746cdb 100644 --- a/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql +++ b/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql @@ -22,7 +22,7 @@ type AppData { username: String! } -type SubmitContentChangesInput { +input SubmitContentChangesInput { project: String! sourcePath: String! content: String! diff --git a/app/assets/javascripts/static_site_editor/image_repository.js b/app/assets/javascripts/static_site_editor/image_repository.js new file mode 100644 index 00000000000..541d581bda8 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/image_repository.js @@ -0,0 +1,20 @@ +import { __ } from '~/locale'; +import Flash from '~/flash'; +import { getBinary } from './services/image_service'; + +const imageRepository = () => { + const images = new Map(); + const flash = message => new Flash(message); + + const add = (file, url) => { + getBinary(file) + .then(content => images.set(url, content)) + .catch(() => flash(__('Something went wrong while inserting your image. Please try again.'))); + }; + + const getAll = () => images; + + return { add, getAll }; +}; + +export default imageRepository; diff --git a/app/assets/javascripts/static_site_editor/pages/home.vue b/app/assets/javascripts/static_site_editor/pages/home.vue index a1314c8a478..156b815e07a 100644 --- a/app/assets/javascripts/static_site_editor/pages/home.vue +++ b/app/assets/javascripts/static_site_editor/pages/home.vue @@ -67,11 +67,11 @@ export default { onDismissError() { this.submitChangesError = null; }, - onSubmit({ content }) { + onSubmit({ content, images }) { this.content = content; - this.submitChanges(); + this.submitChanges(images); }, - submitChanges() { + submitChanges(images) { this.isSavingChanges = true; this.$apollo @@ -83,6 +83,7 @@ export default { username: this.appData.username, sourcePath: this.appData.sourcePath, content: this.content, + images, }, }, }) diff --git a/app/assets/javascripts/static_site_editor/services/image_service.js b/app/assets/javascripts/static_site_editor/services/image_service.js new file mode 100644 index 00000000000..edc69d0579a --- /dev/null +++ b/app/assets/javascripts/static_site_editor/services/image_service.js @@ -0,0 +1,9 @@ +// eslint-disable-next-line import/prefer-default-export +export const getBinary = file => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => resolve(reader.result.split(',')[1]); + reader.onerror = error => reject(error); + }); +}; diff --git a/app/assets/javascripts/static_site_editor/services/parse_source_file.js b/app/assets/javascripts/static_site_editor/services/parse_source_file.js index f32c693411f..126dfe81b90 100644 --- a/app/assets/javascripts/static_site_editor/services/parse_source_file.js +++ b/app/assets/javascripts/static_site_editor/services/parse_source_file.js @@ -22,33 +22,43 @@ const parseSourceFile = raw => { return buildPayload(source, '', '', source); }; - const computedRaw = () => `${editable.header}${editable.spacing}${editable.body}`; - - const syncBody = () => { + const syncEditable = () => { /* We re-parse as markdown editing could have added non-body changes (preFrontMatter, frontMatter, or spacing). - Re-parsing additionally gets us the desired body that was extracted from the mutated editable.raw - Additionally we intentionally mutate the existing editable's key values as opposed to reassigning the object itself so consumers of the potentially reactive property stay in sync. + Re-parsing additionally gets us the desired body that was extracted from the potentially mutated editable.raw */ - Object.assign(editable, parse(editable.raw)); + editable = parse(editable.raw); + }; + + const syncBodyToRaw = () => { + editable.raw = `${editable.header}${editable.spacing}${editable.body}`; + }; + + const sync = (newVal, isBodyToRaw) => { + const editableKey = isBodyToRaw ? 'body' : 'raw'; + editable[editableKey] = newVal; + + if (isBodyToRaw) { + syncBodyToRaw(); + } + + syncEditable(); }; - const syncRaw = () => { - editable.raw = computedRaw(); + const content = (isBody = false) => { + const editableKey = isBody ? 'body' : 'raw'; + return editable[editableKey]; }; - const isModifiedRaw = () => initial.raw !== editable.raw; - const isModifiedBody = () => initial.raw !== computedRaw(); + const isModified = () => initial.raw !== editable.raw; initial = parse(raw); editable = parse(raw); return { - editable, - isModifiedRaw, - isModifiedBody, - syncRaw, - syncBody, + content, + isModified, + sync, }; }; diff --git a/app/assets/javascripts/static_site_editor/services/submit_content_changes.js b/app/assets/javascripts/static_site_editor/services/submit_content_changes.js index fce7c1f918f..da62d3fa4fc 100644 --- a/app/assets/javascripts/static_site_editor/services/submit_content_changes.js +++ b/app/assets/javascripts/static_site_editor/services/submit_content_changes.js @@ -21,7 +21,32 @@ const createBranch = (projectId, branch) => throw new Error(SUBMIT_CHANGES_BRANCH_ERROR); }); -const commitContent = (projectId, message, branch, sourcePath, content) => { +const createImageActions = (images, markdown) => { + const actions = []; + + if (!markdown) { + return actions; + } + + images.forEach((imageContent, filePath) => { + const imageExistsInMarkdown = path => new RegExp(`!\\[([^[\\]\\n]*)\\](\\(${path})\\)`); // matches the image markdown syntax: ![<any-string-except-newline>](<path>) + + if (imageExistsInMarkdown(filePath).test(markdown)) { + actions.push( + convertObjectPropsToSnakeCase({ + encoding: 'base64', + action: 'create', + content: imageContent, + filePath, + }), + ); + } + }); + + return actions; +}; + +const commitContent = (projectId, message, branch, sourcePath, content, images) => { Tracking.event(document.body.dataset.page, TRACKING_ACTION_CREATE_COMMIT); return Api.commitMultiple( @@ -35,6 +60,7 @@ const commitContent = (projectId, message, branch, sourcePath, content) => { filePath: sourcePath, content, }), + ...createImageActions(images, content), ], }), ).catch(() => { @@ -62,7 +88,7 @@ const createMergeRequest = ( }); }; -const submitContentChanges = ({ username, projectId, sourcePath, content }) => { +const submitContentChanges = ({ username, projectId, sourcePath, content, images }) => { const branch = generateBranchName(username); const mergeRequestTitle = sprintf(s__(`StaticSiteEditor|Update %{sourcePath} file`), { sourcePath, @@ -73,7 +99,7 @@ const submitContentChanges = ({ username, projectId, sourcePath, content }) => { .then(({ data: { web_url: url } }) => { Object.assign(meta, { branch: { label: branch, url } }); - return commitContent(projectId, mergeRequestTitle, branch, sourcePath, content); + return commitContent(projectId, mergeRequestTitle, branch, sourcePath, content, images); }) .then(({ data: { short_id: label, web_url: url } }) => { Object.assign(meta, { commit: { label, url } }); diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js index bde00d72620..290de55e6f9 100644 --- a/app/assets/javascripts/user_popovers.js +++ b/app/assets/javascripts/user_popovers.js @@ -1,5 +1,7 @@ import Vue from 'vue'; +import sanitize from 'sanitize-html'; + import UsersCache from './lib/utils/users_cache'; import UserPopover from './vue_shared/components/user_popover/user_popover.vue'; @@ -38,6 +40,7 @@ const populateUserInfo = user => { name: userData.name, location: userData.location, bio: userData.bio, + bioHtml: sanitize(userData.bio_html), workInformation: userData.work_information, loaded: true, }); diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js index 2dbe5a8171e..f72de8c2f4d 100644 --- a/app/assets/javascripts/users_select/index.js +++ b/app/assets/javascripts/users_select/index.js @@ -21,8 +21,8 @@ function UsersSelect(currentUser, els, options = {}) { const $els = $(els || '.js-user-search'); this.users = this.users.bind(this); this.user = this.user.bind(this); - this.usersPath = '/autocomplete/users.json'; - this.userPath = '/autocomplete/users/:id.json'; + this.usersPath = '/-/autocomplete/users.json'; + this.userPath = '/-/autocomplete/users/:id.json'; if (currentUser != null) { if (typeof currentUser === 'object') { this.currentUser = currentUser; @@ -263,7 +263,7 @@ function UsersSelect(currentUser, els, options = {}) { const userId = parseInt(input.value, 10); const { avatarUrl, avatar_url, name, username, canMerge } = input.dataset; return { - avatar_url: avatarUrl || avatar_url, + avatar_url: avatarUrl || avatar_url || gon.default_avatar_url, id: userId, name, username, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue new file mode 100644 index 00000000000..0f9d1b8395b --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue @@ -0,0 +1,212 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { s__ } from '~/locale'; +import eventHub from '../../event_hub'; +import approvalsMixin from '../../mixins/approvals'; +import MrWidgetContainer from '../mr_widget_container.vue'; +import MrWidgetIcon from '../mr_widget_icon.vue'; +import ApprovalsSummary from './approvals_summary.vue'; +import ApprovalsSummaryOptional from './approvals_summary_optional.vue'; +import { FETCH_LOADING, FETCH_ERROR, APPROVE_ERROR, UNAPPROVE_ERROR } from './messages'; + +export default { + name: 'MRWidgetApprovals', + components: { + MrWidgetContainer, + MrWidgetIcon, + ApprovalsSummary, + ApprovalsSummaryOptional, + GlButton, + }, + mixins: [approvalsMixin], + props: { + mr: { + type: Object, + required: true, + }, + service: { + type: Object, + required: true, + }, + isOptionalDefault: { + type: Boolean, + required: false, + default: null, + }, + approveDefault: { + type: Function, + required: false, + default: null, + }, + modalId: { + type: String, + required: false, + default: null, + }, + requirePasswordToApprove: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + fetchingApprovals: true, + hasApprovalAuthError: false, + isApproving: false, + }; + }, + computed: { + isBasic() { + return this.mr.approvalsWidgetType === 'base'; + }, + isApproved() { + return Boolean(this.approvals.approved); + }, + isOptional() { + return this.isOptionalDefault !== null ? this.isOptionalDefault : !this.approvedBy.length; + }, + hasAction() { + return Boolean(this.action); + }, + approvals() { + return this.mr.approvals || {}; + }, + approvedBy() { + return this.approvals.approved_by ? this.approvals.approved_by.map(x => x.user) : []; + }, + userHasApproved() { + return Boolean(this.approvals.user_has_approved); + }, + userCanApprove() { + return Boolean(this.approvals.user_can_approve); + }, + showApprove() { + return !this.userHasApproved && this.userCanApprove && this.mr.isOpen; + }, + showUnapprove() { + return this.userHasApproved && !this.userCanApprove && this.mr.state !== 'merged'; + }, + approvalText() { + return this.isApproved && this.approvedBy.length > 0 + ? s__('mrWidget|Approve additionally') + : s__('mrWidget|Approve'); + }, + action() { + // Use the default approve action, only if we aren't using the auth component for it + if (this.showApprove) { + return { + text: this.approvalText, + category: this.isApproved ? 'secondary' : 'primary', + variant: 'info', + action: () => this.approve(), + }; + } else if (this.showUnapprove) { + return { + text: s__('mrWidget|Revoke approval'), + variant: 'warning', + category: 'secondary', + action: () => this.unapprove(), + }; + } + + return null; + }, + }, + created() { + this.refreshApprovals() + .then(() => { + this.fetchingApprovals = false; + }) + .catch(() => createFlash(FETCH_ERROR)); + }, + methods: { + approve() { + if (this.requirePasswordToApprove) { + this.$root.$emit('bv::show::modal', this.modalId); + return; + } + + this.updateApproval( + () => this.service.approveMergeRequest(), + () => createFlash(APPROVE_ERROR), + ); + }, + approveWithAuth(data) { + this.updateApproval( + () => this.service.approveMergeRequestWithAuth(data), + error => { + if (error && error.response && error.response.status === 401) { + this.hasApprovalAuthError = true; + return; + } + createFlash(APPROVE_ERROR); + }, + ); + }, + unapprove() { + this.updateApproval( + () => this.service.unapproveMergeRequest(), + () => createFlash(UNAPPROVE_ERROR), + ); + }, + updateApproval(serviceFn, errFn) { + this.isApproving = true; + this.clearError(); + return serviceFn() + .then(data => { + this.mr.setApprovals(data); + eventHub.$emit('MRWidgetUpdateRequested'); + this.$emit('updated'); + }) + .catch(errFn) + .then(() => { + this.isApproving = false; + }); + }, + }, + FETCH_LOADING, +}; +</script> +<template> + <mr-widget-container> + <div class="js-mr-approvals d-flex align-items-start align-items-md-center"> + <mr-widget-icon name="approval" /> + <div v-if="fetchingApprovals">{{ $options.FETCH_LOADING }}</div> + <template v-else> + <gl-button + v-if="action" + :variant="action.variant" + :category="action.category" + :loading="isApproving" + class="mr-3" + data-qa-selector="approve_button" + @click="action.action" + > + {{ action.text }} + </gl-button> + <approvals-summary-optional + v-if="isOptional" + :can-approve="hasAction" + :help-path="mr.approvalsHelpPath" + /> + <approvals-summary + v-else + :approved="isApproved" + :approvals-left="approvals.approvals_left || 0" + :rules-left="approvals.approvalRuleNamesLeft" + :approvers="approvedBy" + /> + <slot + :is-approving="isApproving" + :approve-with-auth="approveWithAuth" + :hasApproval-auth-error="hasApprovalAuthError" + ></slot> + </template> + </div> + <template #footer> + <slot name="footer"></slot> + </template> + </mr-widget-container> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue new file mode 100644 index 00000000000..fb342a5d340 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue @@ -0,0 +1,70 @@ +<script> +import { n__, sprintf } from '~/locale'; +import { toNounSeriesText } from '~/lib/utils/grammar'; +import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue'; +import { APPROVED_MESSAGE } from '~/vue_merge_request_widget/components/approvals/messages'; + +export default { + components: { + UserAvatarList, + }, + props: { + approved: { + type: Boolean, + required: true, + }, + approvalsLeft: { + type: Number, + required: true, + }, + rulesLeft: { + type: Array, + required: false, + default: () => [], + }, + approvers: { + type: Array, + required: false, + default: () => [], + }, + }, + computed: { + message() { + if (this.approved) { + return APPROVED_MESSAGE; + } + + if (!this.rulesLeft.length) { + return n__('Requires approval.', 'Requires %d more approvals.', this.approvalsLeft); + } + + return sprintf( + n__( + 'Requires approval from %{names}.', + 'Requires %{count} more approvals from %{names}.', + this.approvalsLeft, + ), + { + names: toNounSeriesText(this.rulesLeft), + count: this.approvalsLeft, + }, + false, + ); + }, + hasApprovers() { + return Boolean(this.approvers.length); + }, + }, + APPROVED_MESSAGE, +}; +</script> + +<template> + <div data-qa-selector="approvals_summary_content"> + <strong>{{ message }}</strong> + <template v-if="hasApprovers"> + <span>{{ s__('mrWidget|Approved by') }}</span> + <user-avatar-list class="d-inline-block align-middle" :items="approvers" /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue new file mode 100644 index 00000000000..66af0c5a83e --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue @@ -0,0 +1,50 @@ +<script> +import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import { + OPTIONAL, + OPTIONAL_CAN_APPROVE, +} from '~/vue_merge_request_widget/components/approvals/messages'; + +export default { + components: { + GlLink, + Icon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + canApprove: { + type: Boolean, + required: true, + }, + helpPath: { + type: String, + required: false, + default: '', + }, + }, + computed: { + message() { + return this.canApprove ? OPTIONAL_CAN_APPROVE : OPTIONAL; + }, + }, +}; +</script> + +<template> + <div class="d-flex align-items-center"> + <span class="text-muted">{{ message }}</span> + <gl-link + v-if="canApprove && helpPath" + v-gl-tooltip + :href="helpPath" + :title="__('About this feature')" + target="_blank" + class="d-flex-center pl-1" + > + <icon name="question" /> + </gl-link> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/messages.js b/app/assets/javascripts/vue_merge_request_widget/components/approvals/messages.js new file mode 100644 index 00000000000..1d9368f71aa --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/messages.js @@ -0,0 +1,11 @@ +import { __, s__ } from '~/locale'; + +export const FETCH_LOADING = __('Checking approval status'); +export const FETCH_ERROR = s__( + 'mrWidget|An error occurred while retrieving approval data for this merge request.', +); +export const APPROVE_ERROR = s__('mrWidget|An error occurred while submitting your approval.'); +export const UNAPPROVE_ERROR = s__('mrWidget|An error occurred while removing your approval.'); +export const APPROVED_MESSAGE = s__('mrWidget|Merge request approved.'); +export const OPTIONAL_CAN_APPROVE = s__('mrWidget|No approval required; you can still approve'); +export const OPTIONAL = s__('mrWidget|No approval required'); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/loading.vue b/app/assets/javascripts/vue_merge_request_widget/components/loading.vue index 78dc28ee92b..cd4e31e0dae 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/loading.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/loading.vue @@ -9,7 +9,7 @@ export default { </script> <template> - <div class="prepend-top-default"> + <div class="gl-mt-3"> <div class="mr-widget-heading p-3"> <gl-skeleton-loader :width="577" :height="12"> <rect width="86" height="12" rx="2" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue index 294871ca5c2..24174c29d51 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue @@ -1,11 +1,11 @@ <script> -import { GlDeprecatedButton, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; export default { components: { - GlDeprecatedButton, + GlButton, GlLoadingIcon, Icon, }, @@ -58,16 +58,17 @@ export default { </div> <template v-else> - <gl-deprecated-button - class="btn-blank btn s32 square append-right-default" + <button + class="btn-blank btn s32 square gl-mr-3" + type="button" :aria-label="ariaLabel" :disabled="isLoading" @click="toggleCollapsed" > <gl-loading-icon v-if="isLoading" /> <icon v-else :name="arrowIconName" class="js-icon" /> - </gl-deprecated-button> - <gl-deprecated-button + </button> + <gl-button variant="link" class="js-title" :disabled="isLoading" @@ -76,7 +77,7 @@ export default { > <template v-if="isCollapsed">{{ title }}</template> <template v-else>{{ __('Collapse') }}</template> - </gl-deprecated-button> + </gl-button> </template> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue index 84937aa9510..598b08f4c16 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue @@ -27,7 +27,7 @@ export default { return this.author.webUrl || this.author.web_url; }, avatarUrl() { - return this.author.avatarUrl || this.author.avatar_url; + return this.author.avatarUrl || this.author.avatar_url || gl.mrWidgetData.defaultAvatarUrl; }, }, }; @@ -40,6 +40,6 @@ export default { class="author-link inline" > <img :src="avatarUrl" class="avatar avatar-inline s16" /> - <span v-if="showAuthorName" class="author"> {{ author.name }} </span> + <span v-if="showAuthorName" class="author">{{ author.name }}</span> </a> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_expandable_section.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_expandable_section.vue new file mode 100644 index 00000000000..fd999540f4a --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_expandable_section.vue @@ -0,0 +1,72 @@ +<script> +import { __ } from '~/locale'; +import { GlButton, GlCollapse, GlIcon } from '@gitlab/ui'; + +/** + * Renders header section with icon and expand button + * Renders expanable content section with grey background + */ +export default { + name: 'MrWidgetExpanableSection', + components: { + GlButton, + GlCollapse, + GlIcon, + }, + props: { + iconName: { + type: String, + required: false, + default: 'status_warning', + }, + }, + data() { + return { + contentIsVisible: false, + }; + }, + computed: { + collapseButtonText() { + if (this.contentIsVisible) { + return __('Collapse'); + } + + return __('Expand'); + }, + }, + methods: { + updateContentVisibility() { + this.contentIsVisible = !this.contentIsVisible; + }, + }, +}; +</script> + +<template> + <div> + <div class="mr-widget-body gl-display-flex"> + <span + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-mr-3 gl-align-self-start gl-mt-1" + > + <gl-icon :name="iconName" :size="24" /> + </span> + + <div class="gl-display-flex gl-flex-fill-1 gl-flex-direction-column gl-md-flex-direction-row"> + <slot name="header"></slot> + + <div> + <gl-button @click="updateContentVisibility"> + {{ collapseButtonText }} + </gl-button> + </div> + </div> + </div> + + <gl-collapse + :visible="contentIsVisible" + class="gl-bg-gray-10 gl-border-t-solid gl-border-gray-100 gl-border-1" + > + <slot name="content"></slot> + </gl-collapse> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue index 0464c4b9c15..897f706290d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue @@ -1,4 +1,5 @@ <script> +import Mousetrap from 'mousetrap'; import { escape } from 'lodash'; import { n__, s__, sprintf } from '~/locale'; import { mergeUrlParams, webIDEUrl } from '~/lib/utils/url_utility'; @@ -74,10 +75,21 @@ export default { : ''; }, }, + mounted() { + Mousetrap.bind('b', this.copyBranchName); + }, + beforeDestroy() { + Mousetrap.unbind('b'); + }, + methods: { + copyBranchName() { + this.$refs.copyBranchNameButton.$el.click(); + }, + }, }; </script> <template> - <div class="d-flex mr-source-target append-bottom-default"> + <div class="d-flex mr-source-target gl-mb-3"> <mr-widget-icon name="git-merge" /> <div class="git-merge-container d-flex"> <div class="normal"> @@ -89,6 +101,7 @@ export default { class="label-branch label-truncate js-source-branch" v-html="mr.sourceBranchLink" /><clipboard-button + ref="copyBranchNameButton" :text="branchNameClipboardData" :title="__('Copy branch name')" css-class="btn-default btn-transparent btn-clipboard" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue index 57d4d8b7ae6..e1659d9a167 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue @@ -13,7 +13,7 @@ export default { </script> <template> - <div class="circle-icon-container append-right-default align-self-start align-self-lg-center"> + <div class="circle-icon-container gl-mr-3 align-self-start align-self-lg-center"> <icon :name="name" :size="24" /> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue index 6df53311ef0..a096eb1a1fe 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -1,21 +1,22 @@ <script> /* eslint-disable vue/require-default-prop */ -import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import { GlIcon, GlLink, GlLoadingIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; import mrWidgetPipelineMixin from 'ee_else_ce/vue_merge_request_widget/mixins/mr_widget_pipeline'; -import { sprintf, s__ } from '~/locale'; -import PipelineStage from '~/pipelines/components/stage.vue'; +import { s__ } from '~/locale'; +import PipelineStage from '~/pipelines/components/pipelines_list/stage.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import Icon from '~/vue_shared/components/icon.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; export default { name: 'MRWidgetPipeline', components: { - PipelineStage, CiIcon, - Icon, - TooltipOnTruncate, GlLink, + GlLoadingIcon, + GlIcon, + GlSprintf, + PipelineStage, + TooltipOnTruncate, LinkedPipelinesMiniList: () => import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'), }, @@ -54,7 +55,11 @@ export default { type: String, required: false, }, - troubleshootingDocsPath: { + mrTroubleshootingDocsPath: { + type: String, + required: true, + }, + ciTroubleshootingDocsPath: { type: String, required: true, }, @@ -64,10 +69,7 @@ export default { return this.pipeline && Object.keys(this.pipeline).length > 0; }, hasCIError() { - return (this.hasCi && !this.ciStatus) || this.hasPipelineMustSucceedConflict; - }, - hasPipelineMustSucceedConflict() { - return !this.hasCi && this.pipelineMustSucceed; + return this.hasPipeline && !this.ciStatus; }, status() { return this.pipeline.details && this.pipeline.details.status @@ -82,22 +84,6 @@ export default { hasCommitInfo() { return this.pipeline.commit && Object.keys(this.pipeline.commit).length > 0; }, - errorText() { - if (this.hasPipelineMustSucceedConflict) { - return s__('Pipeline|No pipeline has been run for this commit.'); - } - - return sprintf( - s__( - 'Pipeline|Could not retrieve the pipeline status. For troubleshooting steps, read the %{linkStart}documentation%{linkEnd}.', - ), - { - linkStart: `<a href="${this.troubleshootingDocsPath}">`, - linkEnd: '</a>', - }, - false, - ); - }, isTriggeredByMergeRequest() { return Boolean(this.pipeline.merge_request); }, @@ -118,31 +104,69 @@ export default { return ''; }, }, + errorText: s__( + 'Pipeline|Could not retrieve the pipeline status. For troubleshooting steps, read the %{linkStart}documentation%{linkEnd}.', + ), + monitoringPipelineText: s__('Pipeline|Checking pipeline status.'), }; </script> <template> - <div class="ci-widget media js-ci-widget"> - <template v-if="!hasPipeline || hasCIError"> - <div class="add-border ci-status-icon ci-status-icon-failed ci-error js-ci-error"> - <icon :size="24" name="status_failed_borderless" /> + <div class="ci-widget media"> + <template v-if="hasCIError"> + <gl-icon name="status_failed" class="gl-text-red-500" :size="24" /> + <div + class="gl-flex-fill-1 gl-ml-5" + tabindex="0" + role="text" + :aria-label="$options.errorText" + data-testid="ci-error-message" + > + <gl-sprintf :message="$options.errorText"> + <template #link="{content}"> + <gl-link :href="mrTroubleshootingDocsPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> + </template> + <template v-else-if="!hasPipeline"> + <gl-loading-icon size="md" /> + <div class="gl-flex-fill-1 gl-display-flex gl-ml-5" data-testid="monitoring-pipeline-message"> + <span tabindex="0" role="text" :aria-label="$options.monitoringPipelineText"> + <gl-sprintf :message="$options.monitoringPipelineText" /> + </span> + <gl-link + :href="ciTroubleshootingDocsPath" + target="_blank" + class="gl-display-flex gl-align-items-center gl-ml-2" + tabindex="0" + > + <gl-icon + name="question" + :small="12" + tabindex="0" + role="text" + :aria-label="__('Link to go to GitLab pipeline documentation')" + /> + </gl-link> </div> - <div class="media-body prepend-left-default" v-html="errorText"></div> </template> <template v-else-if="hasPipeline"> - <a :href="status.details_path" class="align-self-start append-right-default"> + <a :href="status.details_path" class="align-self-start gl-mr-3"> <ci-icon :status="status" :size="24" :borderless="true" class="add-border" /> </a> <div class="ci-widget-container d-flex"> <div class="ci-widget-content"> <div class="media-body"> <div - class="font-weight-bold js-pipeline-info-container" + class="gl-font-weight-bold" + data-testid="pipeline-info-container" data-qa-selector="merge_request_pipeline_info_content" > {{ pipeline.details.name }} <gl-link :href="pipeline.path" - class="pipeline-id font-weight-normal pipeline-number" + class="pipeline-id gl-font-weight-normal pipeline-number" + data-testid="pipeline-id" data-qa-selector="pipeline_link" >#{{ pipeline.id }}</gl-link > @@ -151,7 +175,8 @@ export default { {{ s__('Pipeline|for') }} <gl-link :href="pipeline.commit.commit_path" - class="commit-sha js-commit-link font-weight-normal" + class="commit-sha gl-font-weight-normal" + data-testid="commit-link" >{{ pipeline.commit.short_id }}</gl-link > </template> @@ -160,18 +185,18 @@ export default { <tooltip-on-truncate :title="sourceBranch" truncate-target="child" - class="label-branch label-truncate font-weight-normal" + class="label-branch label-truncate gl-font-weight-normal" v-html="sourceBranchLink" /> </template> </div> - <div v-if="pipeline.coverage" class="coverage"> + <div v-if="pipeline.coverage" class="coverage" data-testid="pipeline-coverage"> {{ s__('Pipeline|Coverage') }} {{ pipeline.coverage }}% <span v-if="pipelineCoverageDelta" - class="js-pipeline-coverage-delta" :class="coverageDeltaClass" + data-testid="pipeline-coverage-delta" > ({{ pipelineCoverageDelta }}%) </span> @@ -189,13 +214,13 @@ export default { :class="{ 'has-downstream': hasDownstream(i), }" - class="stage-container dropdown js-mini-pipeline-graph mr-widget-pipeline-stages" + class="stage-container dropdown mr-widget-pipeline-stages" + data-testid="widget-mini-pipeline-graph" > <pipeline-stage :stage="stage" /> </div> </template> </span> - <linked-pipelines-mini-list v-if="triggered.length" :triggered="triggered" /> </span> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue index 8fba0e2981f..5c307b5ff0c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue @@ -82,7 +82,8 @@ export default { :pipeline-must-succeed="mr.onlyAllowMergeIfPipelineSucceeds" :source-branch="branch" :source-branch-link="branchLink" - :troubleshooting-docs-path="mr.troubleshootingDocsPath" + :mr-troubleshooting-docs-path="mr.mrTroubleshootingDocsPath" + :ci-troubleshooting-docs-path="mr.ciTroubleshootingDocsPath" /> <template #footer> <div v-if="mr.exposedArtifactsPath" class="js-exposed-artifacts"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue index d0df8309dc7..82566682bca 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue @@ -33,7 +33,7 @@ export default { </script> <template> <div class="d-flex align-self-start"> - <div class="square s24 h-auto d-flex-center append-right-default"> + <div class="square s24 h-auto d-flex-center gl-mr-3"> <div v-if="isLoading" class="mr-widget-icon d-inline-flex"> <gl-loading-icon size="md" class="mr-loading-icon d-inline-flex" /> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue index 9942861d9e4..de01821a292 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue @@ -1,22 +1,31 @@ <script> -import { GlLink, GlSprintf } from '@gitlab/ui'; +import { GlLink, GlSprintf, GlButton } from '@gitlab/ui'; import MrWidgetIcon from './mr_widget_icon.vue'; -import PipelineTourState from './states/mr_widget_pipeline_tour.vue'; +import Tracking from '~/tracking'; +import { s__ } from '~/locale'; + +const trackingMixin = Tracking.mixin(); +const TRACK_LABEL = 'no_pipeline_noticed'; export default { name: 'MRWidgetSuggestPipeline', iconName: 'status_notfound', - popoverTarget: 'suggest-popover', - popoverContainer: 'suggest-pipeline', - trackLabel: 'no_pipeline_noticed', + trackLabel: TRACK_LABEL, linkTrackValue: 30, linkTrackEvent: 'click_link', + showTrackValue: 10, + showTrackEvent: 'click_button', + helpContent: s__( + `mrWidget|Use %{linkStart}CI pipelines to test your code%{linkEnd} by simply adding a GitLab CI configuration file to your project. It only takes a minute to make your code more secure and robust.`, + ), + helpURL: 'https://about.gitlab.com/blog/2019/07/12/guide-to-ci-cd-pipelines/', components: { GlLink, GlSprintf, + GlButton, MrWidgetIcon, - PipelineTourState, }, + mixins: [trackingMixin], props: { pipelinePath: { type: String, @@ -31,45 +40,89 @@ export default { required: true, }, }, + computed: { + tracking() { + return { + label: TRACK_LABEL, + property: this.humanAccess, + }; + }, + }, + mounted() { + this.track(); + }, }; </script> <template> - <div :id="$options.popoverContainer" class="d-flex mr-pipeline-suggest append-bottom-default"> - <mr-widget-icon :name="$options.iconName" /> - <div :id="$options.popoverTarget"> - <gl-sprintf - :message=" - s__(`mrWidget|%{prefixToLinkStart}No pipeline%{prefixToLinkEnd} + <div class="mr-widget-body mr-pipeline-suggest gl-mb-3"> + <div class="gl-display-flex gl-align-items-center"> + <mr-widget-icon :name="$options.iconName" /> + <div> + <gl-sprintf + :message=" + s__(`mrWidget|%{prefixToLinkStart}No pipeline%{prefixToLinkEnd} %{addPipelineLinkStart}Add the .gitlab-ci.yml file%{addPipelineLinkEnd} to create one.`) - " - > - <template #prefixToLink="{content}"> + " + > + <template #prefixToLink="{content}"> + <strong> + {{ content }} + </strong> + </template> + <template #addPipelineLink="{content}"> + <gl-link + :href="pipelinePath" + class="gl-ml-1" + data-testid="add-pipeline-link" + :data-track-property="humanAccess" + :data-track-value="$options.linkTrackValue" + :data-track-event="$options.linkTrackEvent" + :data-track-label="$options.trackLabel" + > + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </div> + </div> + <div class="row"> + <div class="col-md-5 order-md-last col-12 gl-mt-5 mt-md-n3 svg-content svg-225"> + <img data-testid="pipeline-image" :src="pipelineSvgPath" /> + </div> + <div class="col-md-7 order-md-first col-12"> + <div class="ml-6 gl-pt-5"> <strong> - {{ content }} + {{ s__('mrWidget|Are you adding technical debt or code vulnerabilities?') }} </strong> - </template> - <template #addPipelineLink="{content}"> - <gl-link + <p class="gl-mt-2"> + <gl-sprintf :message="$options.helpContent"> + <template #link="{ content }"> + <gl-link + data-testid="help" + :href="$options.helpURL" + target="_blank" + class="font-size-inherit" + >{{ content }} + </gl-link> + </template> + </gl-sprintf> + </p> + <gl-button + data-testid="ok" + category="primary" + class="gl-mt-2" + variant="info" :href="pipelinePath" - class="ml-2 js-add-pipeline-path" :data-track-property="humanAccess" - :data-track-value="$options.linkTrackValue" - :data-track-event="$options.linkTrackEvent" + :data-track-value="$options.showTrackValue" + :data-track-event="$options.showTrackEvent" :data-track-label="$options.trackLabel" > - {{ content }} - </gl-link> - </template> - </gl-sprintf> - <pipeline-tour-state - :pipeline-path="pipelinePath" - :pipeline-svg-path="pipelineSvgPath" - :human-access="humanAccess" - :popover-target="$options.popoverTarget" - :popover-container="$options.popoverContainer" - :track-label="$options.trackLabel" - /> + {{ __('Show me how to add a pipeline') }} + </gl-button> + </div> + </div> </div> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_terraform_plan.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_terraform_plan.vue deleted file mode 100644 index 2ef5e81b36b..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_terraform_plan.vue +++ /dev/null @@ -1,139 +0,0 @@ -<script> -import { __ } from '~/locale'; -import { GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; -import axios from '~/lib/utils/axios_utils'; -import flash from '~/flash'; -import Poll from '~/lib/utils/poll'; - -export default { - name: 'MRWidgetTerraformPlan', - components: { - GlIcon, - GlLink, - GlLoadingIcon, - GlSprintf, - }, - props: { - endpoint: { - type: String, - required: true, - }, - }, - data() { - return { - loading: true, - plans: {}, - }; - }, - computed: { - addNum() { - return Number(this.plan.create); - }, - changeNum() { - return Number(this.plan.update); - }, - deleteNum() { - return Number(this.plan.delete); - }, - logUrl() { - return this.plan.job_path; - }, - plan() { - const firstPlanKey = Object.keys(this.plans)[0]; - return this.plans[firstPlanKey] ?? {}; - }, - validPlanValues() { - return this.addNum + this.changeNum + this.deleteNum >= 0; - }, - }, - created() { - this.fetchPlans(); - }, - methods: { - fetchPlans() { - this.loading = true; - - const poll = new Poll({ - resource: { - fetchPlans: () => axios.get(this.endpoint), - }, - data: this.endpoint, - method: 'fetchPlans', - successCallback: ({ data }) => { - this.plans = data; - - if (Object.keys(this.plan).length) { - this.loading = false; - poll.stop(); - } - }, - errorCallback: () => { - this.plans = {}; - this.loading = false; - flash(__('An error occurred while loading terraform report')); - }, - }); - - poll.makeRequest(); - }, - }, -}; -</script> - -<template> - <section class="mr-widget-section"> - <div class="mr-widget-body media d-flex flex-row"> - <span class="append-right-default align-self-start align-self-lg-center"> - <gl-icon name="status_warning" :size="24" /> - </span> - - <div class="d-flex flex-fill flex-column flex-md-row"> - <div class="terraform-mr-plan-text normal d-flex flex-column flex-lg-row"> - <p class="m-0 pr-1">{{ __('A terraform report was generated in your pipelines.') }}</p> - - <gl-loading-icon v-if="loading" size="md" /> - - <p v-else-if="validPlanValues" class="m-0"> - <gl-sprintf - :message=" - __( - 'Reported Resource Changes: %{addNum} to add, %{changeNum} to change, %{deleteNum} to delete', - ) - " - > - <template #addNum> - <strong>{{ addNum }}</strong> - </template> - - <template #changeNum> - <strong>{{ changeNum }}</strong> - </template> - - <template #deleteNum> - <strong>{{ deleteNum }}</strong> - </template> - </gl-sprintf> - </p> - - <p v-else class="m-0">{{ __('Changes are unknown') }}</p> - </div> - - <div class="terraform-mr-plan-actions"> - <gl-link - v-if="logUrl" - :href="logUrl" - target="_blank" - data-track-event="click_terraform_mr_plan_button" - data-track-label="mr_widget_terraform_mr_plan_button" - data-track-property="terraform_mr_plan_button" - class="btn btn-sm js-terraform-report-link" - rel="noopener" - > - {{ __('View full log') }} - <gl-icon name="external-link" /> - </gl-link> - </div> - </div> - </div> - </section> -</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue index acd8037cfb2..44bdc4a3be8 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue @@ -29,7 +29,7 @@ export default { <textarea :id="inputId" :value="value" - class="form-control js-gfm-input append-bottom-default commit-message-edit" + class="form-control js-gfm-input gl-mb-3 commit-message-edit" dir="auto" required="required" rows="7" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue index e4f4032776b..d52e6d38ac6 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue @@ -83,7 +83,7 @@ export default { <gl-deprecated-button :aria-label="ariaLabel" variant="blank" - class="commit-edit-toggle square s24 append-right-default" + class="commit-edit-toggle square s24 gl-mr-3" @click.stop="toggle()" > <icon :name="collapseIcon" :size="16" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue index 92848e86e76..f02e0ac84da 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue @@ -87,7 +87,7 @@ export default { <status-icon status="success" /> <div class="media-body"> <h4 class="d-flex align-items-start"> - <span class="append-right-10"> + <span class="gl-mr-3"> <span class="js-status-text-before-author">{{ statusTextBeforeAuthor }}</span> <mr-widget-author :author="mr.setToAutoMergeBy" /> <span class="js-status-text-after-author">{{ statusTextAfterAuthor }}</span> @@ -113,9 +113,7 @@ export default { {{ s__('mrWidget|The source branch will be deleted') }} </p> <p v-else class="d-flex align-items-start"> - <span class="append-right-10">{{ - s__('mrWidget|The source branch will not be deleted') - }}</span> + <span class="gl-mr-3">{{ s__('mrWidget|The source branch will not be deleted') }}</span> <a v-if="canRemoveSourceBranch" :disabled="isRemovingSourceBranch" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue index a5e3115397a..e02be6dc2f7 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue @@ -12,7 +12,7 @@ export default { <div class="mr-widget-body media"> <status-icon :show-disabled-button="true" status="loading" /> <div class="media-body space-children"> - <span class="bold"> {{ s__('mrWidget|Checking ability to merge automatically…') }} </span> + <span class="bold"> {{ s__('mrWidget|Checking if merge request can be merged…') }} </span> </div> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_tour.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_tour.vue deleted file mode 100644 index f6bfb178437..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_tour.vue +++ /dev/null @@ -1,136 +0,0 @@ -<script> -import { GlPopover, GlDeprecatedButton } from '@gitlab/ui'; -import Icon from '~/vue_shared/components/icon.vue'; -import Cookies from 'js-cookie'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import Tracking from '~/tracking'; - -const trackingMixin = Tracking.mixin(); - -const cookieKey = 'suggest_pipeline_dismissed'; - -export default { - name: 'MRWidgetPipelineTour', - dismissTrackValue: 20, - showTrackValue: 10, - trackEvent: 'click_button', - components: { - GlPopover, - GlDeprecatedButton, - Icon, - }, - mixins: [trackingMixin], - props: { - pipelinePath: { - type: String, - required: true, - }, - pipelineSvgPath: { - type: String, - required: true, - }, - humanAccess: { - type: String, - required: true, - }, - popoverTarget: { - type: String, - required: true, - }, - popoverContainer: { - type: String, - required: true, - }, - trackLabel: { - type: String, - required: true, - }, - }, - data() { - return { - popoverDismissed: parseBoolean(Cookies.get(cookieKey)), - tracking: { - label: this.trackLabel, - property: this.humanAccess, - }, - }; - }, - mounted() { - this.trackOnShow(); - }, - methods: { - trackOnShow() { - if (!this.popoverDismissed) { - this.track(); - } - }, - dismissPopover() { - this.popoverDismissed = true; - Cookies.set(cookieKey, this.popoverDismissed, { expires: 365 }); - }, - }, -}; -</script> -<template> - <gl-popover - v-if="!popoverDismissed" - show - :target="popoverTarget" - :container="popoverContainer" - placement="rightbottom" - > - <template #title> - <button - class="btn-blank float-right mt-1" - type="button" - :aria-label="__('Close')" - :data-track-property="humanAccess" - :data-track-value="$options.dismissTrackValue" - :data-track-event="$options.trackEvent" - :data-track-label="trackLabel" - @click="dismissPopover" - > - <icon name="close" aria-hidden="true" /> - </button> - {{ s__('mrWidget|Are you adding technical debt or code vulnerabilities?') }} - </template> - <div class="svg-content svg-150 pt-1"> - <img :src="pipelineSvgPath" /> - </div> - <p> - {{ - s__( - 'mrWidget|Detect issues before deployment with a CI pipeline that continuously tests your code. We created a quick guide that will show you how to create one. Make your code more secure and more robust in just a minute.', - ) - }} - </p> - <gl-deprecated-button - ref="ok" - category="primary" - class="mt-2 mb-0" - variant="info" - block - :href="pipelinePath" - :data-track-property="humanAccess" - :data-track-value="$options.showTrackValue" - :data-track-event="$options.trackEvent" - :data-track-label="trackLabel" - > - {{ __('Show me how') }} - </gl-deprecated-button> - <gl-deprecated-button - ref="no-thanks" - category="secondary" - class="mt-2 mb-0" - variant="info" - block - :data-track-property="humanAccess" - :data-track-value="$options.dismissTrackValue" - :data-track-event="$options.trackEvent" - :data-track-label="trackLabel" - @click="dismissPopover" - > - {{ __("No thanks, don't show this again") }} - </gl-deprecated-button> - </gl-popover> -</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index 82be5eeb5ff..cc43135f50a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -45,7 +45,8 @@ export default { isMakingRequest: false, isMergingImmediately: false, commitMessage: this.mr.commitMessage, - squashBeforeMerge: this.mr.squash, + squashBeforeMerge: this.mr.squashIsSelected, + isSquashReadOnly: this.mr.squashIsReadonly, successSvg, warningSvg, squashCommitMessage: this.mr.squashCommitMessage, @@ -106,7 +107,12 @@ export default { return this.isMergeButtonDisabled; }, shouldShowSquashBeforeMerge() { - const { commitsCount, enableSquashBeforeMerge } = this.mr; + const { commitsCount, enableSquashBeforeMerge, squashIsReadonly, squashIsSelected } = this.mr; + + if (squashIsReadonly && !squashIsSelected) { + return false; + } + return enableSquashBeforeMerge && commitsCount > 1; }, shouldShowMergeControls() { @@ -344,21 +350,24 @@ export default { v-if="shouldShowSquashBeforeMerge" v-model="squashBeforeMerge" :help-path="mr.squashBeforeMergeHelpPath" - :is-disabled="isMergeButtonDisabled" + :is-disabled="isSquashReadOnly" /> </template> <template v-else> <div class="bold js-resolve-mr-widget-items-message"> - <gl-sprintf + <div v-if="hasPipelineMustSucceedConflict" - :message="pipelineMustSucceedConflictText" + class="gl-display-flex gl-align-items-center" > - <template #link="{ content }"> - <gl-link :href="mr.pipelineMustSucceedDocsPath" target="_blank"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> + <gl-sprintf :message="pipelineMustSucceedConflictText" /> + <gl-link + :href="mr.pipelineMustSucceedDocsPath" + target="_blank" + class="gl-display-flex gl-ml-2" + > + <gl-icon name="question" /> + </gl-link> + </div> <gl-sprintf v-else :message="mergeDisabledText" /> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue index 5305894873f..efd58341a2d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue @@ -1,6 +1,7 @@ <script> import Icon from '~/vue_shared/components/icon.vue'; import tooltip from '~/vue_shared/directives/tooltip'; +import { __ } from '~/locale'; export default { components: { @@ -25,12 +26,22 @@ export default { default: false, }, }, + computed: { + tooltipTitle() { + return this.isDisabled ? __('Required in this project.') : false; + }, + }, }; </script> <template> <div class="inline"> - <label> + <label + v-tooltip + :class="{ 'gl-text-gray-600': isDisabled }" + data-testid="squashLabel" + :data-title="tooltipTitle" + > <input :checked="value" :disabled="isDisabled" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/terraform/mr_widget_terraform_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/terraform/mr_widget_terraform_container.vue new file mode 100644 index 00000000000..f6e21dc1ec1 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/terraform/mr_widget_terraform_container.vue @@ -0,0 +1,140 @@ +<script> +import { n__ } from '~/locale'; +import { GlSkeletonLoading, GlSprintf } from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import MrWidgetExpanableSection from '../mr_widget_expandable_section.vue'; +import Poll from '~/lib/utils/poll'; +import TerraformPlan from './terraform_plan.vue'; + +export default { + name: 'MRWidgetTerraformContainer', + components: { + GlSkeletonLoading, + GlSprintf, + MrWidgetExpanableSection, + TerraformPlan, + }, + props: { + endpoint: { + type: String, + required: true, + }, + }, + data() { + return { + loading: true, + plansObject: {}, + poll: null, + }; + }, + computed: { + inValidPlanCountText() { + if (this.numberOfInvalidPlans === 0) { + return null; + } + + return n__( + 'Terraform|%{number} Terraform report failed to generate', + 'Terraform|%{number} Terraform reports failed to generate', + this.numberOfInvalidPlans, + ); + }, + numberOfInvalidPlans() { + return Object.values(this.plansObject).filter(plan => plan.tf_report_error).length; + }, + numberOfPlans() { + return Object.keys(this.plansObject).length; + }, + numberOfValidPlans() { + return this.numberOfPlans - this.numberOfInvalidPlans; + }, + validPlanCountText() { + if (this.numberOfValidPlans === 0) { + return null; + } + + return n__( + 'Terraform|%{number} Terraform report was generated in your pipelines', + 'Terraform|%{number} Terraform reports were generated in your pipelines', + this.numberOfValidPlans, + ); + }, + }, + created() { + this.fetchPlans(); + }, + beforeDestroy() { + this.poll.stop(); + }, + methods: { + fetchPlans() { + this.loading = true; + + this.poll = new Poll({ + resource: { + fetchPlans: () => axios.get(this.endpoint), + }, + data: this.endpoint, + method: 'fetchPlans', + successCallback: ({ data }) => { + this.plansObject = data; + + if (this.numberOfPlans > 0) { + this.loading = false; + this.poll.stop(); + } + }, + errorCallback: () => { + this.plansObject = { bad_plan: { tf_report_error: 'api_error' } }; + this.loading = false; + this.poll.stop(); + }, + }); + + this.poll.makeRequest(); + }, + }, +}; +</script> + +<template> + <section class="mr-widget-section"> + <div v-if="loading" class="mr-widget-body"> + <gl-skeleton-loading /> + </div> + + <mr-widget-expanable-section v-else> + <template #header> + <div + data-testid="terraform-header-text" + class="gl-flex-fill-1 gl-display-flex gl-flex-direction-column" + > + <p v-if="validPlanCountText" class="gl-m-0"> + <gl-sprintf :message="validPlanCountText"> + <template #number> + <strong>{{ numberOfValidPlans }}</strong> + </template> + </gl-sprintf> + </p> + + <p v-if="inValidPlanCountText" class="gl-m-0"> + <gl-sprintf :message="inValidPlanCountText"> + <template #number> + <strong>{{ numberOfInvalidPlans }}</strong> + </template> + </gl-sprintf> + </p> + </div> + </template> + + <template #content> + <terraform-plan + v-for="(plan, key) in plansObject" + :key="key" + :plan="plan" + class="mr-widget-body" + /> + </template> + </mr-widget-expanable-section> + </section> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue b/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue new file mode 100644 index 00000000000..dc16d46dd8e --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue @@ -0,0 +1,111 @@ +<script> +import { s__ } from '~/locale'; +import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; + +export default { + name: 'TerraformPlan', + components: { + GlIcon, + GlLink, + GlSprintf, + }, + props: { + plan: { + required: true, + type: Object, + }, + }, + computed: { + addNum() { + return Number(this.plan.create); + }, + changeNum() { + return Number(this.plan.update); + }, + deleteNum() { + return Number(this.plan.delete); + }, + iconType() { + return this.validPlanValues ? 'doc-changes' : 'warning'; + }, + reportChangeText() { + if (this.validPlanValues) { + return s__( + 'Terraform|Reported Resource Changes: %{addNum} to add, %{changeNum} to change, %{deleteNum} to delete', + ); + } + + return s__('Terraform|Generating the report caused an error.'); + }, + reportHeaderText() { + if (this.validPlanValues) { + return this.plan.job_name + ? s__('Terraform|The Terraform report %{name} was generated in your pipelines.') + : s__('Terraform|A Terraform report was generated in your pipelines.'); + } + + return this.plan.job_name + ? s__('Terraform|The Terraform report %{name} failed to generate.') + : s__('Terraform|A Terraform report failed to generate.'); + }, + validPlanValues() { + return this.addNum + this.changeNum + this.deleteNum >= 0; + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex"> + <span + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-mr-3 gl-align-self-start gl-mt-1" + > + <gl-icon :name="iconType" :size="18" data-testid="change-type-icon" /> + </span> + + <div class="gl-display-flex gl-flex-fill-1 gl-flex-direction-column flex-md-row"> + <div class="gl-flex-fill-1 gl-display-flex gl-flex-direction-column"> + <p class="gl-m-0 gl-pr-1"> + <gl-sprintf :message="reportHeaderText"> + <template #name> + <strong>{{ plan.job_name }}</strong> + </template> + </gl-sprintf> + </p> + + <p class="gl-m-0"> + <gl-sprintf :message="reportChangeText"> + <template #addNum> + <strong>{{ addNum }}</strong> + </template> + + <template #changeNum> + <strong>{{ changeNum }}</strong> + </template> + + <template #deleteNum> + <strong>{{ deleteNum }}</strong> + </template> + </gl-sprintf> + </p> + </div> + + <div> + <gl-link + v-if="plan.job_path" + :href="plan.job_path" + target="_blank" + data-testid="terraform-report-link" + data-track-event="click_terraform_mr_plan_button" + data-track-label="mr_widget_terraform_mr_plan_button" + data-track-property="terraform_mr_plan_button" + class="btn btn-sm" + rel="noopener" + > + {{ __('View full log') }} + <gl-icon name="external-link" /> + </gl-link> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js index 6f6d145815e..1002bb728a0 100644 --- a/app/assets/javascripts/vue_merge_request_widget/constants.js +++ b/app/assets/javascripts/vue_merge_request_widget/constants.js @@ -1,3 +1,4 @@ +export const SUCCESS = 'success'; export const WARNING = 'warning'; export const DANGER = 'danger'; diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js index 7a9ef7e496e..068829912bf 100644 --- a/app/assets/javascripts/vue_merge_request_widget/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/index.js @@ -1,15 +1,23 @@ import Vue from 'vue'; import MrWidgetOptions from 'ee_else_ce/vue_merge_request_widget/mr_widget_options.vue'; import Translate from '../vue_shared/translate'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; Vue.use(Translate); +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); export default () => { if (gl.mrWidget) return; gl.mrWidgetData.gitlabLogo = gon.gitlab_logo; + gl.mrWidgetData.defaultAvatarUrl = gon.default_avatar_url; - const vm = new Vue(MrWidgetOptions); + const vm = new Vue({ ...MrWidgetOptions, apolloProvider }); window.gl.mrWidget = { checkStatus: vm.checkStatus, diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js new file mode 100644 index 00000000000..e50555ca875 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js @@ -0,0 +1,19 @@ +import { hideFlash } from '~/flash'; + +export default { + methods: { + clearError() { + this.$emit('clearError'); + this.hasApprovalAuthError = false; + const flashEl = document.querySelector('.flash-alert'); + if (flashEl) { + hideFlash(flashEl); + } + }, + refreshApprovals() { + return this.service.fetchApprovals().then(data => { + this.mr.setApprovals(data); + }); + }, + }, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js index 39fa5e465b8..319b6c333f4 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js @@ -2,7 +2,7 @@ import { __ } from '~/locale'; export const MERGE_DISABLED_TEXT = __('You can only merge once the items above are resolved.'); export const PIPELINE_MUST_SUCCEED_CONFLICT_TEXT = __( - 'Pipelines must succeed for merge requests to be eligible to merge. Please enable pipelines for this project to continue. For more information, see the %{linkStart}documentation.%{linkEnd}', + 'A CI/CD pipeline must run and be successful before merge.', ); export default { diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index 265ff81f39f..cff85fe232d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -2,6 +2,7 @@ import { isEmpty } from 'lodash'; import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_store'; import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service'; +import MrWidgetApprovals from 'ee_else_ce/vue_merge_request_widget/components/approvals/approvals.vue'; import stateMaps from 'ee_else_ce/vue_merge_request_widget/stores/state_maps'; import { sprintf, s__, __ } from '~/locale'; import Project from '~/pages/projects/project'; @@ -36,7 +37,8 @@ import CheckingState from './components/states/mr_widget_checking.vue'; import eventHub from './event_hub'; import notify from '~/lib/utils/notify'; import SourceBranchRemovalStatus from './components/source_branch_removal_status.vue'; -import TerraformPlan from './components/mr_widget_terraform_plan.vue'; +import TerraformPlan from './components/terraform/mr_widget_terraform_container.vue'; +import GroupedCodequalityReportsApp from '../reports/codequality_report/grouped_codequality_reports_app.vue'; import GroupedTestReportsApp from '../reports/components/grouped_test_reports_app.vue'; import { setFaviconOverlay } from '../lib/utils/common_utils'; import GroupedAccessibilityReportsApp from '../reports/accessibility_report/grouped_accessibility_reports_app.vue'; @@ -75,9 +77,11 @@ export default { 'mr-widget-auto-merge-failed': AutoMergeFailed, 'mr-widget-rebase': RebaseState, SourceBranchRemovalStatus, + GroupedCodequalityReportsApp, GroupedTestReportsApp, TerraformPlan, GroupedAccessibilityReportsApp, + MrWidgetApprovals, }, props: { mrData: { @@ -96,6 +100,9 @@ export default { }; }, computed: { + shouldRenderApprovals() { + return this.mr.state !== 'nothingToMerge'; + }, componentName() { return stateMaps.stateToComponentMap[this.mr.state]; }, @@ -111,6 +118,9 @@ export default { shouldSuggestPipelines() { return gon.features?.suggestPipeline && !this.mr.hasCI && this.mr.mergeRequestAddCiConfigPath; }, + shouldRenderCodeQuality() { + return this.mr?.codeclimate?.head_path; + }, shouldRenderRelatedLinks() { return Boolean(this.mr.relatedLinks) && !this.mr.isNothingToMergeState; }, @@ -216,6 +226,9 @@ export default { mergeRequestCachedWidgetPath: store.mergeRequestCachedWidgetPath, mergeActionsContentPath: store.mergeActionsContentPath, rebasePath: store.rebasePath, + apiApprovalsPath: store.apiApprovalsPath, + apiApprovePath: store.apiApprovePath, + apiUnapprovePath: store.apiUnapprovePath, }; }, createService(store) { @@ -365,7 +378,7 @@ export default { }; </script> <template> - <div v-if="mr" class="mr-state-widget prepend-top-default"> + <div v-if="mr" class="mr-state-widget gl-mt-3"> <mr-widget-header :mr="mr" /> <mr-widget-suggest-pipeline v-if="shouldSuggestPipelines" @@ -379,11 +392,27 @@ export default { class="mr-widget-workflow" :mr="mr" /> + <mr-widget-approvals + v-if="shouldRenderApprovals" + class="mr-widget-workflow" + :mr="mr" + :service="service" + /> <div class="mr-section-container mr-widget-workflow"> + <grouped-codequality-reports-app + v-if="shouldRenderCodeQuality" + :base-path="mr.codeclimate.base_path" + :head-path="mr.codeclimate.head_path" + :head-blob-path="mr.headBlobPath" + :base-blob-path="mr.baseBlobPath" + :codequality-help-path="mr.codequalityHelpPath" + /> + <grouped-test-reports-app v-if="mr.testResultsPath" class="js-reports-container" :endpoint="mr.testResultsPath" + :pipeline-path="mr.pipeline.path" /> <terraform-plan v-if="mr.terraformReportsPath" :endpoint="mr.terraformReportsPath" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js index c620023a6d6..ee9e3cc6d08 100644 --- a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js +++ b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js @@ -3,6 +3,10 @@ import axios from '../../lib/utils/axios_utils'; export default class MRWidgetService { constructor(endpoints) { this.endpoints = endpoints; + + this.apiApprovalsPath = endpoints.apiApprovalsPath; + this.apiApprovePath = endpoints.apiApprovePath; + this.apiUnapprovePath = endpoints.apiUnapprovePath; } merge(data) { @@ -54,6 +58,18 @@ export default class MRWidgetService { return axios.post(this.endpoints.rebasePath); } + fetchApprovals() { + return axios.get(this.apiApprovalsPath).then(res => res.data); + } + + approveMergeRequest() { + return axios.post(this.apiApprovePath).then(res => res.data); + } + + unapproveMergeRequest() { + return axios.post(this.apiUnapprovePath).then(res => res.data); + } + static executeInlineAction(url) { return axios.post(url); } diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js index a2ee0bc3ca1..44e8167d6a3 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js @@ -11,12 +11,12 @@ export default function deviseState(data) { return stateKey.checking; } else if (data.has_conflicts) { return stateKey.conflicts; - } else if (data.work_in_progress) { - return stateKey.workInProgress; } else if (this.shouldBeRebased) { return stateKey.rebase; } else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) { return stateKey.pipelineFailed; + } else if (data.work_in_progress) { + return stateKey.workInProgress; } else if (this.hasMergeableDiscussionsState) { return stateKey.unresolvedDiscussions; } else if (this.isPipelineBlocked) { diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index d61e122d612..8b9799d9775 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -9,12 +9,19 @@ export default class MergeRequestStore { this.sha = data.diff_head_sha; this.gitlabLogo = data.gitlabLogo; + this.apiApprovalsPath = data.api_approvals_path; + this.apiApprovePath = data.api_approve_path; + this.apiUnapprovePath = data.api_unapprove_path; + this.hasApprovalsAvailable = data.has_approvals_available; + this.setPaths(data); this.setData(data); } setData(data, isRebased) { + this.initApprovals(); + if (isRebased) { this.sha = data.diff_head_sha; } @@ -22,7 +29,10 @@ export default class MergeRequestStore { const pipelineStatus = data.pipeline ? data.pipeline.details.status : null; this.squash = data.squash; + this.squashIsEnabledByDefault = data.squash_enabled_by_default; + this.squashIsReadonly = data.squash_readonly; this.enableSquashBeforeMerge = this.enableSquashBeforeMerge || true; + this.squashIsSelected = data.squash_readonly ? data.squash_on_merge : data.squash; this.iid = data.iid; this.title = data.title; @@ -49,6 +59,7 @@ export default class MergeRequestStore { this.squashCommitMessage = data.default_squash_commit_message; this.rebaseInProgress = data.rebase_in_progress; this.mergeRequestDiffsPath = data.diffs_path; + this.approvalsWidgetType = data.approvals_widget_type; if (data.issues_links) { const links = data.issues_links; @@ -160,7 +171,8 @@ export default class MergeRequestStore { setPaths(data) { // Paths are set on the first load of the page and not auto-refreshed this.squashBeforeMergeHelpPath = data.squash_before_merge_help_path; - this.troubleshootingDocsPath = data.troubleshooting_docs_path; + this.mrTroubleshootingDocsPath = data.mr_troubleshooting_docs_path; + this.ciTroubleshootingDocsPath = data.ci_troubleshooting_docs_path; this.pipelineMustSucceedDocsPath = data.pipeline_must_succeed_docs_path; this.mergeRequestBasicPath = data.merge_request_basic_path; this.mergeRequestWidgetPath = data.merge_request_widget_path; @@ -177,10 +189,18 @@ export default class MergeRequestStore { this.securityApprovalsHelpPagePath = data.security_approvals_help_page_path; this.eligibleApproversDocsPath = data.eligible_approvers_docs_path; this.mergeImmediatelyDocsPath = data.merge_immediately_docs_path; + this.approvalsHelpPath = data.approvals_help_path; this.mergeRequestAddCiConfigPath = data.merge_request_add_ci_config_path; this.pipelinesEmptySvgPath = data.pipelines_empty_svg_path; this.humanAccess = data.human_access; this.newPipelinePath = data.new_project_pipeline_path; + + // codeclimate + const blobPath = data.blob_path || {}; + this.headBlobPath = blobPath.head_path || ''; + this.baseBlobPath = blobPath.base_path || ''; + this.codequalityHelpPath = data.codequality_help_path; + this.codeclimate = data.codeclimate; } get isNothingToMergeState() { @@ -240,4 +260,14 @@ export default class MergeRequestStore { return undefined; } + + initApprovals() { + this.isApproved = this.isApproved || false; + this.approvals = this.approvals || null; + } + + setApprovals(data) { + this.approvals = data; + this.isApproved = data.approved || false; + } } diff --git a/app/assets/javascripts/vue_shared/components/file_finder/index.vue b/app/assets/javascripts/vue_shared/components/file_finder/index.vue index 9f6f3d2d63a..d6f591ccca1 100644 --- a/app/assets/javascripts/vue_shared/components/file_finder/index.vue +++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue @@ -261,7 +261,7 @@ export default { </li> </template> <li v-else class="dropdown-menu-empty-item"> - <div class="append-right-default prepend-left-default gl-mt-3 gl-mb-3"> + <div class="gl-mr-3 gl-ml-3 gl-mt-3 gl-mb-3"> <template v-if="loading"> {{ __('Loading...') }} </template> diff --git a/app/assets/javascripts/vue_shared/components/file_finder/item.vue b/app/assets/javascripts/vue_shared/components/file_finder/item.vue index 590501a975a..79c62cd9938 100644 --- a/app/assets/javascripts/vue_shared/components/file_finder/item.vue +++ b/app/assets/javascripts/vue_shared/components/file_finder/item.vue @@ -88,7 +88,7 @@ export default { > </span> </strong> - <span class="diff-changed-file-path prepend-top-5"> + <span class="diff-changed-file-path gl-mt-2"> <span v-for="(char, charIndex) in pathWithEllipsis.split('')" :key="charIndex + char" diff --git a/app/assets/javascripts/vue_shared/components/file_icon.vue b/app/assets/javascripts/vue_shared/components/file_icon.vue index b084ebdf774..7484486d6b4 100644 --- a/app/assets/javascripts/vue_shared/components/file_icon.vue +++ b/app/assets/javascripts/vue_shared/components/file_icon.vue @@ -1,6 +1,7 @@ <script> import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; import getIconForFile from './file_icon/file_icon_map'; +import { FILE_SYMLINK_MODE } from '../constants'; /* This is a re-usable vue component for rendering a svg sprite icon @@ -24,6 +25,11 @@ export default { type: String, required: true, }, + fileMode: { + type: String, + required: false, + default: '', + }, folder: { type: Boolean, @@ -60,8 +66,12 @@ export default { }, }, computed: { + isSymlink() { + return this.fileMode === FILE_SYMLINK_MODE; + }, spriteHref() { const iconName = this.submodule ? 'folder-git' : getIconForFile(this.fileName) || 'file'; + return `${gon.sprite_file_icons}#${iconName}`; }, folderIconName() { @@ -75,13 +85,11 @@ export default { </script> <template> <span> - <svg v-if="!loading && !folder" :class="[iconSizeClass, cssClasses]"> - <use v-bind="{ 'xlink:href': spriteHref }" /></svg - ><gl-icon - v-if="!loading && folder" - :name="folderIconName" - :size="size" - class="folder-icon" - /><gl-loading-icon v-if="loading" :inline="true" /> + <gl-loading-icon v-if="loading" :inline="true" /> + <gl-icon v-else-if="isSymlink" name="symlink" :size="size" /> + <svg v-else-if="!folder" :class="[iconSizeClass, cssClasses]"> + <use v-bind="{ 'xlink:href': spriteHref }" /> + </svg> + <gl-icon v-else :name="folderIconName" :size="size" class="folder-icon" /> </span> </template> diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index 0cc96309a92..0952e37e46e 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -118,7 +118,12 @@ export default { @mouseleave="$emit('mouseleave', $event)" > <div class="file-row-name-container"> - <span ref="textOutput" :style="levelIndentation" class="file-row-name str-truncated"> + <span + ref="textOutput" + :style="levelIndentation" + class="file-row-name str-truncated" + data-qa-selector="file_name_content" + > <file-icon class="file-row-icon" :class="{ 'text-secondary': file.type === 'tree' }" diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue index a858ffdbed5..04090213218 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue @@ -83,6 +83,7 @@ export default { return { initialRender: true, recentSearchesPromise: null, + recentSearches: [], filterValue: this.initialFilterValue, selectedSortOption, selectedSortDirection, @@ -98,6 +99,15 @@ export default { {}, ); }, + tokenTitles() { + return this.tokens.reduce( + (tokenSymbols, token) => ({ + ...tokenSymbols, + [token.type]: token.title, + }), + {}, + ); + }, sortDirectionIcon() { return this.selectedSortDirection === SortDirection.ascending ? 'sort-lowest' @@ -112,11 +122,10 @@ export default { watch: { /** * GlFilteredSearch currently doesn't emit any event when - * search field is cleared, but we still want our parent - * component to know that filters were cleared and do - * necessary data refetch, so this watcher is basically - * a dirty hack/workaround to identify if filter input - * was cleared. :( + * tokens are manually removed from search field so we'd + * never know when user actually clears all the tokens. + * This watcher listens for updates to `filterValue` on + * such instances. :( */ filterValue(value) { const [firstVal] = value; @@ -172,11 +181,9 @@ export default { this.recentSearchesStore.state.recentSearches.concat(searches), ); this.recentSearchesService.save(resultantSearches); + this.recentSearches = resultantSearches; }); }, - getRecentSearches() { - return this.recentSearchesStore?.state.recentSearches; - }, handleSortOptionClick(sortBy) { this.selectedSortOption = sortBy; this.$emit('onSort', sortBy.sortDirection[this.selectedSortDirection]); @@ -188,26 +195,22 @@ export default { : SortDirection.ascending; this.$emit('onSort', this.selectedSortOption.sortDirection[this.selectedSortDirection]); }, + handleHistoryItemSelected(filters) { + this.$emit('onFilter', filters); + }, + handleClearHistory() { + const resultantSearches = this.recentSearchesStore.setRecentSearches([]); + this.recentSearchesService.save(resultantSearches); + this.recentSearches = []; + }, handleFilterSubmit(filters) { if (this.recentSearchesStorageKey) { this.recentSearchesPromise .then(() => { if (filters.length) { - const searchTokens = filters.map(filter => { - // check filter was plain text search - if (typeof filter === 'string') { - return filter; - } - // filter was a token. - return `${filter.type}:${filter.value.operator}${this.tokenSymbols[filter.type]}${ - filter.value.data - }`; - }); - - const resultantSearches = this.recentSearchesStore.addRecentSearch( - searchTokens.join(' '), - ); + const resultantSearches = this.recentSearchesStore.addRecentSearch(filters); this.recentSearchesService.save(resultantSearches); + this.recentSearches = resultantSearches; } }) .catch(() => { @@ -226,10 +229,24 @@ export default { v-model="filterValue" :placeholder="searchInputPlaceholder" :available-tokens="tokens" - :history-items="getRecentSearches()" + :history-items="recentSearches" class="flex-grow-1" + @history-item-selected="handleHistoryItemSelected" + @clear-history="handleClearHistory" @submit="handleFilterSubmit" - /> + > + <template #history-item="{ historyItem }"> + <template v-for="(token, index) in historyItem"> + <span v-if="typeof token === 'string'" :key="index" class="gl-px-1">"{{ token }}"</span> + <span v-else :key="`${token.type}-${token.value.data}`" class="gl-px-1"> + <span v-if="tokenTitles[token.type]" + >{{ tokenTitles[token.type] }} :{{ token.value.operator }}</span + > + <strong>{{ tokenSymbols[token.type] }}{{ token.value.data }}</strong> + </span> + </template> + </template> + </gl-filtered-search> <gl-button-group class="sort-dropdown-container d-flex"> <gl-dropdown :text="selectedSortOption.title" :right="true" class="w-100"> <gl-dropdown-item diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue index 412bfa5aa7f..d50649d2581 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue @@ -46,6 +46,16 @@ export default { return this.authors.find(author => author.username.toLowerCase() === this.currentValue); }, }, + watch: { + active: { + immediate: true, + handler(newValue) { + if (!newValue && !this.authors.length) { + this.fetchAuthorBySearchTerm(this.value.data); + } + }, + }, + }, methods: { fetchAuthorBySearchTerm(searchTerm) { const fetchPromise = this.config.fetchPath @@ -89,9 +99,9 @@ export default { <span>{{ activeAuthor ? activeAuthor.name : inputValue }}</span> </template> <template #suggestions> - <gl-filtered-search-suggestion :value="$options.anyAuthor">{{ - __('Any') - }}</gl-filtered-search-suggestion> + <gl-filtered-search-suggestion :value="$options.anyAuthor"> + {{ __('Any') }} + </gl-filtered-search-suggestion> <gl-dropdown-divider /> <gl-loading-icon v-if="loading" /> <template v-else> diff --git a/app/assets/javascripts/vue_shared/components/gl_mentions.vue b/app/assets/javascripts/vue_shared/components/gl_mentions.vue index a7fba5e760b..0ef4f1eda27 100644 --- a/app/assets/javascripts/vue_shared/components/gl_mentions.vue +++ b/app/assets/javascripts/vue_shared/components/gl_mentions.vue @@ -3,18 +3,19 @@ import { escape } from 'lodash'; import Tribute from 'tributejs'; import axios from '~/lib/utils/axios_utils'; import { spriteIcon } from '~/lib/utils/common_utils'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; /** * Creates the HTML template for each row of the mentions dropdown. * - * @param original An object from the array returned from the `autocomplete_sources/members` API - * @returns {string} An HTML template + * @param original - An object from the array returned from the `autocomplete_sources/members` API + * @returns {string} - An HTML template */ function menuItemTemplate({ original }) { const rectAvatarClass = original.type === 'Group' ? 'rect-avatar' : ''; const avatarClasses = `avatar avatar-inline center s26 ${rectAvatarClass} - gl-display-inline-flex gl-align-items-center gl-justify-content-center`; + gl-display-inline-flex! gl-align-items-center gl-justify-content-center`; const avatarTag = original.avatar_url ? `<img @@ -48,6 +49,7 @@ export default { }, data() { return { + assignees: undefined, members: undefined, }; }, @@ -76,19 +78,37 @@ export default { */ getMembers(inputText, processValues) { if (this.members) { - processValues(this.members); + processValues(this.getFilteredMembers()); } else if (this.dataSources.members) { axios .get(this.dataSources.members) .then(response => { this.members = response.data; - processValues(response.data); + processValues(this.getFilteredMembers()); }) .catch(() => {}); } else { processValues([]); } }, + getFilteredMembers() { + const fullText = this.$slots.default[0].elm.value; + + if (!this.assignees) { + this.assignees = + SidebarMediator.singleton?.store?.assignees?.map(assignee => assignee.username) || []; + } + + if (fullText.startsWith('/assign @')) { + return this.members.filter(member => !this.assignees.includes(member.username)); + } + + if (fullText.startsWith('/unassign @')) { + return this.members.filter(member => this.assignees.includes(member.username)); + } + + return this.members; + }, }, render(createElement) { return createElement('div', this.$slots.default); diff --git a/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue b/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue index df6fadf10cd..e14f6a04d3c 100644 --- a/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue +++ b/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue @@ -52,6 +52,14 @@ export default { // $root.$emit is a workaround because other b-modal approaches don't work yet with gl-modal this.$root.$emit('bv::hide::modal', this.modalId); }, + cancel() { + this.$emit('cancel'); + this.syncHide(); + }, + ok() { + this.$emit('ok'); + this.syncHide(); + }, }, }; </script> @@ -65,5 +73,6 @@ export default { @hidden="syncHide" > <slot></slot> + <slot slot="modal-footer" name="modal-footer" :ok="ok" :cancel="cancel"></slot> </gl-modal> </template> diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue index 63de1e009fd..caf13bc898b 100644 --- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue +++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue @@ -82,7 +82,7 @@ export default { v-gl-tooltip name="eye-slash" :title="__('Confidential')" - class="confidential-icon append-right-4 align-self-baseline align-self-md-auto mt-xl-0" + class="confidential-icon gl-mr-2 align-self-baseline align-self-md-auto mt-xl-0" :aria-label="__('Confidential')" /> <a :href="computedPath" class="sortable-link gl-font-weight-normal">{{ title }}</a> diff --git a/app/assets/javascripts/vue_shared/components/loading_button.vue b/app/assets/javascripts/vue_shared/components/loading_button.vue index 3508c557289..59ce632c4a2 100644 --- a/app/assets/javascripts/vue_shared/components/loading_button.vue +++ b/app/assets/javascripts/vue_shared/components/loading_button.vue @@ -47,7 +47,7 @@ export default { v-if="loading" :inline="true" :class="{ - 'append-right-5': label, + 'gl-mr-2': label, }" class="js-loading-button-icon" /> diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 0e05f4a4622..f954b8eb4f4 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -4,21 +4,25 @@ import '~/behaviors/markdown/render_gfm'; import { unescape } from 'lodash'; import { __, sprintf } from '~/locale'; import { stripHtml } from '~/lib/utils/text_utility'; -import Flash from '../../../flash'; -import GLForm from '../../../gl_form'; -import markdownHeader from './header.vue'; -import markdownToolbar from './toolbar.vue'; -import icon from '../icon.vue'; +import Flash from '~/flash'; +import GLForm from '~/gl_form'; +import MarkdownHeader from './header.vue'; +import MarkdownToolbar from './toolbar.vue'; +import Icon from '../icon.vue'; +import GlMentions from '~/vue_shared/components/gl_mentions.vue'; import Suggestions from '~/vue_shared/components/markdown/suggestions.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import axios from '~/lib/utils/axios_utils'; export default { components: { - markdownHeader, - markdownToolbar, - icon, + GlMentions, + MarkdownHeader, + MarkdownToolbar, + Icon, Suggestions, }, + mixins: [glFeatureFlagsMixin()], props: { isSubmitting: { type: Boolean, @@ -159,12 +163,10 @@ export default { }, }, mounted() { - /* - GLForm class handles all the toolbar buttons - */ + // GLForm class handles all the toolbar buttons return new GLForm($(this.$refs['gl-form']), { emojis: this.enableAutocomplete, - members: this.enableAutocomplete, + members: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, issues: this.enableAutocomplete, mergeRequests: this.enableAutocomplete, epics: this.enableAutocomplete, @@ -229,7 +231,7 @@ export default { <template> <div ref="gl-form" - :class="{ 'prepend-top-default append-bottom-default': addSpacingClasses }" + :class="{ 'gl-mt-3 gl-mb-3': addSpacingClasses }" class="js-vue-markdown-field md-area position-relative" > <markdown-header @@ -243,7 +245,10 @@ export default { /> <div v-show="!previewMarkdown" class="md-write-holder"> <div class="zen-backdrop"> - <slot name="textarea"></slot> + <gl-mentions v-if="glFeatures.tributeAutocomplete"> + <slot name="textarea"></slot> + </gl-mentions> + <slot v-else name="textarea"></slot> <a class="zen-control zen-control-leave js-zen-leave gl-text-gray-700" href="#" diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index aa1abb5adb6..049f5e71849 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -89,14 +89,13 @@ export default { <div class="md-header"> <ul class="nav-links clearfix"> <li :class="{ active: !previewMarkdown }" class="md-header-tab"> - <button class="js-write-link" tabindex="-1" type="button" @click="writeMarkdownTab($event)"> + <button class="js-write-link" type="button" @click="writeMarkdownTab($event)"> {{ __('Write') }} </button> </li> <li :class="{ active: previewMarkdown }" class="md-header-tab"> <button class="js-preview-link js-md-preview-button" - tabindex="-1" type="button" @click="previewMarkdownTab($event)" > diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue index 6dac448d5de..13c42d35b04 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue @@ -68,6 +68,7 @@ export default { :is-applying-batch="suggestion.is_applying_batch" :batch-suggestions-count="batchSuggestionsCount" :help-page-path="helpPagePath" + :inapplicable-reason="suggestion.inapplicable_reason" @apply="applySuggestion" @applyBatch="applySuggestionBatch" @addToBatch="addSuggestionToBatch" diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue index e26ff51e01e..4de80e9b4c2 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue @@ -38,6 +38,11 @@ export default { type: String, required: true, }, + inapplicableReason: { + type: String, + required: false, + default: null, + }, }, data() { return { @@ -52,14 +57,7 @@ export default { return this.isApplyingSingle || this.isApplyingBatch; }, tooltipMessage() { - return this.canApply - ? __('This also resolves the discussion') - : __("Can't apply as this line has changed or the suggestion already matches its content."); - }, - tooltipMessageBatch() { - return !this.canBeBatched - ? __("Suggestions that change line count can't be added to batches, yet.") - : this.tooltipMessage; + return this.canApply ? __('This also resolves this thread') : this.inapplicableReason; }, isDisableButton() { return this.isApplying || !this.canApply; @@ -129,15 +127,14 @@ export default { </gl-deprecated-button> </div> <div v-else class="d-flex align-items-center"> - <span v-if="canBeBatched" v-gl-tooltip.viewport="tooltipMessageBatch" tabindex="0"> - <gl-deprecated-button - class="btn-inverted js-add-to-batch-btn btn-grouped" - :disabled="isDisableButton" - @click="addSuggestionToBatch" - > - {{ __('Add suggestion to batch') }} - </gl-deprecated-button> - </span> + <gl-deprecated-button + v-if="canBeBatched && !isDisableButton" + class="btn-inverted js-add-to-batch-btn btn-grouped" + :disabled="isDisableButton" + @click="addSuggestionToBatch" + > + {{ __('Add suggestion to batch') }} + </gl-deprecated-button> <span v-gl-tooltip.viewport="tooltipMessage" tabindex="0"> <gl-deprecated-button class="btn-inverted js-apply-btn btn-grouped" diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue index 330785c9319..5d47aed9643 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue @@ -61,7 +61,7 @@ export default { <span v-if="canAttachFile" class="uploading-container"> <span class="uploading-progress-container hide"> <template> - <gl-icon name="media" :size="16" /> + <gl-icon name="media" :size="16" class="gl-vertical-align-text-bottom" /> </template> <span class="attaching-file-message"></span> <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings --> @@ -71,7 +71,7 @@ export default { <span class="uploading-error-container hide"> <span class="uploading-error-icon"> <template> - <gl-icon name="media" :size="16" /> + <gl-icon name="media" :size="16" class="gl-vertical-align-text-bottom" /> </template> </span> <span class="uploading-error-message"></span> diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue index 94f78c0c085..f37dd9e171c 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue @@ -64,7 +64,6 @@ export default { :aria-label="buttonTitle" type="button" class="toolbar-btn js-md" - tabindex="-1" data-container="body" @click="() => $emit('click')" > diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue index cb3cd18e5a7..f986b105f20 100644 --- a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue +++ b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue @@ -8,6 +8,12 @@ function buildDocsLinkStart(path) { return `<a href="${escape(path)}" target="_blank" rel="noopener noreferrer">`; } +const NoteableTypeText = { + Issue: __('issue'), + Epic: __('epic'), + MergeRequest: __('merge request'), +}; + export default { components: { icon, @@ -24,12 +30,18 @@ export default { default: false, required: false, }, - lockedIssueDocsPath: { + noteableType: { + type: String, + required: false, + // eslint-disable-next-line @gitlab/require-i18n-strings + default: 'Issue', + }, + lockedNoteableDocsPath: { type: String, required: false, default: '', }, - confidentialIssueDocsPath: { + confidentialNoteableDocsPath: { type: String, required: false, default: '', @@ -45,19 +57,33 @@ export default { isLockedAndConfidential() { return this.isConfidential && this.isLocked; }, + noteableTypeText() { + return NoteableTypeText[this.noteableType]; + }, confidentialAndLockedDiscussionText() { return sprintf( __( - 'This issue is %{confidentialLinkStart}confidential%{linkEnd} and %{lockedLinkStart}locked%{linkEnd}.', + 'This %{noteableTypeText} is %{confidentialLinkStart}confidential%{linkEnd} and %{lockedLinkStart}locked%{linkEnd}.', ), { - confidentialLinkStart: buildDocsLinkStart(this.confidentialIssueDocsPath), - lockedLinkStart: buildDocsLinkStart(this.lockedIssueDocsPath), + noteableTypeText: this.noteableTypeText, + confidentialLinkStart: buildDocsLinkStart(this.confidentialNoteableDocsPath), + lockedLinkStart: buildDocsLinkStart(this.lockedNoteableDocsPath), linkEnd: '</a>', }, false, ); }, + confidentialContextText() { + return sprintf(__('This is a confidential %{noteableTypeText}.'), { + noteableTypeText: this.noteableTypeText, + }); + }, + lockedContextText() { + return sprintf(__('This %{noteableTypeText} is locked.'), { + noteableTypeText: this.noteableTypeText, + }); + }, }, }; </script> @@ -73,19 +99,15 @@ export default { </span> <span v-else-if="isConfidential" ref="confidential"> - {{ __('This is a confidential issue.') }} + {{ confidentialContextText }} {{ __('People without permission will never get a notification.') }} - <gl-link :href="confidentialIssueDocsPath" target="_blank"> - {{ __('Learn more') }} - </gl-link> + <gl-link :href="confidentialNoteableDocsPath" target="_blank">{{ __('Learn more') }}</gl-link> </span> <span v-else-if="isLocked" ref="locked"> - {{ __('This issue is locked.') }} + {{ lockedContextText }} {{ __('Only project members can comment.') }} - <gl-link :href="lockedIssueDocsPath" target="_blank"> - {{ __('Learn more') }} - </gl-link> + <gl-link :href="lockedNoteableDocsPath" target="_blank">{{ __('Learn more') }}</gl-link> </span> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue index b6271a95008..fe57d4f29ca 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -122,7 +122,7 @@ export default { ></div> <div v-if="hasMoreCommits" class="flex-list"> <div class="system-note-commit-list-toggler flex-row" @click="expanded = !expanded"> - <icon :name="toggleIcon" :size="8" class="append-right-5" /> + <icon :name="toggleIcon" :size="8" class="gl-mr-2" /> <span>{{ __('Toggle commit list') }}</span> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue b/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue index 29a4a90a59f..5f2a66ee0b7 100644 --- a/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue +++ b/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue @@ -20,7 +20,7 @@ export default { Here is an example `change` method: change(pagenum) { - gl.utils.visitUrl(`?page=${pagenum}`); + visitUrl(`?page=${pagenum}`); }, */ change: { @@ -64,7 +64,7 @@ export default { <template> <gl-pagination v-if="showPagination" - class="justify-content-center prepend-top-default" + class="justify-content-center gl-mt-3" v-bind="$attrs" :value="pageInfo.page" :per-page="pageInfo.perPage" diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue index 3d52f4176db..e053a9ddaa6 100644 --- a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue +++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue @@ -8,30 +8,25 @@ import { truncateNamespace } from '~/lib/utils/text_utility'; export default { name: 'ProjectListItem', - components: { - Icon, - ProjectAvatar, - GlDeprecatedButton, - }, + components: { Icon, ProjectAvatar, GlDeprecatedButton }, props: { project: { type: Object, required: true, - validator: p => Number.isFinite(p.id) && isString(p.name) && isString(p.name_with_namespace), - }, - selected: { - type: Boolean, - required: true, - }, - matcher: { - type: String, - required: false, - default: '', + validator: p => + (Number.isFinite(p.id) || isString(p.id)) && + isString(p.name) && + (isString(p.name_with_namespace) || isString(p.nameWithNamespace)), }, + selected: { type: Boolean, required: true }, + matcher: { type: String, required: false, default: '' }, }, computed: { + projectNameWithNamespace() { + return this.project.nameWithNamespace || this.project.name_with_namespace; + }, truncatedNamespace() { - return truncateNamespace(this.project.name_with_namespace); + return truncateNamespace(this.projectNameWithNamespace); }, highlightedProjectName() { return highlight(this.project.name, this.matcher); @@ -50,7 +45,7 @@ export default { @click="onClick" > <icon - class="prepend-left-10 append-right-10 flex-shrink-0 position-top-0 js-selected-icon" + class="gl-ml-3 gl-mr-3 flex-shrink-0 position-top-0 js-selected-icon" :class="{ 'js-selected visible': selected, 'js-unselected invisible': !selected }" name="mobile-issue-close" /> @@ -58,7 +53,7 @@ export default { <div class="d-flex flex-wrap project-namespace-name-container"> <div v-if="truncatedNamespace" - :title="project.name_with_namespace" + :title="projectNameWithNamespace" class="text-secondary text-truncate js-project-namespace" > {{ truncatedNamespace }} diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue index 15a5ce85046..0b91588a006 100644 --- a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue +++ b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue @@ -41,7 +41,8 @@ export default { }, totalResults: { type: Number, - required: true, + required: false, + default: 0, }, }, data() { @@ -87,6 +88,7 @@ export default { type="search" class="mb-3" autofocus + data-qa-selector="project_search_field" @input="onInput" /> <div class="d-flex flex-column"> @@ -106,6 +108,7 @@ export default { :project="project" :matcher="searchQuery" class="js-project-list-item" + data-qa-selector="project_list_item" @click="projectClicked(project)" /> </div> diff --git a/app/assets/javascripts/vue_shared/components/remove_member_modal.vue b/app/assets/javascripts/vue_shared/components/remove_member_modal.vue new file mode 100644 index 00000000000..88d1b15aee3 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/remove_member_modal.vue @@ -0,0 +1,78 @@ +<script> +import { GlFormCheckbox, GlModal } from '@gitlab/ui'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import csrf from '~/lib/utils/csrf'; +import { __ } from '~/locale'; + +export default { + actionCancel: { + text: __('Cancel'), + }, + csrf, + components: { + GlFormCheckbox, + GlModal, + }, + data() { + return { + modalData: {}, + }; + }, + computed: { + isAccessRequest() { + return parseBoolean(this.modalData.isAccessRequest); + }, + actionText() { + return this.isAccessRequest ? __('Deny access request') : __('Remove member'); + }, + actionPrimary() { + return { + text: this.actionText, + attributes: { + variant: 'danger', + }, + }; + }, + }, + mounted() { + document.addEventListener('click', this.handleClick); + }, + beforeDestroy() { + document.removeEventListener('click', this.handleClick); + }, + methods: { + handleClick(event) { + const removeButton = event.target.closest('.js-remove-member-button'); + if (removeButton) { + this.modalData = removeButton.dataset; + this.$refs.modal.show(); + } + }, + submitForm() { + this.$refs.form.submit(); + }, + }, +}; +</script> + +<template> + <gl-modal + ref="modal" + modal-id="remove-member-modal" + :action-cancel="$options.actionCancel" + :action-primary="actionPrimary" + :title="actionText" + data-qa-selector="remove_member_modal_content" + @primary="submitForm" + > + <form ref="form" :action="modalData.memberPath" method="post"> + <p data-testid="modal-message">{{ modalData.message }}</p> + + <input ref="method" type="hidden" name="_method" value="delete" /> + <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> + <gl-form-checkbox v-if="!isAccessRequest" name="unassign_issuables"> + {{ __('Also unassign this user from related issues and merge requests') }} + </gl-form-checkbox> + </form> + </gl-modal> +</template> diff --git a/app/assets/javascripts/vue_shared/components/resizable_chart/constants.js b/app/assets/javascripts/vue_shared/components/resizable_chart/constants.js new file mode 100644 index 00000000000..edc5ffb7b77 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/resizable_chart/constants.js @@ -0,0 +1,6 @@ +export const DEFAULT_RX = 0.4; +export const DEFAULT_BAR_WIDTH = 6; +export const DEFAULT_LABEL_WIDTH = 4; +export const DEFAULT_LABEL_HEIGHT = 5; +export const BAR_HEIGHTS = [5, 7, 9, 14, 21, 35, 50, 80]; +export const GRID_YS = [30, 60, 90]; diff --git a/app/assets/javascripts/vue_shared/components/resizable_chart/skeleton_loader.vue b/app/assets/javascripts/vue_shared/components/resizable_chart/skeleton_loader.vue new file mode 100644 index 00000000000..306fa61780f --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/resizable_chart/skeleton_loader.vue @@ -0,0 +1,95 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; + +import { + DEFAULT_RX, + DEFAULT_BAR_WIDTH, + DEFAULT_LABEL_WIDTH, + DEFAULT_LABEL_HEIGHT, + BAR_HEIGHTS, + GRID_YS, +} from './constants'; + +export default { + components: { + GlSkeletonLoader, + }, + props: { + barWidth: { + type: Number, + default: DEFAULT_BAR_WIDTH, + required: false, + }, + labelWidth: { + type: Number, + default: DEFAULT_LABEL_WIDTH, + required: false, + }, + labelHeight: { + type: Number, + default: DEFAULT_LABEL_HEIGHT, + required: false, + }, + rx: { + type: Number, + default: DEFAULT_RX, + required: false, + }, + // skeleton-loader will generate a unique key if not defined + uniqueKey: { + type: String, + default: undefined, + required: false, + }, + }, + computed: { + labelCentering() { + return (this.barWidth - this.labelWidth) / 2; + }, + }, + methods: { + getBarXPosition(index) { + const numberOfBars = this.$options.BAR_HEIGHTS.length; + const numberOfSpaces = numberOfBars + 1; + const spaceBetweenBars = (100 - numberOfSpaces * this.barWidth) / numberOfBars; + + return (0.5 + index) * (this.barWidth + spaceBetweenBars); + }, + }, + BAR_HEIGHTS, + GRID_YS, +}; +</script> +<template> + <gl-skeleton-loader :unique-key="uniqueKey"> + <rect + v-for="(y, index) in $options.GRID_YS" + :key="`grid-${index}`" + data-testid="skeleton-chart-grid" + x="0" + :y="`${y}%`" + width="100%" + height="1px" + /> + <rect + v-for="(height, index) in $options.BAR_HEIGHTS" + :key="`bar-${index}`" + data-testid="skeleton-chart-bar" + :x="`${getBarXPosition(index)}%`" + :y="`${90 - height}%`" + :width="`${barWidth}%`" + :height="`${height}%`" + :rx="`${rx}%`" + /> + <rect + v-for="(height, index) in $options.BAR_HEIGHTS" + :key="`label-${index}`" + data-testid="skeleton-chart-label" + :x="`${labelCentering + getBarXPosition(index)}%`" + :y="`${100 - labelHeight}%`" + :width="`${labelWidth}%`" + :height="`${labelHeight}%`" + :rx="`${rx}%`" + /> + </gl-skeleton-loader> +</template> diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js index 1566c2c784b..dd1da847001 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js @@ -1,5 +1,6 @@ import { __ } from '~/locale'; -import { generateToolbarItem } from './editor_service'; +import { generateToolbarItem } from './services/editor_service'; +import buildCustomHTMLRenderer from './services/build_custom_renderer'; export const CUSTOM_EVENTS = { openAddImageModal: 'gl_openAddImageModal', @@ -31,6 +32,7 @@ const TOOLBAR_ITEM_CONFIGS = [ export const EDITOR_OPTIONS = { toolbarItems: TOOLBAR_ITEM_CONFIGS.map(config => generateToolbarItem(config)), + customHTMLRenderer: buildCustomHTMLRenderer(), }; export const EDITOR_TYPES = { @@ -41,3 +43,7 @@ export const EDITOR_TYPES = { export const EDITOR_HEIGHT = '100%'; export const EDITOR_PREVIEW_STYLE = 'horizontal'; + +export const IMAGE_TABS = { UPLOAD_TAB: 0, URL_TAB: 1 }; + +export const MAX_FILE_SIZE = 2097152; // 2Mb diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue new file mode 100644 index 00000000000..0a444b2295d --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue @@ -0,0 +1,147 @@ +<script> +import { isSafeURL } from '~/lib/utils/url_utility'; +import { GlModal, GlFormGroup, GlFormInput, GlTabs, GlTab } from '@gitlab/ui'; +import { __ } from '~/locale'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { IMAGE_TABS } from '../../constants'; +import UploadImageTab from './upload_image_tab.vue'; + +export default { + components: { + UploadImageTab, + GlModal, + GlFormGroup, + GlFormInput, + GlTabs, + GlTab, + }, + mixins: [glFeatureFlagMixin()], + props: { + imageRoot: { + type: String, + required: true, + }, + }, + data() { + return { + file: null, + urlError: null, + imageUrl: null, + description: null, + tabIndex: IMAGE_TABS.UPLOAD_TAB, + uploadImageTab: null, + }; + }, + modalTitle: __('Image Details'), + okTitle: __('Insert'), + urlTabTitle: __('By URL'), + urlLabel: __('Image URL'), + descriptionLabel: __('Description'), + uploadTabTitle: __('Upload file'), + computed: { + altText() { + return this.description; + }, + }, + methods: { + show() { + this.file = null; + this.urlError = null; + this.imageUrl = null; + this.description = null; + this.tabIndex = IMAGE_TABS.UPLOAD_TAB; + + this.$refs.modal.show(); + }, + onOk(event) { + if (this.glFeatures.sseImageUploads && this.tabIndex === IMAGE_TABS.UPLOAD_TAB) { + this.submitFile(event); + return; + } + this.submitURL(event); + }, + setFile(file) { + this.file = file; + }, + submitFile(event) { + const { file, altText } = this; + const { uploadImageTab } = this.$refs; + + uploadImageTab.validateFile(); + + if (uploadImageTab.fileError) { + event.preventDefault(); + return; + } + + const imageUrl = `${this.imageRoot}${file.name}`; + + this.$emit('addImage', { imageUrl, file, altText: altText || file.name }); + }, + submitURL(event) { + if (!this.validateUrl()) { + event.preventDefault(); + return; + } + + const { imageUrl, altText } = this; + + this.$emit('addImage', { imageUrl, altText: altText || imageUrl }); + }, + validateUrl() { + if (!isSafeURL(this.imageUrl)) { + this.urlError = __('Please provide a valid URL'); + this.$refs.urlInput.$el.focus(); + return false; + } + + return true; + }, + }, +}; +</script> +<template> + <gl-modal + ref="modal" + modal-id="add-image-modal" + :title="$options.modalTitle" + :ok-title="$options.okTitle" + @ok="onOk" + > + <gl-tabs v-if="glFeatures.sseImageUploads" v-model="tabIndex"> + <!-- Upload file Tab --> + <gl-tab :title="$options.uploadTabTitle"> + <upload-image-tab ref="uploadImageTab" @input="setFile" /> + </gl-tab> + + <!-- By URL Tab --> + <gl-tab :title="$options.urlTabTitle"> + <gl-form-group + class="gl-mt-5 gl-mb-3" + :label="$options.urlLabel" + label-for="url-input" + :state="!Boolean(urlError)" + :invalid-feedback="urlError" + > + <gl-form-input id="url-input" ref="urlInput" v-model="imageUrl" /> + </gl-form-group> + </gl-tab> + </gl-tabs> + + <gl-form-group + v-else + class="gl-mt-5 gl-mb-3" + :label="$options.urlLabel" + label-for="url-input" + :state="!Boolean(urlError)" + :invalid-feedback="urlError" + > + <gl-form-input id="url-input" ref="urlInput" v-model="imageUrl" /> + </gl-form-group> + + <!-- Description Input --> + <gl-form-group :label="$options.descriptionLabel" label-for="description-input"> + <gl-form-input id="description-input" ref="descriptionInput" v-model="description" /> + </gl-form-group> + </gl-modal> +</template> diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue new file mode 100644 index 00000000000..739f8b502c9 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue @@ -0,0 +1,56 @@ +<script> +import { __ } from '~/locale'; +import { GlFormGroup } from '@gitlab/ui'; +import { MAX_FILE_SIZE } from '../../constants'; + +export default { + components: { + GlFormGroup, + }, + data() { + return { + file: null, + fileError: null, + }; + }, + fileLabel: __('Select file'), + methods: { + onInput(event) { + [this.file] = event.target.files; + + this.validateFile(); + + if (!this.fileError) { + this.$emit('input', this.file); + } + }, + validateFile() { + this.fileError = null; + + if (!this.file) { + this.fileError = __('Please choose a file'); + } else if (this.file.size > MAX_FILE_SIZE) { + this.fileError = __('Maximum file size is 2MB. Please select a smaller file.'); + } + }, + }, +}; +</script> +<template> + <gl-form-group + class="gl-mt-5 gl-mb-3" + :label="$options.fileLabel" + label-for="file-input" + :state="!Boolean(fileError)" + :invalid-feedback="fileError" + > + <input + id="file-input" + ref="fileInput" + class="gl-mt-3 gl-mb-2" + type="file" + accept="image/*" + @input="onInput" + /> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image_modal.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image_modal.vue deleted file mode 100644 index 40063065926..00000000000 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image_modal.vue +++ /dev/null @@ -1,74 +0,0 @@ -<script> -import { isSafeURL } from '~/lib/utils/url_utility'; -import { GlModal, GlFormGroup, GlFormInput } from '@gitlab/ui'; -import { __ } from '~/locale'; - -export default { - components: { - GlModal, - GlFormGroup, - GlFormInput, - }, - data() { - return { - error: null, - imageUrl: null, - altText: null, - modalTitle: __('Image Details'), - okTitle: __('Insert'), - urlLabel: __('Image URL'), - descriptionLabel: __('Description'), - }; - }, - methods: { - show() { - this.error = null; - this.imageUrl = null; - this.altText = null; - - this.$refs.modal.show(); - }, - onOk(event) { - if (!this.isValid()) { - event.preventDefault(); - return; - } - - const { imageUrl, altText } = this; - - this.$emit('addImage', { imageUrl, altText: altText || __('image') }); - }, - isValid() { - if (!isSafeURL(this.imageUrl)) { - this.error = __('Please provide a valid URL'); - this.$refs.urlInput.$el.focus(); - return false; - } - - return true; - }, - }, -}; -</script> -<template> - <gl-modal - ref="modal" - modal-id="add-image-modal" - :title="modalTitle" - :ok-title="okTitle" - @ok="onOk" - > - <gl-form-group - :label="urlLabel" - label-for="url-input" - :state="!Boolean(error)" - :invalid-feedback="error" - > - <gl-form-input id="url-input" ref="urlInput" v-model="imageUrl" /> - </gl-form-group> - - <gl-form-group :label="descriptionLabel" label-for="description-input"> - <gl-form-input id="description-input" ref="descriptionInput" v-model="altText" /> - </gl-form-group> - </gl-modal> -</template> diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue index 5c310fc059b..baeb98bec75 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue @@ -2,7 +2,7 @@ import 'codemirror/lib/codemirror.css'; import '@toast-ui/editor/dist/toastui-editor.css'; -import AddImageModal from './modals/add_image_modal.vue'; +import AddImageModal from './modals/add_image/add_image_modal.vue'; import { EDITOR_OPTIONS, EDITOR_TYPES, @@ -12,11 +12,12 @@ import { } from './constants'; import { + registerHTMLToMarkdownRenderer, addCustomEventListener, removeCustomEventListener, addImage, getMarkdown, -} from './editor_service'; +} from './services/editor_service'; export default { components: { @@ -27,7 +28,7 @@ export default { AddImageModal, }, props: { - value: { + content: { type: String, required: true, }, @@ -51,6 +52,11 @@ export default { required: false, default: EDITOR_PREVIEW_STYLE, }, + imageRoot: { + type: String, + required: true, + validator: prop => prop.endsWith('/'), + }, }, data() { return { @@ -66,51 +72,48 @@ export default { return this.$refs.editor; }, }, - watch: { - value(newVal) { - const isSameMode = this.previousMode === this.editorApi.currentMode; - if (!isSameMode) { - /* - The ToastUI Editor consumes its content via the `initial-value` prop and then internally - manages changes. If we desire the `v-model` to work as expected, we need to manually call - `setMarkdown`. However, if we do this in each v-model change we'll continually prevent - the editor from internally managing changes. Thus we use the `previousMode` flag as - confirmation to actually update its internals. This is initially designed so that front - matter is excluded from editing in wysiwyg mode, but included in markdown mode. - */ - this.editorInstance.invoke('setMarkdown', newVal); - this.previousMode = this.editorApi.currentMode; - } - }, - }, beforeDestroy() { - removeCustomEventListener( - this.editorApi, - CUSTOM_EVENTS.openAddImageModal, - this.onOpenAddImageModal, - ); - - this.editorApi.eventManager.removeEventHandler('changeMode', this.onChangeMode); + this.removeListeners(); }, methods: { + addListeners(editorApi) { + addCustomEventListener(editorApi, CUSTOM_EVENTS.openAddImageModal, this.onOpenAddImageModal); + + editorApi.eventManager.listen('changeMode', this.onChangeMode); + }, + removeListeners() { + removeCustomEventListener( + this.editorApi, + CUSTOM_EVENTS.openAddImageModal, + this.onOpenAddImageModal, + ); + + this.editorApi.eventManager.removeEventHandler('changeMode', this.onChangeMode); + }, + resetInitialValue(newVal) { + this.editorInstance.invoke('setMarkdown', newVal); + }, onContentChanged() { this.$emit('input', getMarkdown(this.editorInstance)); }, onLoad(editorApi) { this.editorApi = editorApi; - addCustomEventListener( - this.editorApi, - CUSTOM_EVENTS.openAddImageModal, - this.onOpenAddImageModal, - ); + registerHTMLToMarkdownRenderer(editorApi); - this.editorApi.eventManager.listen('changeMode', this.onChangeMode); + this.addListeners(editorApi); }, onOpenAddImageModal() { this.$refs.addImageModal.show(); }, - onAddImage(image) { + onAddImage({ imageUrl, altText, file }) { + const image = { imageUrl, altText }; + + if (file) { + this.$emit('uploadImage', { file, imageUrl }); + // TODO - ensure that the actual repo URL for the image is used in Markdown mode + } + addImage(this.editorInstance, image); }, onChangeMode(newMode) { @@ -123,7 +126,7 @@ export default { <div> <toast-editor ref="editor" - :initial-value="value" + :initial-value="content" :options="editorOptions" :preview-style="previewStyle" :initial-edit-type="initialEditType" @@ -131,6 +134,6 @@ export default { @change="onContentChanged" @load="onLoad" /> - <add-image-modal ref="addImageModal" @addImage="onAddImage" /> + <add-image-modal ref="addImageModal" :image-root="imageRoot" @addImage="onAddImage" /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js new file mode 100644 index 00000000000..70d29b5b3df --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js @@ -0,0 +1,68 @@ +import renderBlockHtml from './renderers/render_html_block'; +import renderKramdownList from './renderers/render_kramdown_list'; +import renderKramdownText from './renderers/render_kramdown_text'; +import renderIdentifierInstanceText from './renderers/render_identifier_instance_text'; +import renderIdentifierParagraph from './renderers/render_identifier_paragraph'; +import renderEmbeddedRubyText from './renderers/render_embedded_ruby_text'; +import renderFontAwesomeHtmlInline from './renderers/render_font_awesome_html_inline'; + +const htmlInlineRenderers = [renderFontAwesomeHtmlInline]; +const htmlBlockRenderers = [renderBlockHtml]; +const listRenderers = [renderKramdownList]; +const paragraphRenderers = [renderIdentifierParagraph]; +const textRenderers = [renderKramdownText, renderEmbeddedRubyText, renderIdentifierInstanceText]; + +const executeRenderer = (renderers, node, context) => { + const availableRenderer = renderers.find(renderer => renderer.canRender(node, context)); + + return availableRenderer ? availableRenderer.render(node, context) : context.origin(); +}; + +const buildCustomRendererFunctions = (customRenderers, defaults) => { + const customTypes = Object.keys(customRenderers).filter(type => !defaults[type]); + const customEntries = customTypes.map(type => { + const fn = (node, context) => executeRenderer(customRenderers[type], node, context); + return [type, fn]; + }); + + return Object.fromEntries(customEntries); +}; + +const buildCustomHTMLRenderer = ( + customRenderers = { htmlBlock: [], htmlInline: [], list: [], paragraph: [], text: [] }, +) => { + const defaults = { + htmlBlock(node, context) { + const allHtmlBlockRenderers = [...customRenderers.htmlBlock, ...htmlBlockRenderers]; + + return executeRenderer(allHtmlBlockRenderers, node, context); + }, + htmlInline(node, context) { + const allHtmlInlineRenderers = [...customRenderers.htmlInline, ...htmlInlineRenderers]; + + return executeRenderer(allHtmlInlineRenderers, node, context); + }, + list(node, context) { + const allListRenderers = [...customRenderers.list, ...listRenderers]; + + return executeRenderer(allListRenderers, node, context); + }, + paragraph(node, context) { + const allParagraphRenderers = [...customRenderers.paragraph, ...paragraphRenderers]; + + return executeRenderer(allParagraphRenderers, node, context); + }, + text(node, context) { + const allTextRenderers = [...customRenderers.text, ...textRenderers]; + + return executeRenderer(allTextRenderers, node, context); + }, + }; + + return { + ...buildCustomRendererFunctions(customRenderers, defaults), + ...defaults, + }; +}; + +export default buildCustomHTMLRenderer; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js new file mode 100644 index 00000000000..ed04765c871 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js @@ -0,0 +1,53 @@ +import { defaults, repeat } from 'lodash'; + +const DEFAULTS = { + subListIndentSpaces: 4, +}; + +const countIndentSpaces = text => { + const matches = text.match(/^\s+/m); + + return matches ? matches[0].length : 0; +}; + +const buildHTMLToMarkdownRender = (baseRenderer, formattingPreferences = {}) => { + const { subListIndentSpaces } = defaults(formattingPreferences, DEFAULTS); + // eslint-disable-next-line @gitlab/require-i18n-strings + const sublistNode = 'LI OL, LI UL'; + + return { + TEXT_NODE(node) { + return baseRenderer.getSpaceControlled( + baseRenderer.trim(baseRenderer.getSpaceCollapsedText(node.nodeValue)), + node, + ); + }, + /* + * This converter overwrites the default indented list converter + * to allow us to parameterize the number of indent spaces for + * sublists. + * + * See the original implementation in + * https://github.com/nhn/tui.editor/blob/master/libs/to-mark/src/renderer.basic.js#L161 + */ + [sublistNode](node, subContent) { + const baseResult = baseRenderer.convert(node, subContent); + // Default to 1 to prevent possible divide by 0 + const firstLevelIndentSpacesCount = countIndentSpaces(baseResult) || 1; + const reindentedList = baseResult + .split('\n') + .map(line => { + const itemIndentSpacesCount = countIndentSpaces(line); + const nestingLevel = Math.ceil(itemIndentSpacesCount / firstLevelIndentSpacesCount); + const indentSpaces = repeat(' ', subListIndentSpaces * nestingLevel); + + return line.replace(/^ +/, indentSpaces); + }) + .join('\n'); + + return reindentedList; + }, + }; +}; + +export default buildHTMLToMarkdownRender; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/editor_service.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js index 278cd50a947..6436dcaae64 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/editor_service.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js @@ -1,5 +1,6 @@ import Vue from 'vue'; -import ToolbarItem from './toolbar_item.vue'; +import ToolbarItem from '../toolbar_item.vue'; +import buildHtmlToMarkdownRenderer from './build_html_to_markdown_renderer'; const buildWrapper = propsData => { const instance = new Vue({ @@ -40,3 +41,16 @@ export const removeCustomEventListener = (editorApi, event, handler) => export const addImage = ({ editor }, image) => editor.exec('AddImage', image); export const getMarkdown = editorInstance => editorInstance.invoke('getMarkdown'); + +/** + * This function allow us to extend Toast UI HTML to Markdown renderer. It is + * a temporary measure because Toast UI does not provide an API + * to achieve this goal. + */ +export const registerHTMLToMarkdownRenderer = editorApi => { + const { renderer } = editorApi.toMarkOptions; + + Object.assign(editorApi.toMarkOptions, { + renderer: renderer.constructor.factory(renderer, buildHtmlToMarkdownRenderer(renderer)), + }); +}; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js new file mode 100644 index 00000000000..d96cadafdbb --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js @@ -0,0 +1,63 @@ +const buildToken = (type, tagName, props) => { + return { type, tagName, ...props }; +}; + +const TAG_TYPES = { + block: 'div', + inline: 'a', +}; + +// Open helpers (singular and multiple) + +const buildUneditableOpenToken = (tagType = TAG_TYPES.block) => + buildToken('openTag', tagType, { + attributes: { contenteditable: false }, + classNames: [ + 'gl-px-4 gl-py-2 gl-my-5 gl-opacity-5 gl-bg-gray-100 gl-user-select-none gl-cursor-not-allowed', + ], + }); + +export const buildUneditableOpenTokens = (token, tagType = TAG_TYPES.block) => { + return [buildUneditableOpenToken(tagType), token]; +}; + +// Close helpers (singular and multiple) + +export const buildUneditableCloseToken = (tagType = TAG_TYPES.block) => + buildToken('closeTag', tagType); + +export const buildUneditableCloseTokens = (token, tagType = TAG_TYPES.block) => { + return [token, buildUneditableCloseToken(tagType)]; +}; + +// Complete helpers (open plus close) + +export const buildTextToken = content => buildToken('text', null, { content }); + +export const buildUneditableTokens = token => { + return [...buildUneditableOpenTokens(token), buildUneditableCloseToken()]; +}; + +export const buildUneditableInlineTokens = token => { + return [ + ...buildUneditableOpenTokens(token, TAG_TYPES.inline), + buildUneditableCloseToken(TAG_TYPES.inline), + ]; +}; + +export const buildUneditableHtmlAsTextTokens = node => { + /* + Toast UI internally appends ' data-tomark-pass ' attribute flags so it can target certain + nested nodes for internal use during Markdown <=> WYSIWYG conversions. In our case, we want + to prevent HTML being rendered completely in WYSIWYG mode and thus we use a `text` vs. `html` + type when building the token. However, in doing so, we need to strip out the ` data-tomark-pass ` + to prevent their persistence within the `text` content as the user did not intend these as edits. + + https://github.com/nhn/tui.editor/blob/cc54ec224fc3a4b6e5a2b19a71650959f41adc0e/apps/editor/src/js/convertor.js#L72 + */ + const regex = / data-tomark-pass /gm; + const content = node.literal.replace(regex, ''); + const htmlAsTextToken = buildToken('text', null, { content }); + + return [buildUneditableOpenToken(), htmlAsTextToken, buildUneditableCloseToken()]; +}; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text.js new file mode 100644 index 00000000000..494057fc75b --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text.js @@ -0,0 +1,13 @@ +import { buildUneditableTokens } from './build_uneditable_token'; + +const embeddedRubyRegex = /(^<%.+%>$)/; + +const canRender = ({ literal }) => { + return embeddedRubyRegex.test(literal); +}; + +const render = (_, { origin }) => { + return buildUneditableTokens(origin()); +}; + +export default { canRender, render }; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline.js new file mode 100644 index 00000000000..572f6e3cf9d --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline.js @@ -0,0 +1,11 @@ +import { buildUneditableInlineTokens } from './build_uneditable_token'; + +const fontAwesomeRegexOpen = /<i class="fa.+>/; + +const canRender = ({ literal }) => { + return fontAwesomeRegexOpen.test(literal); +}; + +const render = (_, { origin }) => buildUneditableInlineTokens(origin()); + +export default { canRender, render }; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js new file mode 100644 index 00000000000..b179ca61dba --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js @@ -0,0 +1,9 @@ +import { buildUneditableHtmlAsTextTokens } from './build_uneditable_token'; + +const canRender = ({ type }) => { + return type === 'htmlBlock'; +}; + +const render = node => buildUneditableHtmlAsTextTokens(node); + +export default { canRender, render }; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text.js new file mode 100644 index 00000000000..a9c3dfcd728 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text.js @@ -0,0 +1,40 @@ +import { buildTextToken, buildUneditableInlineTokens } from './build_uneditable_token'; + +/* +Use case examples: +- Majority: two bracket pairs, back-to-back, each with content (including spaces) + - `[environment terraform plans][terraform]` + - `[an issue labelled `~"master:broken"`][broken-master-issues]` +- Minority: two bracket pairs the latter being empty or only one pair with content (including spaces) + - `[this link][]` + - `[this link]` + +Regexp notes: + - `(?:\[.+?\]){1}`: Always one bracket pair with content (including spaces) + - `(?:\[\]|\[.+?\])?`: Optional second pair that may or may not contain content (including spaces) + - `(?!:)`: Never followed by a `:` which is reserved for identifier definition syntax (`[identifier]: /the-link`) + - Each of the three parts is non-captured, but the match as a whole is captured +*/ +const identifierInstanceRegex = /((?:\[.+?\]){1}(?:\[\]|\[.+?\])?(?!:))/g; + +const isIdentifierInstance = literal => { + // Reset lastIndex as global flag in regexp are stateful (https://stackoverflow.com/a/11477448) + identifierInstanceRegex.lastIndex = 0; + return identifierInstanceRegex.test(literal); +}; + +const canRender = ({ literal }) => isIdentifierInstance(literal); + +const tokenize = text => { + const matches = text.split(identifierInstanceRegex); + const tokens = matches.map(match => { + const token = buildTextToken(match); + return isIdentifierInstance(match) ? buildUneditableInlineTokens(token) : token; + }); + + return tokens.flat(); +}; + +const render = (_, { origin }) => tokenize(origin().content); + +export default { canRender, render }; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js new file mode 100644 index 00000000000..f5b4502ea3c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js @@ -0,0 +1,16 @@ +import { buildUneditableOpenTokens, buildUneditableCloseToken } from './build_uneditable_token'; + +const identifierRegex = /(^\[.+\]: .+)/; + +const isIdentifier = text => { + return identifierRegex.test(text); +}; + +const canRender = (node, context) => { + return isIdentifier(context.getChildrenText(node)); +}; + +const render = (_, { entering, origin }) => + entering ? buildUneditableOpenTokens(origin()) : buildUneditableCloseToken(); + +export default { canRender, render }; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list.js new file mode 100644 index 00000000000..491a26c81d0 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list.js @@ -0,0 +1,27 @@ +import { buildUneditableOpenTokens, buildUneditableCloseToken } from './build_uneditable_token'; + +const isKramdownTOC = ({ type, literal }) => type === 'text' && literal === 'TOC'; + +const canRender = node => { + let targetNode = node; + while (targetNode !== null) { + const { firstChild } = targetNode; + const isLeaf = firstChild === null; + if (isLeaf) { + if (isKramdownTOC(targetNode)) { + return true; + } + + break; + } + + targetNode = targetNode.firstChild; + } + + return false; +}; + +const render = (_, { entering, origin }) => + entering ? buildUneditableOpenTokens(origin()) : buildUneditableCloseToken(); + +export default { canRender, render }; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text.js new file mode 100644 index 00000000000..01384699e4f --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text.js @@ -0,0 +1,13 @@ +import { buildUneditableTokens } from './build_uneditable_token'; + +const kramdownRegex = /(^{:.+}$)/; + +const canRender = ({ literal }) => { + return kramdownRegex.test(literal); +}; + +const render = (_, { origin }) => { + return buildUneditableTokens(origin()); +}; + +export default { canRender, render }; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue index 30f7e6a5980..1be5284fa9c 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue @@ -1,7 +1,11 @@ <script> import { __, s__, sprintf } from '~/locale'; +import { GlIcon } from '@gitlab/ui'; export default { + components: { + GlIcon, + }, props: { abilityName: { type: String, @@ -72,6 +76,10 @@ export default { data-toggle="dropdown" > <span class="dropdown-toggle-text"> {{ dropdownToggleText }} </span> - <i aria-hidden="true" class="fa fa-chevron-down" data-hidden="true"> </i> + <gl-icon + name="chevron-down" + class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-700" + :size="16" + /> </button> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue index bf51fa3dc38..f0a846c4924 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue @@ -1,5 +1,11 @@ <script> -export default {}; +import { GlIcon } from '@gitlab/ui'; + +export default { + components: { + GlIcon, + }, +}; </script> <template> @@ -10,13 +16,13 @@ export default {}; class="dropdown-input-field" type="search" /> - <i aria-hidden="true" class="fa fa-search dropdown-input-search" data-hidden="true"> </i> - <i - aria-hidden="true" - class="fa fa-times dropdown-input-clear js-dropdown-input-clear" - data-hidden="true" - role="button" - > - </i> + <gl-icon + name="search" + class="dropdown-input-search gl-absolute gl-top-3 gl-right-5 gl-text-gray-500 gl-pointer-events-none" + /> + <gl-icon + name="close" + class="dropdown-input-clear js-dropdown-input-clear gl-absolute gl-top-3 gl-right-5 gl-text-gray-700" + /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js index e94e7d46f85..746e38e98e8 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js @@ -1,6 +1,7 @@ export const DropdownVariant = { Sidebar: 'sidebar', Standalone: 'standalone', + Embedded: 'embedded', }; export const LIST_BUFFER_SIZE = 5; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue index f45c14f8344..cf77aa37d14 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue @@ -8,12 +8,16 @@ export default { GlIcon, }, computed: { - ...mapGetters(['dropdownButtonText', 'isDropdownVariantStandalone']), + ...mapGetters([ + 'dropdownButtonText', + 'isDropdownVariantStandalone', + 'isDropdownVariantEmbedded', + ]), }, methods: { ...mapActions(['toggleDropdownContents']), handleButtonClick(e) { - if (this.isDropdownVariantStandalone) { + if (this.isDropdownVariantStandalone || this.isDropdownVariantEmbedded) { this.toggleDropdownContents(); e.stopPropagation(); } diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue index ba8d8391952..94671f8a109 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue @@ -88,12 +88,16 @@ export default { @click.prevent="handleColorClick(color)" /> </div> - <div class="color-input-container d-flex"> + <div class="color-input-container gl-display-flex"> <span class="dropdown-label-color-preview position-relative position-relative d-inline-block" :style="{ backgroundColor: selectedColor }" ></span> - <gl-form-input v-model.trim="selectedColor" :placeholder="__('Use custom color #FF0000')" /> + <gl-form-input + v-model.trim="selectedColor" + class="gl-rounded-top-left-none gl-rounded-bottom-left-none" + :placeholder="__('Use custom color #FF0000')" + /> </div> </div> <div class="dropdown-actions clearfix pt-2 px-2"> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue index af16088b6b9..ef506d00d9a 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue @@ -36,7 +36,7 @@ export default { 'footerCreateLabelTitle', 'footerManageLabelTitle', ]), - ...mapGetters(['selectedLabelsList', 'isDropdownVariantSidebar']), + ...mapGetters(['selectedLabelsList', 'isDropdownVariantSidebar', 'isDropdownVariantEmbedded']), visibleLabels() { if (this.searchKey) { return this.labels.filter(label => @@ -126,16 +126,19 @@ export default { <div class="labels-select-contents-list js-labels-list" @keydown="handleKeyDown"> <gl-loading-icon v-if="labelsFetchInProgress" - class="labels-fetch-loading position-absolute d-flex align-items-center w-100 h-100" + class="labels-fetch-loading position-absolute gl-display-flex gl-align-items-center w-100 h-100" size="md" /> - <div v-if="isDropdownVariantSidebar" class="dropdown-title d-flex align-items-center pt-0 pb-2"> + <div + v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded" + class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!" + > <span class="flex-grow-1">{{ labelsListTitle }}</span> <gl-button :aria-label="__('Close')" variant="link" size="small" - class="dropdown-header-button p-0" + class="dropdown-header-button gl-p-0!" icon="close" @click="toggleDropdownContents" /> @@ -165,17 +168,21 @@ export default { </li> </smart-virtual-list> </div> - <div v-if="isDropdownVariantSidebar" class="dropdown-footer"> + <div v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded" class="dropdown-footer"> <ul class="list-unstyled"> <li v-if="allowLabelCreate"> <gl-link - class="d-flex w-100 flex-row text-break-word label-item" + class="gl-display-flex w-100 flex-row text-break-word label-item" @click="toggleDropdownContentsCreateView" - >{{ footerCreateLabelTitle }}</gl-link > + {{ footerCreateLabelTitle }} + </gl-link> </li> <li> - <gl-link :href="labelsManagePath" class="d-flex flex-row text-break-word label-item"> + <gl-link + :href="labelsManagePath" + class="gl-display-flex flex-row text-break-word label-item" + > {{ footerManageLabelTitle }} </gl-link> </li> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue index f38b66fdfdf..258a87e62b9 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue @@ -74,6 +74,11 @@ export default { required: false, default: '', }, + dropdownButtonText: { + type: String, + required: false, + default: __('Label'), + }, labelsListTitle: { type: String, required: false, @@ -97,7 +102,11 @@ export default { }, computed: { ...mapState(['showDropdownButton', 'showDropdownContents']), - ...mapGetters(['isDropdownVariantSidebar', 'isDropdownVariantStandalone']), + ...mapGetters([ + 'isDropdownVariantSidebar', + 'isDropdownVariantStandalone', + 'isDropdownVariantEmbedded', + ]), dropdownButtonVisible() { return this.isDropdownVariantSidebar ? this.showDropdownButton : true; }, @@ -116,6 +125,7 @@ export default { allowLabelCreate: this.allowLabelCreate, allowMultiselect: this.allowMultiselect, allowScopedLabels: this.allowScopedLabels, + dropdownButtonText: this.dropdownButtonText, selectedLabels: this.selectedLabels, labelsFetchPath: this.labelsFetchPath, labelsManagePath: this.labelsManagePath, @@ -200,7 +210,10 @@ export default { <template> <div class="labels-select-wrapper position-relative" - :class="{ 'is-standalone': isDropdownVariantStandalone }" + :class="{ + 'is-standalone': isDropdownVariantStandalone, + 'is-embedded': isDropdownVariantEmbedded, + }" > <template v-if="isDropdownVariantSidebar"> <dropdown-value-collapsed @@ -221,7 +234,7 @@ export default { ref="dropdownContents" /> </template> - <template v-if="isDropdownVariantStandalone"> + <template v-if="isDropdownVariantStandalone || isDropdownVariantEmbedded"> <dropdown-button v-show="dropdownButtonVisible" /> <dropdown-contents v-if="dropdownButtonVisible && showDropdownContents" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js index c39222959a9..e035a866048 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js @@ -13,7 +13,7 @@ export const dropdownButtonText = (state, getters) => { : state.selectedLabels; if (!selectedLabels.length) { - return __('Label'); + return state.dropdownButtonText || __('Label'); } else if (selectedLabels.length > 1) { return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), { firstLabelName: selectedLabels[0].title, @@ -44,5 +44,12 @@ export const isDropdownVariantSidebar = state => state.variant === DropdownVaria */ export const isDropdownVariantStandalone = state => state.variant === DropdownVariant.Standalone; +/** + * Returns boolean representing whether dropdown variant + * is `embedded` + * @param {object} state + */ +export const isDropdownVariantEmbedded = state => state.variant === DropdownVariant.Embedded; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js index 6a6c0b4c0ee..3f3358d4805 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js @@ -6,6 +6,7 @@ export default () => ({ labelsCreateTitle: '', footerCreateLabelTitle: '', footerManageLabelTitle: '', + dropdownButtonText: '', // Paths namespace: '', diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue index 595baeeb14f..bd35d3fead9 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -4,8 +4,11 @@ import Icon from '~/vue_shared/components/icon.vue'; import UserAvatarImage from '../user_avatar/user_avatar_image.vue'; import { glEmojiTag } from '../../../emoji'; +const MAX_SKELETON_LINES = 4; + export default { name: 'UserPopover', + maxSkeletonLines: MAX_SKELETON_LINES, components: { Icon, GlPopover, @@ -22,11 +25,6 @@ export default { required: true, default: null, }, - loaded: { - type: Boolean, - required: false, - default: false, - }, }, computed: { statusHtml() { @@ -42,14 +40,8 @@ export default { return ''; }, - nameIsLoading() { - return !this.user.name; - }, - workInformationIsLoading() { - return !this.user.loaded && this.user.workInformation === null; - }, - locationIsLoading() { - return !this.user.loaded && this.user.location === null; + userIsLoading() { + return !this.user?.loaded; }, }, }; @@ -58,54 +50,46 @@ export default { <template> <!-- 200ms delay so not every mouseover triggers Popover --> <gl-popover :target="target" :delay="200" boundary="viewport" triggers="hover" placement="top"> - <div class="user-popover d-flex"> - <div class="p-1 flex-shrink-1"> - <user-avatar-image :img-src="user.avatarUrl" :size="60" css-classes="mr-2" /> + <div class="gl-p-3 gl-line-height-normal gl-display-flex" data-testid="user-popover"> + <div class="gl-p-2 flex-shrink-1"> + <user-avatar-image :img-src="user.avatarUrl" :size="60" css-classes="gl-mr-3!" /> </div> - <div class="p-1 w-100"> - <h5 class="m-0"> - <span v-if="user.name">{{ user.name }}</span> - <gl-skeleton-loading v-else :lines="1" class="animation-container-small mb-1" /> - </h5> - <div class="text-secondary mb-2"> - <span v-if="user.username">@{{ user.username }}</span> - <gl-skeleton-loading v-else :lines="1" class="animation-container-small mb-1" /> - </div> - <div class="text-secondary"> - <div v-if="user.bio" class="d-flex mb-1"> - <icon name="profile" class="category-icon flex-shrink-0" /> - <span ref="bio" class="ml-1">{{ user.bio }}</span> - </div> - <div v-if="user.workInformation" class="d-flex mb-1"> - <icon - v-show="!workInformationIsLoading" - name="work" - class="category-icon flex-shrink-0" - /> - <span ref="workInformation" class="ml-1">{{ user.workInformation }}</span> - </div> - <gl-skeleton-loading - v-if="workInformationIsLoading" - :lines="1" - class="animation-container-small mb-1" - /> - </div> - <div class="js-location text-secondary d-flex"> - <icon - v-show="!locationIsLoading && user.location" - name="location" - class="category-icon flex-shrink-0" - /> - <span v-if="user.location" class="ml-1">{{ user.location }}</span> + <div class="gl-p-2 gl-w-full"> + <template v-if="userIsLoading"> + <!-- `gl-skeleton-loading` does not support equal length lines --> + <!-- This can be migrated to `gl-skeleton-loader` when https://gitlab.com/gitlab-org/gitlab-ui/-/issues/872 is completed --> <gl-skeleton-loading - v-if="locationIsLoading" + v-for="n in $options.maxSkeletonLines" + :key="n" :lines="1" - class="animation-container-small mb-1" + class="animation-container-small gl-mb-2" /> - </div> - <div v-if="statusHtml" class="js-user-status mt-2"> - <span v-html="statusHtml"></span> - </div> + </template> + <template v-else> + <div class="gl-mb-3"> + <h5 class="gl-m-0"> + {{ user.name }} + </h5> + <span class="gl-text-gray-700">@{{ user.username }}</span> + </div> + <div class="gl-text-gray-700"> + <div v-if="user.bio" class="gl-display-flex gl-mb-2"> + <icon name="profile" class="gl-text-gray-600 gl-flex-shrink-0" /> + <span ref="bio" class="ml-1" v-html="user.bioHtml"></span> + </div> + <div v-if="user.workInformation" class="gl-display-flex gl-mb-2"> + <icon name="work" class="gl-text-gray-600 gl-flex-shrink-0" /> + <span ref="workInformation" class="gl-ml-2">{{ user.workInformation }}</span> + </div> + </div> + <div v-if="user.location" class="js-location gl-text-gray-700 gl-display-flex"> + <icon name="location" class="gl-text-gray-600 flex-shrink-0" /> + <span class="gl-ml-2">{{ user.location }}</span> + </div> + <div v-if="statusHtml" class="js-user-status gl-mt-3"> + <span v-html="statusHtml"></span> + </div> + </template> </div> </div> </gl-popover> diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js index 63ce4212717..235beb1f22d 100644 --- a/app/assets/javascripts/vue_shared/constants.js +++ b/app/assets/javascripts/vue_shared/constants.js @@ -6,6 +6,8 @@ const INTERVALS = { day: 'day', }; +export const FILE_SYMLINK_MODE = '120000'; + export const timeRanges = [ { label: __('30 minutes'), |